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