mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
Claude Code's EnterWorktree moves the running process into <repo>/.claude/worktrees/<name>, while Session.workingDir stays on the main checkout. The Files panel kept walking workingDir, so it listed a tree the agent had left. paneCwd already follows the move, measured on Claude Code 2.1.318 in both directions, so nothing new has to track the agent. It also follows every cd in a shell, so a new SessionState.liveWorktree narrows it to a linked worktree, read from the .git file without starting git. Only a folder strictly below workingDir is ever an answer, and the listing only starts there: returned paths stay relative to workingDir, so the preview, raw and edit routes are unchanged and confinement does not widen. A worktree outside the session folder stays unfollowed. The panel asks with root=live, shows a strip naming the worktree with a switch to the session folder, and follows the agent in and out while open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
689 lines
29 KiB
TypeScript
689 lines
29 KiB
TypeScript
/**
|
|
* @fileoverview A relative path printed in the terminal resolves against the
|
|
* directory the pane is actually in, not only the session's `workingDir`.
|
|
*
|
|
* `workingDir` is recorded once at create time and never follows a `cd`, so in
|
|
* a shell session that moved to a subdirectory or a sibling repo every relative
|
|
* path resolved against the wrong root, and the preview reported "File not
|
|
* found" for a file sitting right there.
|
|
*
|
|
* Pinned here:
|
|
* 1. Parsing: the pane-exit watcher's batched `list-panes -a` read already
|
|
* carries `#{pane_current_path}`; `derivePaneCurrentPaths()` turns it into
|
|
* one directory per session (single-pane sessions only, absolute paths
|
|
* only).
|
|
* 2. The manager keeps the last authoritative answer, so a failed read or a
|
|
* blank field never blanks a known directory, while a read that lists the
|
|
* session as split, or not at all, clears it.
|
|
* 3. Scoping: remote and Docker sessions never carry one (their local pane
|
|
* runs the ssh / docker-exec CLIENT), and neither does a direct-PTY or a
|
|
* rediscovered session.
|
|
* 4. Wiring: the reading reaches `toState()` through the existing
|
|
* `paneExitsUpdated` event, with no new tmux call and no new SSE event.
|
|
* 5. Resolution order in the browser: live cwd, then `workingDir`. The
|
|
* resolved path still goes through the same preview routing, so a target
|
|
* outside the workspace takes the attachment route like any other.
|
|
* 6. Spelling: tmux reports the RESOLVED directory, so a workspace reached
|
|
* through a symlink has its reading written back in `workingDir`'s
|
|
* spelling, or every relative link would route as out-of-workspace.
|
|
*
|
|
* Port: ephemeral
|
|
*/
|
|
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join, resolve } from 'node:path';
|
|
import vm from 'node:vm';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { Session, rebasePath } from '../src/session.js';
|
|
import { WebServer } from '../src/web/server.js';
|
|
import type { SessionDocker, SessionRemote } from '../src/types.js';
|
|
import type { MuxSession, TerminalMultiplexer } from '../src/mux-interface.js';
|
|
import { TmuxManager, derivePaneCurrentPaths, parsePaneRows, type PaneRow } from '../src/tmux-manager.js';
|
|
|
|
const remote: SessionRemote = { hostId: 'h1', label: 'box', host: 'box', user: 'dev' } as SessionRemote;
|
|
const docker: SessionDocker = { hostId: 'd1', label: 'ctr', containerName: 'ctr' } as SessionDocker;
|
|
|
|
describe('derivePaneCurrentPaths', () => {
|
|
it("maps each single-pane session to its pane's directory", () => {
|
|
const rows = parsePaneRows(['codeman-a|100|0|||/work/proj/sub', 'codeman-b|200|0|||/srv/app'].join('\n'));
|
|
const paths = derivePaneCurrentPaths(rows);
|
|
expect(paths.get('codeman-a')).toBe('/work/proj/sub');
|
|
expect(paths.get('codeman-b')).toBe('/srv/app');
|
|
expect(paths.size).toBe(2);
|
|
});
|
|
|
|
it('keeps a separator that belongs to the directory name', () => {
|
|
const paths = derivePaneCurrentPaths(parsePaneRows('codeman-a|100|0|||/home/u/weird|dir/sub'));
|
|
expect(paths.get('codeman-a')).toBe('/home/u/weird|dir/sub');
|
|
});
|
|
|
|
it('answers nothing for a session split into several panes', () => {
|
|
// Codeman creates one pane per session; which of a hand-split session's
|
|
// panes "the" directory belongs to is a guess, so none is made.
|
|
const rows = parsePaneRows(['codeman-a|100|0|||/one', 'codeman-a|101|0|||/two'].join('\n'));
|
|
expect(derivePaneCurrentPaths(rows).has('codeman-a')).toBe(false);
|
|
});
|
|
|
|
it('drops a missing or relative directory', () => {
|
|
const rows = parsePaneRows(['codeman-a|100|0|||', 'codeman-b|200|0|||rel/ative'].join('\n'));
|
|
expect(derivePaneCurrentPaths(rows).size).toBe(0);
|
|
});
|
|
|
|
it('carries the previous directory forward for a blank field on a single-pane session', () => {
|
|
// A blank `#{pane_current_path}` is a tmux that could not read the process
|
|
// cwd that tick, not a pane that has no directory.
|
|
const previous = new Map([['codeman-a', '/work/proj/sub']]);
|
|
const paths = derivePaneCurrentPaths(parsePaneRows('codeman-a|100|0|||'), previous);
|
|
expect(paths.get('codeman-a')).toBe('/work/proj/sub');
|
|
});
|
|
|
|
it('carries nothing forward for a split session, an unlisted one or a relative field', () => {
|
|
// Those are authoritative absences: the read answered, and its answer is
|
|
// "no single directory".
|
|
const previous = new Map([
|
|
['codeman-split', '/one'],
|
|
['codeman-gone', '/two'],
|
|
['codeman-rel', '/three'],
|
|
]);
|
|
const rows = parsePaneRows(
|
|
['codeman-split|100|0|||', 'codeman-split|101|0|||', 'codeman-rel|300|0|||rel/ative'].join('\n')
|
|
);
|
|
expect(derivePaneCurrentPaths(rows, previous).size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('TmuxManager pane directory bookkeeping', () => {
|
|
class TestManager extends TmuxManager {
|
|
rows: PaneRow[] = [];
|
|
protected override async readPaneRows(): Promise<PaneRow[]> {
|
|
return this.rows;
|
|
}
|
|
}
|
|
|
|
const localSession = (sessionId: string): MuxSession =>
|
|
({
|
|
sessionId,
|
|
muxName: `codeman-${sessionId}`,
|
|
pid: 100,
|
|
createdAt: 0,
|
|
workingDir: '/work/proj',
|
|
mode: 'shell',
|
|
attached: true,
|
|
}) as MuxSession;
|
|
|
|
it('records the directory from the same read the pane-exit watcher makes', async () => {
|
|
const manager = new TestManager();
|
|
manager.registerSession(localSession('s1'));
|
|
manager.rows = parsePaneRows('codeman-s1|100|0|||/work/proj/sub');
|
|
await manager.refreshPaneExits();
|
|
expect(manager.getPaneCurrentPath('codeman-s1')).toBe('/work/proj/sub');
|
|
});
|
|
|
|
it('keeps the last known directory when a read comes back empty', async () => {
|
|
const manager = new TestManager();
|
|
manager.registerSession(localSession('s1'));
|
|
manager.rows = parsePaneRows('codeman-s1|100|0|||/work/proj/sub');
|
|
await manager.refreshPaneExits();
|
|
manager.rows = [];
|
|
await manager.refreshPaneExits();
|
|
expect(manager.getPaneCurrentPath('codeman-s1')).toBe('/work/proj/sub');
|
|
});
|
|
|
|
it('keeps the last directory across a blank field on a single-pane read', async () => {
|
|
const manager = new TestManager();
|
|
manager.registerSession(localSession('s1'));
|
|
manager.rows = parsePaneRows('codeman-s1|100|0|||/work/proj/sub');
|
|
await manager.refreshPaneExits();
|
|
manager.rows = parsePaneRows('codeman-s1|100|0|||');
|
|
await manager.refreshPaneExits();
|
|
expect(manager.getPaneCurrentPath('codeman-s1')).toBe('/work/proj/sub');
|
|
});
|
|
|
|
it('forgets the directory once the session is split into several panes', async () => {
|
|
const manager = new TestManager();
|
|
manager.registerSession(localSession('s1'));
|
|
manager.rows = parsePaneRows('codeman-s1|100|0|||/work/proj/sub');
|
|
await manager.refreshPaneExits();
|
|
manager.rows = parsePaneRows(['codeman-s1|100|0|||/work/proj/sub', 'codeman-s1|101|0|||/work/proj'].join('\n'));
|
|
await manager.refreshPaneExits();
|
|
expect(manager.getPaneCurrentPath('codeman-s1')).toBeUndefined();
|
|
});
|
|
|
|
it('records the directory even when a respawn races the read', async () => {
|
|
// A respawn bumps the generation and discards the read's EXIT observation;
|
|
// the directory the pane was in at read time is still the best answer.
|
|
class RacingManager extends TestManager {
|
|
protected override async readPaneRows(): Promise<PaneRow[]> {
|
|
this.clearPaneExit('codeman-s1');
|
|
return this.rows;
|
|
}
|
|
}
|
|
const manager = new RacingManager();
|
|
manager.registerSession(localSession('s1'));
|
|
manager.rows = parsePaneRows('codeman-s1|100|1|0||/work/proj/sub');
|
|
await manager.refreshPaneExits();
|
|
expect(manager.getPaneCurrentPath('codeman-s1')).toBe('/work/proj/sub');
|
|
expect(manager.getPaneExit('codeman-s1')).toBeUndefined();
|
|
});
|
|
|
|
it('makes no read, and keeps no directory, when only remote or docker sessions exist', async () => {
|
|
const manager = new TestManager();
|
|
const read = vi.spyOn(manager as unknown as { readPaneRows: () => Promise<PaneRow[]> }, 'readPaneRows');
|
|
manager.registerSession({ ...localSession('s1'), remote } as MuxSession);
|
|
manager.registerSession({ ...localSession('s2'), docker } as MuxSession);
|
|
manager.rows = parsePaneRows('codeman-s1|100|0|||/tmp');
|
|
await manager.refreshPaneExits();
|
|
expect(read).not.toHaveBeenCalled();
|
|
expect(manager.getPaneCurrentPath('codeman-s1')).toBeUndefined();
|
|
});
|
|
|
|
it('forgets a session that an authoritative read no longer lists', async () => {
|
|
const manager = new TestManager();
|
|
manager.registerSession(localSession('s1'));
|
|
manager.rows = parsePaneRows('codeman-s1|100|0|||/work/proj/sub');
|
|
await manager.refreshPaneExits();
|
|
manager.rows = parsePaneRows('codeman-other|300|0|||/elsewhere');
|
|
await manager.refreshPaneExits();
|
|
expect(manager.getPaneCurrentPath('codeman-s1')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
const stubMux = () => ({ isAvailable: () => true }) as unknown as TerminalMultiplexer;
|
|
const stubMuxSession = (muxName = 'codeman-aaaa') => ({ muxName, sessionId: 'aaaa' }) as unknown as MuxSession;
|
|
|
|
function localMuxSession(extra: Record<string, unknown> = {}) {
|
|
return new Session({
|
|
workingDir: '/work/proj',
|
|
mode: 'shell',
|
|
useMux: true,
|
|
mux: stubMux(),
|
|
muxSession: stubMuxSession(),
|
|
...extra,
|
|
});
|
|
}
|
|
|
|
describe('Session.setPaneCwd scoping', () => {
|
|
it('records an absolute directory for a local pane and reports the change once', () => {
|
|
const session = localMuxSession();
|
|
expect(session.setPaneCwd('/work/proj/sub')).toBe(true);
|
|
expect(session.paneCwd).toBe('/work/proj/sub');
|
|
expect(session.setPaneCwd('/work/proj/sub')).toBe(false);
|
|
expect(session.toState().paneCwd).toBe('/work/proj/sub');
|
|
});
|
|
|
|
it('clears the directory when the reading goes away', () => {
|
|
const session = localMuxSession();
|
|
session.setPaneCwd('/work/proj/sub');
|
|
expect(session.setPaneCwd(undefined)).toBe(true);
|
|
expect(session.paneCwd).toBeUndefined();
|
|
});
|
|
|
|
it('refuses a relative directory', () => {
|
|
const session = localMuxSession();
|
|
expect(session.setPaneCwd('rel/ative')).toBe(false);
|
|
expect(session.paneCwd).toBeUndefined();
|
|
});
|
|
|
|
it('refuses a directory for a remote session, whose local pane runs the ssh client', () => {
|
|
const session = localMuxSession({ remote });
|
|
expect(session.setPaneCwd('/home/dev')).toBe(false);
|
|
expect(session.toState().paneCwd).toBeUndefined();
|
|
});
|
|
|
|
it('refuses a directory for a docker case, whose local pane runs docker exec', () => {
|
|
const session = localMuxSession({ docker });
|
|
expect(session.setPaneCwd('/work/proj')).toBe(false);
|
|
expect(session.toState().paneCwd).toBeUndefined();
|
|
});
|
|
|
|
it('refuses a directory for a direct-PTY session and a rediscovered one', () => {
|
|
const direct = new Session({ workingDir: '/work/proj', mode: 'shell', useMux: false });
|
|
expect(direct.setPaneCwd('/work/proj/sub')).toBe(false);
|
|
const discovered = localMuxSession({ discoveredMuxSession: true });
|
|
expect(discovered.setPaneCwd('/work/proj/sub')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('a pane read reaching the session record', () => {
|
|
let server: WebServer | null = null;
|
|
|
|
afterEach(async () => {
|
|
await server?.stop?.();
|
|
server = null;
|
|
});
|
|
|
|
const build = () => {
|
|
const web = new WebServer(0, false, true);
|
|
server = web;
|
|
const mux = (web as unknown as { mux: Record<string, unknown> & { emit: (e: string) => void } }).mux;
|
|
const sessions = (web as unknown as { sessions: Map<string, Session> }).sessions;
|
|
return { mux, sessions, web };
|
|
};
|
|
|
|
it("publishes the pane's directory on the existing paneExitsUpdated tick", () => {
|
|
const { mux, sessions, web } = build();
|
|
const session = localMuxSession();
|
|
sessions.set(session.id, session);
|
|
mux.getPaneExit = () => undefined;
|
|
mux.getPaneCurrentPath = (muxName: string) => (muxName === 'codeman-aaaa' ? '/work/proj/sub' : undefined);
|
|
mux.emit('paneExitsUpdated');
|
|
|
|
expect(session.toState().paneCwd).toBe('/work/proj/sub');
|
|
// What the browser actually receives: a future explicit field list must not drop it.
|
|
const state = (
|
|
web as unknown as { getSessionStateWithRespawn: (s: Session) => { paneCwd?: string } }
|
|
).getSessionStateWithRespawn(session);
|
|
expect(state.paneCwd).toBe('/work/proj/sub');
|
|
// A resolution base only: the confinement root is untouched.
|
|
expect(session.toState().workingDir).toBe('/work/proj');
|
|
});
|
|
|
|
describe('liveWorktree (#765)', () => {
|
|
let base: string;
|
|
let repo: string;
|
|
let worktree: string;
|
|
|
|
beforeEach(() => {
|
|
base = mkdtempSync(join(tmpdir(), 'pane-live-worktree-'));
|
|
repo = join(base, 'repo');
|
|
worktree = join(repo, '.claude', 'worktrees', 'probe');
|
|
mkdirSync(worktree, { recursive: true });
|
|
writeFileSync(join(worktree, '.git'), `gitdir: ${repo}/.git/worktrees/probe\n`);
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(base, { recursive: true, force: true });
|
|
});
|
|
|
|
const inRepo = () => {
|
|
const { mux, sessions, web } = build();
|
|
const session = localMuxSession({ workingDir: repo });
|
|
sessions.set(session.id, session);
|
|
const broadcast = vi.fn();
|
|
(web as unknown as { broadcastSessionStateDebounced: (id: string) => void }).broadcastSessionStateDebounced =
|
|
broadcast;
|
|
mux.getPaneExit = () => undefined;
|
|
return { mux, session, web, broadcast };
|
|
};
|
|
|
|
it('publishes the worktree the pane moved into, and clears it when the pane leaves', async () => {
|
|
const { mux, session, web, broadcast } = inRepo();
|
|
let dir = worktree;
|
|
mux.getPaneCurrentPath = () => dir;
|
|
mux.emit('paneExitsUpdated');
|
|
await vi.waitFor(() => expect(session.liveWorktree).toEqual({ name: 'probe', path: worktree }));
|
|
// What the browser receives, and what the file routes read: never the confinement root.
|
|
const state = (
|
|
web as unknown as { getSessionStateWithRespawn: (s: Session) => { liveWorktree?: unknown } }
|
|
).getSessionStateWithRespawn(session);
|
|
expect(state.liveWorktree).toEqual({ name: 'probe', path: worktree });
|
|
expect(session.toState().workingDir).toBe(repo);
|
|
|
|
const broadcasts = broadcast.mock.calls.length;
|
|
dir = repo;
|
|
mux.emit('paneExitsUpdated');
|
|
await vi.waitFor(() => expect(session.liveWorktree).toBeUndefined());
|
|
expect(broadcast.mock.calls.length).toBeGreaterThan(broadcasts);
|
|
});
|
|
|
|
it('does nothing for a pane that has not moved', async () => {
|
|
const { mux, session } = inRepo();
|
|
mux.getPaneCurrentPath = () => repo;
|
|
const lookup = vi.spyOn(session, 'setLiveWorktree');
|
|
mux.emit('paneExitsUpdated');
|
|
await vi.waitFor(() => expect(lookup).toHaveBeenCalledTimes(1));
|
|
mux.emit('paneExitsUpdated');
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
expect(lookup).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('drops an answer for a directory the pane has already left', async () => {
|
|
const { web, session } = inRepo();
|
|
session.setPaneCwd(worktree);
|
|
const refresh = (
|
|
web as unknown as { refreshLiveWorktree: (s: Session) => Promise<void> }
|
|
).refreshLiveWorktree.call(web, session);
|
|
session.setPaneCwd(repo);
|
|
await refresh;
|
|
expect(session.liveWorktree).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it('broadcasts a directory change once, and nothing on an unchanged tick', () => {
|
|
const { mux, sessions } = build();
|
|
const session = localMuxSession();
|
|
sessions.set(session.id, session);
|
|
const broadcast = vi.fn();
|
|
(server as unknown as { broadcastSessionStateDebounced: (id: string) => void }).broadcastSessionStateDebounced =
|
|
broadcast;
|
|
const persist = vi.fn();
|
|
(server as unknown as { persistSessionState: (s: Session) => void }).persistSessionState = persist;
|
|
let dir = '/work/proj/sub';
|
|
mux.getPaneExit = () => undefined;
|
|
mux.getPaneCurrentPath = () => dir;
|
|
|
|
mux.emit('paneExitsUpdated');
|
|
expect(broadcast).toHaveBeenCalledTimes(1);
|
|
expect(broadcast).toHaveBeenCalledWith(session.id);
|
|
mux.emit('paneExitsUpdated');
|
|
expect(broadcast).toHaveBeenCalledTimes(1);
|
|
dir = '/work/proj/other';
|
|
mux.emit('paneExitsUpdated');
|
|
expect(broadcast).toHaveBeenCalledTimes(2);
|
|
expect(session.toState().paneCwd).toBe('/work/proj/other');
|
|
// Runtime-only: a directory change alone is never written to state.json.
|
|
expect(persist).not.toHaveBeenCalled();
|
|
});
|
|
|
|
/**
|
|
* Drive the server's REAL TmuxManager: its parsing, its derivation and its
|
|
* `getPaneCurrentPath()`. Only the tmux exec seam (`readPaneRows`) is faked,
|
|
* with raw `list-panes -a` output, as the bookkeeping tests above do.
|
|
*/
|
|
const buildWithRealManager = () => {
|
|
const { sessions } = build();
|
|
const mux = (server as unknown as { mux: TmuxManager }).mux;
|
|
expect(mux).toBeInstanceOf(TmuxManager);
|
|
let output = '';
|
|
(mux as unknown as { readPaneRows: () => Promise<PaneRow[]> }).readPaneRows = async () => parsePaneRows(output);
|
|
mux.registerSession({
|
|
sessionId: 'aaaa',
|
|
muxName: 'codeman-aaaa',
|
|
pid: 100,
|
|
createdAt: 0,
|
|
workingDir: '/work/proj',
|
|
mode: 'shell',
|
|
attached: true,
|
|
} as MuxSession);
|
|
const session = localMuxSession();
|
|
sessions.set(session.id, session);
|
|
const broadcast = vi.fn();
|
|
(server as unknown as { broadcastSessionStateDebounced: (id: string) => void }).broadcastSessionStateDebounced =
|
|
broadcast;
|
|
const tick = async (listPanes: string) => {
|
|
output = listPanes;
|
|
await mux.refreshPaneExits();
|
|
mux.emit('paneExitsUpdated');
|
|
};
|
|
return { session, broadcast, tick };
|
|
};
|
|
|
|
it('clears the directory once the session is split into several panes', async () => {
|
|
const { session, broadcast, tick } = buildWithRealManager();
|
|
await tick('codeman-aaaa|100|0|||/work/proj/sub');
|
|
expect(session.toState().paneCwd).toBe('/work/proj/sub');
|
|
expect(broadcast).toHaveBeenCalledTimes(1);
|
|
|
|
await tick(['codeman-aaaa|100|0|||/work/proj/sub', 'codeman-aaaa|101|0|||/work/proj'].join('\n'));
|
|
// An absent cwd is what makes the browser fall back to workingDir.
|
|
expect(session.toState().paneCwd).toBeUndefined();
|
|
expect(broadcast).toHaveBeenCalledTimes(2);
|
|
|
|
// The clear is announced once, not on every later tick that agrees.
|
|
await tick(['codeman-aaaa|100|0|||/work/proj/sub', 'codeman-aaaa|101|0|||/work/proj'].join('\n'));
|
|
expect(broadcast).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('clears the directory once a read no longer lists the session', async () => {
|
|
const { session, broadcast, tick } = buildWithRealManager();
|
|
await tick('codeman-aaaa|100|0|||/work/proj/sub');
|
|
expect(session.toState().paneCwd).toBe('/work/proj/sub');
|
|
|
|
await tick('codeman-other|300|0|||/elsewhere');
|
|
expect(session.toState().paneCwd).toBeUndefined();
|
|
expect(broadcast).toHaveBeenCalledTimes(2);
|
|
await tick('codeman-other|300|0|||/elsewhere');
|
|
expect(broadcast).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('keeps the directory across a blank field and across a failed read', async () => {
|
|
const { session, broadcast, tick } = buildWithRealManager();
|
|
await tick('codeman-aaaa|100|0|||/work/proj/sub');
|
|
// tmux could not read the process cwd for one tick.
|
|
await tick('codeman-aaaa|100|0|||');
|
|
expect(session.toState().paneCwd).toBe('/work/proj/sub');
|
|
// A failed read arrives as no rows at all.
|
|
await tick('');
|
|
expect(session.toState().paneCwd).toBe('/work/proj/sub');
|
|
expect(broadcast).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('publishes nothing for a docker case', () => {
|
|
const { mux, sessions } = build();
|
|
const session = localMuxSession({ docker });
|
|
sessions.set(session.id, session);
|
|
mux.getPaneExit = () => undefined;
|
|
mux.getPaneCurrentPath = () => '/work/proj';
|
|
mux.emit('paneExitsUpdated');
|
|
|
|
expect(session.toState().paneCwd).toBeUndefined();
|
|
});
|
|
|
|
it('publishes nothing for a remote session', () => {
|
|
const { mux, sessions } = build();
|
|
const session = localMuxSession({ remote });
|
|
sessions.set(session.id, session);
|
|
mux.getPaneExit = () => undefined;
|
|
mux.getPaneCurrentPath = () => '/home/dev';
|
|
mux.emit('paneExitsUpdated');
|
|
|
|
expect(session.toState().paneCwd).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('browser resolution order: live cwd, then workingDir', () => {
|
|
const read = (name: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', name), 'utf8');
|
|
|
|
const loadProto = () => {
|
|
const context = vm.createContext({
|
|
console,
|
|
CodemanApp: class CodemanApp {},
|
|
document: { addEventListener: vi.fn() },
|
|
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
|
});
|
|
vm.runInContext(
|
|
`${read('constants.js')}\n${read('terminal-ui.js')}\nglobalThis.__proto = CodemanApp.prototype;`,
|
|
context
|
|
);
|
|
return (context as unknown as { __proto: { _resolveLinkedFilePath: (p: string, id: string) => string } }).__proto;
|
|
};
|
|
|
|
const resolveFor = (session: Record<string, unknown>, filePath: string) => {
|
|
const proto = loadProto();
|
|
return proto._resolveLinkedFilePath.call({ sessions: new Map([['s1', session]]) }, filePath, 's1');
|
|
};
|
|
|
|
it("resolves against the pane's live directory after a cd into a subdirectory", () => {
|
|
expect(resolveFor({ workingDir: '/work/proj', paneCwd: '/work/proj/sub' }, 'note.md')).toBe(
|
|
'/work/proj/sub/note.md'
|
|
);
|
|
});
|
|
|
|
it('resolves outside the workspace when the pane cd-ed to a sibling repo', () => {
|
|
// Routing is unchanged: an absolute target outside the workspace is what
|
|
// `_isExternalPreviewPath` already sends through the attachment routes.
|
|
expect(resolveFor({ workingDir: '/work/proj', paneCwd: '/work/other' }, 'src/a.js')).toBe('/work/other/src/a.js');
|
|
});
|
|
|
|
it('falls back to workingDir when no live directory is known', () => {
|
|
expect(resolveFor({ workingDir: '/work/proj' }, 'note.md')).toBe('/work/proj/note.md');
|
|
});
|
|
|
|
it('ignores a live directory on a remote or docker session', () => {
|
|
expect(resolveFor({ workingDir: '/work/proj', paneCwd: '/tmp/x', remote: {} }, 'note.md')).toBe(
|
|
'/work/proj/note.md'
|
|
);
|
|
expect(resolveFor({ workingDir: '/work/proj', paneCwd: '/tmp/x', docker: {} }, 'note.md')).toBe(
|
|
'/work/proj/note.md'
|
|
);
|
|
});
|
|
|
|
it('ignores a relative live directory', () => {
|
|
expect(resolveFor({ workingDir: '/work/proj', paneCwd: 'sub' }, 'note.md')).toBe('/work/proj/note.md');
|
|
});
|
|
|
|
it('leaves absolute and home paths alone', () => {
|
|
const session = { workingDir: '/work/proj', paneCwd: '/work/proj/sub' };
|
|
expect(resolveFor(session, '/etc/hosts.md')).toBe('/etc/hosts.md');
|
|
expect(resolveFor(session, '~/x.md')).toBe('~/x.md');
|
|
});
|
|
});
|
|
|
|
describe('confinement is unchanged by the live cwd', () => {
|
|
// The live cwd only picks the base. Whether the preview reads a path through
|
|
// the workspace-confined file routes or registers it through the attachment
|
|
// routes is still decided against `workingDir` by `_isExternalPreviewPath`.
|
|
const read = (name: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', name), 'utf8');
|
|
|
|
const loadApp = (session: Record<string, unknown>) => {
|
|
const context = vm.createContext({
|
|
console,
|
|
CodemanApp: class CodemanApp {},
|
|
document: { addEventListener: vi.fn(), getElementById: () => null },
|
|
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
|
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
|
});
|
|
vm.runInContext(
|
|
`${read('constants.js')}\n${read('terminal-ui.js')}\n${read('panels-ui.js')}\nglobalThis.__proto = CodemanApp.prototype;`,
|
|
context
|
|
);
|
|
const proto = (
|
|
context as unknown as {
|
|
__proto: {
|
|
_resolveLinkedFilePath: (p: string, id: string) => string;
|
|
_isExternalPreviewPath: (p: string, id: string) => boolean;
|
|
};
|
|
}
|
|
).__proto;
|
|
const app = { sessions: new Map([['s1', session]]) };
|
|
return {
|
|
target: (p: string) => proto._resolveLinkedFilePath.call(app, p, 's1'),
|
|
external: (p: string) => proto._isExternalPreviewPath.call(app, p, 's1'),
|
|
};
|
|
};
|
|
|
|
it('keeps a target inside the workspace on the workspace route', () => {
|
|
const app = loadApp({ workingDir: '/work/proj', paneCwd: '/work/proj/sub' });
|
|
expect(app.external(app.target('note.md'))).toBe(false);
|
|
});
|
|
|
|
it('sends a target outside the workspace through the attachment route', () => {
|
|
const app = loadApp({ workingDir: '/work/proj', paneCwd: '/work/other' });
|
|
expect(app.target('src/a.js')).toBe('/work/other/src/a.js');
|
|
expect(app.external(app.target('src/a.js'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('a workspace reached through a symlink', () => {
|
|
// tmux reads `/proc/<pgrp>/cwd`, always the resolved path, while `workingDir`
|
|
// keeps the spelling the case was created with. Without the rewrite, the
|
|
// browser's lexical routing check would call every relative link external.
|
|
let root: string;
|
|
let physical: string;
|
|
let link: string;
|
|
|
|
const read = (name: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', name), 'utf8');
|
|
const routingFor = (session: Record<string, unknown>) => {
|
|
const context = vm.createContext({
|
|
console,
|
|
CodemanApp: class CodemanApp {},
|
|
document: { addEventListener: vi.fn(), getElementById: () => null },
|
|
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
|
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
|
});
|
|
vm.runInContext(
|
|
`${read('constants.js')}\n${read('terminal-ui.js')}\n${read('panels-ui.js')}\nglobalThis.__proto = CodemanApp.prototype;`,
|
|
context
|
|
);
|
|
const proto = (
|
|
context as unknown as {
|
|
__proto: {
|
|
_resolveLinkedFilePath: (p: string, id: string) => string;
|
|
_isExternalPreviewPath: (p: string, id: string) => boolean;
|
|
};
|
|
}
|
|
).__proto;
|
|
const app = { sessions: new Map([['s1', session]]) };
|
|
return (p: string) => proto._isExternalPreviewPath.call(app, proto._resolveLinkedFilePath.call(app, p, 's1'), 's1');
|
|
};
|
|
|
|
/** Feed a reading until the background resolution has settled and it is taken. */
|
|
const settle = async (session: Session, reading: string) => {
|
|
await vi.waitFor(() => {
|
|
session.setPaneCwd(reading);
|
|
expect(session.paneCwd).toBeDefined();
|
|
});
|
|
};
|
|
|
|
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
|
|
|
const setup = () => {
|
|
root = realpathSync(mkdtempSync(join(tmpdir(), 'pane-cwd-link-')));
|
|
physical = join(root, 'data', 'app');
|
|
mkdirSync(join(physical, 'src'), { recursive: true });
|
|
mkdirSync(join(root, 'other'), { recursive: true });
|
|
link = join(root, 'cases-app');
|
|
symlinkSync(physical, link);
|
|
};
|
|
|
|
it('writes a reading of the resolved workspace back in workingDir spelling', async () => {
|
|
setup();
|
|
const session = localMuxSession({ workingDir: link });
|
|
await settle(session, physical);
|
|
expect(session.paneCwd).toBe(link);
|
|
expect(session.setPaneCwd(join(physical, 'src'))).toBe(true);
|
|
expect(session.paneCwd).toBe(join(link, 'src'));
|
|
});
|
|
|
|
it('keeps an in-workspace relative link on the workspace route from the first reading', async () => {
|
|
setup();
|
|
const session = localMuxSession({ workingDir: link });
|
|
await settle(session, physical);
|
|
const external = routingFor(session.toState() as unknown as Record<string, unknown>);
|
|
expect(external('src/index.ts')).toBe(false);
|
|
});
|
|
|
|
it('still routes a cd outside the workspace through the attachment route', async () => {
|
|
setup();
|
|
const session = localMuxSession({ workingDir: link });
|
|
await settle(session, join(root, 'other'));
|
|
expect(session.paneCwd).toBe(join(root, 'other'));
|
|
const external = routingFor(session.toState() as unknown as Record<string, unknown>);
|
|
expect(external('notes.md')).toBe(true);
|
|
});
|
|
|
|
it('withholds an out-of-workspace reading until the resolution settles', () => {
|
|
setup();
|
|
const session = localMuxSession({ workingDir: link });
|
|
// The first call starts the resolution; a physical reading cannot be told
|
|
// apart from a real cd yet, so it is not stored this tick.
|
|
expect(session.setPaneCwd(physical)).toBe(false);
|
|
expect(session.paneCwd).toBeUndefined();
|
|
// A reading lexically inside workingDir needs no translation and is taken.
|
|
expect(session.setPaneCwd(join(link, 'src'))).toBe(true);
|
|
});
|
|
|
|
it('leaves the reading alone when workingDir has no symlink in it', async () => {
|
|
setup();
|
|
const session = localMuxSession({ workingDir: physical });
|
|
await settle(session, join(physical, 'src'));
|
|
expect(session.paneCwd).toBe(join(physical, 'src'));
|
|
});
|
|
|
|
it('takes readings untranslated once a missing workingDir has settled', async () => {
|
|
setup();
|
|
const session = localMuxSession({ workingDir: join(root, 'gone') });
|
|
await settle(session, join(root, 'other'));
|
|
expect(session.paneCwd).toBe(join(root, 'other'));
|
|
});
|
|
});
|
|
|
|
describe('rebasePath', () => {
|
|
it('rewrites the prefix itself and paths under it, and nothing else', () => {
|
|
expect(rebasePath('/mnt/data/app', '/mnt/data/app', '/home/u/app')).toBe('/home/u/app');
|
|
expect(rebasePath('/mnt/data/app/src', '/mnt/data/app', '/home/u/app')).toBe('/home/u/app/src');
|
|
expect(rebasePath('/mnt/data/application', '/mnt/data/app', '/home/u/app')).toBe('/mnt/data/application');
|
|
expect(rebasePath('/mnt/other', '/mnt/data/app', '/home/u/app')).toBe('/mnt/other');
|
|
});
|
|
});
|