diff --git a/src/features/terminal/persist.test.ts b/src/features/terminal/persist.test.ts index 8a0061412..aff470f25 100644 --- a/src/features/terminal/persist.test.ts +++ b/src/features/terminal/persist.test.ts @@ -17,10 +17,17 @@ vi.mock("./session", () => ({ key: string; state: { cwd: string }; command: { program: string; args: string[]; shell?: boolean } | undefined; - constructor(opts: { key: string; cwd: string; command?: { program: string; args: string[]; shell?: boolean } }) { + fallbackRoot: string | undefined; + constructor(opts: { + key: string; + cwd: string; + command?: { program: string; args: string[]; shell?: boolean }; + fallbackRoot?: string; + }) { this.key = opts.key; this.state = { cwd: opts.cwd }; this.command = opts.command; + this.fallbackRoot = opts.fallbackRoot; } dispose() {} }, @@ -28,6 +35,10 @@ vi.mock("./session", () => ({ const state = () => useTerminals.getState(); const names = () => state().tabs.map((t) => t.customName ?? t.name); +const sessionOf = (key: string) => + getSession(key) as + | { state: { cwd: string }; command?: { program: string; args: string[]; shell?: boolean }; fallbackRoot?: string } + | undefined; beforeEach(() => { unbindTerminals(); @@ -99,4 +110,35 @@ describe("terminal tab memory", () => { expect(names()).toEqual(["Ship"]); expect(getSession(state().tabs[0]!.session)?.state.cwd).toBe("/repo"); }); + + it("restored tabs fall back to the project root, fresh tabs do not", () => { + syncTerminalProject("/repo"); + newTab("/repo/logs"); + syncTerminalProject(null); + syncTerminalProject("/repo"); + const restored = state().tabs[0]!; + expect(sessionOf(restored.session)?.fallbackRoot).toBe("/repo"); + + const fresh = newTab("/repo/app"); + expect(sessionOf(fresh.session)?.fallbackRoot).toBeUndefined(); + + const side = splitTab(restored.id, "horizontal")!; + const sideTab = state().tabs.find((t) => t.id === side)!; + expect(sessionOf(sideTab.session)?.fallbackRoot).toBe("/repo"); + }); + + it("restores a tab whose saved directory is gone with the project root as its fallback", () => { + syncTerminalProject("/repo"); + const tab = newTab("/repo"); + getSession(tab.session)!.state.cwd = "/repo/gone"; + useTerminals.setState((s) => ({ + sessions: { ...s.sessions, [tab.session]: { ...s.sessions[tab.session]!, cwd: "/repo/gone" } }, + })); + + syncTerminalProject(null); + syncTerminalProject("/repo"); + const restored = sessionOf(state().tabs[0]!.session)!; + expect(restored.state.cwd).toBe("/repo/gone"); + expect(restored.fallbackRoot).toBe("/repo"); + }); }); diff --git a/src/features/terminal/session.test.ts b/src/features/terminal/session.test.ts index dc4e36f09..9d010d97b 100644 --- a/src/features/terminal/session.test.ts +++ b/src/features/terminal/session.test.ts @@ -1,11 +1,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { FIT_INTERVAL_MS } from "./fit"; import { findTerminalLinks, type TerminalLink } from "./links"; -import { openFileLink, TerminalSession } from "./session"; +import { openFileLink, resolveStartCwd, TerminalSession } from "./session"; const ipc = vi.hoisted(() => ({ call: vi.fn(async (v: unknown) => v), - commands: { fsReadDir: vi.fn(), findFiles: vi.fn() }, + commands: { + fsReadDir: vi.fn(), + fsEntryKind: vi.fn(), + findFiles: vi.fn(), + termSpawn: vi.fn(), + termKill: vi.fn(), + termResize: vi.fn(), + termIsBusy: vi.fn(), + }, })); const workbench = vi.hoisted(() => ({ openFile: vi.fn(async () => undefined), @@ -18,6 +26,13 @@ vi.mock("@/workbench/contributions", () => ({ notifications: () => ({ notify: workbench.notify }), OWNS_FILE_DROP: { "data-owner": true }, })); +// `Channel` needs the Tauri runtime; a start() test only hands it to the mocked spawn call. +vi.mock("@tauri-apps/api/core", async (importOriginal) => ({ + ...(await importOriginal()), + Channel: class { + onmessage: ((chunk: string) => void) | null = null; + }, +})); const fileLink = (text: string): Extract => { const [l] = findTerminalLinks(text); @@ -74,6 +89,73 @@ describe("openFileLink", () => { }); }); +describe("resolveStartCwd", () => { + beforeEach(() => vi.clearAllMocks()); + + it("keeps a saved directory that still exists", async () => { + ipc.commands.fsEntryKind.mockResolvedValue("dir"); + await expect(resolveStartCwd("/repo/logs", "/repo")).resolves.toBe("/repo/logs"); + expect(ipc.commands.fsEntryKind).toHaveBeenCalledWith("/repo/logs"); + }); + + it("falls back to the project root when the saved directory is gone", async () => { + ipc.commands.fsEntryKind.mockRejectedValue(new Error("not found")); + await expect(resolveStartCwd("/repo/logs", "/repo")).resolves.toBe("/repo"); + }); + + it("falls back when the saved path is a file rather than a directory", async () => { + ipc.commands.fsEntryKind.mockResolvedValue("file"); + await expect(resolveStartCwd("/repo/logs", "/repo")).resolves.toBe("/repo"); + }); + + it("does not touch the file system without a fallback or when the paths match", async () => { + await expect(resolveStartCwd("/repo", "/repo")).resolves.toBe("/repo"); + await expect(resolveStartCwd("/anywhere", undefined)).resolves.toBe("/anywhere"); + expect(ipc.commands.fsEntryKind).not.toHaveBeenCalled(); + }); +}); + +describe("terminal start directory fallback", () => { + beforeEach(() => vi.clearAllMocks()); + + it("spawns a restored session in the project root when its saved directory is gone", async () => { + ipc.commands.fsEntryKind.mockRejectedValue(new Error("gone")); + ipc.commands.termSpawn.mockResolvedValue({ id: 7, title: "zsh" }); + const session = new TerminalSession({ + key: "s-fallback", + cwd: "/repo/gone", + fallbackRoot: "/repo", + onChange: () => undefined, + onFocus: () => undefined, + }); + await session.start(); + expect(ipc.commands.termSpawn).toHaveBeenCalledWith(expect.objectContaining({ cwd: "/repo" }), expect.anything()); + expect(session.state.cwd).toBe("/repo"); + expect(session.state.status).toBe("running"); + session.dispose(); + expect(ipc.commands.termKill).toHaveBeenCalledWith(7); + }); + + it("keeps spawning in a saved directory that still exists", async () => { + ipc.commands.fsEntryKind.mockResolvedValue("dir"); + ipc.commands.termSpawn.mockResolvedValue({ id: 8, title: "zsh" }); + const session = new TerminalSession({ + key: "s-kept", + cwd: "/repo/logs", + fallbackRoot: "/repo", + onChange: () => undefined, + onFocus: () => undefined, + }); + await session.start(); + expect(ipc.commands.termSpawn).toHaveBeenCalledWith( + expect.objectContaining({ cwd: "/repo/logs" }), + expect.anything(), + ); + expect(session.state.cwd).toBe("/repo/logs"); + session.dispose(); + }); +}); + describe("terminal open", () => { afterEach(() => vi.useRealTimers()); diff --git a/src/features/terminal/session.ts b/src/features/terminal/session.ts index af0f806ef..0a39013f3 100644 --- a/src/features/terminal/session.ts +++ b/src/features/terminal/session.ts @@ -71,6 +71,11 @@ export interface SessionOptions { * `shell`: it is a shell (a predefined session), so shell integration still applies. */ command?: { program: string; args: string[]; shell?: boolean }; + /** + * Restored tabs only: the project root, used when `cwd` no longer exists + * (deleted since the last run, see `resolveStartCwd`). + */ + fallbackRoot?: string; } /** Mouse-tracking DEC private modes (X10, normal, button, any-event, encodings). */ @@ -167,6 +172,21 @@ export async function openFileLink(link: Extract notifications().notify({ level: "warning", title: "File not found", body: link.path }); } +/** + * A tab restored from the last session may remember a directory that has since + * been deleted. The backend would silently start such a shell in $HOME; when a + * `fallbackRoot` (the project root) is given, start there instead. + */ +export async function resolveStartCwd(cwd: string, fallbackRoot: string | undefined): Promise { + if (!fallbackRoot || cwd === fallbackRoot) return cwd; + try { + if ((await call(commands.fsEntryKind(cwd))) === "dir") return cwd; + } catch { + /* deleted or unreachable */ + } + return fallbackRoot; +} + export class TerminalSession { readonly key: string; readonly host: HTMLDivElement; @@ -525,10 +545,12 @@ export class TerminalSession { const [shellPath, ...shellArgs] = parseShellCommand(t.shellPath, hostOs() === "windows"); const shell = custom ? custom.program : shellPath; const args = custom ? custom.args : shellArgs; + const cwd = await resolveStartCwd(this.opts.cwd, this.opts.fallbackRoot); + if (cwd !== this.opts.cwd) this.update({ cwd }); const info = await call( commands.termSpawn( { - cwd: this.opts.cwd, + cwd, shell: shell ?? null, args, env: { ...t.env }, diff --git a/src/features/terminal/store.ts b/src/features/terminal/store.ts index 5864b5fd4..76e00f9a5 100644 --- a/src/features/terminal/store.ts +++ b/src/features/terminal/store.ts @@ -82,12 +82,13 @@ export function sessionForTerminalId(id: number): TerminalSession | undefined { return allSessions().find((s) => s.terminalId === id); } -function createSession(cwd: string, command?: SessionOptions["command"]): TerminalSession { +function createSession(cwd: string, command?: SessionOptions["command"], fallbackRoot?: string): TerminalSession { const key = `s${++seq}`; const session = new TerminalSession({ key, cwd, command, + fallbackRoot, onChange: (k, snapshot) => useTerminals.setState((s) => ({ sessions: { ...s.sessions, [k]: snapshot } })), onFocus: (k) => { const tab = tabOfSession(k); @@ -381,7 +382,7 @@ export function splitTab(id: string, orientation: SplitOrientation, move = false }); return id; } - const session = createSession(sessions.get(tab.session)?.state.cwd ?? "/"); + const session = createSession(sessions.get(tab.session)?.state.cwd ?? "/", undefined, boundProject ?? undefined); const copy: TerminalTab = { id: `t${seq}`, name: defaultTabName(), @@ -560,7 +561,7 @@ function remapLayout(node: GroupLayout, mapGroup: (group: string) => string): Gr } /** Opens the saved tabs in fresh sessions. Group ids are new, so they don't collide with ones minted later. */ -function restore(saved: StoredTerminals) { +function restore(saved: StoredTerminals, root: string) { const groupOf = new Map(); const mapGroup = (group: string) => { let mapped = groupOf.get(group); @@ -574,6 +575,8 @@ function restore(saved: StoredTerminals) { const session = createSession( savedTab.cwd, savedTab.shell ? { program: savedTab.shell, args: [], shell: true } : undefined, + // A saved directory may have been deleted since the last run: start in the project root then. + root, ); const id = `t${seq}`; idOf.set(savedTab.id, id); @@ -611,7 +614,7 @@ export function syncTerminalProject(root: string | null) { closeAll(); if (!root) return; const saved = readStored(root); - if (saved?.tabs.length) restore(saved); + if (saved?.tabs.length) restore(saved, root); } finally { restoring = false; }