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:
1 parent
8eacd34338
commit
beeb32a76b
17 files changed
+917
-37
No files matched your search
+104
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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, '')
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 }
|
||||
}
|
||||
Reference in new issue
Block a user