fix(session): keep panel ids distinct from session ids so restore rebinds panes

面板 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 个终端。
This commit is contained in:
Bill committed 2026-09-14 11:29:56 +08:00
1 parent 8eacd34338
commit beeb32a76b
17 files changed
+917 -37

No files matched your search

+104
View File
@@ -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://<host>/<path>` (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
}
}
+10
View File
@@ -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))
}
+29 -2
View File
@@ -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, '')
+82
View File
@@ -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))
}
+5 -2
View File
@@ -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
}
}
}
+114
View File
@@ -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://<host><path>
* 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 ; <path> 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<string, string>
}
/** `ESC ] 7 ; file://<host>/<path> 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()
+63
View File
@@ -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<T = unknown>(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<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
+7 -2
View File
@@ -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)
+5 -1
View File
@@ -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
}
@@ -317,6 +317,26 @@ export function SystemSettingsTab(): React.JSX.Element {
/>
}
/>
<SettingRow
label="启动时恢复上次会话"
desc="下次启动恢复分屏布局,并让每个终端回到它上次所在的目录;关闭则每次都以单个终端启动"
control={
<Switch
checked={settings.system.restoreSession !== false}
onChange={(checked) => void updateSystem({ restoreSession: checked })}
/>
}
/>
<SettingRow
label="Shell 集成"
desc="让 shell 主动上报工作目录(OSC 7),路径记忆更精确(含脚本内的 cd);会包装你的命令提示符,遇到提示符主题异常时关掉即可"
control={
<Switch
checked={settings.system.shellIntegration === true}
onChange={(checked) => void updateSystem({ shellIntegration: checked })}
/>
}
/>
<SettingRow
label="关闭按钮行为"
desc="点击窗口关闭按钮时的动作;最小化到托盘后可在托盘图标右键菜单中退出"
@@ -14,6 +14,8 @@ import type { TerminalSettings } from '@shared/settings'
import { useSettingsStore } from '@renderer/settings/store'
import { writeBroadcast } from '@renderer/workspace/broadcastStore'
import { compileRules, HighlightStream, type CompiledRule } from './highlightEngine'
import { cdArgument, conemuCwd } from './cwdTracker'
import { getSessionCwd, setSessionCwd } from '@renderer/workspace/sessionCwdStore'
import './terminal.css'
/** Platform-modifier-based chord (Cmd on macOS, Ctrl elsewhere), plus Shift. */
@@ -638,6 +640,29 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
term.loadAddon(searchAddon)
searchRef.current = searchAddon
// cwd reports straight from the shell: OSC 7 is the portable form (bash,
// zsh, fish, iTerm2, WezTerm, kitty all speak it), OSC 9;9 is ConEmu's,
// used by the Windows shells. Normalization happens in the main process.
const reportCwd = (payload: string): void => {
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<TerminalViewProps & { ref?:
scheduleCarryFlush()
}
const disposables: { dispose(): void }[] = [
...oscDisposables,
term.onData((data) => {
if (!deadRef.current) writeBroadcast(sessionId, data)
// M5: a completion acceptance rewrite armed `diffRewriteRef`; consume it
@@ -707,6 +733,17 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
const step = stepLineBuffer(lineBufRef, data, consumeRewrite)
if (typeof step.submit === 'string' && step.submit) {
void window.api.recordCommand(step.submit).catch(() => 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 = []
+39
View File
@@ -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;<path>` convention. */
export function conemuCwd(payload: string): string | null {
if (!payload.startsWith('9;')) return null
const value = payload.slice(2).trim()
return value || null
}
+306 -27
View File
@@ -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<string, { id?: string; params?: { sessionId?: string } }>
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<string, string>
}
/**
* 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<string, string>()
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<string, unknown>): Record<string, unknown> => {
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<string, unknown>
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<string, unknown>) }
}
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<void> => {
const result = await window.api.createPty()
setSessionCwd(result.id, result.cwd)
containerApi.addPanel<TerminalParams>({
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<ConnectionSidebarHandle>(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<string | null>(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<string> => {
const result = await window.api.createPty()
const createSession = useCallback(async (cwd?: string): Promise<string> => {
// `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<void> => {
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<TerminalParams>({
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<TerminalParams>({
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<TerminalParams>({
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 `<TerminalView>` 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<void> => {
const bindPanelSession = useCallback(
async (panel: DockviewPanelApi | undefined, currentSessionId: string, cwd?: string): Promise<void> => {
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<string, string>
): Promise<void> => {
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<void> => {
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<boolean> => {
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(
@@ -0,0 +1,36 @@
/**
* Last known working directory per pty session.
*
* Filled by <TerminalView> (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<string, string>()
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)
}
+10 -1
View File
@@ -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<void>
updateChangelog(): Promise<ReleaseNote[]>
onUpdateState(cb: (s: UpdateState) => void): () => void
// ---- session snapshot (restore last layout + per-pane cwd) ----
getSessionState(): Promise<SessionSnapshot | null>
saveSessionState(snapshot: SessionSnapshot): Promise<SessionSnapshot | null>
/** resolve a cd-style argument against the current cwd (platform-aware) */
resolveCwd(current: string | undefined, arg: string): Promise<string | null>
/** normalize a shell-reported cwd payload (OSC 7 / OSC 9;9) */
reportCwd(payload: string): Promise<string | null>
}
+38 -1
View File
@@ -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<string, string>
}
@@ -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
+12 -1
View File
@@ -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 }
}