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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 05:01:41 +01:00
commit f8832ec78f
21 files changed
+1002 -36

No files matched your search

+2
View File
@@ -2458,6 +2458,8 @@ class CodemanApp {
}
// cliMouseTracking (Claude's fullscreen mode) ends a keyboard row hold.
if (session.id === this.activeSessionId) this._reconcileKeyboardHold?.();
// The agent moved into or out of a git worktree while the Files panel is open (#765).
if (session.id === this.activeSessionId) this._followFileBrowserWorktree?.(session);
// Update parentSessionName for any subagents belonging to this session
// (fixes stale name display after session rename)
this.updateSubagentParentNames(session.id);
+3
View File
@@ -1133,6 +1133,9 @@
'Filter by date range': '按日期范围筛选',
'Filter by session status': '按会话状态筛选',
'Filter files...': '筛选文件…',
Worktree: '工作树',
'Session folder': '会话文件夹',
'Back to worktree': '返回工作树',
'Any status': '任意状态',
'Any time': '任意时间',
'Last hour': '最近一小时',
+5
View File
@@ -617,6 +617,11 @@
<button class="btn-icon-sm" onclick="app.closeFileBrowserPanel()" title="Close">&times;</button>
</div>
</div>
<div class="file-browser-root" id="fileBrowserRoot" hidden>
<span class="file-browser-root-label" id="fileBrowserRootLabel"></span>
<span class="file-browser-root-name" id="fileBrowserRootName" data-i18n-skip></span>
<button type="button" class="file-browser-root-switch" id="fileBrowserRootSwitch" onclick="app.toggleFileBrowserRoot()"></button>
</div>
<div class="file-browser-search">
<input type="text" id="fileBrowserSearch" placeholder="Filter files..." oninput="app.filterFileBrowser(this.value)">
</div>
+72 -5
View File
@@ -3282,6 +3282,8 @@ Object.assign(CodemanApp.prototype, {
matches: [],
deferredDirectoryTarget: null,
filter: typeof this.fileBrowserFilter === 'string' ? this.fileBrowserFilter : '',
// The person asked for the session folder while the agent is in a worktree (#765).
sessionRootPinned: false,
};
}
return this._fileBrowserState;
@@ -3304,6 +3306,7 @@ Object.assign(CodemanApp.prototype, {
state.deferredDirectoryTarget = null;
state.filter = '';
state.view = 'normal';
state.sessionRootPinned = false;
this.fileBrowserData = null;
this.fileBrowserFilter = '';
this.fileBrowserExpandedDirs?.clear?.();
@@ -3314,6 +3317,7 @@ Object.assign(CodemanApp.prototype, {
this._syncFileBrowserExpandBtn();
const expandBtn = this.$?.('fileBrowserExpandBtn');
if (expandBtn) expandBtn.innerHTML = '\u229E';
this._renderFileBrowserRoot(null);
const panel = this.$?.('fileBrowserPanel');
const treeEl = this.$?.('fileBrowserTree');
@@ -3346,6 +3350,7 @@ Object.assign(CodemanApp.prototype, {
state.deferredDirectoryTarget = null;
state.filter = '';
state.view = 'normal';
state.sessionRootPinned = false;
this.fileBrowserData = null;
this.fileBrowserFilter = '';
this.fileBrowserExpandedDirs?.clear?.();
@@ -3356,6 +3361,7 @@ Object.assign(CodemanApp.prototype, {
this._syncFileBrowserExpandBtn();
const expandBtn = this.$?.('fileBrowserExpandBtn');
if (expandBtn) expandBtn.innerHTML = '\u229E';
this._renderFileBrowserRoot(null);
const treeEl = this.$?.('fileBrowserTree');
if (treeEl) treeEl.innerHTML = '';
const statusEl = this.$?.('fileBrowserStatus');
@@ -3448,6 +3454,60 @@ Object.assign(CodemanApp.prototype, {
this._syncFileBrowserExpandBtn();
this.renderFileBrowserTree(normalState.sessionId);
this._renderFileBrowserNormalStatus(normalState.data, normalState.showHidden);
this._renderFileBrowserRoot(normalState.data);
},
// The strip under the panel header, present only while the session's pane is in a linked
// worktree (#765): it says which of the two folders is listed and switches between them.
// Everything here comes from GET .../files, so the strip describes the tree on screen rather
// than the session's state a moment later.
_renderFileBrowserRoot(data) {
const strip = this.$?.('fileBrowserRoot');
if (!strip) return;
const worktree = data?.worktree;
if (typeof worktree?.path !== 'string' || typeof worktree?.name !== 'string') {
strip.hidden = true;
return;
}
const inWorktree = data.root === worktree.path;
const label = this.$?.('fileBrowserRootLabel');
const name = this.$?.('fileBrowserRootName');
const button = this.$?.('fileBrowserRootSwitch');
if (label) label.textContent = inWorktree ? 'Worktree' : 'Session folder';
if (name) name.textContent = inWorktree ? worktree.name : '';
if (button) button.textContent = inWorktree ? 'Session folder' : 'Back to worktree';
strip.title = typeof data.root === 'string' ? data.root : '';
strip.hidden = false;
},
// The `root` parameter of a listing or search. The server ignores it while the pane is not in
// a worktree, so the panel always asks unless the person chose the session folder.
_fileBrowserRootParam() {
return this._ensureFileBrowserState().sessionRootPinned ? '' : '&root=live';
},
toggleFileBrowserRoot() {
const state = this._ensureFileBrowserState();
state.sessionRootPinned = !state.sessionRootPinned;
return this._reloadFileBrowserKeepingQuery();
},
// Called on every session update of the active session. Compares with what the listing on
// screen reported rather than with the previous update, so a load already in flight when the
// pane moved is corrected by the next update, and an answer that matches ends it: a listing
// always reports the session's worktree, whichever folder it walked, so this cannot loop.
_followFileBrowserWorktree(session) {
const state = this._fileBrowserState;
if (!state || state.ownerSessionId !== session.id) return;
if (this.$?.('fileBrowserPanel')?.classList.contains('visible') !== true) return;
const normal = state.normalState;
if (normal?.phase !== 'ready' || normal.sessionId !== session.id) return;
const live = session.liveWorktree?.path ?? null;
const listed = normal.data?.worktree?.path ?? null;
if (live === listed) return;
// The pin was about the worktree the agent has just left.
state.sessionRootPinned = false;
this._reloadFileBrowserKeepingQuery();
},
_validateFileBrowserTreeEnvelope(result) {
@@ -3552,14 +3612,20 @@ Object.assign(CodemanApp.prototype, {
},
async toggleFileBrowserHidden() {
const state = this._ensureFileBrowserState();
const rawInput = typeof state.filter === 'string' ? state.filter : '';
const query = rawInput.trim();
this.fileBrowserShowHidden = !this.fileBrowserShowHidden;
try {
localStorage.setItem(FILE_BROWSER_SHOW_HIDDEN_KEY, this.fileBrowserShowHidden ? '1' : '0');
} catch {}
this._syncFileBrowserHiddenBtn();
return this._reloadFileBrowserKeepingQuery();
},
// Re-fetch the tree after something the server filters on changed (hidden files, the listing's
// root), then re-run the search in the box against the new tree.
async _reloadFileBrowserKeepingQuery() {
const state = this._ensureFileBrowserState();
const rawInput = typeof state.filter === 'string' ? state.filter : '';
const query = rawInput.trim();
if (state.inFlight?.timer !== undefined && state.inFlight?.timer !== null) {
clearTimeout(state.inFlight.timer);
@@ -3633,7 +3699,7 @@ Object.assign(CodemanApp.prototype, {
const request = (async () => {
try {
const res = await fetch(
`/api/sessions/${encodeURIComponent(sessionId)}/files?depth=5&showHidden=${showHidden}`,
`/api/sessions/${encodeURIComponent(sessionId)}/files?depth=5&showHidden=${showHidden}${this._fileBrowserRootParam()}`,
);
if (!res.ok) throw new Error('Failed to load files');
const result = await res.json();
@@ -3820,6 +3886,7 @@ Object.assign(CodemanApp.prototype, {
showHidden: this.fileBrowserShowHidden === true,
rawInput,
query,
rootParam: this._fileBrowserRootParam(),
timer: null,
};
state.view = 'search-pending';
@@ -3834,7 +3901,7 @@ Object.assign(CodemanApp.prototype, {
request.timer = null;
try {
const res = await fetch(
`/api/sessions/${encodeURIComponent(ownerSessionId)}/files?depth=5&showHidden=${request.showHidden}&q=${encodeURIComponent(query)}`,
`/api/sessions/${encodeURIComponent(ownerSessionId)}/files?depth=5&showHidden=${request.showHidden}&q=${encodeURIComponent(query)}${request.rootParam}`,
);
if (!res.ok) throw new Error('Search failed');
const result = await res.json();
+39
View File
@@ -12207,6 +12207,45 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
background: var(--bg-hover);
}
.file-browser-root {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.25rem 0.5rem;
border-bottom: 1px solid var(--border);
font-size: 0.7rem;
color: var(--text-dim);
}
.file-browser-root[hidden] {
display: none;
}
.file-browser-root-name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text);
}
.file-browser-root-switch {
flex: 0 0 auto;
padding: 0.1rem 0.4rem;
border: 1px solid var(--border);
border-radius: 4px;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
.file-browser-root-switch:hover {
color: var(--text);
border-color: var(--text-dim);
}
.file-browser-search {
padding: 0.4rem;
border-bottom: 1px solid var(--border);
+45 -7
View File
@@ -782,6 +782,26 @@ export function isPathWithinRoot(root: string, candidate: string): boolean {
return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`));
}
/**
* Whether a `root=live` listing may start at a session's `liveWorktree` (#765). `findLiveWorktree()`
* judged that path lexically when the pane moved, and a symlink can take its place, or a parent's,
* without moving the pane. The probe and `readdir` both follow one, and the walk's `path`s only look
* relative to `workingDir`, so this is the request-time half of the rule, the same one
* `validateSessionFilePath()` applies to a file: resolved, the worktree must lie strictly inside the
* resolved `workingDir`. Only the CHECK uses the resolved paths: the walk keeps the session's spelling,
* or a workspace reached through a symlink would stop producing paths relative to `workingDir`.
* Probed first (#516); both realpaths then walk the components that probe has just answered for.
*/
async function canWalkLiveWorktree(workingDir: string, worktreePath: string): Promise<boolean> {
if ((await probePathKind(worktreePath, { pastCap: true })) !== 'directory') return false;
try {
const [realRoot, realWorktree] = await Promise.all([fs.realpath(workingDir), fs.realpath(worktreePath)]);
return realWorktree !== realRoot && isPathWithinRoot(realRoot, realWorktree);
} catch {
return false;
}
}
function findMatchingPickerRoot(roots: FilesystemBrowseRoot[], candidate: string): FilesystemBrowseRoot | undefined {
return roots
.filter((root) => isPathWithinRoot(root.path, candidate))
@@ -1595,12 +1615,27 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
// File tree listing
app.get('/api/sessions/:id/files', async (req) => {
const { id } = req.params as { id: string };
const { depth, showHidden, q } = req.query as { depth?: string; showHidden?: string; q?: string };
const { depth, showHidden, q, root } = req.query as {
depth?: string;
showHidden?: string;
q?: string;
root?: string;
};
const session = findSessionOrFail(ctx, id, req);
const maxDepth = Math.min(parseInt(depth || '5', 10), 10);
const includeHidden = showHidden === 'true';
const workingDir = session.workingDir;
// `root=live` starts the walk at the linked worktree the pane is in (#765), once that folder
// proves to be inside `workingDir` right now, and every `path` below stays relative to
// `workingDir`, so file-content, file-raw and the edit route need nothing new and judge these
// paths against the same root as any other. Only the search predicate reads a path relative
// to the listing, since that is the tree the person is looking at.
const liveWorktree = session.liveWorktree;
const walkRoot =
root === 'live' && liveWorktree && (await canWalkLiveWorktree(workingDir, liveWorktree.path))
? liveWorktree.path
: workingDir;
// null for an empty/whitespace query, which is what keeps the default
// tree response byte-identical when no search is requested.
const matcher = compileFileQuery(q ?? '');
@@ -1676,17 +1711,18 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
const fullPath = join(dirPath, entry.name);
const relativePath = relative(workingDir, fullPath);
const listedPath = relative(walkRoot, fullPath);
if (entry.isDirectory()) {
totalDirectories++;
if (matcher(entry.name, relativePath)) {
if (matcher(entry.name, listedPath)) {
matches.push({ name: entry.name, path: relativePath, type: 'directory' });
}
// Always recurse, even when this directory does not match.
await searchDirectory(fullPath, currentDepth + 1);
} else {
totalFiles++;
if (matcher(entry.name, relativePath)) {
if (matcher(entry.name, listedPath)) {
let size: number | undefined;
try {
size = (await fs.stat(fullPath)).size;
@@ -1705,12 +1741,13 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
}
};
await searchDirectory(workingDir, 1);
await searchDirectory(walkRoot, 1);
return {
success: true,
data: {
root: workingDir,
root: walkRoot,
...(liveWorktree && { worktree: liveWorktree }),
tree: [],
matches,
totalFiles,
@@ -1791,12 +1828,13 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
}
};
const tree = await scanDirectory(workingDir, 1);
const tree = await scanDirectory(walkRoot, 1);
return {
success: true,
data: {
root: workingDir,
root: walkRoot,
...(liveWorktree && { worktree: liveWorktree }),
tree,
totalFiles,
totalDirectories,
+14
View File
@@ -35,6 +35,7 @@ import fastifyWebsocket from '@fastify/websocket';
import fastifyMultipart from '@fastify/multipart';
import { pasteImageDirInUseByOtherSession, startPasteImageGc, uploadDirs } from './paste-image-gc.js';
import { CLEAN_EXIT_CLOSE_REASON, shouldCloseCleanlyExitedSession } from '../pane-exit-sweep.js';
import { findLiveWorktree } from '../live-worktree.js';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync, mkdirSync, readFileSync, chmodSync, statSync } from 'node:fs';
@@ -2980,6 +2981,7 @@ export class WebServer extends EventEmitter {
// `undefined` here means an authoritative read listed no single directory
// for the session (split into several panes, or gone from the socket).
const paneCwdChanged = getPaneCurrentPath ? session.setPaneCwd(getPaneCurrentPath(muxName)) : false;
if (paneCwdChanged) void this.refreshLiveWorktree(session);
if (session.setPaneExit(getPaneExit(muxName))) {
this.persistSessionState(session);
} else if (!paneCwdChanged) {
@@ -2990,6 +2992,18 @@ export class WebServer extends EventEmitter {
this.closeCleanlyExitedSessions();
}
/**
* Work out which linked worktree a session's pane moved into (#765). In the background, since
* it reads the folder, and only after the directory changed, so an idle pane costs nothing.
* A reading newer than the one this lookup started from owns the answer.
*/
private async refreshLiveWorktree(session: Session): Promise<void> {
const cwd = session.paneCwd;
const worktree = await findLiveWorktree(session.workingDir, cwd);
if (session.paneCwd !== cwd) return;
if (session.setLiveWorktree(worktree)) this.broadcastSessionStateDebounced(session.id);
}
/**
* Close every session whose agent exited cleanly, through the same
* `cleanupSession()` the X button uses (Ark0N/Codeman#446). A pinned session