feat(files): let the path picker jump to a typed path and sort by name or date

The picker's current-folder line was a read-only breadcrumb, so reaching a
deep folder meant tapping through every level, and the 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 that file selected, and a path that does
not resolve keeps the listing you had and says so, instead of the reset to
the root that a stale initialPath gets. A Sort control orders the listing by
name or modified time in either direction, folders always first, and the
choice is remembered per device like the hidden toggle. Each entry shows a
compact modified time (time of day today, month-day this year, else the
date).

GET /api/filesystem/browse stamps every entry with mtimeMs to make that
possible; the stat that already fetched a file's size now serves both, so
it is still one stat per entry. Entries without an mtime (an older server,
the in-container listing) sort after dated ones and then by name.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McLWqCWBuQYGuPMScb4Aou
This commit is contained in:
shenlvkang-collab
2026-09-10 12:42:33 +08:00
co-authored by Claude Fable 5.1
parent 5b667264b4
commit 58b4cb06d8
8 changed files with 627 additions and 29 deletions
@@ -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.
+8 -4
View File
@@ -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
+2
View File
@@ -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;
}
+173 -12
View File
@@ -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 = {
</div>
<div class="path-picker-nav">
<button type="button" class="path-picker-up" title="Parent folder" aria-label="Parent folder">&#x2191;</button>
<div class="path-picker-current" title="Current folder"></div>
<form class="path-picker-jump" title="Current folder — edit and press Enter to jump">
<input type="text" class="path-picker-current" aria-label="Current folder path" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" enterkeyhint="go">
<button type="submit" class="path-picker-go" title="Go to this path" aria-label="Go to this path">Go</button>
</form>
<button type="button" class="path-picker-hidden" title="Show hidden files and folders" aria-label="Show hidden files and folders" aria-pressed="false">.*</button>
<button type="button" class="path-picker-refresh" title="Refresh" aria-label="Refresh">&#x21BB;</button>
</div>
<div class="path-picker-toolbar">
<div class="path-picker-status" aria-live="polite">Loading...</div>
<label class="path-picker-sort-label">Sort
<select class="path-picker-sort" aria-label="Sort entries"></select>
</label>
</div>
<div class="path-picker-list" role="listbox"></div>
<div class="path-picker-selection">
<span class="path-picker-selection-label">Selected</span>
@@ -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 || '');
},
async load(path) {
_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);
});
},
/** 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?.();
},
};
+80 -9
View File
@@ -13617,24 +13617,95 @@ body.touch-device.cjk-input-visible .main {
cursor: default;
}
.path-picker-current {
/* 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;
padding: 9px 11px;
overflow-x: auto;
color: var(--accent);
font-family: var(--font-mono, monospace);
font-size: 0.75rem;
white-space: nowrap;
margin: 0;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 8px;
}
.path-picker-status {
padding: 0 14px 8px;
.path-picker-jump:focus-within {
border-color: var(--accent);
}
.path-picker-current {
flex: 1;
min-width: 0;
height: 36px;
padding: 0 10px;
color: var(--accent);
font-family: var(--font-mono, monospace);
font-size: 0.75rem;
white-space: nowrap;
background: transparent;
border: 0;
outline: none;
}
.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 {
+10 -3
View File
@@ -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,
});
+294
View File
@@ -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('<!DOCTYPE html><html><body></body></html>', { url: 'https://localhost/' });
const jsdomWindow = dom.window as unknown as Window & typeof globalThis;
const jsdomDocument = jsdomWindow.document;
function loadPathPicker(fetchImpl: (url: string) => Promise<unknown>): 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<string, unknown> = {}) => {
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<string, unknown> = {}) => {
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');
});
});
+44
View File
@@ -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