mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
Merge branch 'relprep/pr-772' into relprep/pr-614
Both sides rewrite the /files walk. Keep the fair rounds from #614 and carry the two things #772 does to the walk onto them: both queues start at walkRoot, and the search predicate reads a path relative to the listing while every returned path stays relative to workingDir. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
bb42f8105e
21 files changed
+1002
-36
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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': '最近一小时',
|
||||
|
||||
@@ -617,6 +617,11 @@
|
||||
<button class="btn-icon-sm" onclick="app.closeFileBrowserPanel()" title="Close">×</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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ?? '');
|
||||
@@ -1654,7 +1689,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
index: number;
|
||||
}
|
||||
|
||||
const queue: SearchDirectoryCursor[] = [{ dirPath: workingDir, depth: 1, index: 0 }];
|
||||
const queue: SearchDirectoryCursor[] = [{ dirPath: walkRoot, depth: 1, index: 0 }];
|
||||
while (queue.length > 0 && totalFiles + totalDirectories < maxFiles && matches.length < maxMatches) {
|
||||
// Give each discovered directory a slice before returning to a large sibling.
|
||||
const directoriesInRound = queue.length;
|
||||
@@ -1692,17 +1727,18 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
|
||||
const fullPath = join(cursor.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' });
|
||||
}
|
||||
// Queue siblings together so one large subtree cannot starve later branches.
|
||||
queue.push({ dirPath: fullPath, depth: cursor.depth + 1, index: 0 });
|
||||
} else {
|
||||
totalFiles++;
|
||||
if (matcher(entry.name, relativePath)) {
|
||||
if (matcher(entry.name, listedPath)) {
|
||||
let size: number | undefined;
|
||||
try {
|
||||
size = (await fs.stat(fullPath)).size;
|
||||
@@ -1728,7 +1764,8 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
root: workingDir,
|
||||
root: walkRoot,
|
||||
...(liveWorktree && { worktree: liveWorktree }),
|
||||
tree: [],
|
||||
matches,
|
||||
totalFiles,
|
||||
@@ -1750,7 +1787,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
}
|
||||
|
||||
const tree: FileTreeNode[] = [];
|
||||
const queue: TreeDirectoryCursor[] = [{ dirPath: workingDir, depth: 1, nodes: tree, index: 0 }];
|
||||
const queue: TreeDirectoryCursor[] = [{ dirPath: walkRoot, depth: 1, nodes: tree, index: 0 }];
|
||||
while (queue.length > 0 && totalFiles + totalDirectories < maxFiles) {
|
||||
const directoriesInRound = queue.length;
|
||||
const nodesPerDirectory = Math.max(
|
||||
@@ -1818,7 +1855,8 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
root: workingDir,
|
||||
root: walkRoot,
|
||||
...(liveWorktree && { worktree: liveWorktree }),
|
||||
tree,
|
||||
totalFiles,
|
||||
totalDirectories,
|
||||
|
||||
@@ -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';
|
||||
@@ -2967,6 +2968,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) {
|
||||
@@ -2977,6 +2979,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
|
||||
|
||||
Reference in new issue
Block a user