/** * @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 { 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 { 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 }, '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 = {}) { 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 & { emit: (e: string) => void } }).mux; const sessions = (web as unknown as { sessions: Map }).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 } ).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 }).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, 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) => { 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//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) => { 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); 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); 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'); }); });