feat(terminal): resolve relative path links against the pane's current directory

A relative path printed in the terminal was resolved against the session's
workingDir, which is fixed at create time and never follows a cd. In a shell
that moved into a subdirectory or a sibling repo, the link opened the wrong
file or reported "File not found".

The pane-exit watcher's batched list-panes read already carries
#{pane_current_path}. derivePaneCurrentPaths() turns those rows into one
directory per single-pane session, TmuxManager.getPaneCurrentPath() serves it
from memory, and the server applies it on the existing paneExitsUpdated tick
as SessionState.cwd, broadcasting only on a change. No new tmux call, no new
SSE event. A failed read keeps the previous answers.

Session.setCwd() uses the paneExit scoping rule: remote and Docker sessions
never get a cwd (their local pane runs the ssh or docker exec client), nor do
direct-PTY or rediscovered sessions. The value is runtime-only.

In the browser, relativeResolutionBase() picks the live cwd when known and
workingDir otherwise. It only changes the base a relative path joins: the
result is routed as before, so a target outside the workspace still goes
through the attachment routes, and file-content/file-raw are unchanged.
This commit is contained in:
Aamer Akhter committed 2026-10-28 09:38:06 -04:00
1 parent f1f7eec4d6
commit 1607b1e488
12 files changed
+543 -8

No files matched your search

+26
View File
@@ -2397,6 +2397,31 @@ function resolveLinkedFilePath(filePath, workingDir) {
return `/${parts.join('/')}`;
}
/**
* The directory a RELATIVE path printed for a session is relative TO.
*
* `workingDir` is recorded once at create time and never follows a `cd`, so it
* stops describing where a shell (or an agent that changed directory) really
* is. `session.cwd` is the pane's live `#{pane_current_path}`, published by the
* server from its pane read. It only changes which BASE a relative path joins;
* the result is then routed like any other path, so a target outside the
* workspace still goes through the attachment routes.
*
* Remote and Docker sessions are excluded (the server never sends them a cwd,
* and this checks again): their local pane runs the ssh / docker-exec CLIENT,
* so its directory says nothing about the agent. An unknown cwd falls back to
* `workingDir`.
*
* @param {{workingDir?: string, cwd?: string, remote?: unknown, docker?: unknown}} [session]
* @returns {string|undefined}
*/
function relativeResolutionBase(session) {
if (!session) return undefined;
const cwd = session.cwd;
if (!session.remote && !session.docker && typeof cwd === 'string' && cwd.startsWith('/')) return cwd;
return session.workingDir;
}
/**
* Extensions the file preview renders as a PAGE, from the sandboxed
* `/html-view/<cap>/` route (src/web/routes/html-view-routes.ts, whose
@@ -4025,6 +4050,7 @@ if (typeof window !== 'undefined') {
absoluteFilePathPattern,
findFilePathLinks,
resolveLinkedFilePath,
relativeResolutionBase,
previewsInFileViewer,
FILE_PREVIEW_EXTENSIONS,
rendersAsPage,
+5 -2
View File
@@ -2146,13 +2146,16 @@ Object.assign(CodemanApp.prototype, {
/**
* A clicked path as the preview needs it: relative paths resolved against the
* session's working directory, absolute and `~/` paths unchanged
* directory the session's pane is in now, else its working directory
* (`relativeResolutionBase`), absolute and `~/` paths unchanged
* (`resolveLinkedFilePath`, constants.js). Shared by the terminal link
* provider and the response viewer, so one path opens the same file in both.
*/
_resolveLinkedFilePath(filePath, sessionId) {
if (typeof resolveLinkedFilePath !== 'function') return filePath;
return resolveLinkedFilePath(filePath, this.sessions?.get?.(sessionId)?.workingDir);
const session = this.sessions?.get?.(sessionId);
const base = typeof relativeResolutionBase === 'function' ? relativeResolutionBase(session) : session?.workingDir;
return resolveLinkedFilePath(filePath, base);
},
/**
+11 -2
View File
@@ -2812,13 +2812,22 @@ export class WebServer extends EventEmitter {
private applyPaneExits(): void {
const getPaneExit = this.mux.getPaneExit?.bind(this.mux);
if (!getPaneExit) return;
const getPaneCurrentPath = this.mux.getPaneCurrentPath?.bind(this.mux);
for (const session of this.sessions.values()) {
const muxName = session.muxName;
// No pane, so nothing to report — and `setPaneExit()` would force UNKNOWN
// for such a session anyway.
if (!muxName) continue;
if (!session.setPaneExit(getPaneExit(muxName))) continue;
this.persistSessionState(session);
// The pane's live directory rides the same read: it is the base a relative
// path printed in the terminal resolves against. A session missing from
// the read keeps its last answer (a failed read reports nothing).
const cwd = getPaneCurrentPath?.(muxName);
const cwdChanged = cwd !== undefined && session.setCwd(cwd);
if (session.setPaneExit(getPaneExit(muxName))) {
this.persistSessionState(session);
} else if (!cwdChanged) {
continue;
}
this.broadcastSessionStateDebounced(session.id);
}
this.closeCleanlyExitedSessions();