fix(files): list the git worktree an agent moved into (#765)

Claude Code's EnterWorktree moves the running process into
<repo>/.claude/worktrees/<name>, while Session.workingDir stays on the main
checkout. The Files panel kept walking workingDir, so it listed a tree the
agent had left.

paneCwd already follows the move, measured on Claude Code 2.1.318 in both
directions, so nothing new has to track the agent. It also follows every cd
in a shell, so a new SessionState.liveWorktree narrows it to a linked
worktree, read from the .git file without starting git.

Only a folder strictly below workingDir is ever an answer, and the listing
only starts there: returned paths stay relative to workingDir, so the
preview, raw and edit routes are unchanged and confinement does not widen.
A worktree outside the session folder stays unfollowed.

The panel asks with root=live, shows a strip naming the worktree with a
switch to the session folder, and follows the agent in and out while open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-05 23:47:38 +01:00
1 parent 46b85fa6d0
commit 89a93c871f
19 files changed
+853 -32

No files matched your search

+16 -16
View File
@@ -387,7 +387,7 @@ describe('File Viewer server search', () => {
await vi.advanceTimersByTimeAsync(1);
expect(pending).toHaveLength(1);
expect(pending[0].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=src%20%26%20docs');
expect(pending[0].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=src%20%26%20docs&root=live');
});
it('is a safe no-op without a panel or session and can lazily adopt a later active owner', async () => {
@@ -399,7 +399,7 @@ describe('File Viewer server search', () => {
app.activeSessionId = 'session/B';
app.filterFileBrowser('after-session');
await vi.advanceTimersByTimeAsync(250);
expect(pending[0].url).toBe('/api/sessions/session%2FB/files?depth=5&showHidden=false&q=after-session');
expect(pending[0].url).toBe('/api/sessions/session%2FB/files?depth=5&showHidden=false&q=after-session&root=live');
pending[0].reply.resolve(response(successfulData()));
await vi.advanceTimersByTimeAsync(0);
@@ -450,7 +450,7 @@ describe('File Viewer server search', () => {
expect(pending).toHaveLength(0);
await vi.advanceTimersByTimeAsync(1);
expect(pending.map(({ url }) => url)).toEqual([
'/api/sessions/session%2FA/files?depth=5&showHidden=false&q=second%20query',
'/api/sessions/session%2FA/files?depth=5&showHidden=false&q=second%20query&root=live',
]);
});
@@ -659,7 +659,7 @@ describe('File Viewer server search', () => {
await vi.advanceTimersByTimeAsync(250);
expect(pending).toHaveLength(1);
expect(pending[0].url.endsWith(`&q=${'x'.repeat(256)}`)).toBe(true);
expect(pending[0].url.endsWith(`&q=${'x'.repeat(256)}&root=live`)).toBe(true);
});
it('rejects 257 trimmed characters without a request', async () => {
@@ -786,7 +786,7 @@ describe('File Viewer server search', () => {
expect(elements.fileBrowserTree.innerHTML).toContain('Loading files');
expect(elements.fileBrowserTree.innerHTML).not.toContain('old-result.ts');
expect(elements.fileBrowserTree.innerHTML).not.toContain('old-preference.ts');
expect(pending[1].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true');
expect(pending[1].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&root=live');
pending[1].reply.resolve(response(successfulTree('new-preference.ts')));
await toggle;
@@ -794,7 +794,7 @@ describe('File Viewer server search', () => {
expect(app._fileBrowserState.view).toBe('search-pending');
expect(elements.fileBrowserTree.innerHTML).toContain('Searching');
await vi.advanceTimersByTimeAsync(250);
expect(pending[2].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&q=widget');
expect(pending[2].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&q=widget&root=live');
pending[2].reply.resolve(
response(
successfulData({
@@ -877,8 +877,8 @@ describe('File Viewer server search', () => {
const firstToggle = app.toggleFileBrowserHidden();
const secondToggle = app.toggleFileBrowserHidden();
expect(pending.slice(1).map(({ url }) => url)).toEqual([
'/api/sessions/session%2FA/files?depth=5&showHidden=true',
'/api/sessions/session%2FA/files?depth=5&showHidden=false',
'/api/sessions/session%2FA/files?depth=5&showHidden=true&root=live',
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live',
]);
pending[1].reply.resolve(response(successfulTree('stale-hidden.ts')));
@@ -891,7 +891,7 @@ describe('File Viewer server search', () => {
await vi.advanceTimersByTimeAsync(250);
expect(pending).toHaveLength(4);
expect(pending[3].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=widget');
expect(pending[3].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=widget&root=live');
expect(app.fileBrowserShowHidden).toBe(false);
});
@@ -1674,7 +1674,7 @@ describe('File Viewer server search', () => {
expect(immediateState.activeSessionId).toBe('session/B');
expect(immediateState.ownerSessionId).toBe('session/B');
expect(immediateState.resizeCalls).toBe(1);
expect(immediateState.fileUrls).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false']);
expect(immediateState.fileUrls).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live']);
expect(filePending).toHaveLength(1);
expect(app._fileBrowserState.ownerSessionId).toBe('session/B');
@@ -1755,7 +1755,7 @@ describe('File Viewer server search', () => {
expect(elements.fileBrowserExpandBtn.innerHTML).toBe('\u229E');
expect(elements.fileBrowserTree.querySelectorAll('.file-tree-item')).toHaveLength(0);
expect(elements.fileBrowserTree.innerHTML).toContain('Loading files');
expect(pending.map(({ url }) => url)).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false']);
expect(pending.map(({ url }) => url)).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live']);
await vi.advanceTimersByTimeAsync(250);
expect(pending).toHaveLength(1);
@@ -1797,8 +1797,8 @@ describe('File Viewer server search', () => {
await vi.advanceTimersByTimeAsync(250);
expect(pending.map(({ url }) => url)).toEqual([
'/api/sessions/session%2FB/files?depth=5&showHidden=false',
'/api/sessions/session%2FB/files?depth=5&showHidden=false&q=during%20load',
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live',
'/api/sessions/session%2FB/files?depth=5&showHidden=false&q=during%20load&root=live',
]);
expect(pending.every(({ url }) => !url.includes('session%2FA'))).toBe(true);
});
@@ -1815,9 +1815,9 @@ describe('File Viewer server search', () => {
expect(secondA).not.toBe(firstA);
expect(pending.map(({ url }) => url)).toEqual([
'/api/sessions/session%2FA/files?depth=5&showHidden=false',
'/api/sessions/session%2FB/files?depth=5&showHidden=false',
'/api/sessions/session%2FA/files?depth=5&showHidden=false',
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live',
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live',
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live',
]);
pending[0].reply.resolve(response(successfulTree('stale-A.ts')));
+227
View File
@@ -0,0 +1,227 @@
/**
* @fileoverview Files panel following the agent into a git worktree (#765).
*
* The server does the walking, so what can go wrong here is the client: asking for the wrong root,
* showing a strip that does not describe the tree on screen, or reloading forever. The last one is
* why the follow compares with what the listing reported rather than with the previous update.
*
* Loaded via `vm` with a stubbed context, like file-browser-hidden.test.ts.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const panelsJs = readFileSync(resolve(PUBLIC, 'panels-ui.js'), 'utf8');
const appJs = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
const indexHtml = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
const stylesCss = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
const WORKTREE = { name: 'probe', path: '/repo/.claude/worktrees/probe' };
function element() {
const classes = new Set<string>();
return {
innerHTML: '',
textContent: '',
title: '',
hidden: true,
value: '',
classList: {
toggle: (name: string, on: boolean) => (on ? classes.add(name) : classes.delete(name)),
contains: (name: string) => classes.has(name),
},
setAttribute: () => {},
querySelectorAll: () => [],
};
}
function loadPanel() {
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, any>;
const context = vm.createContext({
CodemanApp,
console,
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
escapeHtml: (s: string) => String(s),
document: { getElementById: () => null, addEventListener: vi.fn() },
window: { addEventListener: vi.fn() },
setTimeout,
clearTimeout,
fetch: undefined as unknown,
CodemanBase: { url: (p: string) => p },
});
vm.runInContext(panelsJs, context, { filename: 'panels-ui.js' });
const ids = [
'fileBrowserPanel',
'fileBrowserTree',
'fileBrowserStatus',
'fileBrowserHiddenBtn',
'fileBrowserSearch',
'fileBrowserRoot',
'fileBrowserRootLabel',
'fileBrowserRootName',
'fileBrowserRootSwitch',
];
const elements = Object.fromEntries(ids.map((id) => [id, element()]));
elements.fileBrowserPanel.classList.toggle('visible', true);
const app = new CodemanApp();
app.$ = (id: string) => elements[id] ?? null;
app.activeSessionId = 'sess-1';
app.fileBrowserData = null;
app.fileBrowserExpandedDirs = new Set<string>();
app.fileBrowserFilter = '';
app.fileBrowserShowHidden = false;
app.getFileIcon = () => '';
app.formatFileSize = () => '';
// A stand-in for the route: the session's worktree, listed when the request asks for it.
const server = { worktree: null as typeof WORKTREE | null, requests: [] as string[] };
context.fetch = async (url: string) => {
server.requests.push(url);
const live = url.includes('root=live') && server.worktree;
const data = {
root: live ? server.worktree!.path : '/repo',
...(server.worktree && { worktree: server.worktree }),
tree: [],
totalFiles: 0,
totalDirectories: 0,
truncated: false,
...(url.includes('q=') && { mode: 'search', matches: [], matchCount: 0 }),
};
return { ok: true, json: async () => ({ success: true, data }) };
};
return { app, elements, server };
}
const strip = (elements: Record<string, ReturnType<typeof element>>) => ({
hidden: elements.fileBrowserRoot.hidden,
label: elements.fileBrowserRootLabel.textContent,
name: elements.fileBrowserRootName.textContent,
button: elements.fileBrowserRootSwitch.textContent,
title: elements.fileBrowserRoot.title,
});
describe('Files panel in a git worktree', () => {
it('asks for the live root by default', async () => {
const { app, server } = loadPanel();
await app.loadFileBrowser('sess-1');
expect(server.requests[0]).toContain('root=live');
});
it('shows no strip for a session in no worktree', async () => {
const { app, elements } = loadPanel();
await app.loadFileBrowser('sess-1');
expect(elements.fileBrowserRoot.hidden).toBe(true);
});
it('says which worktree is listed, and offers the session folder', async () => {
const { app, elements, server } = loadPanel();
server.worktree = WORKTREE;
await app.loadFileBrowser('sess-1');
expect(strip(elements)).toEqual({
hidden: false,
label: 'Worktree',
name: 'probe',
button: 'Session folder',
title: WORKTREE.path,
});
});
it('switches to the session folder and back', async () => {
const { app, elements, server } = loadPanel();
server.worktree = WORKTREE;
await app.loadFileBrowser('sess-1');
await app.toggleFileBrowserRoot();
expect(server.requests[1]).not.toContain('root=live');
expect(strip(elements)).toEqual({
hidden: false,
label: 'Session folder',
name: '',
button: 'Back to worktree',
title: '/repo',
});
await app.toggleFileBrowserRoot();
expect(server.requests[2]).toContain('root=live');
expect(elements.fileBrowserRootName.textContent).toBe('probe');
});
it('keeps the query in the box across the switch, and searches the new root', async () => {
const { app, elements, server } = loadPanel();
server.worktree = WORKTREE;
await app.loadFileBrowser('sess-1');
app._fileBrowserState.filter = 'main';
elements.fileBrowserSearch.value = 'main';
await app.toggleFileBrowserRoot();
await vi.waitFor(() => expect(server.requests.some((url) => url.includes('q=main'))).toBe(true));
const search = server.requests.find((url) => url.includes('q=main'))!;
expect(search).not.toContain('root=live');
});
it('follows the agent into a worktree while the panel is open', async () => {
const { app, elements, server } = loadPanel();
await app.loadFileBrowser('sess-1');
expect(server.requests).toHaveLength(1);
server.worktree = WORKTREE;
app._followFileBrowserWorktree({ id: 'sess-1', liveWorktree: WORKTREE });
await vi.waitFor(() => expect(server.requests).toHaveLength(2));
await vi.waitFor(() => expect(elements.fileBrowserRoot.hidden).toBe(false));
expect(elements.fileBrowserRootName.textContent).toBe('probe');
});
it('follows the agent back out, and a pin on the old worktree does not outlive it', async () => {
const { app, elements, server } = loadPanel();
server.worktree = WORKTREE;
await app.loadFileBrowser('sess-1');
await app.toggleFileBrowserRoot();
server.worktree = null;
app._followFileBrowserWorktree({ id: 'sess-1' });
await vi.waitFor(() => expect(elements.fileBrowserRoot.hidden).toBe(true));
expect(app._fileBrowserState.sessionRootPinned).toBe(false);
});
it('settles: updates that agree with the listing reload nothing', async () => {
const { app, server } = loadPanel();
server.worktree = WORKTREE;
await app.loadFileBrowser('sess-1');
app._followFileBrowserWorktree({ id: 'sess-1', liveWorktree: WORKTREE });
app._followFileBrowserWorktree({ id: 'sess-1', liveWorktree: WORKTREE });
expect(server.requests).toHaveLength(1);
});
it('leaves another session and a closed panel alone', async () => {
const { app, elements, server } = loadPanel();
await app.loadFileBrowser('sess-1');
app._followFileBrowserWorktree({ id: 'other', liveWorktree: WORKTREE });
elements.fileBrowserPanel.classList.toggle('visible', false);
app._followFileBrowserWorktree({ id: 'sess-1', liveWorktree: WORKTREE });
expect(server.requests).toHaveLength(1);
});
it('drops the pin and the strip on a session switch', async () => {
const { app, elements, server } = loadPanel();
server.worktree = WORKTREE;
await app.loadFileBrowser('sess-1');
await app.toggleFileBrowserRoot();
app._activateFileBrowserSession('sess-2');
expect(app._fileBrowserState.sessionRootPinned).toBe(false);
expect(elements.fileBrowserRoot.hidden).toBe(true);
});
it('is wired up in the page', () => {
expect(appJs).toContain('this._followFileBrowserWorktree?.(session)');
expect(indexHtml).toContain('id="fileBrowserRoot" hidden');
expect(indexHtml).toContain('onclick="app.toggleFileBrowserRoot()"');
// The strip sets `display`, so `hidden` needs its own rule or it never hides.
expect(stylesCss).toMatch(/\.file-browser-root\[hidden\]\s*\{\s*display: none;/);
});
});
+125
View File
@@ -0,0 +1,125 @@
/**
* @fileoverview `findLiveWorktree()` (#765): which linked worktree a session's pane is in.
*
* The answer moves the Files panel's listing, so what matters is what it must NOT answer: the
* session folder itself, a folder outside it, a plain subfolder (a `cd` in a shell must not re-root
* the panel), a repository or submodule nested in the folder, and a `.git` that is not a regular
* file. Runs against a real filesystem, and one case against a worktree real git made.
*/
import { execFileSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { findLiveWorktree, isLinkedWorktreeGitFile } from '../src/live-worktree.js';
describe('isLinkedWorktreeGitFile', () => {
it('accepts what git writes for a linked worktree, absolute or relative', () => {
expect(isLinkedWorktreeGitFile('gitdir: /home/me/repo/.git/worktrees/probe\n')).toBe(true);
expect(isLinkedWorktreeGitFile('gitdir: ../../../.git/worktrees/probe\n')).toBe(true);
});
it('refuses a submodule, a bare path and text that is not a gitdir line', () => {
expect(isLinkedWorktreeGitFile('gitdir: ../.git/modules/lib\n')).toBe(false);
expect(isLinkedWorktreeGitFile('gitdir: worktrees\n')).toBe(false);
expect(isLinkedWorktreeGitFile('/home/me/repo/.git/worktrees/probe\n')).toBe(false);
expect(isLinkedWorktreeGitFile('')).toBe(false);
});
});
describe('findLiveWorktree', () => {
let base: string;
let repo: string;
let worktree: string;
beforeEach(() => {
base = mkdtempSync(join(tmpdir(), 'live-worktree-'));
repo = join(base, 'repo');
worktree = join(repo, '.claude', 'worktrees', 'probe');
mkdirSync(join(repo, '.git'), { recursive: true });
mkdirSync(join(worktree, 'src', 'deep'), { recursive: true });
writeFileSync(join(worktree, '.git'), `gitdir: ${repo}/.git/worktrees/probe\n`);
});
afterEach(() => {
rmSync(base, { recursive: true, force: true });
});
it('finds the worktree from its own root and from below it', async () => {
const expected = { name: 'probe', path: worktree };
expect(await findLiveWorktree(repo, worktree)).toEqual(expected);
expect(await findLiveWorktree(repo, join(worktree, 'src', 'deep'))).toEqual(expected);
});
it('answers the same for a workingDir spelled with a trailing slash', async () => {
expect(await findLiveWorktree(`${repo}/`, worktree)).toEqual({ name: 'probe', path: worktree });
});
it('does not treat the session folder as a worktree of itself', async () => {
writeFileSync(join(worktree, 'marker'), '');
expect(await findLiveWorktree(worktree, worktree)).toBeUndefined();
expect(await findLiveWorktree(worktree, join(worktree, 'src'))).toBeUndefined();
});
it('stays on the session folder for a plain subfolder, so a shell cd moves nothing', async () => {
mkdirSync(join(repo, 'src'));
expect(await findLiveWorktree(repo, repo)).toBeUndefined();
expect(await findLiveWorktree(repo, join(repo, 'src'))).toBeUndefined();
});
it('never answers outside the session folder', async () => {
const outside = join(base, 'repo-feature');
mkdirSync(outside);
writeFileSync(join(outside, '.git'), `gitdir: ${repo}/.git/worktrees/feature\n`);
// A sibling sharing the folder's name as a prefix is the case a bare startsWith gets wrong.
expect(await findLiveWorktree(repo, outside)).toBeUndefined();
expect(await findLiveWorktree(repo, '/')).toBeUndefined();
});
it('stops at a submodule and at a repository of its own', async () => {
const submodule = join(repo, 'lib');
mkdirSync(submodule);
writeFileSync(join(submodule, '.git'), 'gitdir: ../.git/modules/lib\n');
expect(await findLiveWorktree(repo, submodule)).toBeUndefined();
const nested = join(worktree, 'vendored');
mkdirSync(join(nested, '.git'), { recursive: true });
expect(await findLiveWorktree(repo, nested)).toBeUndefined();
});
it('refuses a .git that is not a regular file', async () => {
const linked = join(repo, 'linked');
mkdirSync(linked);
symlinkSync(join(worktree, '.git'), join(linked, '.git'));
expect(await findLiveWorktree(repo, linked)).toBeUndefined();
// A FIFO would block open() for good: the test would time out rather than fail.
const piped = join(repo, 'piped');
mkdirSync(piped);
execFileSync('mkfifo', [join(piped, '.git')]);
expect(await findLiveWorktree(repo, piped)).toBeUndefined();
});
it('answers nothing without an absolute reading', async () => {
expect(await findLiveWorktree(repo, undefined)).toBeUndefined();
expect(await findLiveWorktree(repo, '')).toBeUndefined();
expect(await findLiveWorktree(repo, '.claude/worktrees/probe')).toBeUndefined();
});
it('recognises a worktree real git made', async () => {
const real = join(base, 'real');
const git = (...args: string[]) =>
execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@example.com', ...args], {
cwd: real,
stdio: 'ignore',
});
mkdirSync(real);
git('init', '-q');
git('commit', '-q', '--allow-empty', '-m', 'init');
git('worktree', 'add', '-q', '.claude/worktrees/made', '-b', 'worktree-made');
expect(await findLiveWorktree(real, join(real, '.claude', 'worktrees', 'made'))).toEqual({
name: 'made',
path: join(real, '.claude', 'worktrees', 'made'),
});
});
});
+73 -2
View File
@@ -29,11 +29,11 @@
*
* Port: ephemeral
*/
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync } from 'node:fs';
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import vm from 'node:vm';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Session, rebasePath } from '../src/session.js';
import { WebServer } from '../src/web/server.js';
import type { SessionDocker, SessionRemote } from '../src/types.js';
@@ -278,6 +278,77 @@ describe('a pane read reaching the session record', () => {
expect(session.toState().workingDir).toBe('/work/proj');
});
describe('liveWorktree (#765)', () => {
let base: string;
let repo: string;
let worktree: string;
beforeEach(() => {
base = mkdtempSync(join(tmpdir(), 'pane-live-worktree-'));
repo = join(base, 'repo');
worktree = join(repo, '.claude', 'worktrees', 'probe');
mkdirSync(worktree, { recursive: true });
writeFileSync(join(worktree, '.git'), `gitdir: ${repo}/.git/worktrees/probe\n`);
});
afterEach(() => {
rmSync(base, { recursive: true, force: true });
});
const inRepo = () => {
const { mux, sessions, web } = build();
const session = localMuxSession({ workingDir: repo });
sessions.set(session.id, session);
const broadcast = vi.fn();
(web as unknown as { broadcastSessionStateDebounced: (id: string) => void }).broadcastSessionStateDebounced =
broadcast;
mux.getPaneExit = () => undefined;
return { mux, session, web, broadcast };
};
it('publishes the worktree the pane moved into, and clears it when the pane leaves', async () => {
const { mux, session, web, broadcast } = inRepo();
let dir = worktree;
mux.getPaneCurrentPath = () => dir;
mux.emit('paneExitsUpdated');
await vi.waitFor(() => expect(session.liveWorktree).toEqual({ name: 'probe', path: worktree }));
// What the browser receives, and what the file routes read: never the confinement root.
const state = (
web as unknown as { getSessionStateWithRespawn: (s: Session) => { liveWorktree?: unknown } }
).getSessionStateWithRespawn(session);
expect(state.liveWorktree).toEqual({ name: 'probe', path: worktree });
expect(session.toState().workingDir).toBe(repo);
const broadcasts = broadcast.mock.calls.length;
dir = repo;
mux.emit('paneExitsUpdated');
await vi.waitFor(() => expect(session.liveWorktree).toBeUndefined());
expect(broadcast.mock.calls.length).toBeGreaterThan(broadcasts);
});
it('does nothing for a pane that has not moved', async () => {
const { mux, session } = inRepo();
mux.getPaneCurrentPath = () => repo;
const lookup = vi.spyOn(session, 'setLiveWorktree');
mux.emit('paneExitsUpdated');
await vi.waitFor(() => expect(lookup).toHaveBeenCalledTimes(1));
mux.emit('paneExitsUpdated');
await new Promise((resolve) => setTimeout(resolve, 20));
expect(lookup).toHaveBeenCalledTimes(1);
});
it('drops an answer for a directory the pane has already left', async () => {
const { web, session } = inRepo();
session.setPaneCwd(worktree);
const refresh = (
web as unknown as { refreshLiveWorktree: (s: Session) => Promise<void> }
).refreshLiveWorktree.call(web, session);
session.setPaneCwd(repo);
await refresh;
expect(session.liveWorktree).toBeUndefined();
});
});
it('broadcasts a directory change once, and nothing on an unchanged tick', () => {
const { mux, sessions } = build();
const session = localMuxSession();
@@ -0,0 +1,105 @@
/**
* @fileoverview `GET /api/sessions/:id/files?root=live` (#765).
*
* The listing starts at the worktree the pane is in, and every `path` stays relative to
* `workingDir`: that is what keeps the preview, raw and edit routes working unchanged, inside the
* same confinement. Real filesystem on purpose, since the walk is the thing under test.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
const PREFIX = '.claude/worktrees/probe';
describe('Files panel listing of a live worktree', () => {
let harness: RouteTestHarness;
let repo: string;
let worktree: string;
const list = async (query = '') => {
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/files?depth=5${query}`,
});
expect(res.statusCode).toBe(200);
return res.json().data;
};
const names = (nodes: Array<{ name: string }>) => nodes.map((node) => node.name);
beforeEach(async () => {
harness = await createRouteTestHarness(registerFileRoutes);
repo = mkdtempSync(join(homedir(), 'live-worktree-route-'));
worktree = join(repo, PREFIX);
mkdirSync(join(repo, 'src'), { recursive: true });
mkdirSync(join(worktree, 'src'), { recursive: true });
writeFileSync(join(repo, 'src', 'main-only.ts'), 'main');
writeFileSync(join(worktree, 'src', 'worktree-only.ts'), 'worktree');
Object.assign(harness.ctx._session, {
workingDir: repo,
liveWorktree: { name: 'probe', path: worktree },
});
});
afterEach(async () => {
await harness.app.close();
rmSync(repo, { recursive: true, force: true });
});
it('lists the worktree with paths relative to the session folder', async () => {
const data = await list('&root=live');
expect(data.root).toBe(worktree);
expect(data.worktree).toEqual({ name: 'probe', path: worktree });
expect(names(data.tree)).toEqual(['src']);
expect(data.tree[0].path).toBe(`${PREFIX}/src`);
expect(data.tree[0].children[0].path).toBe(`${PREFIX}/src/worktree-only.ts`);
});
it('does not need hidden files on, though the worktree lives under a dot folder', async () => {
const data = await list('&root=live&showHidden=false');
expect(data.totalFiles).toBe(1);
});
it('keeps the session folder without the parameter, and still reports the worktree', async () => {
const data = await list();
expect(data.root).toBe(repo);
expect(data.worktree).toEqual({ name: 'probe', path: worktree });
expect(names(data.tree)).toEqual(['src']);
expect(data.tree[0].children[0].path).toBe('src/main-only.ts');
});
it('answers exactly as before for a session in no worktree', async () => {
Object.assign(harness.ctx._session, { liveWorktree: undefined });
const data = await list('&root=live');
expect(data).toEqual(await list());
expect(data.root).toBe(repo);
expect('worktree' in data).toBe(false);
});
it('falls back to the session folder when the worktree has gone, keeping the report', async () => {
rmSync(worktree, { recursive: true });
const data = await list('&root=live');
expect(data.root).toBe(repo);
// Kept so the panel's comparison with the session's state settles instead of reloading forever.
expect(data.worktree).toEqual({ name: 'probe', path: worktree });
});
it('searches the worktree: the query reads paths in the listing, the result in the session folder', async () => {
const data = await list(`&root=live&q=${encodeURIComponent('src/*.ts')}`);
expect(data.root).toBe(worktree);
expect(data.matches.map((match: { path: string }) => match.path)).toEqual([`${PREFIX}/src/worktree-only.ts`]);
expect(data.worktree).toEqual({ name: 'probe', path: worktree });
});
it('serves a listed path through the unchanged preview route', async () => {
const path = `${PREFIX}/src/worktree-only.ts`;
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=${encodeURIComponent(path)}`,
});
expect(res.json().success).toBe(true);
expect(res.json().data.content).toBe('worktree');
});
});