дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
feat(terminal): restore each project's terminal tabs (#1526)
Restarting the app dropped every open terminal. Each project now remembers its terminal tabs: their working directories, renames and the split layout. They come back as new shells in the saved directories (scrollback is not kept; a predefined shell is started again, a one-shot command is not). Switching projects in a window saves the old project's tabs and opens the new one's. xterm now opens only once its pane has a size: opening it while the tool window was still 0x0 (its first layout, or a tab under display:none) measured a zero cell and left the first tab unable to take keys. A hidden tab stays closed until it is shown, and focus requested before xterm opens is applied when it does. Co-authored-by: tomgor <[email protected]>
Цей коміт міститься в:
1 джерело
253fcfb7e0
коміт
8c915664e9
9 files changed
+466
-29
No files matched your search
@@ -32,6 +32,8 @@ import {
|
||||
moveTabToSplit,
|
||||
newTab,
|
||||
publishFocus,
|
||||
refitSessions,
|
||||
syncTerminalProject,
|
||||
renameTab,
|
||||
requestCloseSession,
|
||||
requestCloseTab,
|
||||
@@ -61,18 +63,21 @@ function tabTooltip(tab: TerminalTab, sessions: Parameters<typeof tabTitle>[1]):
|
||||
return cwd ? `${full}\n${cwd}` : full;
|
||||
}
|
||||
|
||||
function TerminalPane({ sessionKey, autofocus }: { sessionKey: string; autofocus: boolean }) {
|
||||
function TerminalPane({ sessionKey, active, autofocus }: { sessionKey: string; active: boolean; autofocus: boolean }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const status = useTerminals((s) => s.sessions[sessionKey]?.status);
|
||||
const follow = useTerminals((s) => s.sessions[sessionKey]?.follow ?? true);
|
||||
|
||||
// Hidden tabs stay unopened. xterm.open() under display:none measures a 0 cell
|
||||
// and the textarea never accepts input, even after the tab is shown.
|
||||
useLayoutEffect(() => {
|
||||
if (!active) return;
|
||||
const el = ref.current;
|
||||
const session = getSession(sessionKey);
|
||||
if (!el || !session) return;
|
||||
session.attach(el);
|
||||
return () => session.detach(el);
|
||||
}, [sessionKey]);
|
||||
}, [sessionKey, active]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -231,7 +236,7 @@ function TabGroup({ group, corners }: { group: string; corners: Corners | null }
|
||||
className={styles.panel}
|
||||
hidden={t.id !== selected}
|
||||
>
|
||||
<TerminalPane sessionKey={t.session} autofocus={t.id === activeTab} />
|
||||
<TerminalPane sessionKey={t.session} active={t.id === selected} autofocus={t.id === activeTab} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -278,7 +283,13 @@ export function TerminalView() {
|
||||
const el = rootRef.current;
|
||||
if (!root || !el) return;
|
||||
const ensureTab = () => {
|
||||
if (el.clientHeight > 0 && useTerminals.getState().tabs.length === 0) newTab(root);
|
||||
if (el.clientHeight <= 0) return;
|
||||
// Restore this project's tabs once the pane is actually visible. A project
|
||||
// switch while the runtime is loaded restores from the project subscription.
|
||||
syncTerminalProject(root);
|
||||
if (useTerminals.getState().tabs.length === 0) newTab(root);
|
||||
// Sessions created while this window was hidden open once it actually has a box.
|
||||
refitSessions();
|
||||
};
|
||||
ensureTab();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
|
||||
@@ -214,12 +214,12 @@ export function activate(): () => void {
|
||||
if (s.settings?.editor !== prev.settings?.editor) terminalRuntime()?.applySettingsToAll();
|
||||
}),
|
||||
onIdeEvent("terminalExited", (e) => terminalRuntime()?.sessionForTerminalId(e.terminal)?.exited(e.code)),
|
||||
// Sessions belong to the project; closing / switching it ends them.
|
||||
// Sessions belong to the project. Remember this project's tabs, then open the next one's.
|
||||
useProject.subscribe((s, prev) => {
|
||||
const rt = terminalRuntime();
|
||||
if (rt && s.project?.root !== prev.project?.root) {
|
||||
if (rt.useTerminalFind.getState().open) rt.closeTerminalFind();
|
||||
rt.closeAll();
|
||||
rt.syncTerminalProject(s.project?.root ?? null);
|
||||
}
|
||||
}),
|
||||
];
|
||||
@@ -242,6 +242,6 @@ export function activate(): () => void {
|
||||
|
||||
return () => {
|
||||
disposers.reverse().forEach((d) => d());
|
||||
terminalRuntime()?.closeAll();
|
||||
terminalRuntime()?.unbindTerminals();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { decode } from "./persist";
|
||||
import {
|
||||
closeAll,
|
||||
getSession,
|
||||
layoutGroups,
|
||||
newTab,
|
||||
renameTab,
|
||||
splitTab,
|
||||
syncTerminalProject,
|
||||
unbindTerminals,
|
||||
useTerminals,
|
||||
} from "./store";
|
||||
|
||||
vi.mock("./session", () => ({
|
||||
TerminalSession: class {
|
||||
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 } }) {
|
||||
this.key = opts.key;
|
||||
this.state = { cwd: opts.cwd };
|
||||
this.command = opts.command;
|
||||
}
|
||||
dispose() {}
|
||||
},
|
||||
}));
|
||||
|
||||
const state = () => useTerminals.getState();
|
||||
const names = () => state().tabs.map((t) => t.customName ?? t.name);
|
||||
|
||||
beforeEach(() => {
|
||||
unbindTerminals();
|
||||
localStorage.clear();
|
||||
closeAll();
|
||||
});
|
||||
|
||||
describe("terminal tab memory", () => {
|
||||
it("rejects a stored value that is not this version", () => {
|
||||
expect(decode(null)).toBeNull();
|
||||
expect(decode({ v: 2, tabs: [], layout: "g" })).toBeNull();
|
||||
expect(decode({ v: 1, tabs: [{}], layout: "g" })).toMatchObject({ tabs: [] });
|
||||
expect(decode({ v: 1, tabs: "nope", layout: "g" })).toBeNull();
|
||||
});
|
||||
|
||||
it("restores each project's tabs, working directories, renames and splits", () => {
|
||||
syncTerminalProject("/repo");
|
||||
const app = newTab("/repo");
|
||||
renameTab(app.id, "Server");
|
||||
const logs = newTab("/repo/logs");
|
||||
const side = splitTab(logs.id, "horizontal")!;
|
||||
expect(state().activeTab).toBe(side);
|
||||
getSession(logs.session)!.state.cwd = "/repo/logs/today";
|
||||
useTerminals.setState((s) => ({
|
||||
sessions: { ...s.sessions, [logs.session]: { ...s.sessions[logs.session]!, cwd: "/repo/logs/today" } },
|
||||
}));
|
||||
|
||||
syncTerminalProject("/other");
|
||||
expect(state().tabs).toEqual([]);
|
||||
newTab("/other");
|
||||
|
||||
syncTerminalProject("/repo");
|
||||
expect(names()).toEqual(["Server", "Local (2)", "Local (3)"]);
|
||||
const restored = state().tabs;
|
||||
expect(getSession(restored[0]!.session)?.state.cwd).toBe("/repo");
|
||||
expect(getSession(restored[1]!.session)?.state.cwd).toBe("/repo/logs/today");
|
||||
expect(getSession(restored[2]!.session)?.state.cwd).toBe("/repo/logs");
|
||||
expect(layoutGroups(state().layout)).toHaveLength(2);
|
||||
expect(restored[2]!.group).not.toBe(restored[1]!.group);
|
||||
expect(state().activeTab).toBe(restored[2]!.id);
|
||||
|
||||
syncTerminalProject("/other");
|
||||
expect(names()).toEqual(["Local"]);
|
||||
expect(getSession(state().tabs[0]!.session)?.state.cwd).toBe("/other");
|
||||
});
|
||||
|
||||
it("reopens a predefined shell, and a one-shot command as a plain shell in the same directory", () => {
|
||||
syncTerminalProject("/repo");
|
||||
newTab("/repo", "zsh", { program: "/bin/zsh", args: [], shell: true });
|
||||
newTab("/repo/app", "build", { program: "npm", args: ["test"] });
|
||||
syncTerminalProject(null);
|
||||
syncTerminalProject("/repo");
|
||||
const [shell, command] = state().tabs;
|
||||
const started = (key: string) =>
|
||||
getSession(key) as { command?: { program: string; args: string[]; shell?: boolean } } | undefined;
|
||||
expect(started(shell!.session)?.command).toEqual({ program: "/bin/zsh", args: [], shell: true });
|
||||
expect(started(command!.session)?.command).toBeUndefined();
|
||||
expect(getSession(command!.session)?.state.cwd).toBe("/repo/app");
|
||||
expect(command!.name).toBe("build");
|
||||
});
|
||||
|
||||
it("keeps the saved tabs when the terminal runtime is dropped", () => {
|
||||
syncTerminalProject("/repo");
|
||||
const tab = newTab("/repo");
|
||||
renameTab(tab.id, "Ship");
|
||||
unbindTerminals();
|
||||
expect(state().tabs).toEqual([]);
|
||||
syncTerminalProject("/repo");
|
||||
expect(names()).toEqual(["Ship"]);
|
||||
expect(getSession(state().tabs[0]!.session)?.state.cwd).toBe("/repo");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Terminal tabs remembered per project: which tabs were open, each one's
|
||||
* working directory and rename, and how the tab groups were split. Shell
|
||||
* scrollback is not kept; a tab comes back as a new session in that directory.
|
||||
*/
|
||||
import type { GroupLayout, SplitOrientation } from "./store";
|
||||
|
||||
const STORAGE_PREFIX = "openidea.terminal.tabs.v1:";
|
||||
|
||||
interface StoredTab {
|
||||
id: string;
|
||||
name: string;
|
||||
customName: string | null;
|
||||
cwd: string;
|
||||
group: string;
|
||||
/** Predefined shell program. A one-shot command is not replayed. */
|
||||
shell: string | null;
|
||||
}
|
||||
|
||||
export interface StoredTerminals {
|
||||
v: 1;
|
||||
tabs: StoredTab[];
|
||||
layout: GroupLayout;
|
||||
selected: Record<string, string>;
|
||||
activeTab: string | null;
|
||||
}
|
||||
|
||||
const MAX_TABS = 32;
|
||||
|
||||
export function storageKey(root: string): string {
|
||||
return STORAGE_PREFIX + root;
|
||||
}
|
||||
|
||||
export function readStored(root: string): StoredTerminals | null {
|
||||
try {
|
||||
return decode(JSON.parse(localStorage.getItem(storageKey(root)) ?? "null"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStored(root: string, stored: StoredTerminals) {
|
||||
try {
|
||||
localStorage.setItem(storageKey(root), JSON.stringify(stored));
|
||||
} catch {
|
||||
/* storage unavailable or full: the tabs just aren't remembered */
|
||||
}
|
||||
}
|
||||
|
||||
export function decode(raw: unknown): StoredTerminals | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const v = raw as Record<string, unknown>;
|
||||
if (v.v !== 1 || !Array.isArray(v.tabs)) return null;
|
||||
const layout = decodeLayout(v.layout);
|
||||
if (!layout) return null;
|
||||
const groups = new Set(groupIds(layout));
|
||||
const fallback = groupIds(layout)[0];
|
||||
if (!fallback) return null;
|
||||
const tabs: StoredTab[] = [];
|
||||
const ids = new Set<string>();
|
||||
for (const item of v.tabs) {
|
||||
if (tabs.length >= MAX_TABS) break;
|
||||
const tab = decodeTab(item, groups, fallback);
|
||||
if (!tab || ids.has(tab.id)) continue;
|
||||
ids.add(tab.id);
|
||||
tabs.push(tab);
|
||||
}
|
||||
const selected: Record<string, string> = {};
|
||||
if (v.selected && typeof v.selected === "object") {
|
||||
for (const [group, id] of Object.entries(v.selected as Record<string, unknown>)) {
|
||||
if (groups.has(group) && typeof id === "string" && ids.has(id)) selected[group] = id;
|
||||
}
|
||||
}
|
||||
const activeTab = typeof v.activeTab === "string" && ids.has(v.activeTab) ? v.activeTab : null;
|
||||
return { v: 1, tabs, layout, selected, activeTab };
|
||||
}
|
||||
|
||||
function decodeTab(item: unknown, groups: Set<string>, fallback: string): StoredTab | null {
|
||||
if (!item || typeof item !== "object") return null;
|
||||
const t = item as Record<string, unknown>;
|
||||
if (typeof t.id !== "string" || !t.id || typeof t.name !== "string" || !t.name.trim()) return null;
|
||||
if (typeof t.cwd !== "string" || !t.cwd) return null;
|
||||
const group = typeof t.group === "string" && groups.has(t.group) ? t.group : fallback;
|
||||
const customName = typeof t.customName === "string" && t.customName.trim() ? t.customName.trim() : null;
|
||||
const shell = typeof t.shell === "string" && t.shell ? t.shell : null;
|
||||
return { id: t.id, name: t.name.trim(), customName, cwd: t.cwd, group, shell };
|
||||
}
|
||||
|
||||
function decodeLayout(raw: unknown): GroupLayout | null {
|
||||
if (typeof raw === "string") return raw || null;
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const node = raw as Record<string, unknown>;
|
||||
if (typeof node.id !== "string" || !node.id) return null;
|
||||
if (node.orientation !== "horizontal" && node.orientation !== "vertical") return null;
|
||||
if (!Array.isArray(node.children) || node.children.length < 2) return null;
|
||||
const children: GroupLayout[] = [];
|
||||
for (const child of node.children) {
|
||||
const decoded = decodeLayout(child);
|
||||
if (!decoded) return null;
|
||||
children.push(decoded);
|
||||
}
|
||||
return { id: node.id, orientation: node.orientation as SplitOrientation, children };
|
||||
}
|
||||
|
||||
function groupIds(node: GroupLayout): string[] {
|
||||
return typeof node === "string" ? [node] : node.children.flatMap(groupIds);
|
||||
}
|
||||
@@ -5,6 +5,8 @@ export {
|
||||
applyThemeToAll,
|
||||
closeAll,
|
||||
newTab,
|
||||
syncTerminalProject,
|
||||
unbindTerminals,
|
||||
newTabAt,
|
||||
requestCloseSession,
|
||||
sessionForTerminalId,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { FIT_INTERVAL_MS } from "./fit";
|
||||
import { findTerminalLinks, type TerminalLink } from "./links";
|
||||
import { openFileLink } from "./session";
|
||||
import { openFileLink, TerminalSession } from "./session";
|
||||
|
||||
const ipc = vi.hoisted(() => ({
|
||||
call: vi.fn(async (v: unknown) => v),
|
||||
@@ -72,3 +73,36 @@ describe("openFileLink", () => {
|
||||
expect(workbench.notify).toHaveBeenCalledWith({ level: "warning", title: "File not found", body: "gone.php" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("terminal open", () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("waits to open xterm until the pane has a size", () => {
|
||||
vi.useFakeTimers();
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const session = new TerminalSession({
|
||||
key: "s-open",
|
||||
cwd: "/tmp",
|
||||
onChange: () => undefined,
|
||||
onFocus: () => undefined,
|
||||
});
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
Object.defineProperty(session.host, "offsetWidth", { configurable: true, get: () => width });
|
||||
Object.defineProperty(session.host, "offsetHeight", { configurable: true, get: () => height });
|
||||
// jsdom has no canvas, so xterm's real open() cannot build a renderer.
|
||||
const open = vi.spyOn(session.term, "open").mockImplementation(() => undefined);
|
||||
session.attach(container);
|
||||
session.focus();
|
||||
vi.advanceTimersToNextFrame();
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
|
||||
width = 400;
|
||||
height = 300;
|
||||
vi.advanceTimersByTime(FIT_INTERVAL_MS);
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
session.dispose();
|
||||
container.remove();
|
||||
});
|
||||
});
|
||||
@@ -181,6 +181,10 @@ export class TerminalSession {
|
||||
private snapshot: SessionSnapshot;
|
||||
private ptyId: TerminalId | null = null;
|
||||
private opened = false;
|
||||
/** `focus()` arrived before the textarea existed (the pane is focused while xterm is still closed). */
|
||||
private pendingFocus = false;
|
||||
/** Bounds retries when the pane has no box yet, or xterm cannot measure a cell. */
|
||||
private fitAttempts = 0;
|
||||
private disposed = false;
|
||||
private disposables: IDisposable[] = [];
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
@@ -379,30 +383,43 @@ export class TerminalSession {
|
||||
}));
|
||||
}
|
||||
|
||||
/** Moves the host into `container` and opens / fits / starts as needed. */
|
||||
/** Moves the host into `container` and fits / starts once the pane has a size. */
|
||||
attach(container: HTMLElement) {
|
||||
if (this.disposed) return;
|
||||
if (this.host.parentElement !== container) container.appendChild(this.host);
|
||||
if (!this.opened) {
|
||||
this.opened = true;
|
||||
// xterm's DOM renderer: the WebGL one rasterizes glyphs per background, so
|
||||
// selected text rendered at a visibly different weight than the rest.
|
||||
this.term.open(this.host);
|
||||
remeasureWhenFontsLoad(
|
||||
this.term,
|
||||
fontFor(workflowSettings().terminal),
|
||||
() => !this.disposed,
|
||||
() => this.scheduleFit(),
|
||||
);
|
||||
this.term.textarea?.addEventListener("focus", () => this.opts.onFocus(this.key));
|
||||
this.watchImeCommit();
|
||||
}
|
||||
this.resizeObserver?.disconnect();
|
||||
this.resizeObserver = new ResizeObserver(() => this.scheduleFit());
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.fitAttempts = 0;
|
||||
this.scheduleFit();
|
||||
});
|
||||
this.resizeObserver.observe(container);
|
||||
this.scheduleFit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens xterm only after the host has a box. `open()` measures the cell size
|
||||
* immediately; doing it while the tool window is still 0×0 (the first layout
|
||||
* pass, or a tab under `display: none`) leaves the helper textarea unable to
|
||||
* take keys for the rest of the session.
|
||||
*/
|
||||
private openIfReady() {
|
||||
if (this.opened || this.disposed) return;
|
||||
if (this.host.offsetWidth === 0 || this.host.offsetHeight === 0) return;
|
||||
// xterm's DOM renderer: the WebGL one rasterizes glyphs per background, so
|
||||
// selected text rendered at a visibly different weight than the rest.
|
||||
this.term.open(this.host);
|
||||
this.opened = true;
|
||||
remeasureWhenFontsLoad(
|
||||
this.term,
|
||||
fontFor(workflowSettings().terminal),
|
||||
() => !this.disposed,
|
||||
() => this.scheduleFit(),
|
||||
);
|
||||
this.term.textarea?.addEventListener("focus", () => this.opts.onFocus(this.key));
|
||||
this.watchImeCommit();
|
||||
if (this.pendingFocus) this.focus();
|
||||
}
|
||||
|
||||
detach(container: HTMLElement) {
|
||||
this.resizeObserver?.disconnect();
|
||||
this.resizeObserver = null;
|
||||
@@ -463,9 +480,21 @@ export class TerminalSession {
|
||||
|
||||
private doFit() {
|
||||
if (this.disposed || !this.host.isConnected) return;
|
||||
if (this.host.offsetWidth === 0 || this.host.offsetHeight === 0) return;
|
||||
// The grid row is still 0 on the first layout pass (the track is set by a
|
||||
// parent layout effect after this one). Keep asking until it has a box;
|
||||
// ResizeObserver also resets the budget when the size actually changes.
|
||||
if (this.host.offsetWidth === 0 || this.host.offsetHeight === 0) {
|
||||
if (this.fitAttempts++ < 20) this.scheduleFit();
|
||||
return;
|
||||
}
|
||||
this.openIfReady();
|
||||
if (!this.opened) return;
|
||||
const dims = this.fit.proposeDimensions();
|
||||
if (!dims || !Number.isFinite(dims.cols) || !Number.isFinite(dims.rows)) return;
|
||||
if (!dims || !Number.isFinite(dims.cols) || !Number.isFinite(dims.rows)) {
|
||||
if (this.fitAttempts++ < 20) this.scheduleFit();
|
||||
return;
|
||||
}
|
||||
this.fitAttempts = 0;
|
||||
if (dims.cols !== this.term.cols || dims.rows !== this.term.rows) this.term.resize(dims.cols, dims.rows);
|
||||
if (this.snapshot.status === "idle") {
|
||||
void this.start();
|
||||
@@ -526,6 +555,7 @@ export class TerminalSession {
|
||||
this.update({ status: "running", title: info.title || "Local" });
|
||||
this.flushPendingInput();
|
||||
} catch (e) {
|
||||
this.pendingInput = "";
|
||||
this.term.writeln(`\x1b[31mFailed to start terminal: ${e instanceof Error ? e.message : String(e)}\x1b[0m`);
|
||||
this.term.writeln(RESTART_HINT);
|
||||
this.update({ status: "error" });
|
||||
@@ -556,6 +586,7 @@ export class TerminalSession {
|
||||
exited(code: number | null) {
|
||||
if (this.disposed) return;
|
||||
this.ptyId = null;
|
||||
this.pendingInput = "";
|
||||
this.update({ status: "exited", exitCode: code });
|
||||
// Let output still in flight on the channel land first.
|
||||
setTimeout(() => {
|
||||
@@ -637,10 +668,24 @@ export class TerminalSession {
|
||||
if (follow !== this.snapshot.follow) this.update({ follow });
|
||||
}
|
||||
|
||||
/** Asks xterm to take the caret. Remembered when the textarea does not exist yet. */
|
||||
focus() {
|
||||
if (this.disposed) return;
|
||||
if (!this.opened || !this.term.textarea) {
|
||||
this.pendingFocus = true;
|
||||
this.scheduleFit();
|
||||
return;
|
||||
}
|
||||
this.pendingFocus = false;
|
||||
this.term.focus();
|
||||
}
|
||||
|
||||
/** Fits again after the tool window is shown or its box changes. */
|
||||
refit() {
|
||||
this.fitAttempts = 0;
|
||||
this.scheduleFit();
|
||||
}
|
||||
|
||||
applyTheme() {
|
||||
this.term.options.theme = readTerminalTheme();
|
||||
this.searchDecorations = null;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { t } from "@/platform/i18n";
|
||||
import { basename } from "@/platform/project";
|
||||
import { workflowSettings } from "@/platform/workflowSettings";
|
||||
import { confirm } from "@/workbench/messages";
|
||||
import { readStored, writeStored, type StoredTerminals } from "./persist";
|
||||
import { TerminalSession, type SessionOptions, type SessionSnapshot } from "./session";
|
||||
|
||||
export type SplitOrientation = "horizontal" | "vertical";
|
||||
@@ -27,6 +28,8 @@ export interface TerminalTab {
|
||||
name: string;
|
||||
/** Set by the user via rename; wins over `name`. */
|
||||
customName: string | null;
|
||||
/** Predefined shell program. Absent for the default shell and for one-shot commands. */
|
||||
shell: string | null;
|
||||
/** Key of the tab's session. */
|
||||
session: string;
|
||||
/** The tab group showing it. */
|
||||
@@ -70,6 +73,11 @@ export function allSessions(): TerminalSession[] {
|
||||
return [...sessions.values()];
|
||||
}
|
||||
|
||||
/** Refits every live session. The terminal tool window calls this when it gains a box. */
|
||||
export function refitSessions() {
|
||||
for (const session of sessions.values()) session.refit();
|
||||
}
|
||||
|
||||
export function sessionForTerminalId(id: number): TerminalSession | undefined {
|
||||
return allSessions().find((s) => s.terminalId === id);
|
||||
}
|
||||
@@ -177,6 +185,7 @@ export function newTab(cwd: string, name?: string, command?: SessionOptions["com
|
||||
id: `t${seq}`,
|
||||
name: defaultTabName(name),
|
||||
customName: null,
|
||||
shell: command?.shell ? command.program : null,
|
||||
session: session.key,
|
||||
group: tabs.find((t) => t.id === activeTab)?.group ?? layoutGroups(layout)[0]!,
|
||||
};
|
||||
@@ -373,7 +382,14 @@ export function splitTab(id: string, orientation: SplitOrientation, move = false
|
||||
return id;
|
||||
}
|
||||
const session = createSession(sessions.get(tab.session)?.state.cwd ?? "/");
|
||||
const copy: TerminalTab = { id: `t${seq}`, name: defaultTabName(), customName: null, session: session.key, group };
|
||||
const copy: TerminalTab = {
|
||||
id: `t${seq}`,
|
||||
name: defaultTabName(),
|
||||
customName: null,
|
||||
shell: null,
|
||||
session: session.key,
|
||||
group,
|
||||
};
|
||||
useTerminals.setState((st) => ({
|
||||
tabs: [...st.tabs, copy],
|
||||
layout,
|
||||
@@ -492,3 +508,123 @@ export function applyThemeToAll() {
|
||||
export function publishFocus(focused: boolean) {
|
||||
setContext("terminalFocus", focused);
|
||||
}
|
||||
|
||||
/** Project whose tabs `useTerminals` currently holds. Null before the first bind and after unbind. */
|
||||
let boundProject: string | null = null;
|
||||
/** Set while swapping projects, so closing the old tabs is not saved as the new project's layout. */
|
||||
let restoring = false;
|
||||
|
||||
function sessionCwd(key: string): string {
|
||||
return sessions.get(key)?.state.cwd ?? "";
|
||||
}
|
||||
|
||||
function capture(): StoredTerminals {
|
||||
const s = useTerminals.getState();
|
||||
const tabs = s.tabs.flatMap((t) => {
|
||||
const cwd = sessionCwd(t.session);
|
||||
return cwd ? [{ id: t.id, name: t.name, customName: t.customName, cwd, group: t.group, shell: t.shell }] : [];
|
||||
});
|
||||
return { v: 1, tabs, layout: s.layout, selected: s.selected, activeTab: s.activeTab };
|
||||
}
|
||||
|
||||
function remember() {
|
||||
if (restoring || !boundProject) return;
|
||||
writeStored(boundProject, capture());
|
||||
}
|
||||
|
||||
function cwdSignature(s: TerminalState): string {
|
||||
return s.tabs.map((t) => s.sessions[t.session]?.cwd ?? "").join("\n");
|
||||
}
|
||||
|
||||
useTerminals.subscribe((s, prev) => {
|
||||
if (restoring || !boundProject) return;
|
||||
if (
|
||||
s.tabs === prev.tabs &&
|
||||
s.layout === prev.layout &&
|
||||
s.selected === prev.selected &&
|
||||
s.activeTab === prev.activeTab &&
|
||||
cwdSignature(s) === cwdSignature(prev)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
remember();
|
||||
});
|
||||
|
||||
function remapLayout(node: GroupLayout, mapGroup: (group: string) => string): GroupLayout {
|
||||
if (typeof node === "string") return mapGroup(node);
|
||||
return {
|
||||
id: `split${++splitSeq}`,
|
||||
orientation: node.orientation,
|
||||
children: node.children.map((child) => remapLayout(child, mapGroup)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Opens the saved tabs in fresh sessions. Group ids are new, so they don't collide with ones minted later. */
|
||||
function restore(saved: StoredTerminals) {
|
||||
const groupOf = new Map<string, string>();
|
||||
const mapGroup = (group: string) => {
|
||||
let mapped = groupOf.get(group);
|
||||
if (!mapped) groupOf.set(group, (mapped = newGroupId()));
|
||||
return mapped;
|
||||
};
|
||||
const layout = remapLayout(saved.layout, mapGroup);
|
||||
const idOf = new Map<string, string>();
|
||||
const tabs: TerminalTab[] = [];
|
||||
for (const savedTab of saved.tabs) {
|
||||
const session = createSession(
|
||||
savedTab.cwd,
|
||||
savedTab.shell ? { program: savedTab.shell, args: [], shell: true } : undefined,
|
||||
);
|
||||
const id = `t${seq}`;
|
||||
idOf.set(savedTab.id, id);
|
||||
tabs.push({
|
||||
id,
|
||||
name: savedTab.name,
|
||||
customName: savedTab.customName,
|
||||
shell: savedTab.shell,
|
||||
session: session.key,
|
||||
group: mapGroup(savedTab.group),
|
||||
});
|
||||
}
|
||||
const selected: Record<string, string> = {};
|
||||
for (const group of layoutGroups(layout)) {
|
||||
const savedGroup = [...groupOf.entries()].find(([, mapped]) => mapped === group)?.[0];
|
||||
const wanted = savedGroup ? idOf.get(saved.selected[savedGroup] ?? "") : undefined;
|
||||
const inGroup = tabs.filter((t) => t.group === group);
|
||||
const id = (wanted && inGroup.some((t) => t.id === wanted) ? wanted : inGroup[0]?.id) ?? "";
|
||||
if (id) selected[group] = id;
|
||||
}
|
||||
const activeTab = (saved.activeTab && idOf.get(saved.activeTab)) || tabs[0]?.id || null;
|
||||
useTerminals.setState({ tabs, layout, selected, activeTab });
|
||||
}
|
||||
|
||||
/**
|
||||
* Points the terminal at `root`, remembering the project it was bound to.
|
||||
* The same root again is a no-op, so a tool window re-render doesn't reopen tabs.
|
||||
*/
|
||||
export function syncTerminalProject(root: string | null) {
|
||||
if (root === boundProject) return;
|
||||
if (boundProject) writeStored(boundProject, capture());
|
||||
restoring = true;
|
||||
boundProject = root;
|
||||
try {
|
||||
closeAll();
|
||||
if (!root) return;
|
||||
const saved = readStored(root);
|
||||
if (saved?.tabs.length) restore(saved);
|
||||
} finally {
|
||||
restoring = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops the in-memory sessions without writing the empty layout over the project that owned them. */
|
||||
export function unbindTerminals() {
|
||||
if (boundProject) writeStored(boundProject, capture());
|
||||
restoring = true;
|
||||
boundProject = null;
|
||||
try {
|
||||
closeAll();
|
||||
} finally {
|
||||
restoring = false;
|
||||
}
|
||||
}
|
||||
@@ -387,7 +387,7 @@ describe("terminalFont", () => {
|
||||
});
|
||||
|
||||
describe("terminal tab titles", () => {
|
||||
const tab: TerminalTab = { id: "t1", name: "Local", customName: null, session: "s1", group: "g1" };
|
||||
const tab: TerminalTab = { id: "t1", name: "Local", customName: null, shell: null, session: "s1", group: "g1" };
|
||||
const snap = (appTitle: string | null, commandRunning = true): Record<string, SessionSnapshot> => ({
|
||||
s1: { status: "running", exitCode: null, title: "zsh", appTitle, commandRunning, cwd: "/p", follow: true },
|
||||
});
|
||||
|
||||
Reference in new issue
Заблокувати користувача