mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 06:59:42 +02:00
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:
co-authored by
Claude Fable 5.1
parent
5b667264b4
commit
58b4cb06d8
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user