From 23fa566fa908389f04b875af0628561cbf5db9f0 Mon Sep 17 00:00:00 2001 From: Bill Date: Sun, 20 Sep 2026 20:27:02 +0800 Subject: [PATCH] fix(renderer): terminal input tracking, workspace/session safety, panel correctness - terminal: drop the diffRewriteRef echo assumption (Tab-accept corrupted history and cwd tracking), handle readline control keys in the line buffer, clamp degenerate PTY sizes, live copyOnSelect, bound highlight regex input, wide-char-safe link ranges - workspace: boot-restore try/finally (a failure used to disable snapshot saving for the whole run), connect re-entrancy guard + real error messages, broadcast registry keyed by panel id (split panes), tab close-others/right live-array fix, pointer-captured bottom-panel drag, dockview constants hoisted, hydrate-gated restore - panels: no chmod 000 on unknown mode, chown keeps current gid, 12-bit special-permission round trip, overwrite confirm, redraw monitor charts, gate polling on visibility, no secret carry-over between connections, settings sent as minimal patches, update-state pull - language applies on the first render; accent fg recomputed on theme switch; window.api is properly typed again (env.d.ts import path) --- src/renderer/src/App.tsx | 14 +- src/renderer/src/commands/CommandsPanel.tsx | 3 + .../src/connections/ConnectionEditDialog.tsx | 5 + .../src/connections/ConnectionSidebar.tsx | 15 +- src/renderer/src/env.d.ts | 2 +- src/renderer/src/main.tsx | 3 +- src/renderer/src/monitor/MonitorPanel.tsx | 15 +- src/renderer/src/settings/AboutTab.tsx | 6 + src/renderer/src/settings/HighlightTab.tsx | 17 +- src/renderer/src/settings/SettingsDialog.tsx | 23 +- src/renderer/src/settings/SettingsTabs.tsx | 60 ++++- src/renderer/src/settings/store.ts | 81 ++++--- src/renderer/src/sftp/FilePanel.tsx | 160 +++++++++++-- src/renderer/src/sftp/TransferPanel.tsx | 93 +++++--- src/renderer/src/terminal/TerminalView.tsx | 124 +++++----- src/renderer/src/terminal/highlightEngine.ts | 24 +- src/renderer/src/theme/chrome.ts | 26 +- src/renderer/src/workspace/SshBottomPanel.tsx | 90 ++++--- src/renderer/src/workspace/TerminalPanel.tsx | 14 +- src/renderer/src/workspace/TerminalTab.tsx | 9 +- src/renderer/src/workspace/Workspace.tsx | 224 +++++++++++++----- src/renderer/src/workspace/ZmodemOffers.tsx | 10 + src/renderer/src/workspace/broadcastStore.ts | 67 ++++-- 23 files changed, 784 insertions(+), 301 deletions(-) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 960de8f..6ae7c43 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState, useSyncExternalStore } from 'react' import { ConfigProvider } from 'antd' import zhCN from 'antd/locale/zh_CN' import zhTW from 'antd/locale/zh_TW' @@ -9,7 +9,7 @@ import { SettingsDialog } from '@renderer/settings/SettingsDialog' import { TransferPanel } from '@renderer/sftp/TransferPanel' import { useSettingsStore } from '@renderer/settings/store' import { getThemeById } from '@shared/theme' -import { DEFAULT_LANGUAGE, setLanguage, type Language } from '@shared/i18n' +import { DEFAULT_LANGUAGE, getLanguage, onLanguageChange, setLanguage, type Language } from '@shared/i18n' import { applyChromeTheme, applyTabAccent } from '@renderer/theme/chrome' import appIconUrl from '../../../build/icon.png' @@ -42,16 +42,20 @@ export default function App(): React.JSX.Element { const themeId = useSettingsStore((s) => s.settings.terminal.themeId) const customThemes = useSettingsStore((s) => s.settings.customThemes) const tabAccentColor = useSettingsStore((s) => s.settings.terminal.tabAccentColor) - const language = useSettingsStore((s) => s.settings.system.language ?? DEFAULT_LANGUAGE) + const storedLanguage = useSettingsStore((s) => s.settings.system.language ?? DEFAULT_LANGUAGE) const [settingsOpen, setSettingsOpen] = useState(false) useEffect(() => { void hydrate() }, [hydrate]) - // Interface language: drives t() and antd's built-in component texts. + // Interface language: t() reads the module-level language at render time, so + // sync it before the tree renders — an effect would paint one frame late. The + // subscription re-renders every t() caller when the language changes elsewhere. + if (getLanguage() !== storedLanguage) setLanguage(storedLanguage) + const language = useSyncExternalStore(onLanguageChange, getLanguage) useEffect(() => { - setLanguage(language) + document.documentElement.lang = language }, [language]) // Sidebar / tab bars / dividers follow the active terminal theme. diff --git a/src/renderer/src/commands/CommandsPanel.tsx b/src/renderer/src/commands/CommandsPanel.tsx index 0238c26..166d1b6 100644 --- a/src/renderer/src/commands/CommandsPanel.tsx +++ b/src/renderer/src/commands/CommandsPanel.tsx @@ -260,6 +260,9 @@ function Row({ icon={} className="commands-row-del" aria-label={t('panels.commands.deleteAria')} + // The row itself runs the command — opening the confirm must not + // leak that click, or the command is typed into the live terminal. + onClick={(e) => e.stopPropagation()} /> )} diff --git a/src/renderer/src/connections/ConnectionEditDialog.tsx b/src/renderer/src/connections/ConnectionEditDialog.tsx index eedda42..f04272b 100644 --- a/src/renderer/src/connections/ConnectionEditDialog.tsx +++ b/src/renderer/src/connections/ConnectionEditDialog.tsx @@ -61,6 +61,11 @@ export function ConnectionEditDialog({ setErrorMsg(null) const base: SshAuthMethod = editing?.auth ?? 'password' setAuth(base) + // Fields keep their value when unmounted (rc-form preserve: true), so the + // secrets below must be cleared explicitly — otherwise a password typed for + // connection A survives into connection B's save. resetFields() drops every + // stored value; setFieldsValue() then re-seeds the non-secret fields. + form.resetFields() form.setFieldsValue({ name: editing?.name, group: editing?.group, diff --git a/src/renderer/src/connections/ConnectionSidebar.tsx b/src/renderer/src/connections/ConnectionSidebar.tsx index db69fd9..13cc734 100644 --- a/src/renderer/src/connections/ConnectionSidebar.tsx +++ b/src/renderer/src/connections/ConnectionSidebar.tsx @@ -85,13 +85,10 @@ export const ConnectionSidebar = forwardRef { - const groups = new Set() - for (const c of connections) groups.add(c.group ?? defaultGroup) - return [...groups] - }, [connections, defaultGroup]) + const [closedGroups, setClosedGroups] = useState([]) const groups = useMemo(() => { const map = new Map() @@ -223,8 +220,10 @@ export const ConnectionSidebar = forwardRef g.group)} - activeKey={activeGroups} + activeKey={groups.map((g) => g.group).filter((key) => !closedGroups.includes(key))} + onChange={(keys) => + setClosedGroups(groups.map((g) => g.group).filter((key) => !keys.includes(key))) + } /> )} diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index 40cab1c..2244d44 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -1,6 +1,6 @@ /// -import type { AppApi } from '../shared/api' +import type { AppApi } from '@shared/api' declare global { interface Window { diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 9681c31..771f065 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -81,7 +81,7 @@ if (typeof window !== 'undefined' && !window.api) { onHostKeyPrompt: () => noop, respondHostKey: noop, getSettings: async () => DEFAULT_SETTINGS, - saveSettings: async (s) => s, + saveSettings: async (s) => ({ ...DEFAULT_SETTINGS, ...s }), onSettingsChanged: () => noop, listFonts: async () => [], listLayouts: async () => [], @@ -89,6 +89,7 @@ if (typeof window !== 'undefined' && !window.api) { saveLayout: async () => undefined, deleteLayout: async () => undefined, updateCheck: async () => ({ status: 'dev' as const, currentVersion: 'dev' }), + getUpdateState: async () => ({ status: 'dev' as const, currentVersion: 'dev' }), updateDownload: async () => undefined, updateInstall: async () => undefined, updateChangelog: async () => [], diff --git a/src/renderer/src/monitor/MonitorPanel.tsx b/src/renderer/src/monitor/MonitorPanel.tsx index 6b5def1..7abcffb 100644 --- a/src/renderer/src/monitor/MonitorPanel.tsx +++ b/src/renderer/src/monitor/MonitorPanel.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react' import type { SysinfoMeta, SysinfoSample } from '@shared/sysinfo' import { t } from '@shared/i18n' +import { useWorkspaceModeStore } from '../workspace/workspaceModeStore' import './monitor.css' export interface MonitorPanelProps { @@ -25,8 +26,6 @@ interface HistoryChartProps { function HistoryChart({ data, lineColor, fillColor, height, maxY }: HistoryChartProps): React.JSX.Element { const containerRef = useRef(null) const canvasRef = useRef(null) - const dataRef = useRef(data) - dataRef.current = data useEffect(() => { const container = containerRef.current @@ -45,7 +44,7 @@ function HistoryChart({ data, lineColor, fillColor, height, maxY }: HistoryChart ctx.setTransform(dpr, 0, 0, dpr, 0, 0) ctx.clearRect(0, 0, width, height) - const pts = dataRef.current + const pts = data if (pts.length < 2) return const top = maxY ?? Math.max(1, Math.max(...pts) * 1.2) @@ -81,7 +80,9 @@ function HistoryChart({ data, lineColor, fillColor, height, maxY }: HistoryChart const ro = new ResizeObserver(draw) ro.observe(container) return () => ro.disconnect() - }, [height, maxY, lineColor, fillColor]) + // `data` is a dep on purpose: without it the chart only ever drew at mount, + // when the history is still empty, and the sparklines never updated. + }, [data, height, maxY, lineColor, fillColor]) return (
@@ -134,8 +135,12 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen const [cpuHist, setCpuHist] = useState([]) const [rxHist, setRxHist] = useState([]) const [txHist, setTxHist] = useState([]) + /** Switching workspace mode only hides the dockview (panels stay mounted), so + * polling has to stop explicitly while the SSH workspace is not visible. */ + const workspaceMode = useWorkspaceModeStore((s) => s.mode) useEffect(() => { + if (workspaceMode !== 'ssh') return let disposed = false setCpuHist([]) setRxHist([]) @@ -161,7 +166,7 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen offSample() window.api.sysinfoStop(sessionId) } - }, [sessionId]) + }, [sessionId, workspaceMode]) if (!meta || !sample) { return ( diff --git a/src/renderer/src/settings/AboutTab.tsx b/src/renderer/src/settings/AboutTab.tsx index bc24f45..48f627f 100644 --- a/src/renderer/src/settings/AboutTab.tsx +++ b/src/renderer/src/settings/AboutTab.tsx @@ -74,6 +74,12 @@ export function AboutTab(): React.JSX.Element { useEffect(() => { void window.api.appInfo().then((i: AppInfo) => setVersion(i.appVersion)) + // Pull the updater's current state (no network check); the check itself + // stays behind the manual button and the auto-check-on-launch setting. + void window.api + .getUpdateState() + .then(setState) + .catch(() => undefined) return window.api.onUpdateState(setState) }, []) diff --git a/src/renderer/src/settings/HighlightTab.tsx b/src/renderer/src/settings/HighlightTab.tsx index 18ac3af..f52cb28 100644 --- a/src/renderer/src/settings/HighlightTab.tsx +++ b/src/renderer/src/settings/HighlightTab.tsx @@ -25,6 +25,17 @@ const PRESET_COLORS = ['#3fb950', '#f85149', '#e3b341', '#58a6ff', '#d2a8ff', '# /** clone the builtin preset set to prevent reference pollution by mutations. */ const cloneDefaults = (): HighlightRule[] => JSON.parse(JSON.stringify(DEFAULT_HIGHLIGHT_RULES)) as HighlightRule[] +/** + * Built-in rule notes are stored as zh-CN presets; the dictionaries carry a + * `settings.highlight.builtin.` entry per built-in id. `t()` returns the key + * itself when it is missing, so fall back to the stored note in that case. + */ +function ruleNote(rule: HighlightRule): string | undefined { + const key = `settings.highlight.builtin.${rule.id}` + const translated = t(key) + return translated === key ? rule.note : translated +} + function tryCompile(pattern: string): { ok: boolean; message?: string } { if (pattern.trim() === '') return { ok: true } try { @@ -128,8 +139,10 @@ export function HighlightTab(): React.JSX.Element { title: t('settings.highlight.note'), dataIndex: 'note', ellipsis: true, - render: (note: string | undefined) => - note ? {note} : — + render: (_note: string | undefined, record) => { + const note = ruleNote(record) + return note ? {note} : — + } }, { title: t('settings.highlight.actions'), diff --git a/src/renderer/src/settings/SettingsDialog.tsx b/src/renderer/src/settings/SettingsDialog.tsx index 2e02f98..7007595 100644 --- a/src/renderer/src/settings/SettingsDialog.tsx +++ b/src/renderer/src/settings/SettingsDialog.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react' -import { Button, Modal, Tabs } from 'antd' +import { Button, Modal, Popconfirm, Tabs } from 'antd' import type { TabsProps } from 'antd' import { t } from '@shared/i18n' import { DEFAULT_SETTINGS } from '@shared/settings' @@ -11,6 +11,12 @@ import { AboutTab } from './AboutTab' import { ThemeEditor } from '../theme/editor/ThemeEditor' import './settings.css' +/** t() falls back to the key itself when a translation is missing. */ +function tOr(key: string, fallback: string): string { + const value = t(key) + return value === key ? fallback : value +} + export interface SettingsDialogProps { open: boolean onClose: () => void @@ -94,6 +100,10 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JS void updateTerminal({ ...DEFAULT_SETTINGS.terminal }) } + // Reset touches the whole terminal group, so it asks first and says so. + const resetLabel = tOr('settings.resetTerminal', t('common.reset')) + const resetDesc = tOr('settings.resetTerminalDesc', '') + return ( - + + + diff --git a/src/renderer/src/settings/SettingsTabs.tsx b/src/renderer/src/settings/SettingsTabs.tsx index ff08887..79ed10b 100644 --- a/src/renderer/src/settings/SettingsTabs.tsx +++ b/src/renderer/src/settings/SettingsTabs.tsx @@ -357,6 +357,8 @@ export function RenderSettingsTab(): React.JSX.Element { export function SystemSettingsTab(): React.JSX.Element { const settings = useSettingsStore((s) => s.settings) const updateSystem = useSettingsStore((s) => s.updateSystem) + /** set when the recorder refused a combo reserved by an in-app shortcut */ + const [shortcutConflict, setShortcutConflict] = useState(false) return (
{/* Interface language — first row: the whole dialog re-renders on change. */} @@ -433,11 +435,14 @@ export function SystemSettingsTab(): React.JSX.Element { void updateSystem({ globalShowHide: v })} + onConflict={setShortcutConflict} /> } />
- {t('settings.system.globalShortcutHint')} + {shortcutConflict + ? tOr('settings.system.shortcutConflict', t('settings.system.globalShortcutHint')) + : t('settings.system.globalShortcutHint')}
) @@ -486,12 +491,43 @@ function acceleratorFromEvent(e: React.KeyboardEvent): string return parts.join('+') } +/** + * Keys this app binds while Control is held: font size (Ctrl+=/-/0, main.tsx) + * and tab cycling (Ctrl+PgUp/PgDn, Workspace). Neither handler looks at the + * other modifiers, so any Control combo on one of these keys would shadow the + * in-app action — the recorder refuses it instead of saving a shortcut that + * silently loses its original meaning. + */ +const RESERVED_CONTROL_KEYS = new Set(['=', '-', '0', 'PageUp', 'PageDown']) + +/** true when `accel` (e.g. "Control+Shift+=") collides with an in-app shortcut */ +function isReservedAccelerator(accel: string): boolean { + const parts = accel.split('+') + return parts.includes('Control') && RESERVED_CONTROL_KEYS.has(parts[parts.length - 1]) +} + +/** t() falls back to the key itself when a translation is missing. */ +function tOr(key: string, fallback: string): string { + const value = t(key) + return value === key ? fallback : value +} + /** * Press-to-record input for the global shortcut: focus it, hit the combo, the * accelerator is captured and saved. Esc cancels recording, Backspace/Delete * clears (disables) the shortcut. Read-only so no stray text can be typed. + * A combo reserved by an in-app shortcut is refused (and reported through + * `onConflict`) without leaving the recording state, so the user can retry. */ -function ShortcutInput({ value, onChange }: { value: string; onChange: (v: string) => void }): React.JSX.Element { +function ShortcutInput({ + value, + onChange, + onConflict +}: { + value: string + onChange: (v: string) => void + onConflict: (conflict: boolean) => void +}): React.JSX.Element { const [recording, setRecording] = useState(false) return ( setRecording(true)} - onBlur={() => setRecording(false)} + onFocus={() => { + setRecording(true) + onConflict(false) + }} + onBlur={() => { + setRecording(false) + onConflict(false) + }} onKeyDown={(e) => { e.preventDefault() e.stopPropagation() @@ -514,10 +556,14 @@ function ShortcutInput({ value, onChange }: { value: string; onChange: (v: strin return } const accel = acceleratorFromEvent(e) - if (accel) { - onChange(accel) - e.currentTarget.blur() + if (!accel) return + if (isReservedAccelerator(accel)) { + onConflict(true) + return } + onConflict(false) + onChange(accel) + e.currentTarget.blur() }} /> ) diff --git a/src/renderer/src/settings/store.ts b/src/renderer/src/settings/store.ts index 2ee13f2..2752084 100644 --- a/src/renderer/src/settings/store.ts +++ b/src/renderer/src/settings/store.ts @@ -24,6 +24,52 @@ let unsubscribeStoreListener: (() => void) | null = null /** re-entrancy guard so concurrent hydrate() calls don't double-subscribe */ let hydrating = false +/** Sub-keys of `prev` → `next` that actually changed, or undefined when none did. */ +function diffGroup(prev: T, next: T): Partial | undefined { + const patch: Partial = {} + for (const key of Object.keys(next) as (keyof T)[]) { + if (prev[key] !== next[key]) patch[key] = next[key] + } + return Object.keys(patch).length > 0 ? patch : undefined +} + +/** + * Optimistic write + persist. The SETTINGS_CHANGED echo re-sets the same value + * (idempotent), so no rollback is needed for it. On failure, revert only while + * the store still holds exactly what this call wrote: a newer call (e.g. a + * ColorPicker drag) may already have saved a different value, and reverting + * would drop it from the UI although it is on disk. + */ +async function persist( + get: () => SettingsState, + set: (partial: Partial) => void, + written: AppSettings, + prev: AppSettings, + label: string +): Promise { + set({ settings: written }) + try { + // Send a minimal patch, not the full snapshot: main lets stale-but-sent + // fields win, so a snapshot captured before a main-process mutateSettings + // write (e.g. the tray's persistCloseAction) would silently revert it. + // Absent fields are left untouched on disk. + const patch: Partial = {} + if (written.customThemes !== prev.customThemes) patch.customThemes = written.customThemes + if (written.highlightRules !== prev.highlightRules) patch.highlightRules = written.highlightRules + const terminal = diffGroup(prev.terminal, written.terminal) + // Main merges each group one level deep, so a group holding only the + // changed sub-keys is a valid patch payload even though the contract types + // groups as full objects. + if (terminal) patch.terminal = terminal as TerminalSettings + const system = diffGroup(prev.system, written.system) + if (system) patch.system = system as SystemSettings + await window.api.saveSettings(patch) + } catch (err) { + console.error(`[settings] ${label} failed, rolling back`, err) + if (get().settings === written) set({ settings: prev }) + } +} + export const useSettingsStore = create((set, get) => ({ hydrated: false, settings: DEFAULT_SETTINGS, @@ -55,52 +101,25 @@ export const useSettingsStore = create((set, get) => ({ ...prev, terminal: { ...prev.terminal, ...partial } } - // optimistic update; the SETTINGS_CHANGED echo will re-set the same value (idempotent) - set({ settings: next }) - try { - await window.api.saveSettings(next) - } catch (err) { - console.error('[settings] updateTerminal failed, rolling back', err) - set({ settings: prev }) - } + await persist(get, set, next, prev, 'updateTerminal') }, setCustomThemes: async (themes) => { const prev = get().settings const next: AppSettings = { ...prev, customThemes: themes } - set({ settings: next }) - try { - await window.api.saveSettings(next) - } catch (err) { - console.error('[settings] setCustomThemes failed, rolling back', err) - set({ settings: prev }) - } + await persist(get, set, next, prev, 'setCustomThemes') }, setHighlightRules: async (rules) => { const prev = get().settings const next: AppSettings = { ...prev, highlightRules: rules } - // optimistic update; the SETTINGS_CHANGED echo will re-set the same value (idempotent) - set({ settings: next }) - try { - await window.api.saveSettings(next) - } catch (err) { - console.error('[settings] setHighlightRules failed, rolling back', err) - set({ settings: prev }) - } + await persist(get, set, next, prev, 'setHighlightRules') }, updateSystem: async (partial) => { const prev = get().settings const next: AppSettings = { ...prev, system: { ...prev.system, ...partial } } - // optimistic update; the SETTINGS_CHANGED echo will re-set the same value (idempotent) - set({ settings: next }) - try { - await window.api.saveSettings(next) - } catch (err) { - console.error('[settings] updateSystem failed, rolling back', err) - set({ settings: prev }) - } + await persist(get, set, next, prev, 'updateSystem') } })) diff --git a/src/renderer/src/sftp/FilePanel.tsx b/src/renderer/src/sftp/FilePanel.tsx index b4a1bc9..f5ad814 100644 --- a/src/renderer/src/sftp/FilePanel.tsx +++ b/src/renderer/src/sftp/FilePanel.tsx @@ -25,12 +25,32 @@ export interface FilePanelProps { sessionId: string } -/** 9 permission bits: [owner r,w,x, group r,w,x, other r,w,x]. */ +/** + * Permission bits: [owner r,w,x, group r,w,x, other r,w,x, setuid, setgid, sticky]. + * The last three (indices 9..11) only exist as the 4th octal digit — the grid + * does not toggle them, the octal input does. + */ type PermBits = boolean[] +const MODE_LETTERS = ['r', 'w', 'x', 'r', 'w', 'x', 'r', 'w', 'x'] +/** x-column index → index of the special bit that changes its letter. */ +const SPECIAL_BIT_AT: Record = { 2: 9, 5: 10, 8: 11 } + +const emptyBits = (): PermBits => Array.from({ length: 12 }, () => false) + function parseModeBits(mode: string | undefined): PermBits { const chars = mode ? Array.from(mode.slice(1)) : [] - return Array.from({ length: 9 }, (_, i) => chars[i] != null && chars[i] !== '-') + const bits = emptyBits() + for (let i = 0; i < 9; i++) { + const c = chars[i] + if (c == null || c === '-') continue + // uppercase S/T means "special bit set, execute bit clear" + bits[i] = c !== 'S' && c !== 'T' + } + if (chars[2] === 's' || chars[2] === 'S') bits[9] = true + if (chars[5] === 's' || chars[5] === 'S') bits[10] = true + if (chars[8] === 't' || chars[8] === 'T') bits[11] = true + return bits } function formatModeType(mode: string | undefined): string { @@ -39,9 +59,13 @@ function formatModeType(mode: string | undefined): string { } function bitsToModeStr(type: string, bits: PermBits): string { - const letters = bits.map((on, i) => { - if (!on) return '-' - return ['r', 'w', 'x', 'r', 'w', 'x', 'r', 'w', 'x'][i] + const letters = MODE_LETTERS.map((on, i) => { + const special = SPECIAL_BIT_AT[i] + if (special !== undefined && bits[special]) { + // setuid/setgid → s/S, sticky → t/T + return bits[i] ? (i === 8 ? 't' : 's') : i === 8 ? 'T' : 'S' + } + return bits[i] ? on : '-' }) return type + letters.join('') } @@ -56,7 +80,17 @@ function bitsToOctal(bits: PermBits): string { // arithmetic version computed 5*64+1*8+1 = 329 decimal, which is NOT the // octal "511" and made chmod a silent no-op when both sides agreed on the // same wrong value.) - return `${octalDigit(0, bits)}${octalDigit(3, bits)}${octalDigit(6, bits)}` + const special = (bits[9] ? 4 : 0) + (bits[10] ? 2 : 0) + (bits[11] ? 1 : 0) + const perms = `${octalDigit(0, bits)}${octalDigit(3, bits)}${octalDigit(6, bits)}` + // Keep the 3-digit form when no special bit is set so it round-trips with + // what main sends (its mode string is 9 bits wide). + return special > 0 ? `${special}${perms}` : perms +} + +/** t() falls back to the key itself when a translation is missing. */ +function tOr(key: string, fallback: string): string { + const value = t(key) + return value === key ? fallback : value } function fmtSize(bytes: number): string { @@ -90,12 +124,14 @@ interface PermissionModalProps { function PermissionModal({ open, sessionId, entry, onClose, onSaved }: PermissionModalProps): React.JSX.Element { const { message } = App.useApp() - const [bits, setBits] = useState(Array.from({ length: 9 }, () => false)) + const [bits, setBits] = useState(emptyBits) const [octal, setOctal] = useState('') const [uid, setUid] = useState('') const [gid, setGid] = useState('') const [saving, setSaving] = useState(false) const hasUidGid = entry != null && (entry.uid != null || entry.gid != null) + /** lstat failed while listing → no mode to compare against or preserve. */ + const modeKnown = Boolean(entry?.mode) const rowLabels = permRowLabels() const colLabels = permColLabels() @@ -110,11 +146,11 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio if (entry) { const b = parseModeBits(entry.mode) setBits(b) - setOctal(bitsToOctal(b)) + setOctal(entry.mode ? bitsToOctal(b) : '') setUid(entry.uid != null ? String(entry.uid) : '') setGid(entry.gid != null ? String(entry.gid) : '') } else { - setBits(Array.from({ length: 9 }, () => false)) + setBits(emptyBits()) setOctal('') setUid('') setGid('') @@ -125,6 +161,7 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio const s = (v ?? '').trim() if (/^[0-7]{1,4}$/.test(s)) { const num = parseInt(s, 8) || 0 + const special = s.length === 4 ? parseInt(s[0], 8) : 0 setBits([ (num >> 6) & 4 ? true : false, (num >> 6) & 2 ? true : false, @@ -134,7 +171,10 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio (num >> 3) & 1 ? true : false, num & 4 ? true : false, num & 2 ? true : false, - num & 1 ? true : false + num & 1 ? true : false, + (special & 4) !== 0, + (special & 2) !== 0, + (special & 1) !== 0 ]) } setOctal(s) @@ -149,6 +189,15 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio }) } + /** Non-negative integer input, '' meaning "keep current". */ + const parseId = (v: string): number | null => { + const s = v.trim() + if (s === '') return null + const n = Number(s) + return Number.isInteger(n) && n >= 0 ? n : null + } + const idInvalid = (v: string): boolean => v.trim() !== '' && parseId(v) === null + const onSave = (): void => { void (async () => { if (!entry) return @@ -156,23 +205,27 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio try { const octalStr = bitsToOctal(bits) const curOctal = entry.mode ? bitsToOctal(parseModeBits(entry.mode)) : null + // Unknown mode (lstat failed): bits start all-false, so only chmod when + // the user actually touched something — otherwise an untouched dialog + // would send "chmod 000". + const modeChanged = curOctal === null ? bits.some(Boolean) : octalStr !== curOctal // Only chown when the uid/gid actually differ from the entry's current // owner — the inputs are seeded with the current values, so "untouched" // would otherwise chown on every save. - const newUid = uid.trim() !== '' && !Number.isNaN(Number(uid)) ? Number(uid) : null - const newGid = gid.trim() !== '' && !Number.isNaN(Number(gid)) ? Number(gid) : null + const newUid = parseId(uid) + const newGid = parseId(gid) const ownerChanged = (newUid !== null && newUid !== entry.uid) || (newGid !== null && newGid !== entry.gid) - if (octalStr !== curOctal) { + if (modeChanged) { await window.api.chmodRemote(sessionId, entry.path, octalStr) } if (ownerChanged) { const u = newUid ?? entry.uid ?? 0 - const g = newGid ?? newUid ?? entry.gid ?? 0 + const g = newGid ?? entry.gid ?? 0 await window.api.chownRemote(sessionId, entry.path, u, g) } - if (octalStr !== curOctal || ownerChanged) { + if (modeChanged || ownerChanged) { message.success(t('ssh.file.permApplied')) } onClose() @@ -194,6 +247,7 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio confirmLoading={saving} okText={t('common.save')} cancelText={t('common.cancel')} + okButtonProps={{ disabled: idInvalid(uid) || idInvalid(gid) }} width={380} >
@@ -253,6 +307,10 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
{t('ssh.file.ownerHint')}
+ {entry && !modeKnown &&
{t('ssh.file.modeUnavailable')}
} + {(idInvalid(uid) || idInvalid(gid)) && ( +
{t('main.sftp.invalidUidGid')}
+ )} ) @@ -302,18 +360,74 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element { const selectedPath = selected?.path ?? '' + /** local file name as main computes it for the remote path (basename). */ + const remoteNameOf = (local: string): string => local.split(/[\\/]/).pop() ?? local + + /** + * Start the upload. The listener is registered BEFORE the transfer id is + * known: uploadRemote resolves with the id right away while the transfer + * itself runs detached, so a small file can finish before the id arrives — + * registering afterwards would miss the terminal event and never refresh. + */ + const startUpload = (files: string[]): void => { + let targetId: string | null = null + let settled = false + const earlyTerminalIds: string[] = [] + const finish = (): void => { + if (settled) return + settled = true + off() + void refresh(dir) + } + const off = window.api.onTransferProgress((e: TransferProgressEvent) => { + if (e.state !== 'done' && e.state !== 'error' && e.state !== 'cancelled') return + if (targetId === null) { + earlyTerminalIds.push(e.transferId) + return + } + if (e.transferId === targetId) finish() + }) + window.api.uploadRemote(sessionId, files, dir).then( + (id: string) => { + targetId = id + if (earlyTerminalIds.includes(id)) finish() + }, + (err: unknown) => { + off() + message.error((err as Error).message) + } + ) + } + const doUpload = (): void => { void (async () => { try { - const files = await window.api.pickFiles() + // Explicit type: env.d.ts resolves `AppApi` through a wrong path, so + // `window.api` is untyped and the callback params lose their context. + const files: string[] = await window.api.pickFiles() if (files.length === 0) return - await window.api.uploadRemote(sessionId, files, dir) - // refresh when the transfer completes - const off = window.api.onTransferProgress((e: TransferProgressEvent) => { - if (e.state === 'done' || e.state === 'error' || e.state === 'cancelled') { - off() - void refresh(dir) - } + // Main opens the target with 'w' (truncate), so an existing file would + // be silently overwritten — ask first, like delete and clear-history do. + const existing = files.map(remoteNameOf).filter((name) => entries.some((e) => e.name === name)) + if (existing.length === 0) { + startUpload(files) + return + } + const desc = tOr('ssh.file.overwriteDesc', '') + modal.confirm({ + title: tOr('ssh.file.overwriteTitle', t('ssh.file.uploadFile')), + content: ( +
+ {existing.map((name) => ( +
{name}
+ ))} + {desc &&
{desc}
} +
+ ), + okText: t('common.ok'), + okButtonProps: { danger: true }, + cancelText: t('common.cancel'), + onOk: () => startUpload(files) }) } catch (err) { message.error((err as Error).message) diff --git a/src/renderer/src/sftp/TransferPanel.tsx b/src/renderer/src/sftp/TransferPanel.tsx index 7770a06..de16acd 100644 --- a/src/renderer/src/sftp/TransferPanel.tsx +++ b/src/renderer/src/sftp/TransferPanel.tsx @@ -1,14 +1,16 @@ -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { CloseOutlined } from '@ant-design/icons' -import type { TransferKind, TransferProgressEvent } from '@shared/sftp' +import type { TransferKind, TransferProgressEvent, TransferState } from '@shared/sftp' import { t } from '@shared/i18n' -interface TransferState { +/** One aggregated transfer row in the overlay (the per-event state string is + * the shared `TransferState`). */ +interface TransferEntry { kind: TransferKind file: string bytes: number totalBytes: number - state: 'running' | 'done' | 'error' | 'cancelled' + state: TransferState error?: string } @@ -17,13 +19,29 @@ interface TransferState { * transferId; done transfers fade out, errors stay until dismissed. */ export function TransferPanel(): React.JSX.Element | null { - const [transfers, setTransfers] = useState>(new Map()) - const timersRef = useRef([]) + const [transfers, setTransfers] = useState>(new Map()) + /** auto-dismiss timers, keyed by transfer id (never more than one per run) */ + const timersRef = useRef>(new Map()) + + const dismiss = useCallback((id: string): void => { + const timer = timersRef.current.get(id) + if (timer !== undefined) { + window.clearTimeout(timer) + timersRef.current.delete(id) + } + setTransfers((prev) => { + if (!prev.has(id)) return prev + const next = new Map(prev) + next.delete(id) + return next + }) + }, []) useEffect(() => { const off = window.api.onTransferProgress((e: TransferProgressEvent) => { setTransfers((prev) => { const next = new Map(prev) + const existing = next.get(e.transferId) if (e.file) { next.set(e.transferId, { kind: e.kind, @@ -34,42 +52,51 @@ export function TransferPanel(): React.JSX.Element | null { error: e.error }) } else { - // terminal event for the whole transfer - const existing = next.get(e.transferId) - if (existing) { - if (e.state === 'error') { - next.set(e.transferId, { ...existing, state: 'error', error: e.error }) - } else if (e.state === 'cancelled') { - next.set(e.transferId, { ...existing, state: 'cancelled' }) - } else { - next.set(e.transferId, { ...existing, state: 'done' }) - } + // Terminal event for the whole transfer. It can arrive without any + // per-file progress first (e.g. the remote open was denied), so a + // missing entry is created rather than dropped — otherwise the + // failure would be visible nowhere. + const base: TransferEntry = existing ?? { + kind: e.kind, + file: '', + bytes: e.bytes, + totalBytes: e.totalBytes, + state: 'running' + } + if (e.state === 'error') { + next.set(e.transferId, { ...base, state: 'error', error: e.error }) + } else if (e.state === 'cancelled') { + next.set(e.transferId, { ...base, state: 'cancelled' }) + } else { + next.set(e.transferId, { ...base, state: 'done' }) } } return next }) }) - return () => { - off() - for (const t of timersRef.current) window.clearTimeout(t) - } + return off }, []) // auto-dismiss finished entries after 3s useEffect(() => { - for (const [id, t] of transfers) { - if ((t.state === 'done' || t.state === 'cancelled') && !t.error) { + for (const [id, tr] of transfers) { + if ((tr.state === 'done' || tr.state === 'cancelled') && !tr.error && !timersRef.current.has(id)) { const timer = window.setTimeout(() => { - setTransfers((prev) => { - const next = new Map(prev) - next.delete(id) - return next - }) + timersRef.current.delete(id) + dismiss(id) }, 3000) - timersRef.current.push(timer) + timersRef.current.set(id, timer) } } - }, [transfers]) + }, [transfers, dismiss]) + + useEffect(() => { + const timers = timersRef.current + return () => { + for (const timer of timers.values()) window.clearTimeout(timer) + timers.clear() + } + }, []) const list = [...transfers.entries()] if (list.length === 0) return null @@ -100,13 +127,7 @@ export function TransferPanel(): React.JSX.Element | null { diff --git a/src/renderer/src/terminal/TerminalView.tsx b/src/renderer/src/terminal/TerminalView.tsx index 22ec99e..b5a442d 100644 --- a/src/renderer/src/terminal/TerminalView.tsx +++ b/src/renderer/src/terminal/TerminalView.tsx @@ -14,7 +14,7 @@ import type { CommandItem } from '@shared/commands' 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 { compileRules, HighlightStream } from './highlightEngine' import { findUrls } from './urlLinks' import { subscribePtyData, subscribePtyExit } from './ptyDispatcher' import { cdArgument, conemuCwd } from './cwdTracker' @@ -139,39 +139,17 @@ function SearchBar({ value, onChange, onPrev, onNext, onClose, enabled, result } * handled here; escape sequences (\x1b) and the leading non-printable control * bytes on a chunk — like \x1b[K clear-line — never enter the buffer. * - * `rewrite` flags a completion acceptance that just rewrote the shell line (a - * Ctrl+U kill + full-command write). The very next onData chunk is the shell's - * redraw echoing the accepted line, so instead of appending onto the stale - * pre-accept buffer we *replace* it with what the pty actually echoed. This is - * the echo-suppression that keeps the local buffer in sync with the shell and - * prevents the accepted text from being duplicated. + * readline's line-editing keys (Ctrl+U/W/K, Ctrl+A/E/L/Y, …) are handled too: + * the shell applies them to its own line and never echoes the resulting edit + * back to us, so appending the raw control byte would silently desync this + * buffer from the shell's line and make the history record the pre-edit text. */ function stepLineBuffer( outBuf: React.MutableRefObject, - data: string, - rewrite: boolean + data: string ): { line: string; submit?: string } { const b = outBuf.current if (!data) return { line: b } - // Completion-rewrite redraw: reset to this chunk's visible text verbatim. For - // a shell like PowerShell the redraw is a leading escape/control sequence - // + the line text; strip leading control bytes so only the echoed command - // enters the buffer (matching how a normal chunk is captured). Any trailing - // text on the chunk is appended as usual. - if (rewrite) { - let i = 0 - while ((data[i] === '\x1b' && (data[i + 1] === '[' || data[i + 1] === '(')) || data[i] === '\r' || data[i] === '\n') { - i += data[i] === '\x1b' ? 2 : 1 - } - const visible = data.slice(i) - if (visible) { - outBuf.current = visible - return { line: visible } - } - // Only control output (e.g. the kill sequence handling) — treat as a reset. - outBuf.current = '' - return { line: '' } - } // xterm-native paste (plain Ctrl+V): with the shell's bracketed-paste mode // on, the pasted block arrives wrapped in \x1b[200~ / \x1b[201~ and the chunk // starts with ESC, which the guards below would drop wholesale. Strip the @@ -195,6 +173,21 @@ function stepLineBuffer( return { line: '' } } if (first === '\x1b') return { line: b } + // Ctrl+U / Ctrl+K kill the shell's line (before / after the cursor); with no + // cursor tracking the safe mirror of that is an empty buffer. + if (first === '\x15' || first === '\x0b') { + outBuf.current = '' + return { line: '' } + } + // Ctrl+W: readline skips trailing whitespace and kills the word before it. + if (first === '\x17') { + outBuf.current = b.replace(/\S+\s*$/, '') + return { line: outBuf.current } + } + // Any other C0 byte is a key the shell interprets itself (Ctrl+A/E/L/Y, + // Ctrl+R, Ctrl+D, Tab, …) and adds no text to the line — the buffer must not + // grow either. + if (data.charCodeAt(0) < 0x20) return { line: b } outBuf.current = b + data return { line: outBuf.current } } @@ -273,17 +266,12 @@ export const TerminalView: ForwardRefExoticComponent(null) const onCloseRef = useRef(onClose) - const compiledRef = useRef([]) const streamRef = useRef(null) + // Renderer mode currently installed on the terminal, so the mode effect can + // tell a real switch from its own mount-time run. + const rendererModeRef = useRef(null) // M5: per-session line-capture buffer + completion candidate cache. const lineBufRef = useRef('') - // M5: when a completion acceptance rewrites the shell line (Ctrl+U + full - // command via writeBroadcast in `differenceWrite`), the shell answers with a - // redraw that echoes `full`. If that echo were folded into the local buffer - // on top of the pre-accept text it would double the line. `diffRewriteRef` - // flags that the *next* onData chunk is that redraw output, so stepLineBuffer - // replaces the buffer with the freshly-echoed line instead of appending. - const diffRewriteRef = useRef(false) const completionCacheRef = useRef([]) const completionKeyRef = useRef('') const recordingRef = useRef(false) @@ -302,7 +290,6 @@ export const TerminalView: ForwardRefExoticComponent compileRules(settings.highlightRules), [settings.highlightRules]) - compiledRef.current = compiledRules streamRef.current?.setRules(compiledRules) // M5: load completion candidates once per session (history + library cached). @@ -352,7 +339,10 @@ export const TerminalView: ForwardRefExoticComponent { @@ -366,6 +356,7 @@ export const TerminalView: ForwardRefExoticComponent { @@ -386,6 +377,13 @@ export const TerminalView: ForwardRefExoticComponent { const live = termRef.current if (!live || deadRef.current) return + // A pane without a layout box (hidden or not-yet-laid-out dock tab) + // reports a degenerate grid; that — and any other sub-minimum size — + // must never reach the pty, which would reflow the shell into a + // one-column line. Skipping leaves ptySizeRef untouched, so the next + // fit retries instead of remembering the bad size. + if (hostRef.current?.offsetParent === null) return + if (live.cols < 20 || live.rows < 4) return if (live.cols === ptySizeRef.current?.cols && live.rows === ptySizeRef.current?.rows) return ptySizeRef.current = { cols: live.cols, rows: live.rows } window.api.resizePty(sessionId, live.cols, live.rows) @@ -555,8 +553,11 @@ export const TerminalView: ForwardRefExoticComponent { try { @@ -573,11 +574,11 @@ export const TerminalView: ForwardRefExoticComponent { const cmd = selected.command // Rewrite the whole shell line (Ctrl+U then `cmd`) instead of appending a - // delta — the pty redraw, not our local buffer, becomes the source of - // truth for the line (see differenceWrite). The redraw trigger sets the - // rewrite flag so onData resets the line buffer to the echoed line. + // delta, so the pty never has to reconcile our local buffer with its own + // (see differenceWrite). The local buffer mirrors the rewrite — the shell + // does not echo it back through onData, so nothing else would update it, + // and the next Enter would otherwise record the pre-accept text. differenceWrite(sessionId, cmd, lineBufRef.current) - diffRewriteRef.current = true lineBufRef.current = cmd suggestionsRef.current = [] selIndexRef.current = 0 @@ -792,15 +793,27 @@ export const TerminalView: ForwardRefExoticComponent r.text).join('') + // translateToString emits one char per cell run — a double-width char + // is ONE char spanning TWO columns — so a char offset cannot be mapped + // to a column with `% cols` (every CJK char on the line shifts the + // link range). Walk the cells once and record the 1-based x / buffer + // row of each char position in `text`. const cols = term.cols > 0 ? term.cols : 1 - const base = rows[0]?.index ?? first + const colAt: number[] = [] + const rowAt: number[] = [] + for (const { index: row } of rows) { + const line = buffer.getLine(row) + for (let col = 0; col < cols; col++) { + if ((line?.getCell(col)?.getWidth() ?? 1) === 0) continue // wide-char tail + colAt.push(col + 1) + rowAt.push(row) + } + } const links: ILink[] = findUrls(text).map((found) => { - const startRow = Math.floor(found.start / cols) - const endRow = Math.floor((found.end - 1) / cols) return { range: { - start: { x: (found.start % cols) + 1, y: base + startRow + 1 }, - end: { x: ((found.end - 1) % cols) + 1, y: base + endRow + 1 } + start: { x: colAt[found.start] ?? 1, y: (rowAt[found.start] ?? first) + 1 }, + end: { x: colAt[found.end - 1] ?? 1, y: (rowAt[found.end - 1] ?? first) + 1 } }, text: found.url, decorations: { pointerCursor: true, underline: true }, @@ -822,10 +835,6 @@ export const TerminalView: ForwardRefExoticComponent { if (!deadRef.current) writeBroadcast(sessionId, data) - // M5: a completion acceptance rewrite armed `diffRewriteRef`; consume it - // now so this redraw chunk replaces (not appends to) the line buffer. - const consumeRewrite = diffRewriteRef.current - diffRewriteRef.current = false // Full-screen TUIs (vim, Claude Code, …) own the alternate buffer, their // own key handling and their own cursor: the line buffer, the command // history and the completion popup have no business there. Interfering @@ -841,8 +850,10 @@ export const TerminalView: ForwardRefExoticComponent { const next = tSettings.rendererMode + // The construction effect above already applied the mode on mount (this + // effect runs on mount too); only an actual switch should re-run it. + if (next === rendererModeRef.current) return if (next === 'dom') { applyRenderers('dom') } else if (!webglRef.current) { diff --git a/src/renderer/src/terminal/highlightEngine.ts b/src/renderer/src/terminal/highlightEngine.ts index 9907aa5..b7e38d5 100644 --- a/src/renderer/src/terminal/highlightEngine.ts +++ b/src/renderer/src/terminal/highlightEngine.ts @@ -33,6 +33,16 @@ const ESCAPE_RE = const MAX_PER_RULE = 300 /** Chunks larger than this are returned unmodified (budget guard). */ const MAX_CHUNK = 512 * 1024 +/** + * Longest newline-free segment of a plain-text run a user rule is applied to. + * MAX_PER_RULE bounds the match *count*, not a pattern's backtracking *cost*: + * a pathological pattern like `(a+)+b` walks the whole line before the count + * can stop it, which freezes the renderer on one very long line. A run holding + * such a line is passed through unhighlighted — ordinary output (many short + * lines, whatever the chunk size) is unaffected, since the bound is per line, + * not per run. + */ +const MAX_LINE_LEN = 4 * 1024 /** "#rrggbb" or 3-digit "#rgb" → {r,g,b}; any malformed input → white. */ export function hexToRgb(hex: string): { r: number; g: number; b: number } { @@ -107,14 +117,26 @@ function overlaps(claimed: Span[], s: number, e: number): boolean { return false } +/** True when `text` holds a newline-free segment longer than MAX_LINE_LEN. */ +function hasLongLine(text: string): boolean { + let start = 0 + for (;;) { + const nl = text.indexOf('\n', start) + if (nl < 0) return text.length - start > MAX_LINE_LEN + if (nl - start > MAX_LINE_LEN) return true + start = nl + 1 + } +} + /** * Apply all rules to a single plain-text run. Consumes from `budgets` (per-rule * remaining match allowance shared across the whole chunk). Returns the * SGR-injected text. With a global budget of zero for every rule the run is - * returned untouched. + * returned untouched, as is a run holding an over-long line (see MAX_LINE_LEN). */ function applyRun(text: string, rules: CompiledRule[], budgets: number[]): string { if (text.length === 0) return '' + if (hasLongLine(text)) return text const claimed: Span[] = [] for (let ri = 0; ri < rules.length; ri++) { const rule = rules[ri] diff --git a/src/renderer/src/theme/chrome.ts b/src/renderer/src/theme/chrome.ts index ee61404..f59554d 100644 --- a/src/renderer/src/theme/chrome.ts +++ b/src/renderer/src/theme/chrome.ts @@ -22,6 +22,14 @@ function toHex(v: number): string { return Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0') } +/** WCAG contrast ratio between two colors; 21 when either is not hex. */ +function contrast(a: string, b: string): number { + const la = luminance(a) + const lb = luminance(b) + if (la === null || lb === null) return 21 + return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05) +} + /** t=0 → a, t=1 → b */ export function mix(a: string, b: string, t: number): string | null { const ca = parseHex(a) @@ -34,6 +42,18 @@ export function mix(a: string, b: string, t: number): string | null { * from it flip their overlay direction (white-on-dark vs black-on-light). */ let chromeIsLight = false +/** Dark-chrome tab label: the background mixed toward the theme's own foreground + * (the light branch is the same mix toward black), but never below 4.5:1 against + * the background, since a theme foreground dimmed by 0.68 can land unreadable. */ +function tabLabel(background: string, foreground: string, t: number): string { + const mixed = mix(background, foreground, t) + return mixed && contrast(mixed, background) >= 4.5 ? mixed : foreground +} + +/** Accent color from the last applyTabAccent() call; re-applied at the end of a + * chrome theme change so --tab-accent-fg follows the new theme's luminance. */ +let tabAccent = '' + /** Derive the app chrome palette (sidebar, tab bars, dividers) from the active * terminal theme so the whole window follows theme switches instead of only * the terminal canvas. Values land on :root CSS variables; anything the theme @@ -67,9 +87,10 @@ export function applyChromeTheme(colors: ThemeColors): void { set('--chrome-tab-bg-hover', 'rgba(255, 255, 255, 0.07)') set('--chrome-tab-ring', 'rgba(255, 255, 255, 0.1)') set('--chrome-tab-ring-hover', 'rgba(255, 255, 255, 0.2)') - set('--chrome-tab-fg', '#98a2ab') - set('--chrome-tab-fg-hover', '#d0d0d0') + set('--chrome-tab-fg', tabLabel(colors.background, colors.foreground, 0.68)) + set('--chrome-tab-fg-hover', tabLabel(colors.background, colors.foreground, 0.88)) } + applyTabAccent(tabAccent) } /** Tab accent: base color + a foreground variant that stays readable on the @@ -77,6 +98,7 @@ export function applyChromeTheme(colors: ThemeColors): void { * var(--tab-accent*) in workspace.css. */ export function applyTabAccent(color: string): void { const style = document.documentElement.style + tabAccent = color // Settings hydrated from an older main process may lack the key; clear the // vars so the CSS fallbacks apply instead of an invalid "undefined" value. if (!color) { diff --git a/src/renderer/src/workspace/SshBottomPanel.tsx b/src/renderer/src/workspace/SshBottomPanel.tsx index b40035d..5b715b6 100644 --- a/src/renderer/src/workspace/SshBottomPanel.tsx +++ b/src/renderer/src/workspace/SshBottomPanel.tsx @@ -15,49 +15,79 @@ const MIN_HEIGHT = 120 export function SshBottomPanel({ sessionId, terminal }: SshBottomPanelProps): React.JSX.Element { /** Current file-browser height in px. */ const [filesHeight, setFilesHeight] = useState(220) - /** Container height captured while a drag is in flight. */ - const dragInfoRef = useRef<{ startY: number; startHeight: number; containerH: number } | null>(null) + /** Divider drag in flight — drives the accent highlight. */ + const [resizing, setResizing] = useState(false) + const rootRef = useRef(null) + /** Drag origin; non-null only while a divider drag is in flight. */ + const dragRef = useRef<{ startY: number; startHeight: number } | null>(null) + /** Live container height, refreshed by the observer below: clamping against a + * height captured at mousedown keeps a stale maximum after a window resize. */ + const containerHRef = useRef(0) - const onDividerMouseDown = useCallback((e: React.MouseEvent): void => { - if (e.button !== 0) return - const container = (e.currentTarget.parentElement as HTMLElement | null) ?? null - if (!container) return - dragInfoRef.current = { - startY: e.clientY, - startHeight: filesHeight, - containerH: container.clientHeight - } - e.preventDefault() - }, [filesHeight]) + const clampHeight = useCallback((height: number): number => { + return Math.max(MIN_HEIGHT, Math.min(Math.round(containerHRef.current * 0.6), height)) + }, []) + // Re-clamp on container resize: a shrunk window would otherwise leave the file + // browser taller than 60% of the panel, collapsing the terminal. useEffect(() => { - const onMove = (e: MouseEvent): void => { - const info = dragInfoRef.current + const root = rootRef.current + if (!root) return + const observer = new ResizeObserver(() => { + containerHRef.current = root.clientHeight + setFilesHeight((prev) => clampHeight(prev)) + }) + observer.observe(root) + return () => observer.disconnect() + }, [clampHeight]) + + const onDividerPointerDown = useCallback( + (e: React.PointerEvent): void => { + if (e.button !== 0) return + const root = rootRef.current + if (!root) return + containerHRef.current = root.clientHeight + dragRef.current = { startY: e.clientY, startHeight: filesHeight } + // Pointer capture keeps move/up events coming even when the pointer leaves + // the window, so a release outside it cannot strand the drag. + e.currentTarget.setPointerCapture(e.pointerId) + setResizing(true) + e.preventDefault() + }, + [filesHeight] + ) + + const onDividerPointerMove = useCallback( + (e: React.PointerEvent): void => { + const info = dragRef.current if (!info) return - const delta = e.clientY - info.startY - const max = Math.round(info.containerH * 0.6) - setFilesHeight(Math.min(max, Math.max(MIN_HEIGHT, info.startHeight - delta))) - } - const onUp = (): void => { - dragInfoRef.current = null - } - window.addEventListener('mousemove', onMove) - window.addEventListener('mouseup', onUp) - return () => { - window.removeEventListener('mousemove', onMove) - window.removeEventListener('mouseup', onUp) + setFilesHeight(clampHeight(info.startHeight - (e.clientY - info.startY))) + }, + [clampHeight] + ) + + const endDrag = useCallback((e: React.PointerEvent): void => { + if (!dragRef.current) return + dragRef.current = null + setResizing(false) + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + e.currentTarget.releasePointerCapture(e.pointerId) } }, []) return ( -
+
{terminal}
diff --git a/src/renderer/src/workspace/TerminalPanel.tsx b/src/renderer/src/workspace/TerminalPanel.tsx index 15c65e8..af8653a 100644 --- a/src/renderer/src/workspace/TerminalPanel.tsx +++ b/src/renderer/src/workspace/TerminalPanel.tsx @@ -42,17 +42,23 @@ export function TerminalPanel({ params, api, onSessionDead }: TerminalPanelProps const [sideOpen, setSideOpen] = useState(true) // M6 broadcast: keep the session in the broadcast registry while mounted so - // it can be picked as a target / receive fan-out writes (idempotent upsert). + // it can be picked as a target / receive fan-out writes. Registrations are + // keyed by panel id — an SSH split shows one session in two panes, and + // unmounting one of them must not unregister the other. useEffect(() => { - useBroadcastStore.getState().registerSession({ id: sessionId, title: api.title ?? '', isSsh }) - return () => useBroadcastStore.getState().unregisterSession(sessionId) + useBroadcastStore + .getState() + .registerSession({ id: sessionId, panelId: api.id, title: api.title ?? '', isSsh }) + return () => useBroadcastStore.getState().unregisterSession(api.id) // eslint-disable-next-line react-hooks/exhaustive-deps -- api is stable; title kept fresh below }, [sessionId]) // M6: keep the registered broadcast title in sync with the tab title. useEffect(() => { const disposable = api.onDidTitleChange((event) => { - useBroadcastStore.getState().registerSession({ id: sessionId, title: event.title, isSsh }) + useBroadcastStore + .getState() + .registerSession({ id: sessionId, panelId: api.id, title: event.title, isSsh }) }) return () => disposable.dispose() }, [api, sessionId, isSsh]) diff --git a/src/renderer/src/workspace/TerminalTab.tsx b/src/renderer/src/workspace/TerminalTab.tsx index b0beb42..758a801 100644 --- a/src/renderer/src/workspace/TerminalTab.tsx +++ b/src/renderer/src/workspace/TerminalTab.tsx @@ -63,12 +63,15 @@ export function TerminalTab({ api, params }: TerminalTabProps): React.JSX.Elemen if (key === 'close') { api.close() } else if (key === 'closeOthers') { - for (const sibling of panel.panels) { + // `panel.panels` is the live group array and `close()` splices it, so + // the targets must be snapshotted first — otherwise every element that + // shifts into the current index is skipped. + for (const sibling of [...panel.panels]) { if (sibling.api !== api) closePanel(sibling) } } else if (key === 'closeRight') { - for (let i = selfIndex + 1; i < panel.panels.length; i++) { - closePanel(panel.panels[i]) + for (const target of [...panel.panels].slice(selfIndex + 1)) { + closePanel(target) } } } diff --git a/src/renderer/src/workspace/Workspace.tsx b/src/renderer/src/workspace/Workspace.tsx index 349c8bc..1fcb0be 100644 --- a/src/renderer/src/workspace/Workspace.tsx +++ b/src/renderer/src/workspace/Workspace.tsx @@ -17,6 +17,7 @@ import type { DockviewApi, DockviewPanelApi, DockviewReadyEvent, + DockviewTheme, IDockviewHeaderActionsProps, IDockviewPanel, IDockviewPanelHeaderProps, @@ -61,6 +62,54 @@ export type { TerminalParams } const TERMINAL_COMPONENT = 'terminal' const TERMINAL_TAB_COMPONENT = 'terminal-tab' +/** + * Sessions we already asked to kill — kill once, never twice. + * + * Module-level rather than a ref so the dockview `components` / + * `tabComponents` maps below can be module constants: dockview re-runs its prop + * effects on every identity change and each `updateOptions` ends in an + * unconditional `_layoutFromShell()`. + */ +const deadSessions = new Set() + +/** Mark a session dead ahead of a panel close so the cleanup skips it. */ +function markSessionDead(sessionId: string): void { + deadSessions.add(sessionId) +} + +const DOCKVIEW_THEME: DockviewTheme = { + name: 'Abyss', + className: 'dockview-theme-abyss', + colorScheme: 'dark' +} + +const DOCKVIEW_COMPONENTS = { + [TERMINAL_COMPONENT]: (props: IDockviewPanelProps) => ( + + ) +} + +const DOCKVIEW_TAB_COMPONENTS = { + [TERMINAL_TAB_COMPONENT]: (props: IDockviewPanelHeaderProps) => ( + + ) +} + +/** + * Electron re-throws a main-process rejection as + * `Error: Error invoking remote method 'x': Error: `, so the real + * cause has to be unwrapped to be worth showing. + */ +function connectFailureMessage(error: unknown): string { + const generic = t('workspace.connect.failed') + if (!(error instanceof Error)) return generic + const detail = error.message + .replace(/^Error invoking remote method '[^']*':\s*/, '') + .replace(/^Error:\s*/, '') + .trim() + return detail ? `${generic}: ${detail}` : generic +} + /** * "终端 N" titles fill the lowest free number among live panels: closing a * pane frees its number for the next new terminal, and a session restore can @@ -230,7 +279,7 @@ const SshTabActions = createTabActions('ssh') * (React state / panel instances stay mounted, so the switch is free). * * Sessions: panels carry a `params.sessionId`. Killing a session is idempotent - * through `deadSessionsRef` so a session is killed at most once — either + * through the `deadSessions` set so a session is killed at most once — either * up-front (TerminalView `onClose` after the process exits) or when its panel * is removed. Each dockview registers `onDidRemovePanel` so a panel close in * either workspace kills the bound session. @@ -255,8 +304,6 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX /** The currently-visible mode's active panel (broadcast target). */ const activePanelRef = useRef(undefined) - /** Sessions we already asked to kill — kill once, never twice. */ - const deadSessionsRef = useRef>(new Set()) /** Bind-time disposables for both dockviews, disposed on unmount. */ const disposablesRef = useRef>([]) @@ -264,6 +311,8 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX const [applyOpen, setApplyOpen] = useState(false) /** Terminal dockview ready — gates first-run seeding. */ const [terminalReady, setTerminalReady] = useState(false) + /** Settings hydrate is async — the boot pass must not read its defaults. */ + const settingsHydrated = useSettingsStore((s) => s.hydrated) /** Local terminal panels surfaced to the sidebar's "终端" section. */ const [localPanels, setLocalPanels] = useState>([]) @@ -283,8 +332,12 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX }, []) /** one pending SSH connection request (null = idle). */ const [requestedConn, setRequestedConn] = useState(null) - /** true while openSession is in flight — drives the "连接中…" modal. */ - const [connecting, setConnecting] = useState(false) + /** In-flight openSession attempts (0 = idle); the rail busy dot reads it. */ + const [connectingCount, setConnectingCount] = useState(0) + const connecting = connectingCount > 0 + /** Re-entrancy guard for `connect` — a second attempt while one is in flight + * is dropped (openSession cannot be aborted and would orphan a session). */ + const connectInFlightRef = useRef(0) /** FIFO of pending host-key confirmations — render the head only. */ const [hostKeyQueue, setHostKeyQueue] = useState([]) @@ -342,16 +395,11 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX /** Idempotently kill a pty session. */ const killSession = useCallback((sessionId: string | undefined): void => { if (!sessionId) return - if (deadSessionsRef.current.has(sessionId)) return - deadSessionsRef.current.add(sessionId) + if (deadSessions.has(sessionId)) return + deadSessions.add(sessionId) window.api.killPty(sessionId) }, []) - /** Mark a session dead ahead of a panel close so the cleanup skips it. */ - const markSessionDead = useCallback((sessionId: string): void => { - deadSessionsRef.current.add(sessionId) - }, []) - const createSession = useCallback(async (cwd?: string): Promise => { // `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 @@ -366,14 +414,19 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX * terminal dockview holds only local sessions, so it is the single source. */ const recomputeLocalPanels = useCallback((): void => { const current = terminalApiRef.current - if (!current) { - setLocalPanels([]) - return - } - const panels = current.panels - .filter((p) => (p.params as TerminalParams | undefined)?.sessionKind === 'local') - .map((p) => ({ id: p.id, title: p.title ?? '' })) - setLocalPanels(panels) + const panels = current + ? current.panels + .filter((p) => (p.params as TerminalParams | undefined)?.sessionKind === 'local') + .map((p) => ({ id: p.id, title: p.title ?? '' })) + : [] + // Keep the previous array when nothing changed: a fresh array on every + // layout event re-renders the sidebar (and the whole workspace) for nothing. + setLocalPanels((prev) => + prev.length === panels.length && + prev.every((panel, i) => panel.id === panels[i].id && panel.title === panels[i].title) + ? prev + : panels + ) }, []) /** Recount session panels per dockview into local state. */ @@ -426,6 +479,10 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX } useCount.delete(sid) killSession(sid) + // No pane shows this session any more: drop its bookkeeping too, so + // neither the dead-session set nor the cwd map grows for the whole run. + deadSessions.delete(sid) + forgetSessionCwd(sid) } const cleanups = [ @@ -522,28 +579,41 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX * 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. + * + * Gated on the settings hydrate: reading `restoreSession` before it lands + * always sees the defaults, so a user who turned restore off got their session + * back whenever the dockview became ready first. */ useEffect(() => { - if (!terminalReady) return + if (!terminalReady || !settingsHydrated) return let cancelled = false void (async (): Promise => { 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() + try { + 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))) await addTerminal() + } catch (error) { + // An escaped rejection used to leave the workspace half-applied: the + // boot flag never flipped (so no snapshot was ever saved again) and the + // panes the pass had not reached stayed bound to dead session ids. + console.error('[workspace] session restore failed', error) + if (!cancelled) message.error(t('workspace.error.title')) + } finally { + bootDoneRef.current = true + scheduleSave() + } })() return () => { cancelled = true } - // eslint-disable-next-line react-hooks/exhaustive-deps -- boot exactly once, on first ready - }, [terminalReady]) + // eslint-disable-next-line react-hooks/exhaustive-deps -- boot once, on ready + hydrated + }, [terminalReady, settingsHydrated]) /** Kill remaining sessions on window close. */ useEffect(() => { @@ -649,10 +719,15 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX * stream; `connectionId` + any staged `secretOverride` are the only inputs * the main process needs — no extra IPC channel. New sessions land in the ssh * dockview. + * + * Re-entrant calls are dropped: a double-click would otherwise open two + * sessions for one click, and openSession cannot be aborted once it is away. */ const connect = useCallback( async (conn: SshConnection, secretOverride: SshSecretOverride): Promise => { - setConnecting(true) + if (connectInFlightRef.current > 0) return + connectInFlightRef.current += 1 + setConnectingCount((count) => count + 1) try { const result = await window.api.openSession({ kind: 'ssh', @@ -674,9 +749,10 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX // B's sidebar refreshes lastConnectedAt on connect. void sidebarRef.current?.refresh() } catch (error) { - message.error(typeof error === 'string' ? error : t('workspace.connect.failed')) + message.error(connectFailureMessage(error)) } finally { - setConnecting(false) + connectInFlightRef.current -= 1 + setConnectingCount((count) => count - 1) setRequestedConn(null) } }, @@ -685,6 +761,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX const handleConnectRequest = useCallback( (conn: SshConnection): void => { + if (connectInFlightRef.current > 0) return // Ask-at-connect connections go through the secret prompt first; saved // credentials must connect IMMEDIATELY — routing them through ConnectFlow // would only ever *render* a "connecting" spinner without firing openSession. @@ -694,6 +771,9 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX setRequestedConn(conn) return } + // Immediate path: show the same uncancellable "连接中" modal the secret + // path uses, so the server list is not clickable while openSession flies. + setRequestedConn(conn) void connect(conn, {}) }, [connect] @@ -783,16 +863,35 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX * panel's `` 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. + * + * The owning dockview api comes along so the panel's survival can be checked + * after the await: a pane closed while its pty was still starting would + * otherwise leave that fresh session behind, bound to no panel and never + * killed. */ const bindPanelSession = useCallback( - async (panel: DockviewPanelApi | undefined, currentSessionId: string, cwd?: string): Promise => { - if (!panel) return - const nextSessionId = await createSession(cwd) - panel.updateParameters({ sessionId: nextSessionId, sessionKind: 'local' }) - deadSessionsRef.current.delete(currentSessionId) - forgetSessionCwd(currentSessionId) + async ( + api: DockviewApi | undefined, + panel: DockviewPanelApi | undefined, + currentSessionId: string, + cwd?: string + ): Promise => { + if (!api || !panel) return + try { + const nextSessionId = await createSession(cwd) + if (!api.getPanel(panel.id)) { + killSession(nextSessionId) + forgetSessionCwd(nextSessionId) + return + } + panel.updateParameters({ sessionId: nextSessionId, sessionKind: 'local' }) + deadSessions.delete(currentSessionId) + forgetSessionCwd(currentSessionId) + } catch (error) { + console.error('[workspace] panel rebind failed', error) + } }, - [createSession] + [createSession, killSession] ) /** @@ -836,7 +935,13 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX panel.api.close() continue } - await bindPanelSession(panel.api, id, lookup(panel.id)?.cwd) + // One pane failing to rebind must not abort the panes after it (they + // would keep the stale session id of a previous launch and stay dead). + try { + await bindPanelSession(api, panel.api, id, lookup(panel.id)?.cwd) + } catch (error) { + console.error('[workspace] pane rebind failed', panel.id, error) + } } }, [bindPanelSession] @@ -879,8 +984,13 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX // Retitle duplicates left over from the old counter bug (the snapshot it // wrote keeps colliding titles forever otherwise): the first panel keeps // the title, later ones get the lowest free number. + // + // Local terminals only: an SSH pane is titled with its server name, and a + // split deliberately shares that title with its sibling, so the terminal + // numbering pattern is the wrong remedy there. const seenTitles = new Set() for (const p of [...(terminal?.panels ?? []), ...(ssh?.panels ?? [])]) { + if ((p.params as TerminalParams | undefined)?.sessionKind === 'ssh') continue const t = p.title ?? '' if (!t) continue if (seenTitles.has(t)) { @@ -1014,19 +1124,11 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
) => ( - - ) - }} - tabComponents={{ - [TERMINAL_TAB_COMPONENT]: (props: IDockviewPanelHeaderProps) => ( - - ) - }} + components={DOCKVIEW_COMPONENTS} + tabComponents={DOCKVIEW_TAB_COMPONENTS} rightHeaderActionsComponent={TerminalTabActions} onReady={handleTerminalReady} - theme={{ name: 'Abyss', className: 'dockview-theme-abyss', colorScheme: 'dark' }} + theme={DOCKVIEW_THEME} /> {terminalCount === 0 && (
@@ -1048,19 +1150,11 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
) => ( - - ) - }} - tabComponents={{ - [TERMINAL_TAB_COMPONENT]: (props: IDockviewPanelHeaderProps) => ( - - ) - }} + components={DOCKVIEW_COMPONENTS} + tabComponents={DOCKVIEW_TAB_COMPONENTS} rightHeaderActionsComponent={SshTabActions} onReady={handleSshReady} - theme={{ name: 'Abyss', className: 'dockview-theme-abyss', colorScheme: 'dark' }} + theme={DOCKVIEW_THEME} /> {sshCount === 0 && (
diff --git a/src/renderer/src/workspace/ZmodemOffers.tsx b/src/renderer/src/workspace/ZmodemOffers.tsx index 9fdfc8e..8f31b7e 100644 --- a/src/renderer/src/workspace/ZmodemOffers.tsx +++ b/src/renderer/src/workspace/ZmodemOffers.tsx @@ -83,6 +83,16 @@ export default function ZmodemOffers(): null | React.JSX.Element { const offDone = useCallback( (evt: { id: string; ok: boolean; message?: string }) => { + // The offer can be finished by main without an answer — its 120s watchdog + // aborts it and reports ok:false. Drop the entry either way, otherwise a + // dead offer keeps an unclosable modal (and its busy flag) alive. + busyRef.current.delete(evt.id) + setOffers((prev) => { + if (!prev.has(evt.id)) return prev + const next = new Map(prev) + next.delete(evt.id) + return next + }) if (evt.ok) message.success(t('ssh.zmodem.done')) else message.error( diff --git a/src/renderer/src/workspace/broadcastStore.ts b/src/renderer/src/workspace/broadcastStore.ts index e42cb90..a2e44be 100644 --- a/src/renderer/src/workspace/broadcastStore.ts +++ b/src/renderer/src/workspace/broadcastStore.ts @@ -2,10 +2,17 @@ import { create } from 'zustand' /** One open terminal session eligible to be a broadcast target. */ export interface BroadcastSession { - /** pty / ssh session id (also the dockview panel id) */ + /** pty / ssh session id */ id: string title: string isSsh: boolean + /** + * dockview panel showing this session. One session can back several panels — + * an SSH split deliberately mirrors a single session — so registrations are + * keyed by panel: closing one pane must not unregister the pane that is still + * showing that session. + */ + panelId: string } export interface BroadcastState { @@ -15,10 +22,13 @@ export interface BroadcastState { targets: Set /** open terminal sessions, registration order preserved */ sessions: BroadcastSession[] - /** register a session as broadcast-eligible (idempotent, keeps it fresh) */ + /** register a panel's session as broadcast-eligible (upsert, keeps it fresh) */ registerSession: (session: BroadcastSession) => void - /** remove a session; if it was a target, prune it (may auto-disable <2 targets) */ - unregisterSession: (id: string) => void + /** + * drop one panel's registration; the session leaves `sessions` (and the + * targets, possibly auto-disabling) only once no panel shows it any more + */ + unregisterSession: (panelId: string) => void /** add/remove one target; never lets the set drop below 2 while enabled */ toggleTarget: (id: string) => void /** turn broadcast on/off */ @@ -43,29 +53,42 @@ function pruneOnTargetLoss(enabled: boolean, nextTargets: Set): { enable return { enabled, targets: nextTargets } } +/** + * panelId → registration. The session list is derived from these rather than + * maintained alongside them: one session can back several panels (an SSH split + * mirrors a single session on purpose), and only the last panel to unmount may + * take the session out of the registry. + */ +const registrations = new Map() + +/** One entry per session, in first-registration order, latest title wins. */ +function deriveSessions(): BroadcastSession[] { + const bySession = new Map() + for (const registration of registrations.values()) bySession.set(registration.id, registration) + return [...bySession.values()] +} + export const useBroadcastStore = create((set) => ({ enabled: false, targets: new Set(), sessions: [], - registerSession: (session) => - set((state) => { - const exists = state.sessions.some((s) => s.id === session.id) - if (exists) { - // keep title/isSsh fresh (e.g. template apply retitles a panel) - const sessions = state.sessions.map((s) => (s.id === session.id ? session : s)) - const targets = new Set(state.targets) - return { sessions, targets } - } - return { sessions: [...state.sessions, session] } - }), + registerSession: (session) => { + registrations.set(session.panelId, session) + set({ sessions: deriveSessions() }) + }, - unregisterSession: (id) => + unregisterSession: (panelId) => set((state) => { - const sessions = state.sessions.filter((s) => s.id !== id) - if (!state.targets.has(id)) return { sessions } + const dropped = registrations.get(panelId) + if (!dropped) return {} + registrations.delete(panelId) + const sessions = deriveSessions() + // another pane still shows this session — keep it, and its target slot + if (sessions.some((s) => s.id === dropped.id)) return { sessions } + if (!state.targets.has(dropped.id)) return { sessions } const targets = new Set(state.targets) - targets.delete(id) + targets.delete(dropped.id) return { sessions, ...pruneOnTargetLoss(state.enabled, targets) } }), @@ -74,7 +97,11 @@ export const useBroadcastStore = create((set) => ({ const targets = new Set(state.targets) if (targets.has(id)) { targets.delete(id) - return pruneOnTargetLoss(state.enabled, targets) + // Deliberate uncheck: keep the remaining selection — broadcast just + // turns off below the 2-target minimum. Only session loss (see + // unregisterSession) may wipe the set. + if (targets.size < 2) return { enabled: false, targets } + return { enabled: state.enabled, targets } } targets.add(id) // requires >= 2 targets to become/become-enabled