fix: hardening round from code review (4 P1 + 15 P2)
CI / typecheck + test + build (windows) (push) Canceled after 0s
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:
1 parent
33efbe921e
commit
4e5377f49c
27 files changed
+356
-83
No files matched your search
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user