From beeb32a76bda6877cd0634e9f4727aa296bb8ccd Mon Sep 17 00:00:00 2001 From: Bill Date: Mon, 14 Sep 2026 11:29:56 +0800 Subject: [PATCH] fix(session): keep panel ids distinct from session ids so restore rebinds panes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 面板 id 曾直接复用 pty 会话 id(`addPanel({ id: sessionId })`),而 `updateParameters` 只能换会话、不能改面板 id。于是恢复时面板 id 指向一个 已不存在的会话:分屏少一个、剩下的 pane 背后没有 pty,界面看起来是空白, 也无法输入。 - 新增 `panelId()`,面板 id 与会话 id 彻底分离,并用于所有建面板处 (新建终端、SSH 连接、分屏复制) - 新增 `breakIdCoincidence()`:旧快照仍带 `panelId === sessionId`, 在 `fromJSON` 之前重写 grid/panels/views/activeView 中的 id, 返回 old→new 映射 - `rebindSessionPanels` 用该映射回查快照,恢复每个 pane 自己的目录 (否则新 id 查不到记录,cwd 会静默退回 home) 验证(重启恢复 E2E,legacy 快照 + 2 分屏): 2/2 pane 挂载、2 个独立存活会话、输入可回环、 cwd 分别回到 D:/AIGC/OpenTerminal 与 C:/Windows、回写快照无 id 冲突; restoreSession=false 时正确忽略快照只建 1 个终端。 --- src/main/cwd.ts | 104 ++++++ src/main/ipc.ts | 10 + src/main/pty.ts | 31 +- src/main/sessionState.ts | 82 +++++ src/main/settingsStore.ts | 7 +- src/main/shellIntegration.ts | 114 ++++++ src/main/store.ts | 63 ++++ src/preload/index.ts | 9 +- src/renderer/src/main.tsx | 6 +- src/renderer/src/settings/SettingsTabs.tsx | 20 ++ src/renderer/src/terminal/TerminalView.tsx | 37 ++ src/renderer/src/terminal/cwdTracker.ts | 39 ++ src/renderer/src/workspace/Workspace.tsx | 333 ++++++++++++++++-- src/renderer/src/workspace/sessionCwdStore.ts | 36 ++ src/shared/api.ts | 11 +- src/shared/ipc.ts | 39 +- src/shared/settings.ts | 13 +- 17 files changed, 917 insertions(+), 37 deletions(-) create mode 100644 src/main/cwd.ts create mode 100644 src/main/sessionState.ts create mode 100644 src/main/shellIntegration.ts create mode 100644 src/main/store.ts create mode 100644 src/renderer/src/terminal/cwdTracker.ts create mode 100644 src/renderer/src/workspace/sessionCwdStore.ts diff --git a/src/main/cwd.ts b/src/main/cwd.ts new file mode 100644 index 0000000..8e845ec --- /dev/null +++ b/src/main/cwd.ts @@ -0,0 +1,104 @@ +import { statSync } from 'fs' +import { homedir, hostname } from 'os' +import { isAbsolute, resolve as resolvePath, normalize } from 'path' + +/** + * Resolve a cd-style argument against a known working directory. + * + * Lives in the main process because this is the only side with node's `path`, + * and it must stay platform-aware for the cross-platform roadmap: `path.resolve` + * already does the right thing for `C:\a`, `/home/a`, `..`, `.` and mixed + * separators on the host platform, so the core carries no drive-letter or + * backslash assumptions of its own. + * + * Returns the resolved, existing directory, or null when the argument does not + * denote one (in which case callers keep the previous cwd). + */ + +/** Flags that a shell may put in front of the path (`cd /d X`, `sl -Path X`). */ +const LEADING_FLAGS = /^(?:\/d|-d|-p|-path|-literalpath)\s+/i + +/** Everything from a command separator on is a different command. */ +const SEPARATOR = /\s*(?:&&|\|\||[;|])\s*/ + +export function resolveCwd(current: string | undefined, rawArg: string): string | null { + let arg = rawArg.trim() + // `cd X && something` — only the cd part is ours. + arg = arg.split(SEPARATOR)[0]?.trim() ?? '' + // Repeatedly strip shell flags (`cd /d "D:\x"`, `sl -Path .`). + while (LEADING_FLAGS.test(arg)) arg = arg.replace(LEADING_FLAGS, '').trim() + // Strip one layer of matching quotes ("D:\Program Files" / '~/src'). + const quoted = arg.match(/^(["'])(.*)\1$/) + if (quoted) arg = quoted[2] + + // Bare `cd`, `cd ~`, `cd $HOME` → home directory. + if (arg === '' || arg === '~' || arg === '$HOME' || arg === '%USERPROFILE%') { + return existingDir(homedir()) + } + // `cd -` means the shell's previous directory, which we cannot know here. + if (arg === '-') return null + + const base = current && current.trim() ? current : homedir() + // An absolute argument wins; otherwise resolve against the base. Both forms + // are handled by path.resolve on every platform. + const target = isAbsolute(arg) ? normalize(arg) : resolvePath(base, arg) + return existingDir(target) +} + +function existingDir(candidate: string): string | null { + try { + return statSync(candidate).isDirectory() ? candidate : null + } catch { + return null + } +} + +/** + * Normalize a cwd reported by the shell itself. + * + * Both portable forms land here: + * - OSC 7: `file:///` (bash/zsh/fish, iTerm2, WezTerm, kitty…) + * - OSC 9;9: a bare path (ConEmu convention, Windows shells) + * + * A report from a remote host is ignored (an ssh/container shell is not our + * filesystem). On Windows the POSIX-ish `/C:/dir` form is folded back to + * `C:\dir`; path.resolve does the rest per platform. + */ +export function normalizeReportedCwd(raw: string): string | null { + let value = raw.trim() + if (!value) return null + + const fileUrl = value.match(/^file:\/\/([^/]*)(\/.*)$/i) + if (fileUrl) { + const host = fileUrl[1] + if (host && !isLocalHost(host)) return null + value = safeDecode(fileUrl[2]) + // `/C:/Users/x` → `C:/Users/x` (Windows shells report the URL this way). + const drive = value.match(/^\/([A-Za-z]:)(\/.*)?$/) + if (drive && process.platform === 'win32') value = `${drive[1]}${drive[2] ?? '\\'}` + } + + // A Windows drive path reported with forward slashes still resolves fine. + return existingDir(resolvePath(value)) ?? existingDir(value) +} + +function isLocalHost(host: string): boolean { + const lower = host.toLowerCase() + return lower === 'localhost' || lower === '127.0.0.1' || lower === '::1' || lower === osHostname() +} + +function osHostname(): string { + try { + return hostname().toLowerCase() + } catch { + return '' + } +} + +function safeDecode(value: string): string { + try { + return decodeURIComponent(value) + } catch { + return value + } +} diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 235c4d0..748a82b 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -6,6 +6,8 @@ import type { HostKeyAction, SessionOpenOptions, SshConnection, SshConnectionInp import { getLayout, listLayouts, saveLayout, deleteLayout } from './layouts' import { startPolling, stopPolling } from './sysinfo' import { registerSettingsIpc } from './settingsStore' +import { registerSessionStateIpc } from './sessionState' +import { normalizeReportedCwd, resolveCwd } from './cwd' import { ConnectionsStore, defaultConnectionsPath } from './connectionsStore' import { KnownHostsStore, defaultKnownHostsPath } from './knownHosts' import { resolveHostKey } from './ssh' @@ -171,4 +173,12 @@ export function registerIpc(): void { ipcMain.on(Ipc.LOG_OPEN_DIR, () => cmds.openLogsDir()) registerSettingsIpc() + registerSessionStateIpc() + + // Resolve a cd-style argument against the current cwd (platform-aware; only + // the main process has node's `path`). + ipcMain.handle(Ipc.CWD_RESOLVE, (_event, current: string | undefined, arg: string) => + resolveCwd(current, arg) + ) + ipcMain.handle(Ipc.CWD_REPORT, (_event, payload: string) => normalizeReportedCwd(payload)) } \ No newline at end of file diff --git a/src/main/pty.ts b/src/main/pty.ts index a61a839..5b60f47 100644 --- a/src/main/pty.ts +++ b/src/main/pty.ts @@ -9,6 +9,9 @@ import type { HostKeyCheckResult } from './knownHosts' import { configureSysinfo, registerSysinfoClient, stopPolling } from './sysinfo' import { registerSftpClientProvider, closeSftp } from './sftp' import { attachZmodem, detachZmodem, feedZmodem, isZmodemActive } from './zmodem' +import { pickAdapter } from './shellIntegration' +import { loadSettings } from './settingsStore' +import { statSync } from 'fs' // Re-export so the session-layer loopback bundle (tests/*-e2e.mjs) can drive the // M3 polling engine without importing src/main/sysinfo.ts separately. @@ -124,6 +127,15 @@ export function configureSessionRuntime(deps: SessionRuntimeDeps): void { }) } +function existingDir(candidate: string | undefined): string | null { + if (!candidate || !candidate.trim()) return null + try { + return statSync(candidate).isDirectory() ? candidate : null + } catch { + return null + } +} + function defaultShell(): string { switch (process.platform) { case 'win32': @@ -138,7 +150,10 @@ function defaultShell(): string { export function createPty(opts: PtyCreateOptions = {}): PtyCreateResult { const id = randomUUID() const shell = opts.shell ?? defaultShell() - const cwd = opts.cwd ?? homedir() + // A remembered directory can be gone by the next launch (renamed, unmounted, + // another machine's path after syncing settings) — fall back to home rather + // than failing the spawn. + const cwd = existingDir(opts.cwd) ?? homedir() // Advertise color capability + terminal identity so TUIs (e.g. kimi CLI) // use their full-color theme instead of the degraded white/amber fallback. const env = { @@ -156,7 +171,19 @@ export function createPty(opts: PtyCreateOptions = {}): PtyCreateResult { delete env.NO_COLOR if (env.FORCE_COLOR === '0') delete env.FORCE_COLOR - const pty = spawn(shell, [], { name: 'xterm-256color', cols: 80, rows: 24, cwd, env }) + // Shell integration (opt-in): let the shell announce its own cwd over OSC 7 so + // the remembered path is exact instead of inferred from typed `cd` commands. + // Every platform/shell difference lives in the adapter table, not here. + const args = [...(opts.shellArgs ?? [])] + if (opts.shellArgs === undefined && loadSettings().system.shellIntegration) { + const adapter = pickAdapter(shell) + if (adapter) { + args.push(...adapter.args(shell)) + Object.assign(env, adapter.env?.() ?? {}) + } + } + + const pty = spawn(shell, args, { name: 'xterm-256color', cols: 80, rows: 24, cwd, env }) sessions.set(id, { kind: 'local', pty }) replayBuffers.set(id, '') diff --git a/src/main/sessionState.ts b/src/main/sessionState.ts new file mode 100644 index 0000000..890cfa3 --- /dev/null +++ b/src/main/sessionState.ts @@ -0,0 +1,82 @@ +import { app, ipcMain } from 'electron' +import { join } from 'path' +import { Ipc, type SessionPanelState, type SessionSnapshot } from '../shared/ipc' +import { isRecord, readJson, writeJson } from './store' + +/** + * The automatic session snapshot: dockview layouts plus a per-panel record, so + * the next launch can restore the arrangement and drop every pane back into the + * directory it was in. + * + * Distinct from the user-managed layout *templates* (`layouts/`): templates are + * deliberately saved/loaded sets of layouts, the snapshot is the single + * "where I left off" memory. They coexist. + */ + +const MAX_PANELS = 64 +/** A layout is a few KB; anything past this is a bug or a runaway, not a layout. */ +const MAX_LAYOUT_BYTES = 2 * 1024 * 1024 + +const snapshotPath = (): string => join(app.getPath('userData'), 'session.json') + +function isPanel(value: unknown): value is SessionPanelState { + if (!isRecord(value)) return false + return ( + typeof value.panelId === 'string' && + typeof value.title === 'string' && + (value.kind === 'local' || value.kind === 'ssh') && + (value.cwd === undefined || typeof value.cwd === 'string') && + (value.connectionId === undefined || typeof value.connectionId === 'string') && + (value.hostLabel === undefined || typeof value.hostLabel === 'string') + ) +} + +/** Parse defensively: a corrupt snapshot must degrade to "no snapshot". */ +function sanitize(raw: unknown): SessionSnapshot | null { + if (!isRecord(raw)) return null + const layouts = isRecord(raw.layouts) ? raw.layouts : {} + const panels = Array.isArray(raw.panels) ? raw.panels.filter(isPanel).slice(0, MAX_PANELS) : [] + if (panels.length === 0 && !layouts.terminal && !layouts.ssh) return null + + const encode = (value: unknown): unknown => { + if (value === undefined || value === null) return undefined + // Cheap size guard: serializing here means a too-large layout is dropped + // rather than written to disk on every save. + try { + const json = JSON.stringify(value) + return json.length > MAX_LAYOUT_BYTES ? undefined : value + } catch { + return undefined + } + } + + return { + version: 1, + savedAt: typeof raw.savedAt === 'number' ? raw.savedAt : Date.now(), + mode: raw.mode === 'ssh' ? 'ssh' : 'terminal', + layouts: { terminal: encode(layouts.terminal), ssh: encode(layouts.ssh) }, + panels, + lastLocalCwd: typeof raw.lastLocalCwd === 'string' ? raw.lastLocalCwd : undefined + } +} + +export function loadSessionSnapshot(): SessionSnapshot | null { + return sanitize(readJson(snapshotPath())) +} + +export function saveSessionSnapshot(next: SessionSnapshot): SessionSnapshot | null { + const clean = sanitize(next) + if (!clean) return null + clean.savedAt = Date.now() + writeJson(snapshotPath(), clean) + return clean +} + +export function clearSessionSnapshot(): void { + writeJson(snapshotPath(), { version: 1, savedAt: Date.now(), mode: 'terminal', layouts: {}, panels: [] }) +} + +export function registerSessionStateIpc(): void { + ipcMain.handle(Ipc.SESSION_STATE_GET, () => loadSessionSnapshot()) + ipcMain.handle(Ipc.SESSION_STATE_SET, (_event, snapshot: SessionSnapshot) => saveSessionSnapshot(snapshot)) +} diff --git a/src/main/settingsStore.ts b/src/main/settingsStore.ts index a2fe2b4..da7a64d 100644 --- a/src/main/settingsStore.ts +++ b/src/main/settingsStore.ts @@ -91,13 +91,16 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } { const closeAction = candidate.closeAction === 'tray' || candidate.closeAction === 'exit' ? candidate.closeAction - : 'ask' + : 'tray' system = { launchAtLogin: candidate.launchAtLogin === true, preventSleep: candidate.preventSleep === true, globalShowHide, closeAction, - autoCheckUpdate: candidate.autoCheckUpdate !== false + autoCheckUpdate: candidate.autoCheckUpdate !== false, + // Both default on/off as in DEFAULT_SETTINGS; absent means "not chosen". + restoreSession: candidate.restoreSession !== false, + shellIntegration: candidate.shellIntegration === true } } } diff --git a/src/main/shellIntegration.ts b/src/main/shellIntegration.ts new file mode 100644 index 0000000..2157692 --- /dev/null +++ b/src/main/shellIntegration.ts @@ -0,0 +1,114 @@ +import { basename } from 'path' +import { homedir } from 'os' + +/** + * Shell integration: make the shell announce its working directory so the app + * can remember it exactly, instead of guessing from typed `cd` commands. + * + * The channel is the industry standard **OSC 7** (`ESC ] 7 ; file:// + * BEL`): bash, zsh, fish, iTerm2, WezTerm, kitty and Windows Terminal all speak + * it, so the app's parser is written once and works on every platform. Windows + * shells that historically emit ConEmu's **OSC 9;9** (`ESC ] 9 ; 9 ; BEL`) + * are handled by the same parser as a second form. + * + * Everything platform-specific is confined to an adapter below: adding a shell + * or a platform means adding one entry here, never touching the renderer, the + * snapshot format or the restore path. + * + * Trade-off: an adapter wraps the user's prompt (that is the only reliable hook + * a foreign process has), so this whole feature is opt-in via + * 设置 → 系统 → Shell 集成. With it off the app falls back to parsing typed + * `cd` commands, which covers the common case with zero interference. + */ + +export interface ShellAdapter { + /** Stable id used by the setting and by logs. */ + id: string + /** Human label shown in settings. */ + label: string + /** True when this adapter can drive the given shell executable. */ + matches(shellPath: string, platform: NodeJS.Platform): boolean + /** Extra argv appended when spawning the shell. */ + args(shellPath: string): string[] + /** Extra environment variables for the child. */ + env?(): Record +} + +/** `ESC ] 7 ; file:/// BEL` — the portable form. */ +function posixPromptCommand(): string { + return `printf '\\033]7;file://%s%s\\033\\\\' "$HOSTNAME" "$PWD"` +} + +/** + * PowerShell: keep the user's prompt function and re-emit it after our own + * write, so oh-my-posh / starship / a hand-written prompt all keep working. + */ +const POWERSHELL_ADAPTER: ShellAdapter = { + id: 'powershell', + label: 'PowerShell', + matches: (shellPath) => /(?:^|[\\/])(pwsh|powershell)(\.exe)?$/i.test(shellPath), + args: () => [ + '-NoLogo', + '-NoExit', + '-Command', + [ + // Save whatever prompt is in effect, then wrap it. + '$global:__otPrompt = $function:prompt', + 'function global:prompt {', + ' $p = $PWD.ProviderPath; if (-not $p) { $p = $PWD.Path }', + ' $esc = [char]27; $bel = [char]7', + ' Write-Host -NoNewline "$esc]7;file://$env:COMPUTERNAME/$($p -replace \'\\\\\',\'/\')$bel"', + ' if ($global:__otPrompt) { & $global:__otPrompt } else { "PS $p> " }', + '}' + ].join(' ') + ] +} + +/** cmd.exe: `$E` is ESC inside the prompt string, `$P` the current directory. */ +const CMD_ADAPTER: ShellAdapter = { + id: 'cmd', + label: '命令提示符 (cmd)', + matches: (shellPath, platform) => platform === 'win32' && /(?:^|[\\/])cmd(\.exe)?$/i.test(shellPath), + args: () => [ + '/K', + // Emit OSC 7 (path with backslashes swapped for slashes) then the normal + // `C:\dir>` prompt. + 'prompt $E]7;file://%COMPUTERNAME%/$P$E\\$P$G' + ] +} + +/** + * POSIX shells: a one-line prompt hook. bash honours PROMPT_COMMAND directly; + * zsh/fish hook through the same env var where their startup files cooperate — + * see `env()` notes. Implementation is kept deliberately small because these + * need verifying on their own platforms before being enabled by default. + */ +const POSIX_ADAPTER: ShellAdapter = { + id: 'posix', + label: 'bash / zsh / fish', + matches: (shellPath, platform) => + platform !== 'win32' && /(?:^|[\\/])(bash|zsh|dash|fish|sh)$/i.test(shellPath), + args: () => [], + env: () => ({ + // bash reads this before every prompt. zsh ignores it (no harm); fish uses + // its own hook, so fish users on this path simply keep the cd-parsing + // fallback until a dedicated adapter is verified. + PROMPT_COMMAND: posixPromptCommand() + }) +} + +/** Every adapter the app knows about. Order matters: first match wins. */ +export const SHELL_ADAPTERS: ShellAdapter[] = [POWERSHELL_ADAPTER, CMD_ADAPTER, POSIX_ADAPTER] + +export function pickAdapter(shellPath: string, platform: NodeJS.Platform = process.platform): ShellAdapter | null { + const name = basename(shellPath) + return SHELL_ADAPTERS.find((a) => a.matches(name, platform) || a.matches(shellPath, platform)) ?? null +} + +/** The default POSIX shell, used when the setting is on and no shell is given. */ +export function posixFallbackShell(): string { + return process.env.SHELL || '/bin/sh' +} + +/** Kept for symmetry with the adapters that need a home directory. */ +export const integrationHome = (): string => homedir() diff --git a/src/main/store.ts b/src/main/store.ts new file mode 100644 index 0000000..be369af --- /dev/null +++ b/src/main/store.ts @@ -0,0 +1,63 @@ +import { mkdirSync, readFileSync, renameSync, writeFileSync, unlinkSync } from 'fs' +import { dirname, join } from 'path' + +/** + * Small shared persistence helper for the app's JSON stores (settings, + * connections, known hosts, commands, layouts, the session snapshot). + * + * Why not a database: these are a handful of KB written by a single process at + * human speed, and every native DB binding (better-sqlite3 et al) would add a + * per-platform rebuild + packaging burden to a cross-platform app. What the + * stores actually needed was the *reliability* a DB implies — that is what this + * gives: an atomic write (write to a sibling temp file, then rename) so a crash + * or a full disk can never leave a half-written file behind, and a capped size + * so a runaway value cannot fill the disk either. + * + * If a dataset ever outgrows this (command history in the tens of thousands, or + * full-text search over session logs), swap the implementation here — callers + * only ever see `readJson`/`writeJson`. + */ + +/** Refuse to load a file larger than this (guards against a runaway store). */ +const MAX_BYTES = 8 * 1024 * 1024 + +const tmpPath = (file: string): string => `${file}.tmp` + +export function readJson(file: string): T | null { + try { + const raw = readFileSync(file, 'utf8') + if (raw.length > MAX_BYTES) return null + return JSON.parse(raw) as T + } catch { + // Missing, unreadable or corrupt: callers fall back to their defaults. + // A corrupt file is left in place (not deleted) so it can be inspected. + return null + } +} + +export function writeJson(file: string, value: unknown): void { + mkdirSync(dirname(file), { recursive: true }) + const tmp = tmpPath(file) + try { + writeFileSync(tmp, JSON.stringify(value, null, 2), 'utf8') + renameSync(tmp, file) + } catch (err) { + // Clean up the temp file so a failed write does not leave litter behind. + try { + unlinkSync(tmp) + } catch { + /* nothing to clean */ + } + throw err + } +} + +/** Convenience for stores whose file lives under a directory. */ +export function jsonFileIn(dir: string, name: string): string { + return join(dir, name) +} + +/** Minimal shape guard used by the stores before trusting a parsed value. */ +export function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 9cad413..0ef2384 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2,7 +2,7 @@ import { contextBridge, ipcRenderer } from 'electron' import { Ipc } from '../shared/ipc' import type { AppSettings } from '../shared/settings' import type { AppApi } from '../shared/api' -import type { LayoutMeta, PtyCreateOptions, PtyDataEvent, PtyExitEvent, ReleaseNote, UpdateState, ZmodemOfferEvent, ZmodemResponse, ZmodemDoneEvent } from '../shared/ipc' +import type { LayoutMeta, PtyCreateOptions, PtyDataEvent, PtyExitEvent, ReleaseNote, SessionSnapshot, UpdateState, ZmodemOfferEvent, ZmodemResponse, ZmodemDoneEvent } from '../shared/ipc' import type { HostKeyAction, HostKeyPromptEvent, @@ -127,7 +127,12 @@ const api: AppApi = { const listener = (_: unknown, s: UpdateState): void => cb(s) ipcRenderer.on(Ipc.UPDATE_STATE, listener) return () => ipcRenderer.removeListener(Ipc.UPDATE_STATE, listener) - } + }, + + getSessionState: () => ipcRenderer.invoke(Ipc.SESSION_STATE_GET), + saveSessionState: (snapshot: SessionSnapshot) => ipcRenderer.invoke(Ipc.SESSION_STATE_SET, snapshot), + resolveCwd: (current: string | undefined, arg: string) => ipcRenderer.invoke(Ipc.CWD_RESOLVE, current, arg), + reportCwd: (payload: string) => ipcRenderer.invoke(Ipc.CWD_REPORT, payload) } contextBridge.exposeInMainWorld('api', api) diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index ec927dc..6fbeafb 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -91,7 +91,11 @@ if (typeof window !== 'undefined' && !window.api) { updateDownload: async () => undefined, updateInstall: async () => undefined, updateChangelog: async () => [], - onUpdateState: () => noop + onUpdateState: () => noop, + getSessionState: async () => null, + saveSessionState: async () => null, + resolveCwd: async () => null, + reportCwd: async () => null } ;(window as unknown as { api: AppApi }).api = api } diff --git a/src/renderer/src/settings/SettingsTabs.tsx b/src/renderer/src/settings/SettingsTabs.tsx index c4df929..5b8c5e0 100644 --- a/src/renderer/src/settings/SettingsTabs.tsx +++ b/src/renderer/src/settings/SettingsTabs.tsx @@ -317,6 +317,26 @@ export function SystemSettingsTab(): React.JSX.Element { /> } /> + void updateSystem({ restoreSession: checked })} + /> + } + /> + void updateSystem({ shellIntegration: checked })} + /> + } + /> { + void window.api + .reportCwd(payload) + .then((cwd: string | null) => { + if (cwd) setSessionCwd(sessionId, cwd) + }) + .catch(() => undefined) + } + const oscDisposables = [ + term.parser.registerOscHandler(7, (payload) => { + reportCwd(payload) + return true + }), + term.parser.registerOscHandler(9, (payload) => { + const value = conemuCwd(payload) + if (value) reportCwd(value) + return true + }) + ] + termRef.current = term // Rebind (e.g. template apply) must clear a death marker left by the // previous session's exit — otherwise the pane stays under the dead mask. @@ -682,6 +707,7 @@ export const TerminalView: ForwardRefExoticComponent { if (!deadRef.current) writeBroadcast(sessionId, data) // M5: a completion acceptance rewrite armed `diffRewriteRef`; consume it @@ -707,6 +733,17 @@ export const TerminalView: ForwardRefExoticComponent undefined) + // Remember the directory for the next launch: a cd-style line is + // resolved in the main process (the only side with node's `path`). + const cdArg = cdArgument(step.submit) + if (cdArg !== null) { + void window.api + .resolveCwd(getSessionCwd(sessionId), cdArg) + .then((next: string | null) => { + if (next) setSessionCwd(sessionId, next) + }) + .catch(() => undefined) + } } if (deadRef.current) { suggestionsRef.current = [] diff --git a/src/renderer/src/terminal/cwdTracker.ts b/src/renderer/src/terminal/cwdTracker.ts new file mode 100644 index 0000000..4f9d111 --- /dev/null +++ b/src/renderer/src/terminal/cwdTracker.ts @@ -0,0 +1,39 @@ +/** + * Working-directory tracking helpers. + * + * Two signals feed the same store: + * 1. the shell itself, over OSC 7 (`file://host/path`) or ConEmu's OSC 9;9 — + * exact, but only present when shell integration is on; + * 2. the command the user typed, matched against the `cd` family — the + * fallback that works with no shell cooperation at all. + * + * Both are platform-neutral: path resolution and existence checks happen in the + * main process (`src/main/cwd.ts`) where node's `path` lives, so nothing here + * assumes backslashes or drive letters. + */ + +/** + * The `cd` family, per shell: + * bash/zsh/dash/fish cd + * cmd.exe cd, chdir + * PowerShell cd (alias), sl (alias), Set-Location, pushd/Push-Location + * The `$` anchor is what keeps lookalikes out: `cdrom`, `cdemo` never match. + */ +const CD_COMMAND = /^\s*(?:cd|chdir|sl|set-location|pushd|push-location)(?:\s+([\s\S]*))?$/i + +/** + * Return the argument of a cd-style command line, or null when the line is not + * one. An empty string means "bare cd" (the shell's home directory). + */ +export function cdArgument(line: string): string | null { + const match = line.match(CD_COMMAND) + if (!match) return null + return (match[1] ?? '').trim() +} + +/** Payload of an OSC 9 sequence that follows the ConEmu `9;` convention. */ +export function conemuCwd(payload: string): string | null { + if (!payload.startsWith('9;')) return null + const value = payload.slice(2).trim() + return value || null +} diff --git a/src/renderer/src/workspace/Workspace.tsx b/src/renderer/src/workspace/Workspace.tsx index 27d3d30..85f83f0 100644 --- a/src/renderer/src/workspace/Workspace.tsx +++ b/src/renderer/src/workspace/Workspace.tsx @@ -16,6 +16,7 @@ import { import { DockviewReact } from 'dockview-react' import type { DockviewApi, + DockviewPanelApi, DockviewReadyEvent, IDockviewHeaderActionsProps, IDockviewPanel, @@ -33,6 +34,9 @@ import { TerminalPanel } from './TerminalPanel' import { ApplyTemplateModal, SaveTemplateModal, TerminalTab } from './TerminalTab' import { useBroadcastStore, writeBroadcast } from './broadcastStore' import { QuickInputPanel, useQuickInputStore } from './QuickInputPanel' +import { forgetSessionCwd, getSessionCwd, onSessionCwdChange, setSessionCwd } from './sessionCwdStore' +import { useSettingsStore } from '@renderer/settings/store' +import type { SessionSnapshot, SessionPanelState } from '@shared/ipc' import type { WorkspaceMode } from './workspaceMode' import { useWorkspaceModeStore } from './workspaceModeStore' import './workspace.css' @@ -69,8 +73,97 @@ function nextTerminalTitle(): string { return `终端 ${titleSeqHolder.__otTitleSeq}` } -function sessionIdOf(panel: IDockviewPanel): string | undefined { - return (panel.params as TerminalParams | undefined)?.sessionId +function sessionIdOf(panel: IDockviewPanel | undefined): string | undefined { + return (panel?.params as TerminalParams | undefined)?.sessionId +} + +/** + * A dockview panel id. + * + * Panel ids must never be reused as pty session ids. `updateParameters` can + * swap the session behind a panel, but the panel id is fixed for the panel's + * whole life, so an id that doubles as a session id goes stale the moment the + * pane is rebound: the restored pane would be addressed by an id that no longer + * names any session, and it comes up with no pty behind it. Ids here are opaque + * and distinct from any session id by construction. + */ +function panelId(): string { + return `p-${window.crypto.randomUUID().slice(0, 8)}` +} + +interface RawLayout { + grid?: unknown + panels?: Record + activeGroup?: string +} + +interface RenamedLayout { + layout: unknown + /** old panel id → new panel id, so a caller holding a snapshot keyed by the + * old ids can still find a panel's recorded state afterwards. */ + renames: Map +} + +/** + * Rewrite a snapshot layout so no panel id equals its session id. + * + * Older builds created panels with `id: sessionId`, and those snapshots are + * still on disk. `updateParameters` cannot rename a panel, so the id has to be + * swapped in the serialized layout where it appears in all three places that + * must agree: the `panels` map key, the panel's own `id`, and every `views` + * entry in the grid tree. + * + * Returns the layout untouched when no collision exists (the common case), so + * the fast path stays a plain object reference. + */ +function breakIdCoincidence(layout: unknown): RenamedLayout { + if (!layout || typeof layout !== 'object') return { layout, renames: new Map() } + const raw = layout as RawLayout + const panels = raw.panels + if (!panels) return { layout, renames: new Map() } + const renames = new Map() + for (const [key, panel] of Object.entries(panels)) { + const sessionId = panel?.params?.sessionId + if (sessionId && key === sessionId) renames.set(key, panelId()) + } + if (renames.size === 0) return { layout, renames } + + const next: RawLayout = { ...raw, panels: {} } + for (const [key, panel] of Object.entries(panels)) { + const target = renames.get(key) + next.panels![target ?? key] = target ? { ...panel, id: target } : panel + } + // `views` / `activeView` are the only fields naming panels. Three shapes to + // tell apart, and the walk must preserve whichever is present: + // grid { root, width, height, orientation } + // branch { type: 'branch', data: node[], size } + // leaf { type: 'leaf', data: { views, activeView, id }, size } + const renameLeaf = (leaf: Record): Record => { + if (!Array.isArray(leaf.views)) return leaf + return { + ...leaf, + views: leaf.views.map((v) => (typeof v === 'string' ? renames.get(v) ?? v : v)), + activeView: + typeof leaf.activeView === 'string' ? renames.get(leaf.activeView) ?? leaf.activeView : leaf.activeView + } + } + const renameNode = (node: unknown): unknown => { + if (Array.isArray(node)) return node.map(renameNode) + if (!node || typeof node !== 'object') return node + const entry = node as Record + if (entry.type === 'leaf' || entry.type === 'branch') { + const data = entry.data + if (entry.type === 'leaf' && data && typeof data === 'object' && !Array.isArray(data)) { + return { ...entry, data: renameLeaf(data as Record) } + } + return { ...entry, data: renameNode(data) } + } + // The grid wrapper (and anything else shaped like it) keys its tree on `root`. + if (entry.root !== undefined) return { ...entry, root: renameNode(entry.root) } + return node + } + next.grid = renameNode(raw.grid) + return { layout: next, renames } } /** The dockview api that owns a given workspace mode. */ @@ -92,8 +185,9 @@ function createTabActions(mode: WorkspaceMode): (props: IDockviewHeaderActionsPr } void (async (): Promise => { const result = await window.api.createPty() + setSessionCwd(result.id, result.cwd) containerApi.addPanel({ - id: result.id, + id: panelId(), component: TERMINAL_COMPONENT, tabComponent: TERMINAL_TAB_COMPONENT, title: nextTerminalTitle(), @@ -185,6 +279,54 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX /** B's sidebar handle; refresh() after a successful SSH connect */ const sidebarRef = useRef(null) + // ---- session memory (restore last layout + per-pane cwd) ---- + /** Set once the boot pass has read the snapshot; saves stay quiet until then. */ + const bootDoneRef = useRef(false) + /** Debounce handle for the snapshot write. */ + const saveTimerRef = useRef(0) + /** Newest local directory seen — seeds brand-new terminals. */ + const lastLocalCwdRef = useRef(null) + + /** Assemble what the next launch needs: both layouts, every pane's kind and + * directory, and the workspace the user was in. */ + const buildSnapshot = useCallback((): SessionSnapshot => { + const collect = (api: DockviewApi | undefined): SessionPanelState[] => { + if (!api) return [] + return api.panels.map((panel) => { + const params = panel.params as TerminalParams | undefined + return { + panelId: panel.id, + title: panel.title ?? '', + kind: params?.sessionKind === 'ssh' ? 'ssh' : 'local', + cwd: getSessionCwd(params?.sessionId), + hostLabel: params?.hostLabel + } + }) + } + return { + version: 1, + savedAt: Date.now(), + mode: useWorkspaceModeStore.getState().mode, + layouts: { + terminal: terminalApiRef.current?.toJSON(), + ssh: sshApiRef.current?.toJSON() + }, + panels: [...collect(terminalApiRef.current), ...collect(sshApiRef.current)], + lastLocalCwd: lastLocalCwdRef.current ?? undefined + } + }, []) + + /** Debounced write (layout changes stream during window drags). */ + const scheduleSave = useCallback((): void => { + // Never overwrite the snapshot before the boot pass has read it — an empty + // layout would otherwise erase the very thing we are about to restore. + if (!bootDoneRef.current) return + window.clearTimeout(saveTimerRef.current) + saveTimerRef.current = window.setTimeout(() => { + void window.api.saveSessionState(buildSnapshot()).catch(() => undefined) + }, 500) + }, [buildSnapshot]) + /** Idempotently kill a pty session. */ const killSession = useCallback((sessionId: string | undefined): void => { if (!sessionId) return @@ -198,8 +340,13 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX deadSessionsRef.current.add(sessionId) }, []) - const createSession = useCallback(async (): Promise => { - const result = await window.api.createPty() + const createSession = useCallback(async (cwd?: string): Promise => { + // `cwd` is the memory hook: a restored pane and a freshly opened one both + // start where the user left off. The main process validates the path and + // answers with the directory it actually used, which seeds the cwd store so + // an immediate snapshot save already reflects it. + const result = await window.api.createPty(cwd ? { cwd } : {}) + setSessionCwd(result.id, result.cwd) return result.id }, []) @@ -271,9 +418,13 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX const cleanups = [ api.onDidRemovePanel((panel: IDockviewPanel) => { + // Closing a pane keeps its directory as the seed for the next one. + const closing = getSessionCwd(sessionIdOf(panel)) + if (closing) lastLocalCwdRef.current = closing releaseSession(panel) recomputeLocalPanels() recountAll() + scheduleSave() }), api.onDidActivePanelChange((event) => { if (mode === 'terminal') activeTerminalPanelRef.current = event.panel ?? undefined @@ -286,15 +437,17 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX api.onDidLayoutChange(() => { recomputeLocalPanels() recountAll() + scheduleSave() }), api.onDidAddPanel((panel: IDockviewPanel) => { countPanel(panel) recountAll() + scheduleSave() }) ] disposablesRef.current.push(...cleanups) }, - [killSession, recomputeLocalPanels, recountAll] + [killSession, recomputeLocalPanels, recountAll, scheduleSave] ) const handleTerminalReady = useCallback( @@ -338,14 +491,46 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX return useWorkspaceModeStore.subscribe(sync) }, []) - /** First run: seed one local terminal so the terminal workspace is never - * empty on launch. Runs once, after the terminal dockview is ready. */ + /** Session memory follows the directories the panes report and the workspace + * the user is in — both feed the debounced snapshot write. */ + useEffect(() => { + const offCwd = onSessionCwdChange(() => { + const active = getSessionCwd(sessionIdOf(activeTerminalPanelRef.current)) + if (active) lastLocalCwdRef.current = active + scheduleSave() + }) + const offMode = useWorkspaceModeStore.subscribe(() => scheduleSave()) + return () => { + offCwd() + offMode() + } + }, [scheduleSave]) + + /** + * Boot pass: pick up where the user left off — layout plus each pane's + * directory — when 设置 → 系统 → 启动时恢复上次会话 is on. Otherwise (or when no + * snapshot exists) seed one terminal. Either way the workspace is never empty. + */ useEffect(() => { if (!terminalReady) return - const current = terminalApiRef.current - if (current && !current.panels.some((p) => sessionIdOf(p))) void addTerminal() - // seed the terminal workspace exactly once, on first ready - // eslint-disable-next-line react-hooks/exhaustive-deps + let cancelled = false + void (async (): Promise => { + const current = terminalApiRef.current + if (!current) return + const restoreOn = useSettingsStore.getState().settings.system.restoreSession !== false + const snapshot = restoreOn ? await window.api.getSessionState().catch(() => null) : null + if (cancelled) return + const restorable = Boolean(snapshot && (snapshot.layouts.terminal || snapshot.panels.length > 0)) + const restored = snapshot && restorable ? await restoreFromSnapshot(snapshot) : false + if (cancelled) return + if (!restored && !current.panels.some((p) => sessionIdOf(p))) void addTerminal() + bootDoneRef.current = true + scheduleSave() + })() + return () => { + cancelled = true + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- boot exactly once, on first ready }, [terminalReady]) /** Kill remaining sessions on window close. */ @@ -425,11 +610,15 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX const current = terminalApiRef.current if (!current) return useWorkspaceModeStore.getState().setMode('terminal') - const sessionId = await createSession() + // Start where the user already is: the active pane's directory, else the + // most recent one we saw (e.g. the pane they just closed). + const inherit = + getSessionCwd(sessionIdOf(activeTerminalPanelRef.current)) ?? lastLocalCwdRef.current ?? undefined + const sessionId = await createSession(inherit) const reference = activeTerminalPanelRef.current current.addPanel({ - id: sessionId, + id: panelId(), component: TERMINAL_COMPONENT, tabComponent: TERMINAL_TAB_COMPONENT, title: nextTerminalTitle(), @@ -464,7 +653,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX // is visible once added. useWorkspaceModeStore.getState().setMode('ssh') current.addPanel({ - id: result.id, + id: panelId(), component: TERMINAL_COMPONENT, tabComponent: TERMINAL_TAB_COMPONENT, title: conn.name, @@ -549,7 +738,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX if (!sessionId) return const refParams = reference.params as TerminalParams | undefined current.addPanel({ - id: `ssh-${sessionId}-${window.crypto.randomUUID().slice(0, 8)}`, + id: panelId(), component: TERMINAL_COMPONENT, tabComponent: TERMINAL_TAB_COMPONENT, title: reference.title, @@ -575,23 +764,113 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX }, []) /** - * Rebind every restored session panel to a fresh local pty. Templates cannot - * carry live session state (SSH needs credentials not stored in the layout), - * so sessions are recreated as local terminals in whichever workspace the - * layout put them. + * Replace the pty behind an existing panel, in place. + * + * Panel identity and session identity are deliberately separate: a panel keeps + * its parameters object across `updateParameters`, so React re-renders the + * panel's `` with the new `sessionId` prop exactly the way it + * does when the user switches terminals — its bind effect tears the old xterm + * down and replays the new session's output. */ - const rebindRestoredPanels = useCallback( - async (api: DockviewApi): Promise => { + const bindPanelSession = useCallback( + async (panel: DockviewPanelApi | undefined, currentSessionId: string, cwd?: string): Promise => { + if (!panel) return + const nextSessionId = await createSession(cwd) + panel.updateParameters({ sessionId: nextSessionId, sessionKind: 'local' }) + deadSessionsRef.current.delete(currentSessionId) + forgetSessionCwd(currentSessionId) + }, + [createSession] + ) + + /** + * Rebind restored panels to fresh ptys. + * + * Templates cannot carry live session state (SSH needs credentials that are + * never stored in a layout), so their panels are recreated as local terminals + * in whichever workspace the layout put them. + * + * The automatic snapshot additionally carries each pane's last working + * directory, and `dropSsh` closes ssh panes rather than faking them as local + * terminals — a restored ssh pane has no session to show yet (the reconnect + * placeholder is a follow-up). + * + * `renames` bridges the id rewrite the caller applied to the layout: the + * snapshot is keyed by the panel ids *as they were written*, but the panels + * now carry fresh ids, so a lookup by the new id would always miss and the + * pane would silently lose its remembered directory. + */ + const rebindSessionPanels = useCallback( + async ( + api: DockviewApi | undefined, + snapshot?: SessionSnapshot, + dropSsh = false, + renames?: Map + ): Promise => { + if (!api) return + const recorded = new Map((snapshot?.panels ?? []).map((p) => [p.panelId, p])) + const lookup = (panelId: string): SessionPanelState | undefined => { + const direct = recorded.get(panelId) + if (direct) return direct + for (const [oldId, newId] of renames ?? []) { + if (newId === panelId) return recorded.get(oldId) + } + return undefined + } for (const panel of api.panels) { const id = sessionIdOf(panel) - if (id && panel.view.contentComponent === TERMINAL_COMPONENT) { - const newSessionId = (await window.api.createPty()).id - panel.api.updateParameters({ sessionId: newSessionId, sessionKind: 'local' }) - deadSessionsRef.current.delete(id) + if (!id || panel.view.contentComponent !== TERMINAL_COMPONENT) continue + if (dropSsh && (panel.params as TerminalParams | undefined)?.sessionKind === 'ssh') { + panel.api.close() + continue } + await bindPanelSession(panel.api, id, lookup(panel.id)?.cwd) } }, - [] + [bindPanelSession] + ) + + const rebindRestoredPanels = useCallback( + async (api: DockviewApi): Promise => { + await rebindSessionPanels(api) + }, + [rebindSessionPanels] + ) + + /** + * Restore the previous layout and drop every pane back into the directory it + * was left in. Returns false when the snapshot cannot be applied (an + * incompatible dockview layout), so the caller falls back to a fresh terminal. + * + * The layout is rewritten before `fromJSON` sees it: a snapshot written by an + * older build has `panelId === sessionId` for every pane (panel ids used to be + * session ids), which is a state the restore can no longer represent — it + * would reinstate a panel whose id names a session that does not exist, and + * the pane stays dead. `breakIdCoincidence` gives those panels fresh ids up + * front, so the reapplied layout always satisfies panel id ≠ session id. + */ + const restoreFromSnapshot = useCallback( + async (snapshot: SessionSnapshot): Promise => { + const terminal = terminalApiRef.current + const ssh = sshApiRef.current + const terminalLayout = breakIdCoincidence(snapshot.layouts.terminal) + const sshLayout = breakIdCoincidence(snapshot.layouts.ssh) + try { + if (terminalLayout.layout) terminal?.fromJSON(terminalLayout.layout as never) + if (sshLayout.layout) ssh?.fromJSON(sshLayout.layout as never) + } catch { + return false + } + const renames = new Map([...terminalLayout.renames, ...sshLayout.renames]) + await rebindSessionPanels(terminal, snapshot, true, renames) + await rebindSessionPanels(ssh, snapshot, true, renames) + lastLocalCwdRef.current = snapshot.lastLocalCwd ?? lastLocalCwdRef.current + useWorkspaceModeStore.getState().setMode(snapshot.mode) + recountAll() + recomputeLocalPanels() + return true + }, + [rebindSessionPanels, recountAll, recomputeLocalPanels] ) const handleApplyTemplate = useCallback( diff --git a/src/renderer/src/workspace/sessionCwdStore.ts b/src/renderer/src/workspace/sessionCwdStore.ts new file mode 100644 index 0000000..66b26fa --- /dev/null +++ b/src/renderer/src/workspace/sessionCwdStore.ts @@ -0,0 +1,36 @@ +/** + * Last known working directory per pty session. + * + * Filled by (typed `cd` commands and shell OSC reports) and read + * by the workspace when it builds a session snapshot, so a restored pane opens + * in the directory it was left in. Keyed by sessionId, which is regenerated on + * every launch — the snapshot maps panelId → cwd, and this map is the live + * lookup behind it. + */ + +const cwds = new Map() +const listeners = new Set<() => void>() + +export function setSessionCwd(sessionId: string, cwd: string): void { + if (!sessionId || !cwd) return + if (cwds.get(sessionId) === cwd) return + cwds.set(sessionId, cwd) + for (const listener of listeners) listener() +} + +export function getSessionCwd(sessionId: string | undefined): string | undefined { + return sessionId ? cwds.get(sessionId) : undefined +} + +export function forgetSessionCwd(sessionId: string | undefined): void { + if (!sessionId) return + if (cwds.delete(sessionId)) { + for (const listener of listeners) listener() + } +} + +/** Subscribe to any change; returns an unsubscribe function. */ +export function onSessionCwdChange(listener: () => void): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} diff --git a/src/shared/api.ts b/src/shared/api.ts index 0cddacc..1b983f8 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -5,7 +5,8 @@ import type { PtyCreateResult, PtyDataEvent, PtyExitEvent, - SessionOpenResult + SessionOpenResult, + SessionSnapshot } from './ipc' import type { AppSettings } from './settings' import type { ReleaseNote, UpdateState } from './ipc' @@ -113,4 +114,12 @@ export interface AppApi { updateInstall(): Promise updateChangelog(): Promise onUpdateState(cb: (s: UpdateState) => void): () => void + + // ---- session snapshot (restore last layout + per-pane cwd) ---- + getSessionState(): Promise + saveSessionState(snapshot: SessionSnapshot): Promise + /** resolve a cd-style argument against the current cwd (platform-aware) */ + resolveCwd(current: string | undefined, arg: string): Promise + /** normalize a shell-reported cwd payload (OSC 7 / OSC 9;9) */ + reportCwd(payload: string): Promise } diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index eda8d6e..281502a 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -98,13 +98,23 @@ export const Ipc = { UPDATE_INSTALL: 'update:install', UPDATE_CHANGELOG: 'update:changelog', /** main -> renderer broadcast: UpdateState */ - UPDATE_STATE: 'update:state' + UPDATE_STATE: 'update:state', + + // ---- session snapshot (last layout + per-pane cwd) ---- + SESSION_STATE_GET: 'session:stateGet', + SESSION_STATE_SET: 'session:stateSet', + /** resolve a cd-style argument against the current cwd, platform-aware */ + CWD_RESOLVE: 'session:cwdResolve', + /** normalize a cwd reported by the shell (OSC 7 / OSC 9;9) */ + CWD_REPORT: 'session:cwdReport' } as const export interface PtyCreateOptions { cwd?: string /** executable; omit for platform default shell */ shell?: string + /** extra argv for the shell (shell-integration adapters use this) */ + shellArgs?: string[] env?: Record } @@ -114,6 +124,33 @@ export interface PtyCreateResult { cwd: string } +/** One dockview panel as remembered between runs. */ +export interface SessionPanelState { + panelId: string + title: string + kind: 'local' | 'ssh' + /** last known working directory (local panels; also recorded for ssh panes + * so a reconnected pane starts somewhere sensible) */ + cwd?: string + /** ssh only: bookmark to offer a reconnect for */ + connectionId?: string + hostLabel?: string +} + +/** + * The automatic "pick up where you left off" snapshot: the dockview layouts + * plus a per-panel record. Distinct from the user-managed layout templates. + */ +export interface SessionSnapshot { + version: 1 + savedAt: number + mode: 'terminal' | 'ssh' + layouts: { terminal?: unknown; ssh?: unknown } + panels: SessionPanelState[] + /** newest cwd among local panes — seeds brand-new terminals */ + lastLocalCwd?: string +} + export interface PtyDataEvent { id: string data: string diff --git a/src/shared/settings.ts b/src/shared/settings.ts index dfe497b..fad6be7 100644 --- a/src/shared/settings.ts +++ b/src/shared/settings.ts @@ -51,6 +51,17 @@ export interface SystemSettings { closeAction?: 'ask' | 'tray' | 'exit' /** check for updates shortly after startup (manual check always available) */ autoCheckUpdate?: boolean + /** + * Restore the previous layout and each pane's working directory on launch + * (see the session snapshot). On by default — that is the point of it. + */ + restoreSession?: boolean + /** + * Let the shell announce its cwd over OSC 7 (shell integration). Exact, but + * it wraps the user's prompt, so it is opt-in; without it the app falls back + * to parsing typed `cd` commands. + */ + shellIntegration?: boolean } export interface AppSettings { @@ -107,5 +118,5 @@ export const DEFAULT_SETTINGS: AppSettings = { }, customThemes: [], highlightRules: DEFAULT_HIGHLIGHT_RULES, - system: { launchAtLogin: false, preventSleep: false, globalShowHide: '', closeAction: 'tray', autoCheckUpdate: true } + system: { launchAtLogin: false, preventSleep: false, globalShowHide: '', closeAction: 'tray', autoCheckUpdate: true, restoreSession: true, shellIntegration: false } }