Files
Codeman/test/file-browser-outside-worktree.test.ts
T
Michael GrundbergandClaude Opus 5.5 c9b79097b0 feat(files): follow the agent into a worktree outside the session folder
#772 made the Files panel follow an agent into a worktree inside the
session's folder. A worktree beside the repository (git worktree add
../feature, or an EnterWorktree into one that already exists there)
stayed unfollowed, since serving it needs a second confinement root.

findOutsideWorktree() answers that case without running git. The
nearest .git above the pane must name <common dir>/worktrees/<id> of the
session's own repository, and that registry entry must name the
candidate back (or the .git folder must be the common dir itself). It
refuses /, the home folder and above, any folder holding workingDir and
Docker case workspaces, and multi-user mode applies the owner's
folderAllowedForUser rule. The answer is liveWorktree with outside: true
and the folder's real path.

The file routes serve an absolute path inside that worktree, and only
while the folder still resolves to the recorded path. A relative path
still resolves against workingDir alone. The browser routes such paths
to the editable preview, and root-relative markdown refs resolve from
the top of the worktree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 17:54:59 +01:00

181 lines
7.7 KiB
TypeScript

/**
* @fileoverview The browser side of a worktree OUTSIDE the session folder (#765 follow-up).
*
* The server lists such a worktree with absolute paths and serves them as a second root. The
* browser has to route those paths to the workspace file routes (preview and edit), not to the
* attachment routes that serve an outside file read-only; the terminal has to open them in the
* preview, since the log viewer's `tail-file` stays confined to `workingDir`; and a root-relative
* markdown ref has to resolve from the top of the worktree. Real frontend code in a `vm` context.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { describe, expect, it, vi } from 'vitest';
const read = (name: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', name), 'utf8');
const WORKING_DIR = '/base/myapp';
const SIBLING = '/base/myapp-feature';
const outsideSession = () => ({
workingDir: WORKING_DIR,
liveWorktree: { name: 'myapp-feature', path: SIBLING, outside: true },
});
type Proto = {
_isExternalPreviewPath: (p: string, id: string) => boolean;
_isOutsideWorktreePath: (p: string, id: string) => boolean;
};
const loadRouting = (session: Record<string, unknown>) => {
const context = vm.createContext({
console,
CodemanApp: class CodemanApp {},
document: { addEventListener: vi.fn(), getElementById: () => null },
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
});
vm.runInContext(
`${read('constants.js')}\n${read('terminal-ui.js')}\n${read('panels-ui.js')}\nglobalThis.__proto = CodemanApp.prototype;`,
context
);
const proto = (context as unknown as { __proto: Proto }).__proto;
const app = Object.assign(Object.create(proto), { sessions: new Map([['s1', session]]) }) as Proto;
return {
external: (p: string) => app._isExternalPreviewPath(p, 's1'),
outside: (p: string) => app._isOutsideWorktreePath(p, 's1'),
};
};
describe('preview routing for a worktree outside the session folder', () => {
it('keeps a path inside the outside worktree on the workspace routes', () => {
const app = loadRouting(outsideSession());
expect(app.external(`${SIBLING}/src/a.ts`)).toBe(false);
expect(app.external(SIBLING)).toBe(false);
expect(app.outside(`${SIBLING}/src/a.ts`)).toBe(true);
});
it('still sends every other outside path through the attachment routes', () => {
const app = loadRouting(outsideSession());
expect(app.external('/base/myapp-feature-2/src/a.ts')).toBe(true);
expect(app.external('/tmp/shot.png')).toBe(true);
expect(app.outside('/base/myapp-feature-2/src/a.ts')).toBe(false);
expect(app.outside('relative/a.ts')).toBe(false);
});
it('leaves workspace paths and an inside worktree as they were', () => {
const app = loadRouting({
workingDir: WORKING_DIR,
liveWorktree: { name: 'probe', path: `${WORKING_DIR}/.claude/worktrees/probe` },
});
expect(app.external(`${WORKING_DIR}/src/a.ts`)).toBe(false);
expect(app.outside(`${WORKING_DIR}/.claude/worktrees/probe/a.ts`)).toBe(false);
expect(app.external(`${SIBLING}/src/a.ts`)).toBe(true);
});
it('treats a sibling path as external once the agent has left the worktree', () => {
const app = loadRouting({ workingDir: WORKING_DIR });
expect(app.external(`${SIBLING}/src/a.ts`)).toBe(true);
expect(app.outside(`${SIBLING}/src/a.ts`)).toBe(false);
});
});
describe('terminal links into a worktree outside the session folder', () => {
type Link = { text: string; activate: (ev: unknown, text: string) => void };
type Provider = { provideLinks: (line: number, cb: (links: Link[] | undefined) => void) => void };
const makeApp = (line: string) => {
const CodemanApp = function CodemanApp(this: unknown) {};
const context = vm.createContext({
window: {},
document: {
body: { classList: { contains: () => false } },
activeElement: null,
addEventListener: vi.fn(),
getElementById: () => null,
},
CodemanApp,
console: { warn: vi.fn(), log: vi.fn(), debug: vi.fn() },
_crashDiag: { log: vi.fn() },
performance: { now: () => 0 },
requestAnimationFrame: () => 1,
setTimeout: () => 1,
MobileDetection: { isTouchDevice: () => false },
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
OSC52_STRIP_RE: /\x1b\]52;[^\x07\x1b]*(?:\x07|\x1b\\)/g,
TERMINAL_CHUNK_SIZE: 32 * 1024,
});
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
vm.runInContext(read('terminal-ui.js'), context, { filename: 'terminal-ui.js' });
vm.runInContext(read('panels-ui.js'), context, { filename: 'panels-ui.js' });
const app = new (CodemanApp as unknown as new () => Record<string, any>)();
app.activeSessionId = 's1';
app.sessions = new Map([['s1', outsideSession()]]);
app.openFilePreview = vi.fn();
app.openLogViewerWindow = vi.fn();
const providers: Provider[] = [];
app.terminal = {
cols: 80,
registerLinkProvider: (p: Provider) => providers.push(p),
buffer: {
active: {
length: 1,
getLine: (row: number) =>
row === 0
? { isWrapped: false, translateToString: (trim?: boolean) => (trim === false ? line.padEnd(80) : line) }
: undefined,
},
},
};
app.registerFilePathLinkProvider();
let links: Link[] = [];
providers[0].provideLinks(1, (found) => {
links = found || [];
});
return { app, link: links[0] };
};
it('opens a log in the outside worktree in the preview, since tail-file stays in the session folder', () => {
const { app, link } = makeApp(`tail -f ${SIBLING}/logs/server.log`);
link.activate({}, link.text);
expect(app.openFilePreview).toHaveBeenCalledWith(`${SIBLING}/logs/server.log`, 's1');
expect(app.openLogViewerWindow).not.toHaveBeenCalled();
});
it('keeps a log in the session folder in the log viewer', () => {
const { app, link } = makeApp(`tail -f ${WORKING_DIR}/logs/server.log`);
link.activate({}, link.text);
expect(app.openLogViewerWindow).toHaveBeenCalledWith(`${WORKING_DIR}/logs/server.log`, 's1');
});
});
describe('markdown in a worktree outside the session folder', () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'http://localhost/' });
const { document } = dom.window;
it('resolves root-relative and relative refs from the worktree, not the session folder', () => {
const context = vm.createContext({
console,
CodemanApp: class CodemanApp {},
document,
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
});
vm.runInContext(
`${read('constants.js')}\n${read('panels-ui.js')}\nglobalThis.__proto = CodemanApp.prototype;`,
context
);
const proto = (context as unknown as { __proto: Record<string, any> }).__proto;
const app = Object.assign(Object.create(proto), { sessions: new Map([['s1', outsideSession()]]) });
const root = document.createElement('div');
root.innerHTML = '<img src="/assets/root.png" alt="root"><img src="img/a.png" alt="rel">';
app._rebaseFilePreviewMarkdownRefs(root, { sessionId: 's1', filePath: `${SIBLING}/docs/README.md` });
const raw = (path: string) => `/api/sessions/s1/file-raw?path=${encodeURIComponent(path)}`;
const src = (alt: string) => root.querySelector(`img[alt="${alt}"]`)!.getAttribute('src');
expect(src('root')).toBe(raw(`${SIBLING}/assets/root.png`));
expect(src('rel')).toBe(raw(`${SIBLING}/docs/img/a.png`));
});
});