feat(terminal): persist the working directory of terminal sessions across restarts (#1783)

Signed-off-by: Inf1mum <[email protected]>
Цей коміт міститься в:
Vadim Lazuk authored and GitHub committed 2026-11-07 02:27:26 +08:00
1 джерело 410761e0f7
коміт eae363908d
4 files changed
+157 -8

No files matched your search

+43 -1
Переглянути файл
@@ -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");
});
});
+84 -2
Переглянути файл
@@ -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<object>()),
Channel: class {
onmessage: ((chunk: string) => void) | null = null;
},
}));
const fileLink = (text: string): Extract<TerminalLink, { kind: "file" }> => {
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());
+23 -1
Переглянути файл
@@ -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<TerminalLink, { kind: "file" }>
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<string> {
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 },
+7 -4
Переглянути файл
@@ -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<string, string>();
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;
}