дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
feat(terminal): persist the working directory of terminal sessions across restarts (#1783)
Signed-off-by: Inf1mum <[email protected]>
Цей коміт міститься в:
1 джерело
410761e0f7
коміт
eae363908d
4 files changed
+157
-8
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Заблокувати користувача