mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
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:
1 parent
f1f7eec4d6
commit
1607b1e488
12 files changed
+543
-8
No files matched your search
+11
-1
@@ -271,7 +271,7 @@ export interface PaneCaptureOptions {
|
||||
* - `sessionKilled` (data: { sessionId: string }) - Session terminated
|
||||
* - `sessionDied` (data: { sessionId: string }) - Session died unexpectedly
|
||||
* - `statsUpdated` (sessions: MuxSessionWithStats[]) - Stats refreshed
|
||||
* - `paneExitsUpdated` () - A pane read finished; ask `getPaneExit()` per session
|
||||
* - `paneExitsUpdated` () - A pane read finished; ask `getPaneExit()` (and `getPaneCurrentPath()`) per session
|
||||
*/
|
||||
export interface TerminalMultiplexer extends EventEmitter {
|
||||
/** Which backend this instance uses */
|
||||
@@ -396,6 +396,16 @@ export interface TerminalMultiplexer extends EventEmitter {
|
||||
*/
|
||||
getPaneExit?(muxName: string): PaneExit | undefined;
|
||||
|
||||
/**
|
||||
* The directory the session's pane was in at the last pane read
|
||||
* (`#{pane_current_path}`), or `undefined` when unknown. Read from the same
|
||||
* batched watcher as {@link getPaneExit}, so it costs no tmux call. A relative
|
||||
* path printed in the terminal resolves against it; it is never a
|
||||
* confinement root. A multiplexer without it leaves relative paths resolving
|
||||
* against `workingDir`.
|
||||
*/
|
||||
getPaneCurrentPath?(muxName: string): string | undefined;
|
||||
|
||||
/**
|
||||
* How many authoritative pane reads have agreed on the exit `getPaneExit()`
|
||||
* reports, or 0 when it reports none. The exited-agent sweep closes a session
|
||||
|
||||
@@ -743,6 +743,11 @@ export class Session extends EventEmitter {
|
||||
* rendered as "alive".
|
||||
*/
|
||||
private _paneExit: PaneExit | null = null;
|
||||
/**
|
||||
* The pane's live directory, from the pane-exit watcher's read. Runtime-only:
|
||||
* serialized by toState() but never restored by the constructor.
|
||||
*/
|
||||
private _cwd: string | undefined;
|
||||
/**
|
||||
* How many starts, attaches or relaunches are running for this session's
|
||||
* pane. While one is, a dead-pane reading may describe a pane that is being
|
||||
@@ -1485,6 +1490,28 @@ export class Session extends EventEmitter {
|
||||
if (this._muxSession) this._mux?.clearPaneExit?.(this._muxSession.muxName);
|
||||
}
|
||||
|
||||
/** The pane's live working directory, when the pane read has reported one. */
|
||||
get cwd(): string | undefined {
|
||||
return this._cwd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the pane's live directory. Returns whether it CHANGED, so the caller
|
||||
* broadcasts only on a real transition.
|
||||
*
|
||||
* Scoped by {@link paneExitApplies}, the same rule as the exit reading: a
|
||||
* remote session's local pane runs the ssh client and a docker case's runs a
|
||||
* `docker exec`, so their pane directory is a host directory the agent was
|
||||
* never in, and a direct-PTY or rediscovered session has no pane Codeman can
|
||||
* vouch for. A relative value is refused rather than stored.
|
||||
*/
|
||||
setCwd(next: string | undefined): boolean {
|
||||
const resolved = this.paneExitApplies && next?.startsWith('/') ? next : undefined;
|
||||
if (resolved === this._cwd) return false;
|
||||
this._cwd = resolved;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record what the mux layer observed of this pane's agent, and say whether
|
||||
* that changed the answer. The caller persists and broadcasts on a true.
|
||||
@@ -2162,6 +2189,7 @@ export class Session extends EventEmitter {
|
||||
pid: this.pid,
|
||||
status: this._status,
|
||||
workingDir: this.workingDir,
|
||||
cwd: this._cwd,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
owner: this._owner,
|
||||
|
||||
@@ -422,6 +422,37 @@ export function derivePaneExits(rows: PaneRow[], now: number): Map<string, PaneE
|
||||
return exits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide, from every pane tmux listed, which directory each tmux session's pane
|
||||
* is in now (`#{pane_current_path}`). Exported for unit testing.
|
||||
*
|
||||
* This is what a relative path printed in the terminal is relative TO: a
|
||||
* session's `workingDir` is fixed at create time and never follows a `cd`.
|
||||
*
|
||||
* - Only sessions with EXACTLY ONE pane answer, for the reason
|
||||
* {@link derivePaneExits} gives: a hand-split session has no single "the"
|
||||
* pane to speak for it.
|
||||
* - Only absolute paths count. A blank field is a tmux that did not answer.
|
||||
*
|
||||
* The launch directory is NOT filtered here (unlike the rediscovery path): a
|
||||
* remote or docker pane that never leaves it is refused by `Session.setCwd()`,
|
||||
* and a local pane reporting it really is there.
|
||||
*/
|
||||
export function derivePaneCurrentPaths(rows: PaneRow[]): Map<string, string> {
|
||||
const panesPerSession = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
panesPerSession.set(row.sessionName, (panesPerSession.get(row.sessionName) ?? 0) + 1);
|
||||
}
|
||||
const paths = new Map<string, string>();
|
||||
for (const row of rows) {
|
||||
if (panesPerSession.get(row.sessionName) !== 1) continue;
|
||||
const path = row.currentPath;
|
||||
if (!path || !path.startsWith('/')) continue;
|
||||
paths.set(row.sessionName, path);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Could any of these tmux sessions ever produce a pane-exit answer? Exported
|
||||
* for unit testing.
|
||||
@@ -2005,6 +2036,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
* raw pane reading.
|
||||
*/
|
||||
private paneExits: Map<string, TrackedPaneExit> = new Map();
|
||||
/**
|
||||
* muxName -> `#{pane_current_path}` from the last authoritative pane read
|
||||
* ({@link derivePaneCurrentPaths}). Replaced wholesale by each non-empty read,
|
||||
* so it is bounded by the panes on the socket. Like {@link paneExits} it holds
|
||||
* the raw reading; `Session.setCwd()` decides which sessions may use it.
|
||||
*/
|
||||
private paneCurrentPaths: Map<string, string> = new Map();
|
||||
/** The pane-exit watcher's own interval. Runs whether or not stats are on. */
|
||||
private paneExitInterval: NodeJS.Timeout | null = null;
|
||||
/**
|
||||
@@ -3507,6 +3545,16 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The directory the session's pane was in at the last authoritative pane
|
||||
* read, or `undefined` when unknown. Costs nothing: it reads the map the
|
||||
* pane-exit watcher's batched `list-panes -a` fills, so no tmux call is made
|
||||
* per click. A failed or empty read keeps the previous answers.
|
||||
*/
|
||||
getPaneCurrentPath(muxName: string): string | undefined {
|
||||
return this.paneCurrentPaths.get(muxName);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the last pane read saw of this tmux session's agent. `undefined` is
|
||||
* the UNKNOWN answer and must never be rendered as "alive": it covers a pane
|
||||
@@ -3568,6 +3616,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
this.paneExitReadInFlight = false;
|
||||
}
|
||||
if (rows.length === 0) return;
|
||||
// The directory reading rides the same read. It is applied before the
|
||||
// generation check below: a respawn invalidates an exit observation, but the
|
||||
// pane's directory at read time is still the best answer there is.
|
||||
this.paneCurrentPaths = derivePaneCurrentPaths(rows);
|
||||
// A pane was respawned or killed while this read was out, so what it saw is
|
||||
// already history. Dropping it is what stops a freshly respawned pane from
|
||||
// being republished as exited.
|
||||
|
||||
@@ -834,6 +834,19 @@ export interface SessionState {
|
||||
status: SessionStatus;
|
||||
/** Working directory path */
|
||||
workingDir: string;
|
||||
/**
|
||||
* The directory the session's local tmux pane is in NOW (`#{pane_current_path}`),
|
||||
* when known. `workingDir` is fixed at create time and never follows a `cd`;
|
||||
* this is what a relative path printed in the terminal is relative to.
|
||||
*
|
||||
* Runtime-only: refreshed from the pane-exit watcher's read and never restored
|
||||
* from disk, so a restart starts blank. Absent for remote and docker sessions
|
||||
* (their local pane runs the ssh / docker-exec client) and for direct-PTY or
|
||||
* rediscovered sessions. NOT a confinement root: the file routes stay confined
|
||||
* to `workingDir`, and a path resolved outside it goes through the attachment
|
||||
* routes like any other out-of-workspace path.
|
||||
*/
|
||||
cwd?: string;
|
||||
/** 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 */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user