diff --git a/.changeset/feat-path-picker-sort-and-jump.md b/.changeset/feat-path-picker-sort-and-jump.md new file mode 100644 index 000000000..8846d6517 --- /dev/null +++ b/.changeset/feat-path-picker-sort-and-jump.md @@ -0,0 +1,15 @@ +--- +"aicodeman": minor +--- + +feat(files): let the path picker jump to a typed path and sort by name or date + +The picker's current-folder line was read-only, so reaching a deep folder meant tapping +through every level, and its listing was fixed to name order, so the file an agent had +just written was somewhere in a 500-entry list. The current folder is now an editable +field (Enter or Go jumps there, a full file path lands in its folder with the file +selected, and a typo keeps the listing you had instead of resetting to the root), the +listing can be sorted by name or modified time in either direction with folders always +first (the choice is remembered per device), and each entry shows a compact modified +time. `GET /api/filesystem/browse` entries carry `mtimeMs` to make that possible, with +one stat per entry. diff --git a/docs/wiki/Working-With-Files.md b/docs/wiki/Working-With-Files.md index 7e43ad92b..b579c4cdc 100644 --- a/docs/wiki/Working-With-Files.md +++ b/docs/wiki/Working-With-Files.md @@ -117,10 +117,14 @@ For choosing a path rather than typing one. It appears in two places: - **Browse** in **Add Case → Link Existing**. - The **šŸ“ Path** key on the mobile keyboard bar. -It browses one directory at a time and can show hidden entries on request. The picker -inserts the path into your prompt **without** pressing Enter, so nothing is submitted by -accident. Its sibling **⌫ All** key clears the unsent prompt, and never sends the agent's -`/clear` command. +It browses one directory at a time and can show hidden entries on request. The current +folder is an editable field: type or paste a path and press Enter (or **Go**) to jump +straight there, and a full file path lands in its folder with that file selected. The +**Sort** control orders each listing by name or by modified time (newest first is the +quick way to the file an agent just wrote), with folders always ahead of files; the +choice is remembered per device. The picker inserts the path into your prompt +**without** pressing Enter, so nothing is submitted by accident. Its sibling **⌫ All** +key clears the unsent prompt, and never sends the agent's `/clear` command. This is a separate file-serving surface from the viewer, with its own rules: it allowlists your home directory, the cases directory, and anything in `CODEMAN_FILE_PICKER_ROOTS`, and diff --git a/src/types/common.ts b/src/types/common.ts index 80489655f..6ebebbbd6 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -77,6 +77,8 @@ export interface FilesystemBrowseEntry { path: string; type: 'file' | 'directory'; size?: number; + /** Last-modified time (ms since epoch) of the entry's target; lets the picker sort by date. */ + mtimeMs?: number; symlink?: boolean; previewKind?: FilesystemPreviewKind; } diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index 8cdc16bad..b91ebc9ff 100644 --- a/src/web/public/keyboard-accessory.js +++ b/src/web/public/keyboard-accessory.js @@ -47,11 +47,24 @@ // the picker browses Home and every configured root, so wanting dotfiles in a // project does not imply wanting them in ~. const PATH_PICKER_SHOW_HIDDEN_KEY = 'codeman:pathPickerShowHidden'; +// Per-device like the hidden toggle: how you scan a folder is a habit of the +// hand, not of the workspace. +const PATH_PICKER_SORT_KEY = 'codeman:pathPickerSort'; +const PATH_PICKER_SORT_MODES = [ + { value: 'name-asc', label: 'Name A→Z' }, + { value: 'name-desc', label: 'Name Z→A' }, + { value: 'mtime-desc', label: 'Newest first' }, + { value: 'mtime-asc', label: 'Oldest first' }, +]; +const PATH_PICKER_DEFAULT_SORT = 'name-asc'; const PathPicker = { overlay: null, _options: null, _selectedPath: '', + _currentPath: '', + _entries: [], + _truncated: false, _previousFocus: null, _keydownHandler: null, _loadSequence: 0, @@ -59,6 +72,7 @@ const PathPicker = { _previewRequestSequence: 0, _previewPreviousFocus: null, _showHidden: false, + _sortMode: PATH_PICKER_DEFAULT_SORT, /** * Open the lazy filesystem browser. @@ -69,7 +83,10 @@ const PathPicker = { this.close(false); this._options = options; this._selectedPath = ''; + this._currentPath = ''; + this._entries = []; this._showHidden = this._loadShowHidden(); + this._sortMode = this._loadSortMode(); this._previousFocus = document.activeElement; this._previousFocus?.blur?.(); @@ -90,11 +107,19 @@ const PathPicker = {
-
+
+ + +
-
Loading...
+
+
Loading...
+ +
Selected @@ -114,12 +139,27 @@ const PathPicker = { overlay.querySelector('.path-picker-cancel').addEventListener('click', () => this.close(true)); overlay.querySelector('.path-picker-confirm').addEventListener('click', () => this.confirm()); overlay.querySelector('.path-picker-current-select').addEventListener('click', () => { - const current = overlay.querySelector('.path-picker-current').textContent; - if (current) this.select(current); + if (this._currentPath) this.select(this._currentPath); }); - overlay.querySelector('.path-picker-refresh').addEventListener('click', () => this.load()); + overlay.querySelector('.path-picker-refresh').addEventListener('click', () => this.load(this._currentPath)); overlay.querySelector('.path-picker-hidden').addEventListener('click', () => this.toggleHidden()); this._syncHiddenButton(); + // Typing a path is the fast way there. The listing is loaded ONLY on Enter/Go, + // never on each keystroke: a half-typed path is a 404 the server has to + // answer for nothing, and jumping mid-edit would yank the field around. + overlay.querySelector('.path-picker-jump').addEventListener('submit', (event) => { + event.preventDefault(); + this.jumpTo(overlay.querySelector('.path-picker-current').value); + }); + const sortSelect = overlay.querySelector('.path-picker-sort'); + for (const mode of PATH_PICKER_SORT_MODES) { + const option = document.createElement('option'); + option.value = mode.value; + option.textContent = mode.label; + sortSelect.appendChild(option); + } + sortSelect.value = this._sortMode; + sortSelect.addEventListener('change', (event) => this.setSortMode(event.target.value)); overlay.querySelector('.path-picker-up').addEventListener('click', () => { const parent = overlay.querySelector('.path-picker-up').dataset.parent; if (parent) this.load(parent); @@ -169,15 +209,89 @@ const PathPicker = { // OFF inside a hidden folder makes the current path unbrowsable again; the // server answers 403 and load()'s catch falls back to the default root, // which is the only place left to stand. - this.load(this.overlay.querySelector('.path-picker-current').textContent || ''); + this.load(this._currentPath || ''); + }, + + _loadSortMode() { + try { + const stored = localStorage.getItem(PATH_PICKER_SORT_KEY); + return PATH_PICKER_SORT_MODES.some((mode) => mode.value === stored) ? stored : PATH_PICKER_DEFAULT_SORT; + } catch { + return PATH_PICKER_DEFAULT_SORT; + } + }, + + setSortMode(mode) { + if (!PATH_PICKER_SORT_MODES.some((candidate) => candidate.value === mode)) return; + this._sortMode = mode; + try { + localStorage.setItem(PATH_PICKER_SORT_KEY, mode); + } catch {} + const select = this.overlay?.querySelector('.path-picker-sort'); + if (select && select.value !== mode) select.value = mode; + // Re-order what is already on screen; no round trip, no lost selection. + if (this.overlay) this.renderEntries(); + }, + + /** + * Order entries for display. Folders always come first, whatever the mode: + * a date sort is for finding the file you just made, and the folders are the + * way past it, not the thing being looked for. An entry without an mtime (an + * older server, the in-container source) sorts after every dated one and then + * by name, so a listing never degrades into an unstable order. + */ + _sortEntries(entries) { + const [key, direction] = this._sortMode.split('-'); + const sign = direction === 'desc' ? -1 : 1; + const byName = (a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }); + return entries.slice().sort((a, b) => { + if (a.type !== b.type) return a.type === 'directory' ? -1 : 1; + if (key === 'mtime') { + const aTime = typeof a.mtimeMs === 'number' ? a.mtimeMs : null; + const bTime = typeof b.mtimeMs === 'number' ? b.mtimeMs : null; + if (aTime !== null && bTime !== null && aTime !== bTime) return sign * (aTime - bTime); + if (aTime === null && bTime !== null) return 1; + if (aTime !== null && bTime === null) return -1; + return byName(a, b); + } + return sign * byName(a, b); + }); }, - async load(path) { + /** Compact modified-time label: time of day today, month-day this year, else the date. */ + _formatModified(mtimeMs) { + if (typeof mtimeMs !== 'number' || !Number.isFinite(mtimeMs)) return ''; + const date = new Date(mtimeMs); + if (Number.isNaN(date.getTime())) return ''; + const now = new Date(); + const pad = (n) => String(n).padStart(2, '0'); + if (date.toDateString() === now.toDateString()) return `${pad(date.getHours())}:${pad(date.getMinutes())}`; + if (date.getFullYear() === now.getFullYear()) return `${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; + }, + + /** + * Go to a path the user typed. A file path lands in its folder with the + * file selected, so pasting a full path from a log or a message is one Enter + * away from Select. A path that does not resolve lands in its parent folder + * when that exists (the closest place to stand) and otherwise keeps the + * current listing, and says so either way — unlike a stale initialPath, a + * typo is not a reason to throw the user back to the root. + */ + jumpTo(rawPath) { + const path = String(rawPath || '').trim(); + if (!path) return; + this.load(path, { typed: true }); + }, + + async load(path, options = {}) { if (!this.overlay || !this._options) return; const loadSequence = ++this._loadSequence; const list = this.overlay.querySelector('.path-picker-list'); const status = this.overlay.querySelector('.path-picker-status'); - list.replaceChildren(); + const typed = !!options.typed; + if (!typed) list.replaceChildren(); + status.classList.remove('error'); status.textContent = 'Loading...'; const params = new URLSearchParams(); @@ -194,13 +308,40 @@ const PathPicker = { if (!result?.success) throw new Error(result?.error || 'Failed to browse this folder'); if (!this.overlay || loadSequence !== this._loadSequence) return; this.render(result.data); + if (options.selectIfListed) { + // Landed in the typed path's folder: select the entry if it is there + // (a file path), otherwise say what the server said about the full + // path — the listing is still the closest place to stand. + if (this._entries.some((entry) => entry.path === options.selectIfListed)) { + this.select(options.selectIfListed); + } else { + status.textContent = options.failMessage || 'Path not found'; + status.classList.add('error'); + } + } } catch (error) { if (!this.overlay || loadSequence !== this._loadSequence) return; + const message = error.message || 'Failed to browse this folder'; + if (typed) { + // The browse endpoint answers a FILE path with "not found" (it resolves + // folders only), so one retry lands in the parent folder and selects + // the entry from the listing. Only one level: a typo two segments up + // is an error, not a reason to climb to the root. + const slash = path.lastIndexOf('/'); + if (slash > 0 && !options.selectIfListed) { + this.load(path.slice(0, slash), { typed: true, selectIfListed: path, failMessage: message }); + return; + } + this.renderEntries(); + status.textContent = options.failMessage || message; + status.classList.add('error'); + return; + } if (path) { this.load(''); return; } - status.textContent = error.message || 'Failed to browse this folder'; + status.textContent = message; status.classList.add('error'); } }, @@ -216,19 +357,29 @@ const PathPicker = { rootSelect.appendChild(option); } - this.overlay.querySelector('.path-picker-current').textContent = data.path; + this._currentPath = data.path; + this.overlay.querySelector('.path-picker-current').value = data.path; const up = this.overlay.querySelector('.path-picker-up'); up.dataset.parent = data.parent || ''; up.disabled = !data.parent; + this._entries = Array.isArray(data.entries) ? data.entries : []; + this._truncated = !!data.truncated; + this.renderEntries(); + }, + + /** (Re)build the list from the last listing in the current sort order. */ + renderEntries() { + if (!this.overlay) return; + const entries = this._sortEntries(this._entries); const status = this.overlay.querySelector('.path-picker-status'); status.classList.remove('error'); - status.textContent = data.entries.length === 0 + status.textContent = entries.length === 0 ? 'This folder is empty' - : `${data.entries.length} item${data.entries.length === 1 ? '' : 's'}${data.truncated ? ' (first 500)' : ''}`; + : `${entries.length} item${entries.length === 1 ? '' : 's'}${this._truncated ? ' (first 500)' : ''}`; const list = this.overlay.querySelector('.path-picker-list'); list.replaceChildren(); - for (const entry of data.entries) { + for (const entry of entries) { const row = document.createElement('div'); row.className = 'path-picker-item'; if (entry.type === 'file' && this._options.directoriesOnly && !entry.previewKind) { @@ -248,6 +399,14 @@ const PathPicker = { name.className = 'path-picker-item-name'; name.textContent = entry.name; open.append(icon, name); + const modified = this._formatModified(entry.mtimeMs); + if (modified) { + const meta = document.createElement('span'); + meta.className = 'path-picker-item-meta'; + meta.textContent = modified; + meta.title = new Date(entry.mtimeMs).toLocaleString(); + open.appendChild(meta); + } if (entry.symlink) { const link = document.createElement('span'); link.className = 'path-picker-item-link'; @@ -417,6 +576,8 @@ const PathPicker = { this._previousFocus = null; this._options = null; this._selectedPath = ''; + this._currentPath = ''; + this._entries = []; if (restoreFocus) previousFocus?.focus?.(); }, }; diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 6257d9979..4e34f542a 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -13617,24 +13617,95 @@ body.touch-device.cjk-input-visible .main { cursor: default; } +/* The current path is an editable field: type or paste a path and press Enter + * (or Go) to jump there. The Go button is part of the same rounded control so + * the field keeps the width the read-only breadcrumb had. */ +.path-picker-jump { + display: flex; + flex: 1; + min-width: 0; + margin: 0; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 8px; +} + +.path-picker-jump:focus-within { + border-color: var(--accent); +} + .path-picker-current { flex: 1; min-width: 0; - padding: 9px 11px; - overflow-x: auto; + height: 36px; + padding: 0 10px; color: var(--accent); font-family: var(--font-mono, monospace); font-size: 0.75rem; white-space: nowrap; - background: var(--bg-input); - border: 1px solid var(--border); - border-radius: 8px; + background: transparent; + border: 0; + outline: none; } -.path-picker-status { +.path-picker-go { + flex: 0 0 auto; + padding: 0 10px; + color: var(--text-dim); + font-size: 0.7rem; + font-weight: 600; + background: transparent; + border: 0; + border-left: 1px solid var(--border); + cursor: pointer; +} + +.path-picker-go:hover { + color: var(--accent); +} + +.path-picker-toolbar { + display: flex; + gap: 8px; + align-items: center; + justify-content: space-between; padding: 0 14px 8px; +} + +.path-picker-status { + min-width: 0; + overflow: hidden; color: var(--text-dim); font-size: 0.7rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.path-picker-sort-label { + display: inline-flex; + flex: 0 0 auto; + gap: 6px; + align-items: center; + color: var(--text-dim); + font-size: 0.7rem; +} + +.path-picker-sort { + padding: 3px 6px; + color: var(--text); + font-size: 0.7rem; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 6px; +} + +.path-picker-item-meta { + flex: 0 0 auto; + margin-left: 8px; + color: var(--text-dim); + font-family: var(--font-mono, monospace); + font-size: 0.65rem; + white-space: nowrap; } .path-picker-status.error { diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts index 7f380b37c..98410e112 100644 --- a/src/web/routes/file-routes.ts +++ b/src/web/routes/file-routes.ts @@ -876,6 +876,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even let type: FilesystemBrowseEntry['type']; let size: number | undefined; + let mtimeMs: number | undefined; const symlink = entry.isSymbolicLink(); if (entry.isDirectory()) { type = 'directory'; @@ -886,6 +887,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even const targetStat = await fs.stat(targetPath); type = targetStat.isDirectory() ? 'directory' : 'file'; if (type === 'file') size = targetStat.size; + mtimeMs = targetStat.mtimeMs; } catch { continue; } @@ -894,11 +896,15 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even } if (isBlockedPickerPath(targetPath, blockedTrees, type === 'directory')) continue; - if (type === 'file' && size === undefined) { + if (mtimeMs === undefined) { + // One stat per entry: the modified time lets the picker sort by date, and + // a file's size rides along on the same call. try { - size = (await fs.stat(targetPath)).size; + const targetStat = await fs.stat(targetPath); + mtimeMs = targetStat.mtimeMs; + if (type === 'file') size = targetStat.size; } catch { - // The path is still selectable even when a size lookup races a change. + // The path is still selectable even when a stat races a change. } } entries.push({ @@ -906,6 +912,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even path: visiblePath, type, size, + mtimeMs, symlink: symlink || undefined, previewKind: type === 'file' ? getFilesystemPreviewKind(entry.name) : undefined, }); diff --git a/test/path-picker-sort.test.ts b/test/path-picker-sort.test.ts new file mode 100644 index 000000000..7cb612abb --- /dev/null +++ b/test/path-picker-sort.test.ts @@ -0,0 +1,294 @@ +/** + * @fileoverview Path picker: sort order and the editable path field. + * + * Same jsdom harness as path-picker-hidden.test.ts: keyboard-accessory.js is + * evaluated against a jsdom window with a scripted fetch, so the assertions + * run against the real DOM the picker builds rather than string matches. + * Port: N/A + */ + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { JSDOM } from 'jsdom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); +const accessoryJs = readFileSync(resolve(PUBLIC, 'keyboard-accessory.js'), 'utf8'); +const stylesCss = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8'); + +const SORT_KEY = 'codeman:pathPickerSort'; + +const dom = new JSDOM('', { url: 'https://localhost/' }); +const jsdomWindow = dom.window as unknown as Window & typeof globalThis; +const jsdomDocument = jsdomWindow.document; + +function loadPathPicker(fetchImpl: (url: string) => Promise): any { + const MobileDetection = { isTouchDevice: () => false }; + const factory = new Function( + 'window', + 'document', + 'localStorage', + 'fetch', + 'MobileDetection', + `${accessoryJs}\nreturn PathPicker;` + ); + return factory(jsdomWindow, jsdomDocument, jsdomWindow.localStorage, fetchImpl, MobileDetection); +} + +type Entry = { name: string; type: 'file' | 'directory'; mtimeMs?: number }; + +function browseResponse(entries: Entry[], path = '/home/dev/project') { + return { + ok: true, + json: async () => ({ + success: true, + data: { + path, + parent: path === '/home/dev' ? null : '/home/dev', + root: '/home/dev', + roots: [{ label: 'Home', path: '/home/dev' }], + entries: entries.map((e) => ({ ...e, path: `${path}/${e.name}` })), + truncated: false, + }, + }), + }; +} + +function errorResponse(error: string) { + return { ok: false, json: async () => ({ success: false, error }) }; +} + +const DAY = 24 * 60 * 60 * 1000; +const NOW = Date.now(); +const LISTING: Entry[] = [ + { name: 'zeta.txt', type: 'file', mtimeMs: NOW - 3 * DAY }, + { name: 'alpha.txt', type: 'file', mtimeMs: NOW - 1 * DAY }, + { name: 'mid.txt', type: 'file', mtimeMs: NOW - 2 * DAY }, + { name: 'old-dir', type: 'directory', mtimeMs: NOW - 30 * DAY }, + { name: 'new-dir', type: 'directory', mtimeMs: NOW - 1000 }, +]; + +describe('PathPicker sort order', () => { + let PathPicker: any; + let urls: string[]; + let respond: (url: string) => unknown; + + beforeEach(() => { + jsdomWindow.localStorage.clear(); + jsdomDocument.body.replaceChildren(); + urls = []; + respond = () => browseResponse(LISTING); + PathPicker = loadPathPicker(async (url: string) => { + urls.push(url); + return respond(url); + }); + }); + + afterEach(() => { + PathPicker?.close?.(false); + jsdomDocument.body.replaceChildren(); + }); + + const open = async (options: Record = {}) => { + PathPicker.open({ onSelect: () => {}, ...options }); + await vi.waitFor(() => expect(jsdomDocument.querySelectorAll('.path-picker-item').length).toBeGreaterThan(0)); + }; + const names = () => Array.from(jsdomDocument.querySelectorAll('.path-picker-item-name')).map((el) => el.textContent); + const sortSelect = () => jsdomDocument.querySelector('.path-picker-sort') as HTMLSelectElement; + const setSort = (value: string) => { + sortSelect().value = value; + sortSelect().dispatchEvent(new jsdomWindow.Event('change', { bubbles: true })); + }; + + it('sorts by name with folders first by default', async () => { + await open(); + expect(sortSelect().value).toBe('name-asc'); + expect(names()).toEqual(['new-dir', 'old-dir', 'alpha.txt', 'mid.txt', 'zeta.txt']); + }); + + it('re-orders the listing without another request, keeping folders first', async () => { + await open(); + const requests = urls.length; + + setSort('mtime-desc'); + expect(names()).toEqual(['new-dir', 'old-dir', 'alpha.txt', 'mid.txt', 'zeta.txt']); + + setSort('mtime-asc'); + expect(names()).toEqual(['old-dir', 'new-dir', 'zeta.txt', 'mid.txt', 'alpha.txt']); + + setSort('name-desc'); + expect(names()).toEqual(['old-dir', 'new-dir', 'zeta.txt', 'mid.txt', 'alpha.txt']); + + expect(urls.length).toBe(requests); + }); + + it('remembers the sort mode across reopenings', async () => { + await open(); + setSort('mtime-desc'); + expect(jsdomWindow.localStorage.getItem(SORT_KEY)).toBe('mtime-desc'); + PathPicker.close(false); + + await open(); + expect(sortSelect().value).toBe('mtime-desc'); + }); + + it('ignores a corrupt stored mode and a localStorage that throws', async () => { + jsdomWindow.localStorage.setItem(SORT_KEY, 'bogus'); + await open(); + expect(sortSelect().value).toBe('name-asc'); + PathPicker.close(false); + + const getItem = vi.spyOn(jsdomWindow.localStorage.__proto__, 'getItem').mockImplementation(() => { + throw new Error('private mode'); + }); + try { + await open(); + expect(sortSelect().value).toBe('name-asc'); + setSort('mtime-asc'); + expect(names()[0]).toBe('old-dir'); + } finally { + getItem.mockRestore(); + } + }); + + it('places entries without a modified time after dated ones on a date sort', async () => { + respond = () => + browseResponse([ + { name: 'undated.txt', type: 'file' }, + { name: 'dated.txt', type: 'file', mtimeMs: NOW - DAY }, + ]); + await open(); + setSort('mtime-desc'); + expect(names()).toEqual(['dated.txt', 'undated.txt']); + setSort('mtime-asc'); + expect(names()).toEqual(['dated.txt', 'undated.txt']); + }); + + it('shows a compact modified time only when the server supplied one', async () => { + respond = () => + browseResponse([ + { name: 'undated.txt', type: 'file' }, + { name: 'today.txt', type: 'file', mtimeMs: NOW }, + ]); + await open(); + const rows = Array.from(jsdomDocument.querySelectorAll('.path-picker-item')); + const meta = (row: Element) => row.querySelector('.path-picker-item-meta')?.textContent ?? null; + expect(meta(rows[0])).toMatch(/^\d{2}:\d{2}$/); + expect(meta(rows[1])).toBeNull(); + }); + + it('styles the sort control and the modified column', () => { + expect(stylesCss).toContain('.path-picker-sort {'); + expect(stylesCss).toContain('.path-picker-item-meta {'); + }); +}); + +describe('PathPicker editable path', () => { + let PathPicker: any; + let urls: string[]; + let respond: (url: string) => unknown; + + beforeEach(() => { + jsdomWindow.localStorage.clear(); + jsdomDocument.body.replaceChildren(); + urls = []; + respond = () => browseResponse(LISTING); + PathPicker = loadPathPicker(async (url: string) => { + urls.push(url); + return respond(url); + }); + }); + + afterEach(() => { + PathPicker?.close?.(false); + jsdomDocument.body.replaceChildren(); + }); + + const open = async (options: Record = {}) => { + PathPicker.open({ onSelect: () => {}, ...options }); + await vi.waitFor(() => expect(jsdomDocument.querySelectorAll('.path-picker-item').length).toBeGreaterThan(0)); + }; + const field = () => jsdomDocument.querySelector('.path-picker-current') as HTMLInputElement; + const submit = (value: string) => { + field().value = value; + (jsdomDocument.querySelector('.path-picker-jump') as HTMLFormElement).dispatchEvent( + new jsdomWindow.Event('submit', { bubbles: true, cancelable: true }) + ); + }; + const pathParam = (url: string) => new URL(url, 'https://localhost').searchParams.get('path'); + const status = () => jsdomDocument.querySelector('.path-picker-status') as HTMLElement; + + it('shows the current folder in an editable field and jumps on Enter', async () => { + await open({ initialPath: '/home/dev/project' }); + expect(field().value).toBe('/home/dev/project'); + const before = urls.length; + + // Typing alone never fetches. + field().value = '/home/dev/oth'; + field().dispatchEvent(new jsdomWindow.Event('input', { bubbles: true })); + expect(urls.length).toBe(before); + + respond = () => browseResponse([{ name: 'readme.md', type: 'file' }], '/home/dev/other'); + submit(' /home/dev/other '); + await vi.waitFor(() => expect(field().value).toBe('/home/dev/other')); + expect(pathParam(urls[urls.length - 1])).toBe('/home/dev/other'); + expect(jsdomDocument.querySelector('.path-picker-item-name')?.textContent).toBe('readme.md'); + }); + + it('keeps the current listing and reports the error when neither a typed path nor its parent resolves', async () => { + await open({ initialPath: '/home/dev/project' }); + respond = () => errorResponse('Path not found: /home/dev/nope/deeper'); + submit('/home/dev/nope/deeper'); + await vi.waitFor(() => expect(status().classList.contains('error')).toBe(true)); + expect(status().textContent).toBe('Path not found: /home/dev/nope/deeper'); + // One retry on the parent, then stop: never a climb to the root. + expect(urls.slice(-2).map(pathParam)).toEqual(['/home/dev/nope/deeper', '/home/dev/nope']); + expect(jsdomDocument.querySelectorAll('.path-picker-item').length).toBe(LISTING.length); + // The typed text stays in the field so the typo can be corrected in place. + expect(field().value).toBe('/home/dev/nope/deeper'); + }); + + it('lands in the parent folder, unselected, when only the last segment is wrong', async () => { + await open({ initialPath: '/home/dev' }); + respond = (url) => + pathParam(url) === '/home/dev/project/typo.txt' + ? errorResponse('Path not found: /home/dev/project/typo.txt') + : browseResponse(LISTING); + submit('/home/dev/project/typo.txt'); + await vi.waitFor(() => expect(field().value).toBe('/home/dev/project')); + await vi.waitFor(() => expect(status().classList.contains('error')).toBe(true)); + expect(status().textContent).toBe('Path not found: /home/dev/project/typo.txt'); + expect(jsdomDocument.querySelector('.path-picker-selection-value')?.textContent).toBe('None'); + expect((jsdomDocument.querySelector('.path-picker-confirm') as HTMLButtonElement).disabled).toBe(true); + }); + + it('lands a typed file path in its folder with the file selected', async () => { + await open({ initialPath: '/home/dev/project' }); + respond = (url) => + pathParam(url) === '/home/dev/project/alpha.txt' + ? errorResponse('Path not found: /home/dev/project/alpha.txt') + : browseResponse(LISTING); + submit('/home/dev/project/alpha.txt'); + await vi.waitFor(() => + expect(jsdomDocument.querySelector('.path-picker-selection-value')?.textContent).toBe( + '/home/dev/project/alpha.txt' + ) + ); + expect(field().value).toBe('/home/dev/project'); + expect(jsdomDocument.querySelector('.path-picker-item.selected .path-picker-item-name')?.textContent).toBe( + 'alpha.txt' + ); + expect((jsdomDocument.querySelector('.path-picker-confirm') as HTMLButtonElement).disabled).toBe(false); + }); + + it('selects the current folder from the field value and refreshes in place', async () => { + await open({ initialPath: '/home/dev/project' }); + (jsdomDocument.querySelector('.path-picker-current-select') as HTMLButtonElement).click(); + expect(jsdomDocument.querySelector('.path-picker-selection-value')?.textContent).toBe('/home/dev/project'); + + const before = urls.length; + (jsdomDocument.querySelector('.path-picker-refresh') as HTMLButtonElement).click(); + await vi.waitFor(() => expect(urls.length).toBe(before + 1)); + expect(pathParam(urls[urls.length - 1])).toBe('/home/dev/project'); + }); +}); diff --git a/test/routes/file-routes.test.ts b/test/routes/file-routes.test.ts index b2eed19f6..24604e45c 100644 --- a/test/routes/file-routes.test.ts +++ b/test/routes/file-routes.test.ts @@ -115,6 +115,50 @@ describe('file-routes', () => { ]); }); + it('stamps every entry with its modified time so the picker can sort by date', async () => { + mockedReaddir.mockResolvedValueOnce([ + { name: 'notes.txt', isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false }, + { name: 'src', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false }, + { name: 'link', isDirectory: () => false, isFile: () => false, isSymbolicLink: () => true }, + ] as never); + mockedStat.mockImplementation(async (candidate) => { + const target = String(candidate); + if (target.endsWith('/notes.txt')) { + return { size: 42, mtimeMs: 1_700_000_000_000, isFile: () => true, isDirectory: () => false } as never; + } + if (target.endsWith('/src')) { + return { size: 4096, mtimeMs: 1_700_000_001_000, isFile: () => false, isDirectory: () => true } as never; + } + if (target.endsWith('/link')) { + return { size: 7, mtimeMs: 1_700_000_002_000, isFile: () => true, isDirectory: () => false } as never; + } + return { size: 0, mtimeMs: 0, isFile: () => false, isDirectory: () => true } as never; + }); + + const path = harness.ctx._session.workingDir; + const res = await harness.app.inject({ + method: 'GET', + url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`, + }); + + expect(res.statusCode).toBe(200); + const entries = JSON.parse(res.body).data.entries as Array<{ + name: string; + type: string; + size?: number; + mtimeMs?: number; + }>; + expect(entries.map((entry) => [entry.name, entry.type, entry.size, entry.mtimeMs])).toEqual([ + ['src', 'directory', undefined, 1_700_000_001_000], + ['link', 'file', 7, 1_700_000_002_000], + ['notes.txt', 'file', 42, 1_700_000_000_000], + ]); + // One stat per entry: the date and the size ride on the same call. + const statsFor = (name: string) => + mockedStat.mock.calls.filter(([candidate]) => String(candidate).endsWith(`/${name}`)).length; + expect([statsFor('notes.txt'), statsFor('src'), statsFor('link')]).toEqual([1, 1, 1]); + }); + it('defaults to the Codeman Cases root, not Home, when linking a case with no path chosen yet', async () => { // The "Link Existing" case picker opens with an empty path and no // sessionId. `Home` and `Codeman Cases` are unrelated bind mounts under