fix: hardening round from code review (4 P1 + 15 P2)
CI / typecheck + test + build (windows) (push) Canceled after 0s

P1:
- settingsStore: back up an unparseable settings.json to .bak before
  falling back to defaults, so the next mutation can no longer silently
  wipe custom themes/highlight rules
- ptyDispatcher: fan out per-session data/exit handlers (Set instead of
  a single slot) so SSH split panes stop stealing each other's stream
- FilePanel: monotonic refresh token keeps stale listings from painting
  over a newer navigation; upload finish no longer yanks the panel back
- settings.css: active settings-tab label derives from --chrome-fg so it
  stays visible on the shipped light themes

P2 (main/renderer):
- paste guard: a paste ending in a newline always confirms
- Workspace: closing an SSH pane no longer seeds the local cwd with a
  remote path
- tray: skip close-dialog continuation on a destroyed window
- commands: close zombie 'in-progress' session logs at hydrate
- zmodem: clear the stale offer timer before arming a new one
- connectionsStore: coerce/validate renderer input before persisting
- sftp: OperationError marker class keeps translated errors out of the
  transport-retry classifier
- CommandsPanel: surface save failures inside the dialog
- ConnectionSidebar: drop a tautological tooltip condition

P2 (i18n/tooling/tests):
- localize the 16 ANSI color labels and the highlight sample text
  (21 new keys across zh-CN/zh-TW/en/ja)
- sync-changelog: keep ### subheadings, normalize CRLF notes
- release.cjs: GitHub release reuse-by-tag (idempotent re-runs); fail
  loudly on a failed Gitea asset listing
- commands-store test: absent historyEnabled now truly tests absence
This commit is contained in:
Bill committed 2026-09-27 22:25:03 +08:00
1 parent 33efbe921e
commit 4e5377f49c
27 files changed
+356 -83

No files matched your search

+11 -1
View File
@@ -229,6 +229,7 @@ export class CommandsStore {
if (!existsSync(this.indexFile)) return
const raw: unknown = JSON.parse(readFileSync(this.indexFile, 'utf8'))
if (!Array.isArray(raw)) return
let closed = false
for (const x of raw) {
if (
x &&
@@ -239,10 +240,19 @@ export class CommandsStore {
// index.json is data, not trust: a tampered or hand-edited `file`
// must never turn logWrite into an arbitrary-path append.
if (!this.isLoggableFile(meta.file)) continue
// A log still "in progress" here was interrupted by a crash or a
// force-kill: its session id belonged to the previous run, so no
// logStop will ever match it and its sanitizer is gone (logWrite
// would be a permanent no-op). Close it instead of reviving it as
// an active entry nothing can ever end.
if (meta.endedAt === undefined) {
meta.endedAt = Date.now()
closed = true
}
this.metasByFile.set(meta.file, meta)
if (meta.endedAt === undefined) this.activeBySession.set(meta.sessionId, meta)
}
}
if (closed) this.persistIndex()
} catch {
// index missing / corrupt -> rebuild on next write
}
+37 -1
View File
@@ -20,6 +20,41 @@ import { writeJson } from './store'
const ENC_SUFFIX = '_enc'
const PLAIN_PREFIX = 'plain:'
const SECRET_KEYS = ['password', 'keyContent', 'passphrase'] as const
const AUTH_METHODS: ReadonlySet<string> = new Set(['password', 'privateKey', 'agent'])
/**
* Runtime coercion for renderer-supplied connection fields. saveConnection
* copies input values verbatim, and they flow straight into ssh2's
* ConnectConfig (port, keepalive) — a malformed save or a renderer regression
* must not persist `port: "22"` or a NaN-bound keepalive. Repair over drop,
* like every other store: strings stay strings, numbers are range-checked,
* unknown auth falls back to password, optional fields collapse to undefined.
*/
function coerceConnectionInput(input: SshConnectionInput): SshConnectionInput {
const out = { ...input } as Record<string, unknown>
out.name = typeof input.name === 'string' ? input.name : ''
out.group = typeof input.group === 'string' && input.group !== '' ? input.group : undefined
out.host = typeof input.host === 'string' ? input.host.trim() : ''
out.username = typeof input.username === 'string' ? input.username : ''
const port = Number(input.port)
out.port = Number.isInteger(port) && port >= 1 && port <= 65535 ? port : 22
out.auth = AUTH_METHODS.has(input.auth as string) ? input.auth : 'password'
out.askPasswordAtConnect = input.askPasswordAtConnect === true
out.askPassphraseAtConnect = input.askPassphraseAtConnect === true
out.keyPath =
typeof input.keyPath === 'string' && input.keyPath !== '' ? input.keyPath : undefined
const keepalive = Number(input.keepaliveIntervalSec)
out.keepaliveIntervalSec =
Number.isFinite(keepalive) && keepalive >= 0 ? Math.round(keepalive) : 0
out.highlightProfileId =
typeof input.highlightProfileId === 'string' && input.highlightProfileId !== ''
? input.highlightProfileId
: undefined
for (const key of SECRET_KEYS) {
if (out[key] !== undefined && typeof out[key] !== 'string') out[key] = undefined
}
return out as unknown as SshConnectionInput
}
/** Internal persisted record: public fields + encrypted secret fields. */
interface StoredConnection {
@@ -150,7 +185,8 @@ export class ConnectionsStore {
return stored ? decrypt(stored[encField] as string | undefined) : undefined
}
saveConnection(input: SshConnectionInput): SshConnection {
saveConnection(rawInput: SshConnectionInput): SshConnection {
const input = coerceConnectionInput(rawInput)
const list = this.load()
let stored: StoredConnection | undefined
+25 -2
View File
@@ -1,5 +1,5 @@
import { app, ipcMain, powerSaveBlocker } from 'electron'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { join } from 'path'
import { Ipc } from '../shared/ipc'
import {
@@ -435,13 +435,36 @@ function reportWarnings(warnings: string[]): void {
}
}
/**
* A file that exists but cannot be parsed must never be silently destroyed:
* loadSettings falls back to defaults, and the next mutation (any settings save,
* even the tray persisting its close action) would rewrite settings.json from
* those defaults — taking the user's custom themes and highlight rules with it.
* Keep a one-time copy the user can recover from before that can happen.
*/
function backupUnparseableSettings(err: unknown): void {
try {
const file = settingsPath()
if (!existsSync(file)) return
const bak = `${file}.bak`
if (existsSync(bak)) return // keep the first backup; later loads must not clobber it
copyFileSync(file, bak)
reportWarnings([
`settings.json unparseable (${err instanceof Error ? err.message : String(err)}) — original kept at settings.json.bak`
])
} catch {
// best effort: the backup must never break settings loading
}
}
export function loadSettings(): AppSettings {
try {
const raw: unknown = JSON.parse(readFileSync(settingsPath(), 'utf8'))
const { settings, errors } = deepMerge(raw)
reportWarnings(errors)
return settings
} catch {
} catch (err) {
backupUnparseableSettings(err)
return {
terminal: { ...DEFAULT_SETTINGS.terminal },
customThemes: [...DEFAULT_SETTINGS.customThemes],
+18 -3
View File
@@ -65,6 +65,14 @@ const sftpCache = new Map<string, SFTPWrapper>()
*/
class SessionGoneError extends Error {}
/**
* Our own "operation failed for a non-transport reason" error (server-side
* failure summary, user cancellation). isTransportError rejects these outright:
* their messages are translated and can embed remote paths/output, so text
* matching would make the retry decision locale- and filename-dependent.
*/
class OperationError extends Error {}
async function sftpOf(sessionId: string): Promise<SFTPWrapper> {
const cached = sftpCache.get(sessionId)
if (cached) return cached
@@ -100,9 +108,16 @@ function evictSftp(sessionId: string): void {
* already exists) mean the CHANNEL IS ALIVE — retrying them would just open
* another subsystem channel, which strict sshd (MaxSessions 2) punishes by
* dropping the whole connection. Only transport-level death retries.
*
* Classification is class-based for our own errors (SessionGoneError /
* OperationError) and matches only the ssh2 library's own message strings for
* the rest: ssh2 is English-only and never translated, so the decision cannot
* drift with the UI locale (our translated messages arrive as OperationError
* and are rejected before any text is inspected).
*/
function isTransportError(err: unknown): boolean {
if (err instanceof SessionGoneError) return true
if (err instanceof OperationError) return false
const msg = (err as Error | undefined)?.message ?? ''
return /not connected|ECONNRESET|EPIPE|timed out|disconnected|channel|read past end|no response/i.test(msg)
}
@@ -211,7 +226,7 @@ export function deleteRemote(sessionId: string, paths: string[]): Promise<void>
}
}
if (failures.length > 0) {
throw new Error(t('main.sftp.deleteFailed', { detail: failures.join('; ') }))
throw new OperationError(t('main.sftp.deleteFailed', { detail: failures.join('; ') }))
}
})
}
@@ -329,7 +344,7 @@ export function uploadRemote(
let pos = 0
let lastEmit = 0
for (;;) {
if (transfer.cancelled) throw new Error(t('main.sftp.cancelled'))
if (transfer.cancelled) throw new OperationError(t('main.sftp.cancelled'))
if (firstError) throw firstError
while (inflight.size >= UPLOAD_WINDOW) {
await Promise.race(inflight)
@@ -420,7 +435,7 @@ export function downloadRemote(
let lastEmit = 0
const buf = Buffer.alloc(CHUNK)
for (;;) {
if (transfer.cancelled) throw new Error(t('main.sftp.cancelled'))
if (transfer.cancelled) throw new OperationError(t('main.sftp.cancelled'))
const { bytesRead } = await p<{ bytesRead: number; buffer: Buffer }>(cb =>
sftp.read(handle, buf, 0, CHUNK, pos, cb)
)
+8
View File
@@ -87,6 +87,9 @@ export function initTray(showOrCreate: () => void): void {
}
function hideToTray(win: BrowserWindow): void {
// A quit started while the close dialog was pending destroys the window
// before its resolution runs; touching a destroyed window throws.
if (win.isDestroyed()) return
win.hide()
if (process.platform === 'win32' && tray && !balloonShown) {
balloonShown = true
@@ -126,6 +129,11 @@ export async function onMainWindowClose(win: BrowserWindow, e: Electron.Event, s
checkboxChecked: false,
noLink: true
})
// The dialog resolves when the window is destroyed underneath it (tray exit
// or OS shutdown called app.quit() while it was open): the quit is already
// underway, so neither branch may run — and must not persist a choice the
// user never made.
if (win.isDestroyed()) return
if (response === 0) {
if (checkboxChecked) persistCloseAction('tray')
hideToTray(win)
+5
View File
@@ -342,6 +342,11 @@ export function attachZmodem(sessionId: string, deps: ZmodemDeps): void {
} catch {
// never crash the event loop
}
// Clear any previous offer timer first: a re-detect while an earlier
// offer is still pending overwrites engine.offerTimer, and the orphaned
// timer would fire at its original deadline and abort a legitimate
// pending offer ahead of its own timeout.
if (engine.offerTimer) clearTimeout(engine.offerTimer)
engine.offerTimer = setTimeout(() => {
if (engine.detection && !engine.confirmed) {
abortWithoutSession(engine, t('main.zmodem.offerTimedOut'))
+19 -8
View File
@@ -31,6 +31,8 @@ export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element
const [name, setName] = useState('')
const [command, setCommand] = useState('')
const [note, setNote] = useState('')
/** Save failure surfaced inside the dialog instead of an unhandled rejection. */
const [saveError, setSaveError] = useState('')
/** M6.1: current workspace group — the command panel's 发送目标 belongs to it. */
const workspaceMode = useWorkspaceModeStore((s) => s.mode)
@@ -68,20 +70,28 @@ export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element
setName('')
setCommand('')
setNote('')
setSaveError('')
setAddOpen(true)
}, [])
const handleSave = useCallback(async (): Promise<void> => {
const trimmed = command.trim()
if (!trimmed) return
await window.api.saveLibraryItem({
id: window.crypto.randomUUID(),
name: name.trim() || undefined,
command: trimmed,
note: note.trim() || undefined,
createdAt: Date.now(),
lastUsedAt: Date.now()
})
try {
await window.api.saveLibraryItem({
id: window.crypto.randomUUID(),
name: name.trim() || undefined,
command: trimmed,
note: note.trim() || undefined,
createdAt: Date.now(),
lastUsedAt: Date.now()
})
} catch (err) {
// Keep the dialog open with the typed values; a silent no-op would look
// like the save button doing nothing.
setSaveError((err as Error)?.message || t('common.saveFailed'))
return
}
setAddOpen(false)
refresh()
}, [name, command, note, refresh])
@@ -182,6 +192,7 @@ export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element
className="commands-modal"
>
<div className="commands-form">
{saveError && <div className="commands-save-error">{saveError}</div>}
<label className="commands-field">
<span className="commands-field-label">{t('panels.commands.name')}</span>
<input
+8
View File
@@ -138,6 +138,14 @@
gap: 12px;
}
/* IPC/store failure during save: shown inside the dialog (in the app's danger
colour) so a failed save is visible instead of a silent no-op. */
.commands-save-error {
color: #f85149;
font-size: 12px;
word-break: break-all;
}
.commands-field {
display: flex;
flex-direction: column;
@@ -289,7 +289,7 @@ function Row({
<div className="connections-row-main">
<div className="connections-row-name">
<span className="connections-row-label">{conn.name}</span>
<Tooltip title={conn.host === conn.host ? t('ssh.sidebar.dblClickConnect', { host: conn.host }) : undefined}>
<Tooltip title={t('ssh.sidebar.dblClickConnect', { host: conn.host })}>
<AuthTags auth={conn.auth} />
</Tooltip>
</div>
+1 -1
View File
@@ -233,7 +233,7 @@ export function HighlightTab(): React.JSX.Element {
...(color.bg ? { background: color.bg, borderRadius: 3, padding: '0 4px' } : {})
}}
>
Highlight
{t('settings.highlight.sampleText')}
</span>
)
},
+5 -3
View File
@@ -45,10 +45,12 @@
}
/* antd paints the active tab label in the primary blue, which is hard to read
on the accent-tinted row; keep it identical to the inactive labels. The
extra .ant-tabs-tab class out-specifies antd's runtime-injected rule. */
on the accent-tinted row; keep it identical to the inactive labels. Theme-
derived (not #ffffff): on the shipped light themes a white label on the
near-white tinted row was invisible. The extra .ant-tabs-tab class
out-specifies antd's runtime-injected rule. */
.settings-body .ant-tabs-left > .ant-tabs-nav .ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn {
color: #ffffff;
color: var(--chrome-fg, #cccccc);
}
.settings-body .ant-tabs-left > .ant-tabs-nav .ant-tabs-ink-bar {
+21 -4
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
ArrowUpOutlined,
CopyOutlined,
@@ -332,19 +332,32 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
/** the entry the context menu is operating on (selected when right-clicked) */
const menuTarget = menuEntry ?? selected
/** Monotonic token: only the most recently issued listing may paint state. */
const refreshSeqRef = useRef(0)
/** Mirrors `dir` for callbacks created before a navigation (upload finish). */
const dirRef = useRef(dir)
const refresh = useCallback(
async (target: string): Promise<void> => {
const seq = ++refreshSeqRef.current
setLoading(true)
setError('')
try {
const list = await window.api.listRemote(sessionId, target)
// A slower earlier listing (breadcrumb click racing a double-click) must
// never overwrite a newer one; only the latest request may paint.
if (seq !== refreshSeqRef.current) return
dirRef.current = target
setEntries(list)
setDir(target)
setSelected(null)
} catch (err) {
if (seq !== refreshSeqRef.current) return
setError((err as Error).message)
} finally {
setLoading(false)
// The spinner belongs to the latest request; a stale one completing
// first must not hide it while the latest is still in flight.
if (seq === refreshSeqRef.current) setLoading(false)
}
},
[sessionId]
@@ -377,7 +390,9 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
if (settled) return
settled = true
off()
void refresh(dir)
// `dir` here is the directory the upload was started from; if the user
// has navigated away since, refreshing it would yank the panel back.
if (dirRef.current === dir) void refresh(dir)
}
const off = window.api.onTransferProgress((e: TransferProgressEvent) => {
if (e.state !== 'done' && e.state !== 'error' && e.state !== 'cancelled') return
@@ -604,8 +619,10 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
}
}
}
// `entries` is read by doUpload's overwrite check — a stale snapshot could
// skip the "file exists" confirmation that protects main's truncating 'w'.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [menuTarget, dir, refresh, language])
}, [menuTarget, dir, entries, refresh, language])
return (
<div className="sftp-panel">
+13 -5
View File
@@ -39,16 +39,24 @@ function toFontWeight(n: number): 'normal' | 'bold' | number {
/**
* Paste-risk heuristic, split by shape rather than by raw length:
* - a paste ending in a newline always confirms — the trailing \r is the
* shell's run keystroke, so it executes before the user can review it;
* - two or more lines always confirm — a stray newline executes a command
* the user never reviewed;
* - a single line pastes straight through, unless it is unusually long
* (those are pasted scripts rather than something typed by hand).
* - a single line without a trailing newline pastes straight through, unless
* it is unusually long (those are pasted scripts rather than something
* typed by hand).
*/
const SINGLE_LINE_CONFIRM_LENGTH = 1000
function needsPasteConfirm(text: string): boolean {
const normalized = text.replace(/\r\n?/g, '\n').replace(/\n$/, '')
if (normalized.includes('\n')) return true
return normalized.length > SINGLE_LINE_CONFIRM_LENGTH
const normalized = text.replace(/\r\n?/g, '\n')
// Decided on the raw shape: stripping the trailing newline first would let
// the most common copy shape (`curl … | sh\n`, one line + newline from a
// doc) straight through to immediate execution.
if (normalized.endsWith('\n')) return true
const trimmed = normalized.replace(/\n$/, '')
if (trimmed.includes('\n')) return true
return trimmed.length > SINGLE_LINE_CONFIRM_LENGTH
}
/**
+29 -11
View File
@@ -6,43 +6,61 @@ import type { PtyDataEvent, PtyExitEvent } from '@shared/ipc'
* listeners (N = pane count) and N-1 of them discarded it after an id
* compare. With many terminals under heavy output that per-chunk fan-out is
* pure overhead. Here one IPC listener dispatches through a Map keyed by
* sessionId: one lookup per chunk, and only the owning pane runs.
* sessionId: one lookup per chunk, and only the owning session's panes run.
*
* The per-id value is a Set, not a single handler: an SSH split deliberately
* binds two panes to the same sessionId, so the second pane must not steal
* (and its teardown must not revoke) the first pane's stream.
*/
type DataHandler = (data: string) => void
type ExitHandler = (exitCode: number) => void
const dataHandlers = new Map<string, DataHandler>()
const exitHandlers = new Map<string, ExitHandler>()
const dataHandlers = new Map<string, Set<DataHandler>>()
const exitHandlers = new Map<string, Set<ExitHandler>>()
let bound = false
function ensureBound(): void {
if (bound) return
bound = true
window.api.onPtyData((e: PtyDataEvent) => {
dataHandlers.get(e.id)?.(e.data)
dataHandlers.get(e.id)?.forEach((cb) => cb(e.data))
})
window.api.onPtyExit((e: PtyExitEvent) => {
exitHandlers.get(e.id)?.(e.exitCode)
exitHandlers.get(e.id)?.forEach((cb) => cb(e.exitCode))
})
}
/** Subscribe to one session's output. Returns the unsubscribe function. */
export function subscribePtyData(id: string, cb: DataHandler): () => void {
ensureBound()
dataHandlers.set(id, cb)
let set = dataHandlers.get(id)
if (!set) {
set = new Set()
dataHandlers.set(id, set)
}
set.add(cb)
return () => {
// A rebind (template apply) may have replaced the handler already; only
// delete when this subscription is still the registered one.
if (dataHandlers.get(id) === cb) dataHandlers.delete(id)
const set = dataHandlers.get(id)
if (!set) return
set.delete(cb)
if (set.size === 0) dataHandlers.delete(id)
}
}
/** Subscribe to one session's exit. Returns the unsubscribe function. */
export function subscribePtyExit(id: string, cb: ExitHandler): () => void {
ensureBound()
exitHandlers.set(id, cb)
let set = exitHandlers.get(id)
if (!set) {
set = new Set()
exitHandlers.set(id, set)
}
set.add(cb)
return () => {
if (exitHandlers.get(id) === cb) exitHandlers.delete(id)
const set = exitHandlers.get(id)
if (!set) return
set.delete(cb)
if (set.size === 0) exitHandlers.delete(id)
}
}
+19 -18
View File
@@ -30,23 +30,24 @@ const headerKeys = (): FieldSpec[] => [
{ key: 'selectionBackground', label: t('settings.color.selection') }
]
const ANSI_KEYS: FieldSpec[] = [
{ key: 'black', label: 'Black' },
{ key: 'red', label: 'Red' },
{ key: 'green', label: 'Green' },
{ key: 'yellow', label: 'Yellow' },
{ key: 'blue', label: 'Blue' },
{ key: 'magenta', label: 'Magenta' },
{ key: 'cyan', label: 'Cyan' },
{ key: 'white', label: 'White' },
{ key: 'brightBlack', label: 'Bright Black' },
{ key: 'brightRed', label: 'Bright Red' },
{ key: 'brightGreen', label: 'Bright Green' },
{ key: 'brightYellow', label: 'Bright Yellow' },
{ key: 'brightBlue', label: 'Bright Blue' },
{ key: 'brightMagenta', label: 'Bright Magenta' },
{ key: 'brightCyan', label: 'Bright Cyan' },
{ key: 'brightWhite', label: 'Bright White' }
/** Resolved per call (like headerKeys) so a language switch re-translates. */
const ansiKeys = (): FieldSpec[] => [
{ key: 'black', label: t('settings.color.ansi.black') },
{ key: 'red', label: t('settings.color.ansi.red') },
{ key: 'green', label: t('settings.color.ansi.green') },
{ key: 'yellow', label: t('settings.color.ansi.yellow') },
{ key: 'blue', label: t('settings.color.ansi.blue') },
{ key: 'magenta', label: t('settings.color.ansi.magenta') },
{ key: 'cyan', label: t('settings.color.ansi.cyan') },
{ key: 'white', label: t('settings.color.ansi.white') },
{ key: 'brightBlack', label: t('settings.color.ansi.brightBlack') },
{ key: 'brightRed', label: t('settings.color.ansi.brightRed') },
{ key: 'brightGreen', label: t('settings.color.ansi.brightGreen') },
{ key: 'brightYellow', label: t('settings.color.ansi.brightYellow') },
{ key: 'brightBlue', label: t('settings.color.ansi.brightBlue') },
{ key: 'brightMagenta', label: t('settings.color.ansi.brightMagenta') },
{ key: 'brightCyan', label: t('settings.color.ansi.brightCyan') },
{ key: 'brightWhite', label: t('settings.color.ansi.brightWhite') }
]
/** The native colour input only accepts #rrggbb: clamp every seeded value so a
@@ -164,7 +165,7 @@ export function ThemeEditor({ open, onClose, themeId }: ThemeEditorProps): React
<ColorField key={spec.key} spec={spec} value={colors[spec.key]} onChange={setColor} />
))}
<div className="theme-editor-divider" />
{ANSI_KEYS.map((spec) => (
{ansiKeys().map((spec) => (
<ColorField key={spec.key} spec={spec} value={colors[spec.key]} onChange={setColor} />
))}
</div>
+7 -2
View File
@@ -490,8 +490,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
// SSH panes are excluded: their tracked cwd is a *remote* path (OSC 7
// from the remote shell), and seeding a new local terminal with it
// would silently break "start where I left off".
if ((panel.params as TerminalParams | undefined)?.sessionKind !== 'ssh') {
const closing = getSessionCwd(sessionIdOf(panel))
if (closing) lastLocalCwdRef.current = closing
}
releaseSession(panel)
recomputeLocalPanels()
recountAll()
+1
View File
@@ -4,6 +4,7 @@ const common: Record<string, string> = {
'common.cancel': 'Cancel',
'common.close': 'Close',
'common.save': 'Save',
'common.saveFailed': 'Save failed, please try again',
'common.delete': 'Delete',
'common.edit': 'Edit',
'common.add': 'Add',
+17
View File
@@ -16,6 +16,22 @@ const settings: Record<string, string> = {
'settings.color.background': 'Background',
'settings.color.cursor': 'Cursor',
'settings.color.selection': 'Selection',
'settings.color.ansi.black': 'Black',
'settings.color.ansi.red': 'Red',
'settings.color.ansi.green': 'Green',
'settings.color.ansi.yellow': 'Yellow',
'settings.color.ansi.blue': 'Blue',
'settings.color.ansi.magenta': 'Magenta',
'settings.color.ansi.cyan': 'Cyan',
'settings.color.ansi.white': 'White',
'settings.color.ansi.brightBlack': 'Bright Black',
'settings.color.ansi.brightRed': 'Bright Red',
'settings.color.ansi.brightGreen': 'Bright Green',
'settings.color.ansi.brightYellow': 'Bright Yellow',
'settings.color.ansi.brightBlue': 'Bright Blue',
'settings.color.ansi.brightMagenta': 'Bright Magenta',
'settings.color.ansi.brightCyan': 'Bright Cyan',
'settings.color.ansi.brightWhite': 'Bright White',
'settings.font.label': 'Font',
'settings.font.desc': 'The built-in default or a system monospace font',
'settings.font.placeholder': 'Select a font',
@@ -162,6 +178,7 @@ const settings: Record<string, string> = {
'settings.highlight.clearBg': 'Clear',
'settings.highlight.addBg': 'Add',
'settings.highlight.palette': 'Palette',
'settings.highlight.sampleText': 'Highlight',
'settings.highlight.note': 'Note',
'settings.highlight.notePlaceholder': 'Rule description (optional)',
'settings.highlight.caseInsensitive': 'Ignore case',
+1
View File
@@ -4,6 +4,7 @@ const common: Record<string, string> = {
'common.cancel': 'キャンセル',
'common.close': '閉じる',
'common.save': '保存',
'common.saveFailed': '保存に失敗しました。もう一度お試しください',
'common.delete': '削除',
'common.edit': '編集',
'common.add': '追加',
+17
View File
@@ -16,6 +16,22 @@ const settings: Record<string, string> = {
'settings.color.background': '背景',
'settings.color.cursor': 'カーソル',
'settings.color.selection': '選択範囲',
'settings.color.ansi.black': '黒',
'settings.color.ansi.red': '赤',
'settings.color.ansi.green': '緑',
'settings.color.ansi.yellow': '黄',
'settings.color.ansi.blue': '青',
'settings.color.ansi.magenta': 'マゼンタ',
'settings.color.ansi.cyan': 'シアン',
'settings.color.ansi.white': '白',
'settings.color.ansi.brightBlack': '明るい黒',
'settings.color.ansi.brightRed': '明るい赤',
'settings.color.ansi.brightGreen': '明るい緑',
'settings.color.ansi.brightYellow': '明るい黄',
'settings.color.ansi.brightBlue': '明るい青',
'settings.color.ansi.brightMagenta': '明るいマゼンタ',
'settings.color.ansi.brightCyan': '明るいシアン',
'settings.color.ansi.brightWhite': '明るい白',
'settings.font.label': 'フォント',
'settings.font.desc': '組み込みの既定フォント、またはシステムの等幅フォント',
'settings.font.placeholder': 'フォントを選択',
@@ -159,6 +175,7 @@ const settings: Record<string, string> = {
'settings.highlight.clearBg': 'クリア',
'settings.highlight.addBg': '追加',
'settings.highlight.palette': 'パレット',
'settings.highlight.sampleText': 'ハイライト',
'settings.highlight.note': 'メモ',
'settings.highlight.notePlaceholder': 'ルールの説明(任意)',
'settings.highlight.caseInsensitive': '大文字小文字を無視',
+1
View File
@@ -4,6 +4,7 @@ const common: Record<string, string> = {
'common.cancel': '取消',
'common.close': '关闭',
'common.save': '保存',
'common.saveFailed': '保存失败,请重试',
'common.delete': '删除',
'common.edit': '编辑',
'common.add': '新增',
+17
View File
@@ -16,6 +16,22 @@ const settings: Record<string, string> = {
'settings.color.background': '背景',
'settings.color.cursor': '光标',
'settings.color.selection': '选区',
'settings.color.ansi.black': '黑',
'settings.color.ansi.red': '红',
'settings.color.ansi.green': '绿',
'settings.color.ansi.yellow': '黄',
'settings.color.ansi.blue': '蓝',
'settings.color.ansi.magenta': '品红',
'settings.color.ansi.cyan': '青',
'settings.color.ansi.white': '白',
'settings.color.ansi.brightBlack': '亮黑',
'settings.color.ansi.brightRed': '亮红',
'settings.color.ansi.brightGreen': '亮绿',
'settings.color.ansi.brightYellow': '亮黄',
'settings.color.ansi.brightBlue': '亮蓝',
'settings.color.ansi.brightMagenta': '亮品红',
'settings.color.ansi.brightCyan': '亮青',
'settings.color.ansi.brightWhite': '亮白',
'settings.font.label': '字体',
'settings.font.desc': '内置默认字体或系统等宽字体',
'settings.font.placeholder': '选择字体',
@@ -153,6 +169,7 @@ const settings: Record<string, string> = {
'settings.highlight.clearBg': '清空',
'settings.highlight.addBg': '添加',
'settings.highlight.palette': '色板',
'settings.highlight.sampleText': '高亮',
'settings.highlight.note': '备注',
'settings.highlight.notePlaceholder': '规则说明(可选)',
'settings.highlight.caseInsensitive': '忽略大小写',
+1
View File
@@ -4,6 +4,7 @@ const common: Record<string, string> = {
'common.cancel': '取消',
'common.close': '關閉',
'common.save': '儲存',
'common.saveFailed': '儲存失敗,請重試',
'common.delete': '刪除',
'common.edit': '編輯',
'common.add': '新增',
+17
View File
@@ -16,6 +16,22 @@ const settings: Record<string, string> = {
'settings.color.background': '背景',
'settings.color.cursor': '游標',
'settings.color.selection': '選取區',
'settings.color.ansi.black': '黑',
'settings.color.ansi.red': '紅',
'settings.color.ansi.green': '綠',
'settings.color.ansi.yellow': '黃',
'settings.color.ansi.blue': '藍',
'settings.color.ansi.magenta': '洋紅',
'settings.color.ansi.cyan': '青',
'settings.color.ansi.white': '白',
'settings.color.ansi.brightBlack': '亮黑',
'settings.color.ansi.brightRed': '亮紅',
'settings.color.ansi.brightGreen': '亮綠',
'settings.color.ansi.brightYellow': '亮黃',
'settings.color.ansi.brightBlue': '亮藍',
'settings.color.ansi.brightMagenta': '亮洋紅',
'settings.color.ansi.brightCyan': '亮青',
'settings.color.ansi.brightWhite': '亮白',
'settings.font.label': '字型',
'settings.font.desc': '內建預設字型或系統等寬字型',
'settings.font.placeholder': '選擇字型',
@@ -153,6 +169,7 @@ const settings: Record<string, string> = {
'settings.highlight.clearBg': '清除',
'settings.highlight.addBg': '新增',
'settings.highlight.palette': '色盤',
'settings.highlight.sampleText': '高亮',
'settings.highlight.note': '備註',
'settings.highlight.notePlaceholder': '規則說明(選填)',
'settings.highlight.caseInsensitive': '忽略大小寫',