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...
+
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