diff --git a/src/features/terminal/TerminalView.tsx b/src/features/terminal/TerminalView.tsx index 097ae34a4..441aa202c 100644 --- a/src/features/terminal/TerminalView.tsx +++ b/src/features/terminal/TerminalView.tsx @@ -32,6 +32,8 @@ import { moveTabToSplit, newTab, publishFocus, + refitSessions, + syncTerminalProject, renameTab, requestCloseSession, requestCloseTab, @@ -61,18 +63,21 @@ function tabTooltip(tab: TerminalTab, sessions: Parameters[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(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 ( ))} @@ -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; diff --git a/src/features/terminal/index.ts b/src/features/terminal/index.ts index 1c230f9a6..2aa97c529 100644 --- a/src/features/terminal/index.ts +++ b/src/features/terminal/index.ts @@ -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(); }; } diff --git a/src/features/terminal/persist.test.ts b/src/features/terminal/persist.test.ts new file mode 100644 index 000000000..8a0061412 --- /dev/null +++ b/src/features/terminal/persist.test.ts @@ -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"); + }); +}); diff --git a/src/features/terminal/persist.ts b/src/features/terminal/persist.ts new file mode 100644 index 000000000..f5457d744 --- /dev/null +++ b/src/features/terminal/persist.ts @@ -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; + 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; + 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(); + 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 = {}; + if (v.selected && typeof v.selected === "object") { + for (const [group, id] of Object.entries(v.selected as Record)) { + 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, fallback: string): StoredTab | null { + if (!item || typeof item !== "object") return null; + const t = item as Record; + 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; + 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); +} diff --git a/src/features/terminal/runtime.ts b/src/features/terminal/runtime.ts index 02dcf8f81..fdb6e1d30 100644 --- a/src/features/terminal/runtime.ts +++ b/src/features/terminal/runtime.ts @@ -5,6 +5,8 @@ export { applyThemeToAll, closeAll, newTab, + syncTerminalProject, + unbindTerminals, newTabAt, requestCloseSession, sessionForTerminalId, diff --git a/src/features/terminal/session.test.ts b/src/features/terminal/session.test.ts index 10134adef..dc4e36f09 100644 --- a/src/features/terminal/session.test.ts +++ b/src/features/terminal/session.test.ts @@ -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(); + }); +}); diff --git a/src/features/terminal/session.ts b/src/features/terminal/session.ts index e44d02ead..1d9a4ee14 100644 --- a/src/features/terminal/session.ts +++ b/src/features/terminal/session.ts @@ -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; diff --git a/src/features/terminal/store.ts b/src/features/terminal/store.ts index e704e7751..5864b5fd4 100644 --- a/src/features/terminal/store.ts +++ b/src/features/terminal/store.ts @@ -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(); + 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(); + 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 = {}; + 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; + } +} diff --git a/src/features/terminal/terminal.test.ts b/src/features/terminal/terminal.test.ts index 0cafae6ba..f6b1a7dfc 100644 --- a/src/features/terminal/terminal.test.ts +++ b/src/features/terminal/terminal.test.ts @@ -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 => ({ s1: { status: "running", exitCode: null, title: "zsh", appTitle, commandRunning, cwd: "/p", follow: true }, });