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
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user