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
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* @fileoverview Which linked git worktree a session's pane is in.
|
||||
*
|
||||
* Claude Code's worktree feature moves the running `claude` process into
|
||||
* `<repo>/.claude/worktrees/<name>` part way through a session. `Session.workingDir` is the
|
||||
* session's identity and stays on the main checkout, so the Files panel kept listing a tree the
|
||||
* agent had left (#765). `Session.paneCwd` does follow: tmux reads the process cwd, measured with
|
||||
* Claude Code 2.1.318 going into a worktree on EnterWorktree and back on ExitWorktree. It also
|
||||
* follows every `cd` in a shell session, so it cannot be the panel's root by itself. This module
|
||||
* answers the narrower question: is the pane inside a linked worktree, and which one.
|
||||
*
|
||||
* A linked worktree is recognised by its `.git` FILE (`gitdir: <repo>/.git/worktrees/<id>`) rather
|
||||
* than by running `git worktree list`: this runs from the pane watcher whenever a directory
|
||||
* changes, and a file read starts no process, no repository config and no filter.
|
||||
*
|
||||
* Only a folder strictly below `workingDir` is ever an answer: the file routes already serve
|
||||
* everything under it, so following a pane there widens nothing, and a planted `.git` file can
|
||||
* only move where the listing STARTS inside that same folder. A worktree outside it
|
||||
* (`git worktree add ../feature`) stays unfollowed, since listing it would need a second
|
||||
* confinement root.
|
||||
*
|
||||
* That answer is lexical and only as fresh as the last time the pane moved: a symlink can take the
|
||||
* folder's place afterwards without moving the pane. So nothing here is a confinement decision.
|
||||
* `GET /api/sessions/:id/files` resolves the folder on every request and walks it only while it
|
||||
* really lies inside `workingDir` (`canWalkLiveWorktree()` in file-routes.ts), and the routes keep
|
||||
* judging every path against `workingDir`.
|
||||
*
|
||||
* Probes are bounded (#516). The folder can sit on a mount that stopped answering, and an
|
||||
* `unknown` answer stops the walk rather than being read as "no `.git` here".
|
||||
*
|
||||
* @module live-worktree
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import { probePathKind } from './utils/bounded-path-probe.js';
|
||||
|
||||
/** A linked worktree a session's pane is in. */
|
||||
export interface LiveWorktree {
|
||||
/** The worktree folder's name, for display. */
|
||||
name: string;
|
||||
/** Absolute path, spelled like the session's `workingDir`. */
|
||||
path: string;
|
||||
}
|
||||
|
||||
/** More than any real `.git` file. It is read only to recognise the first line. */
|
||||
const MAX_DOT_GIT_FILE_BYTES = 4096;
|
||||
|
||||
/**
|
||||
* Whether the text of a `.git` file names a linked worktree's git dir
|
||||
* (`<common dir>/worktrees/<id>`), as opposed to a submodule's (`<common dir>/modules/<name>`).
|
||||
* The path may be relative: `git worktree add --relative-paths` writes it that way.
|
||||
*/
|
||||
export function isLinkedWorktreeGitFile(text: string): boolean {
|
||||
const gitDir = /^gitdir:[ \t]*(.+?)[ \t]*$/m.exec(text)?.[1];
|
||||
if (!gitDir) return false;
|
||||
const segments = gitDir.split('/').filter(Boolean);
|
||||
return segments.length >= 2 && segments[segments.length - 2] === 'worktrees';
|
||||
}
|
||||
|
||||
/** What `<dir>/.git` says: a linked worktree's root, a boundary the walk must stop at, or nothing. */
|
||||
async function classifyDotGit(dir: string): Promise<'worktree' | 'stop' | 'none'> {
|
||||
const dotGit = join(dir, '.git');
|
||||
const kind = await probePathKind(dotGit);
|
||||
if (kind === 'absent') return 'none';
|
||||
// A `.git` directory is a repository of its own, and `unknown` is never read as absent.
|
||||
if (kind !== 'file') return 'stop';
|
||||
let handle: fs.FileHandle | undefined;
|
||||
try {
|
||||
// lstat, so a `.git` that is a symlink or a FIFO is refused before anything opens it:
|
||||
// opening a FIFO waits for a writer and would hold a threadpool worker for good.
|
||||
if (!(await fs.lstat(dotGit)).isFile()) return 'stop';
|
||||
handle = await fs.open(dotGit, 'r');
|
||||
const { buffer, bytesRead } = await handle.read(Buffer.alloc(MAX_DOT_GIT_FILE_BYTES), 0, MAX_DOT_GIT_FILE_BYTES, 0);
|
||||
return isLinkedWorktreeGitFile(buffer.toString('utf8', 0, bytesRead)) ? 'worktree' : 'stop';
|
||||
} catch {
|
||||
return 'stop';
|
||||
} finally {
|
||||
await handle?.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The linked worktree strictly below `workingDir` that holds `cwd`, if any. Walks up from `cwd`
|
||||
* and stops at `workingDir`, at a repository or submodule of its own, or at anything it cannot
|
||||
* read. `workingDir` itself is never an answer: a session started in a worktree is already
|
||||
* listing it. Never throws.
|
||||
*/
|
||||
export async function findLiveWorktree(workingDir: string, cwd: string | undefined): Promise<LiveWorktree | undefined> {
|
||||
if (!cwd?.startsWith('/')) return undefined;
|
||||
const root = workingDir.length > 1 ? workingDir.replace(/\/+$/, '') : workingDir;
|
||||
const under = root === '/' ? '/' : `${root}/`;
|
||||
for (let dir = cwd; dir !== root && dir.startsWith(under); dir = dirname(dir)) {
|
||||
const found = await classifyDotGit(dir);
|
||||
if (found === 'worktree') return { name: basename(dir), path: dir };
|
||||
if (found === 'stop') return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -73,6 +73,7 @@ import type { GitFetchStatus } from './git-fetch.js';
|
||||
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
|
||||
import { claudeTranscriptExists } from './utils/claude-transcript.js';
|
||||
import { probePathKind } from './utils/bounded-path-probe.js';
|
||||
import type { LiveWorktree } from './live-worktree.js';
|
||||
import { PATH_PROBE_TIMEOUT_MS, WORKSPACE_REALPATH_RETRY_MS } from './config/path-probe.js';
|
||||
import { matchesPattern } from './config/cli-registry/patterns.js';
|
||||
import { probeDockerCliVersion } from './docker-hosts.js';
|
||||
@@ -766,6 +767,8 @@ export class Session extends EventEmitter {
|
||||
* serialized by toState() but never restored by the constructor.
|
||||
*/
|
||||
private _paneCwd: string | undefined;
|
||||
/** See {@link liveWorktree}. */
|
||||
private _liveWorktree: LiveWorktree | undefined;
|
||||
/**
|
||||
* `workingDir` with symlinks resolved, so a pane reading (tmux reads the
|
||||
* process cwd, always the resolved path) can be put back into `workingDir`'s
|
||||
@@ -1576,6 +1579,23 @@ export class Session extends EventEmitter {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The linked git worktree inside `workingDir` that {@link paneCwd} is in, when there is one
|
||||
* (`live-worktree.ts`). The Files panel lists it instead of the main checkout. Runtime-only and
|
||||
* never a confinement root, like `paneCwd`: it is judged lexically when the pane moves, and the
|
||||
* listing route re-checks the real folder on every request.
|
||||
*/
|
||||
get liveWorktree(): LiveWorktree | undefined {
|
||||
return this._liveWorktree;
|
||||
}
|
||||
|
||||
/** Record the result of a worktree lookup. Returns whether it CHANGED, so the caller broadcasts only then. */
|
||||
setLiveWorktree(next: LiveWorktree | undefined): boolean {
|
||||
if (next?.path === this._liveWorktree?.path) return false;
|
||||
this._liveWorktree = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** `workingDir` without a trailing separator (the root stays `/`). */
|
||||
private _workspaceSpelling(): string {
|
||||
return this.workingDir.length > 1 ? this.workingDir.replace(/\/+$/, '') : this.workingDir;
|
||||
@@ -2300,6 +2320,7 @@ export class Session extends EventEmitter {
|
||||
status: this._status,
|
||||
workingDir: this.workingDir,
|
||||
paneCwd: this._paneCwd,
|
||||
liveWorktree: this._liveWorktree,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
owner: this._owner,
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
import type { RespawnConfig } from './respawn.js';
|
||||
import type { AttachmentDetectedType } from './tools.js';
|
||||
import type { GitFetchStatus } from '../git-fetch.js';
|
||||
import type { LiveWorktree } from '../live-worktree.js';
|
||||
|
||||
/** Status of a Claude session */
|
||||
export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error';
|
||||
@@ -847,6 +848,16 @@ export interface SessionState {
|
||||
* routes like any other out-of-workspace path.
|
||||
*/
|
||||
paneCwd?: string;
|
||||
/**
|
||||
* The linked git worktree inside `workingDir` that the pane is in NOW, when there is one:
|
||||
* Claude Code's `.claude/worktrees/<name>`, or any other worktree of the repository under
|
||||
* the session's folder. Derived from {@link paneCwd} by `live-worktree.ts`, so it is
|
||||
* runtime-only and absent wherever `paneCwd` is, and absent for a worktree outside
|
||||
* `workingDir`: the file routes never serve those. NOT a confinement root; the Files panel
|
||||
* only starts its listing here, after `GET .../files` has checked that the real folder still
|
||||
* lies inside `workingDir`, since a symlink can replace it without moving the pane.
|
||||
*/
|
||||
liveWorktree?: LiveWorktree;
|
||||
/** Remote execution metadata, present when this session runs over SSH through local tmux */
|
||||
remote?: SessionRemote;
|
||||
/** Docker execution metadata, present when this session runs inside a container via local tmux + docker exec */
|
||||
|
||||
@@ -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