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)
This commit is contained in:
1 parent
e04f4f0ac1
commit
23fa566fa9
23 files changed
+784
-301
No files matched your search
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, useSyncExternalStore } from 'react'
|
||||||
import { ConfigProvider } from 'antd'
|
import { ConfigProvider } from 'antd'
|
||||||
import zhCN from 'antd/locale/zh_CN'
|
import zhCN from 'antd/locale/zh_CN'
|
||||||
import zhTW from 'antd/locale/zh_TW'
|
import zhTW from 'antd/locale/zh_TW'
|
||||||
@@ -9,7 +9,7 @@ import { SettingsDialog } from '@renderer/settings/SettingsDialog'
|
|||||||
import { TransferPanel } from '@renderer/sftp/TransferPanel'
|
import { TransferPanel } from '@renderer/sftp/TransferPanel'
|
||||||
import { useSettingsStore } from '@renderer/settings/store'
|
import { useSettingsStore } from '@renderer/settings/store'
|
||||||
import { getThemeById } from '@shared/theme'
|
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 { applyChromeTheme, applyTabAccent } from '@renderer/theme/chrome'
|
||||||
import appIconUrl from '../../../build/icon.png'
|
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 themeId = useSettingsStore((s) => s.settings.terminal.themeId)
|
||||||
const customThemes = useSettingsStore((s) => s.settings.customThemes)
|
const customThemes = useSettingsStore((s) => s.settings.customThemes)
|
||||||
const tabAccentColor = useSettingsStore((s) => s.settings.terminal.tabAccentColor)
|
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)
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void hydrate()
|
void hydrate()
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
setLanguage(language)
|
document.documentElement.lang = language
|
||||||
}, [language])
|
}, [language])
|
||||||
|
|
||||||
// Sidebar / tab bars / dividers follow the active terminal theme.
|
// Sidebar / tab bars / dividers follow the active terminal theme.
|
||||||
|
|||||||
@@ -260,6 +260,9 @@ function Row({
|
|||||||
icon={<DeleteOutlined />}
|
icon={<DeleteOutlined />}
|
||||||
className="commands-row-del"
|
className="commands-row-del"
|
||||||
aria-label={t('panels.commands.deleteAria')}
|
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()}
|
||||||
/>
|
/>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ export function ConnectionEditDialog({
|
|||||||
setErrorMsg(null)
|
setErrorMsg(null)
|
||||||
const base: SshAuthMethod = editing?.auth ?? 'password'
|
const base: SshAuthMethod = editing?.auth ?? 'password'
|
||||||
setAuth(base)
|
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({
|
form.setFieldsValue({
|
||||||
name: editing?.name,
|
name: editing?.name,
|
||||||
group: editing?.group,
|
group: editing?.group,
|
||||||
|
|||||||
@@ -85,13 +85,10 @@ export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionS
|
|||||||
void refresh()
|
void refresh()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Fold every group open whenever the dataset changes.
|
// Fold every group open by default; only the user's collapses are stored,
|
||||||
|
// so a group that appears later starts open as well.
|
||||||
const defaultGroup = t('ssh.sidebar.defaultGroup')
|
const defaultGroup = t('ssh.sidebar.defaultGroup')
|
||||||
const activeGroups = useMemo(() => {
|
const [closedGroups, setClosedGroups] = useState<string[]>([])
|
||||||
const groups = new Set<string>()
|
|
||||||
for (const c of connections) groups.add(c.group ?? defaultGroup)
|
|
||||||
return [...groups]
|
|
||||||
}, [connections, defaultGroup])
|
|
||||||
|
|
||||||
const groups = useMemo(() => {
|
const groups = useMemo(() => {
|
||||||
const map = new Map<string, SshConnection[]>()
|
const map = new Map<string, SshConnection[]>()
|
||||||
@@ -223,8 +220,10 @@ export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionS
|
|||||||
ghost
|
ghost
|
||||||
className="connections-collapse"
|
className="connections-collapse"
|
||||||
items={collapseItems}
|
items={collapseItems}
|
||||||
defaultActiveKey={groups.map((g) => g.group)}
|
activeKey={groups.map((g) => g.group).filter((key) => !closedGroups.includes(key))}
|
||||||
activeKey={activeGroups}
|
onChange={(keys) =>
|
||||||
|
setClosedGroups(groups.map((g) => g.group).filter((key) => !keys.includes(key)))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
import type { AppApi } from '../shared/api'
|
import type { AppApi } from '@shared/api'
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ if (typeof window !== 'undefined' && !window.api) {
|
|||||||
onHostKeyPrompt: () => noop,
|
onHostKeyPrompt: () => noop,
|
||||||
respondHostKey: noop,
|
respondHostKey: noop,
|
||||||
getSettings: async () => DEFAULT_SETTINGS,
|
getSettings: async () => DEFAULT_SETTINGS,
|
||||||
saveSettings: async (s) => s,
|
saveSettings: async (s) => ({ ...DEFAULT_SETTINGS, ...s }),
|
||||||
onSettingsChanged: () => noop,
|
onSettingsChanged: () => noop,
|
||||||
listFonts: async () => [],
|
listFonts: async () => [],
|
||||||
listLayouts: async () => [],
|
listLayouts: async () => [],
|
||||||
@@ -89,6 +89,7 @@ if (typeof window !== 'undefined' && !window.api) {
|
|||||||
saveLayout: async () => undefined,
|
saveLayout: async () => undefined,
|
||||||
deleteLayout: async () => undefined,
|
deleteLayout: async () => undefined,
|
||||||
updateCheck: async () => ({ status: 'dev' as const, currentVersion: 'dev' }),
|
updateCheck: async () => ({ status: 'dev' as const, currentVersion: 'dev' }),
|
||||||
|
getUpdateState: async () => ({ status: 'dev' as const, currentVersion: 'dev' }),
|
||||||
updateDownload: async () => undefined,
|
updateDownload: async () => undefined,
|
||||||
updateInstall: async () => undefined,
|
updateInstall: async () => undefined,
|
||||||
updateChangelog: async () => [],
|
updateChangelog: async () => [],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import type { SysinfoMeta, SysinfoSample } from '@shared/sysinfo'
|
import type { SysinfoMeta, SysinfoSample } from '@shared/sysinfo'
|
||||||
import { t } from '@shared/i18n'
|
import { t } from '@shared/i18n'
|
||||||
|
import { useWorkspaceModeStore } from '../workspace/workspaceModeStore'
|
||||||
import './monitor.css'
|
import './monitor.css'
|
||||||
|
|
||||||
export interface MonitorPanelProps {
|
export interface MonitorPanelProps {
|
||||||
@@ -25,8 +26,6 @@ interface HistoryChartProps {
|
|||||||
function HistoryChart({ data, lineColor, fillColor, height, maxY }: HistoryChartProps): React.JSX.Element {
|
function HistoryChart({ data, lineColor, fillColor, height, maxY }: HistoryChartProps): React.JSX.Element {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||||
const dataRef = useRef<number[]>(data)
|
|
||||||
dataRef.current = data
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current
|
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.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||||
ctx.clearRect(0, 0, width, height)
|
ctx.clearRect(0, 0, width, height)
|
||||||
|
|
||||||
const pts = dataRef.current
|
const pts = data
|
||||||
if (pts.length < 2) return
|
if (pts.length < 2) return
|
||||||
|
|
||||||
const top = maxY ?? Math.max(1, Math.max(...pts) * 1.2)
|
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)
|
const ro = new ResizeObserver(draw)
|
||||||
ro.observe(container)
|
ro.observe(container)
|
||||||
return () => ro.disconnect()
|
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 (
|
return (
|
||||||
<div ref={containerRef} className="mm-chart">
|
<div ref={containerRef} className="mm-chart">
|
||||||
@@ -134,8 +135,12 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
|
|||||||
const [cpuHist, setCpuHist] = useState<number[]>([])
|
const [cpuHist, setCpuHist] = useState<number[]>([])
|
||||||
const [rxHist, setRxHist] = useState<number[]>([])
|
const [rxHist, setRxHist] = useState<number[]>([])
|
||||||
const [txHist, setTxHist] = useState<number[]>([])
|
const [txHist, setTxHist] = useState<number[]>([])
|
||||||
|
/** 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(() => {
|
useEffect(() => {
|
||||||
|
if (workspaceMode !== 'ssh') return
|
||||||
let disposed = false
|
let disposed = false
|
||||||
setCpuHist([])
|
setCpuHist([])
|
||||||
setRxHist([])
|
setRxHist([])
|
||||||
@@ -161,7 +166,7 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
|
|||||||
offSample()
|
offSample()
|
||||||
window.api.sysinfoStop(sessionId)
|
window.api.sysinfoStop(sessionId)
|
||||||
}
|
}
|
||||||
}, [sessionId])
|
}, [sessionId, workspaceMode])
|
||||||
|
|
||||||
if (!meta || !sample) {
|
if (!meta || !sample) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -74,6 +74,12 @@ export function AboutTab(): React.JSX.Element {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void window.api.appInfo().then((i: AppInfo) => setVersion(i.appVersion))
|
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)
|
return window.api.onUpdateState(setState)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,17 @@ const PRESET_COLORS = ['#3fb950', '#f85149', '#e3b341', '#58a6ff', '#d2a8ff', '#
|
|||||||
/** clone the builtin preset set to prevent reference pollution by mutations. */
|
/** clone the builtin preset set to prevent reference pollution by mutations. */
|
||||||
const cloneDefaults = (): HighlightRule[] => JSON.parse(JSON.stringify(DEFAULT_HIGHLIGHT_RULES)) as HighlightRule[]
|
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.<id>` 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 } {
|
function tryCompile(pattern: string): { ok: boolean; message?: string } {
|
||||||
if (pattern.trim() === '') return { ok: true }
|
if (pattern.trim() === '') return { ok: true }
|
||||||
try {
|
try {
|
||||||
@@ -128,8 +139,10 @@ export function HighlightTab(): React.JSX.Element {
|
|||||||
title: t('settings.highlight.note'),
|
title: t('settings.highlight.note'),
|
||||||
dataIndex: 'note',
|
dataIndex: 'note',
|
||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
render: (note: string | undefined) =>
|
render: (_note: string | undefined, record) => {
|
||||||
note ? <span className="hl-cell-note">{note}</span> : <span className="hl-cell-empty">—</span>
|
const note = ruleNote(record)
|
||||||
|
return note ? <span className="hl-cell-note">{note}</span> : <span className="hl-cell-empty">—</span>
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('settings.highlight.actions'),
|
title: t('settings.highlight.actions'),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
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 type { TabsProps } from 'antd'
|
||||||
import { t } from '@shared/i18n'
|
import { t } from '@shared/i18n'
|
||||||
import { DEFAULT_SETTINGS } from '@shared/settings'
|
import { DEFAULT_SETTINGS } from '@shared/settings'
|
||||||
@@ -11,6 +11,12 @@ import { AboutTab } from './AboutTab'
|
|||||||
import { ThemeEditor } from '../theme/editor/ThemeEditor'
|
import { ThemeEditor } from '../theme/editor/ThemeEditor'
|
||||||
import './settings.css'
|
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 {
|
export interface SettingsDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@@ -94,6 +100,10 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JS
|
|||||||
void updateTerminal({ ...DEFAULT_SETTINGS.terminal })
|
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 (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
@@ -103,7 +113,16 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JS
|
|||||||
title={t('settings.title')}
|
title={t('settings.title')}
|
||||||
footer={
|
footer={
|
||||||
<div className="settings-footer">
|
<div className="settings-footer">
|
||||||
<Button onClick={handleResetDefaults}>{t('common.reset')}</Button>
|
<Popconfirm
|
||||||
|
title={tOr('settings.resetTerminalTitle', t('common.reset'))}
|
||||||
|
description={resetDesc || undefined}
|
||||||
|
okText={resetLabel}
|
||||||
|
cancelText={t('common.cancel')}
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={handleResetDefaults}
|
||||||
|
>
|
||||||
|
<Button>{resetLabel}</Button>
|
||||||
|
</Popconfirm>
|
||||||
<Button type="primary" onClick={onClose}>
|
<Button type="primary" onClick={onClose}>
|
||||||
{t('common.close')}
|
{t('common.close')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -357,6 +357,8 @@ export function RenderSettingsTab(): React.JSX.Element {
|
|||||||
export function SystemSettingsTab(): React.JSX.Element {
|
export function SystemSettingsTab(): React.JSX.Element {
|
||||||
const settings = useSettingsStore((s) => s.settings)
|
const settings = useSettingsStore((s) => s.settings)
|
||||||
const updateSystem = useSettingsStore((s) => s.updateSystem)
|
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 (
|
return (
|
||||||
<div className="settings-pane">
|
<div className="settings-pane">
|
||||||
{/* Interface language — first row: the whole dialog re-renders on change. */}
|
{/* Interface language — first row: the whole dialog re-renders on change. */}
|
||||||
@@ -433,11 +435,14 @@ export function SystemSettingsTab(): React.JSX.Element {
|
|||||||
<ShortcutInput
|
<ShortcutInput
|
||||||
value={settings.system.globalShowHide ?? ''}
|
value={settings.system.globalShowHide ?? ''}
|
||||||
onChange={(v) => void updateSystem({ globalShowHide: v })}
|
onChange={(v) => void updateSystem({ globalShowHide: v })}
|
||||||
|
onConflict={setShortcutConflict}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className="settings-block-hint settings-shortcut-hint">
|
<div className="settings-block-hint settings-shortcut-hint">
|
||||||
{t('settings.system.globalShortcutHint')}
|
{shortcutConflict
|
||||||
|
? tOr('settings.system.shortcutConflict', t('settings.system.globalShortcutHint'))
|
||||||
|
: t('settings.system.globalShortcutHint')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -486,12 +491,43 @@ function acceleratorFromEvent(e: React.KeyboardEvent<HTMLInputElement>): string
|
|||||||
return parts.join('+')
|
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
|
* Press-to-record input for the global shortcut: focus it, hit the combo, the
|
||||||
* accelerator is captured and saved. Esc cancels recording, Backspace/Delete
|
* accelerator is captured and saved. Esc cancels recording, Backspace/Delete
|
||||||
* clears (disables) the shortcut. Read-only so no stray text can be typed.
|
* 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)
|
const [recording, setRecording] = useState(false)
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
@@ -499,8 +535,14 @@ function ShortcutInput({ value, onChange }: { value: string; onChange: (v: strin
|
|||||||
readOnly
|
readOnly
|
||||||
value={recording ? t('settings.system.shortcutRecording') : value}
|
value={recording ? t('settings.system.shortcutRecording') : value}
|
||||||
placeholder={t('settings.system.shortcutPlaceholder')}
|
placeholder={t('settings.system.shortcutPlaceholder')}
|
||||||
onFocus={() => setRecording(true)}
|
onFocus={() => {
|
||||||
onBlur={() => setRecording(false)}
|
setRecording(true)
|
||||||
|
onConflict(false)
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setRecording(false)
|
||||||
|
onConflict(false)
|
||||||
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
@@ -514,10 +556,14 @@ function ShortcutInput({ value, onChange }: { value: string; onChange: (v: strin
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const accel = acceleratorFromEvent(e)
|
const accel = acceleratorFromEvent(e)
|
||||||
if (accel) {
|
if (!accel) return
|
||||||
onChange(accel)
|
if (isReservedAccelerator(accel)) {
|
||||||
e.currentTarget.blur()
|
onConflict(true)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
onConflict(false)
|
||||||
|
onChange(accel)
|
||||||
|
e.currentTarget.blur()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -24,6 +24,52 @@ let unsubscribeStoreListener: (() => void) | null = null
|
|||||||
/** re-entrancy guard so concurrent hydrate() calls don't double-subscribe */
|
/** re-entrancy guard so concurrent hydrate() calls don't double-subscribe */
|
||||||
let hydrating = false
|
let hydrating = false
|
||||||
|
|
||||||
|
/** Sub-keys of `prev` → `next` that actually changed, or undefined when none did. */
|
||||||
|
function diffGroup<T extends object>(prev: T, next: T): Partial<T> | undefined {
|
||||||
|
const patch: Partial<T> = {}
|
||||||
|
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<SettingsState>) => void,
|
||||||
|
written: AppSettings,
|
||||||
|
prev: AppSettings,
|
||||||
|
label: string
|
||||||
|
): Promise<void> {
|
||||||
|
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<AppSettings> = {}
|
||||||
|
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<SettingsState>((set, get) => ({
|
export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||||
hydrated: false,
|
hydrated: false,
|
||||||
settings: DEFAULT_SETTINGS,
|
settings: DEFAULT_SETTINGS,
|
||||||
@@ -55,52 +101,25 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
|||||||
...prev,
|
...prev,
|
||||||
terminal: { ...prev.terminal, ...partial }
|
terminal: { ...prev.terminal, ...partial }
|
||||||
}
|
}
|
||||||
// optimistic update; the SETTINGS_CHANGED echo will re-set the same value (idempotent)
|
await persist(get, set, next, prev, 'updateTerminal')
|
||||||
set({ settings: next })
|
|
||||||
try {
|
|
||||||
await window.api.saveSettings(next)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[settings] updateTerminal failed, rolling back', err)
|
|
||||||
set({ settings: prev })
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
setCustomThemes: async (themes) => {
|
setCustomThemes: async (themes) => {
|
||||||
const prev = get().settings
|
const prev = get().settings
|
||||||
const next: AppSettings = { ...prev, customThemes: themes }
|
const next: AppSettings = { ...prev, customThemes: themes }
|
||||||
set({ settings: next })
|
await persist(get, set, next, prev, 'setCustomThemes')
|
||||||
try {
|
|
||||||
await window.api.saveSettings(next)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[settings] setCustomThemes failed, rolling back', err)
|
|
||||||
set({ settings: prev })
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
setHighlightRules: async (rules) => {
|
setHighlightRules: async (rules) => {
|
||||||
const prev = get().settings
|
const prev = get().settings
|
||||||
const next: AppSettings = { ...prev, highlightRules: rules }
|
const next: AppSettings = { ...prev, highlightRules: rules }
|
||||||
// optimistic update; the SETTINGS_CHANGED echo will re-set the same value (idempotent)
|
await persist(get, set, next, prev, 'setHighlightRules')
|
||||||
set({ settings: next })
|
|
||||||
try {
|
|
||||||
await window.api.saveSettings(next)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[settings] setHighlightRules failed, rolling back', err)
|
|
||||||
set({ settings: prev })
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
updateSystem: async (partial) => {
|
updateSystem: async (partial) => {
|
||||||
const prev = get().settings
|
const prev = get().settings
|
||||||
const next: AppSettings = { ...prev, system: { ...prev.system, ...partial } }
|
const next: AppSettings = { ...prev, system: { ...prev.system, ...partial } }
|
||||||
// optimistic update; the SETTINGS_CHANGED echo will re-set the same value (idempotent)
|
await persist(get, set, next, prev, 'updateSystem')
|
||||||
set({ settings: next })
|
|
||||||
try {
|
|
||||||
await window.api.saveSettings(next)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[settings] updateSystem failed, rolling back', err)
|
|
||||||
set({ settings: prev })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -25,12 +25,32 @@ export interface FilePanelProps {
|
|||||||
sessionId: string
|
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[]
|
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<number, number> = { 2: 9, 5: 10, 8: 11 }
|
||||||
|
|
||||||
|
const emptyBits = (): PermBits => Array.from({ length: 12 }, () => false)
|
||||||
|
|
||||||
function parseModeBits(mode: string | undefined): PermBits {
|
function parseModeBits(mode: string | undefined): PermBits {
|
||||||
const chars = mode ? Array.from(mode.slice(1)) : []
|
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 {
|
function formatModeType(mode: string | undefined): string {
|
||||||
@@ -39,9 +59,13 @@ function formatModeType(mode: string | undefined): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function bitsToModeStr(type: string, bits: PermBits): string {
|
function bitsToModeStr(type: string, bits: PermBits): string {
|
||||||
const letters = bits.map((on, i) => {
|
const letters = MODE_LETTERS.map((on, i) => {
|
||||||
if (!on) return '-'
|
const special = SPECIAL_BIT_AT[i]
|
||||||
return ['r', 'w', 'x', 'r', 'w', 'x', 'r', 'w', 'x'][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('')
|
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
|
// 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
|
// octal "511" and made chmod a silent no-op when both sides agreed on the
|
||||||
// same wrong value.)
|
// 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 {
|
function fmtSize(bytes: number): string {
|
||||||
@@ -90,12 +124,14 @@ interface PermissionModalProps {
|
|||||||
|
|
||||||
function PermissionModal({ open, sessionId, entry, onClose, onSaved }: PermissionModalProps): React.JSX.Element {
|
function PermissionModal({ open, sessionId, entry, onClose, onSaved }: PermissionModalProps): React.JSX.Element {
|
||||||
const { message } = App.useApp()
|
const { message } = App.useApp()
|
||||||
const [bits, setBits] = useState<PermBits>(Array.from({ length: 9 }, () => false))
|
const [bits, setBits] = useState<PermBits>(emptyBits)
|
||||||
const [octal, setOctal] = useState('')
|
const [octal, setOctal] = useState('')
|
||||||
const [uid, setUid] = useState('')
|
const [uid, setUid] = useState('')
|
||||||
const [gid, setGid] = useState('')
|
const [gid, setGid] = useState('')
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const hasUidGid = entry != null && (entry.uid != null || entry.gid != null)
|
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 rowLabels = permRowLabels()
|
||||||
const colLabels = permColLabels()
|
const colLabels = permColLabels()
|
||||||
|
|
||||||
@@ -110,11 +146,11 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
|
|||||||
if (entry) {
|
if (entry) {
|
||||||
const b = parseModeBits(entry.mode)
|
const b = parseModeBits(entry.mode)
|
||||||
setBits(b)
|
setBits(b)
|
||||||
setOctal(bitsToOctal(b))
|
setOctal(entry.mode ? bitsToOctal(b) : '')
|
||||||
setUid(entry.uid != null ? String(entry.uid) : '')
|
setUid(entry.uid != null ? String(entry.uid) : '')
|
||||||
setGid(entry.gid != null ? String(entry.gid) : '')
|
setGid(entry.gid != null ? String(entry.gid) : '')
|
||||||
} else {
|
} else {
|
||||||
setBits(Array.from({ length: 9 }, () => false))
|
setBits(emptyBits())
|
||||||
setOctal('')
|
setOctal('')
|
||||||
setUid('')
|
setUid('')
|
||||||
setGid('')
|
setGid('')
|
||||||
@@ -125,6 +161,7 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
|
|||||||
const s = (v ?? '').trim()
|
const s = (v ?? '').trim()
|
||||||
if (/^[0-7]{1,4}$/.test(s)) {
|
if (/^[0-7]{1,4}$/.test(s)) {
|
||||||
const num = parseInt(s, 8) || 0
|
const num = parseInt(s, 8) || 0
|
||||||
|
const special = s.length === 4 ? parseInt(s[0], 8) : 0
|
||||||
setBits([
|
setBits([
|
||||||
(num >> 6) & 4 ? true : false,
|
(num >> 6) & 4 ? true : false,
|
||||||
(num >> 6) & 2 ? 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 >> 3) & 1 ? true : false,
|
||||||
num & 4 ? true : false,
|
num & 4 ? true : false,
|
||||||
num & 2 ? 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)
|
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 => {
|
const onSave = (): void => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
if (!entry) return
|
if (!entry) return
|
||||||
@@ -156,23 +205,27 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
|
|||||||
try {
|
try {
|
||||||
const octalStr = bitsToOctal(bits)
|
const octalStr = bitsToOctal(bits)
|
||||||
const curOctal = entry.mode ? bitsToOctal(parseModeBits(entry.mode)) : null
|
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
|
// Only chown when the uid/gid actually differ from the entry's current
|
||||||
// owner — the inputs are seeded with the current values, so "untouched"
|
// owner — the inputs are seeded with the current values, so "untouched"
|
||||||
// would otherwise chown on every save.
|
// would otherwise chown on every save.
|
||||||
const newUid = uid.trim() !== '' && !Number.isNaN(Number(uid)) ? Number(uid) : null
|
const newUid = parseId(uid)
|
||||||
const newGid = gid.trim() !== '' && !Number.isNaN(Number(gid)) ? Number(gid) : null
|
const newGid = parseId(gid)
|
||||||
const ownerChanged =
|
const ownerChanged =
|
||||||
(newUid !== null && newUid !== entry.uid) || (newGid !== null && newGid !== entry.gid)
|
(newUid !== null && newUid !== entry.uid) || (newGid !== null && newGid !== entry.gid)
|
||||||
|
|
||||||
if (octalStr !== curOctal) {
|
if (modeChanged) {
|
||||||
await window.api.chmodRemote(sessionId, entry.path, octalStr)
|
await window.api.chmodRemote(sessionId, entry.path, octalStr)
|
||||||
}
|
}
|
||||||
if (ownerChanged) {
|
if (ownerChanged) {
|
||||||
const u = newUid ?? entry.uid ?? 0
|
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)
|
await window.api.chownRemote(sessionId, entry.path, u, g)
|
||||||
}
|
}
|
||||||
if (octalStr !== curOctal || ownerChanged) {
|
if (modeChanged || ownerChanged) {
|
||||||
message.success(t('ssh.file.permApplied'))
|
message.success(t('ssh.file.permApplied'))
|
||||||
}
|
}
|
||||||
onClose()
|
onClose()
|
||||||
@@ -194,6 +247,7 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
|
|||||||
confirmLoading={saving}
|
confirmLoading={saving}
|
||||||
okText={t('common.save')}
|
okText={t('common.save')}
|
||||||
cancelText={t('common.cancel')}
|
cancelText={t('common.cancel')}
|
||||||
|
okButtonProps={{ disabled: idInvalid(uid) || idInvalid(gid) }}
|
||||||
width={380}
|
width={380}
|
||||||
>
|
>
|
||||||
<div className="sftp-perm">
|
<div className="sftp-perm">
|
||||||
@@ -253,6 +307,10 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="sftp-perm-hint">{t('ssh.file.ownerHint')}</div>
|
<div className="sftp-perm-hint">{t('ssh.file.ownerHint')}</div>
|
||||||
|
{entry && !modeKnown && <div className="sftp-perm-hint">{t('ssh.file.modeUnavailable')}</div>}
|
||||||
|
{(idInvalid(uid) || idInvalid(gid)) && (
|
||||||
|
<div className="sftp-perm-hint">{t('main.sftp.invalidUidGid')}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
@@ -302,18 +360,74 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
|
|||||||
|
|
||||||
const selectedPath = selected?.path ?? ''
|
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 => {
|
const doUpload = (): void => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
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
|
if (files.length === 0) return
|
||||||
await window.api.uploadRemote(sessionId, files, dir)
|
// Main opens the target with 'w' (truncate), so an existing file would
|
||||||
// refresh when the transfer completes
|
// be silently overwritten — ask first, like delete and clear-history do.
|
||||||
const off = window.api.onTransferProgress((e: TransferProgressEvent) => {
|
const existing = files.map(remoteNameOf).filter((name) => entries.some((e) => e.name === name))
|
||||||
if (e.state === 'done' || e.state === 'error' || e.state === 'cancelled') {
|
if (existing.length === 0) {
|
||||||
off()
|
startUpload(files)
|
||||||
void refresh(dir)
|
return
|
||||||
}
|
}
|
||||||
|
const desc = tOr('ssh.file.overwriteDesc', '')
|
||||||
|
modal.confirm({
|
||||||
|
title: tOr('ssh.file.overwriteTitle', t('ssh.file.uploadFile')),
|
||||||
|
content: (
|
||||||
|
<div>
|
||||||
|
{existing.map((name) => (
|
||||||
|
<div key={name}>{name}</div>
|
||||||
|
))}
|
||||||
|
{desc && <div>{desc}</div>}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
okText: t('common.ok'),
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
cancelText: t('common.cancel'),
|
||||||
|
onOk: () => startUpload(files)
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
message.error((err as Error).message)
|
message.error((err as Error).message)
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { CloseOutlined } from '@ant-design/icons'
|
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'
|
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
|
kind: TransferKind
|
||||||
file: string
|
file: string
|
||||||
bytes: number
|
bytes: number
|
||||||
totalBytes: number
|
totalBytes: number
|
||||||
state: 'running' | 'done' | 'error' | 'cancelled'
|
state: TransferState
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,13 +19,29 @@ interface TransferState {
|
|||||||
* transferId; done transfers fade out, errors stay until dismissed.
|
* transferId; done transfers fade out, errors stay until dismissed.
|
||||||
*/
|
*/
|
||||||
export function TransferPanel(): React.JSX.Element | null {
|
export function TransferPanel(): React.JSX.Element | null {
|
||||||
const [transfers, setTransfers] = useState<Map<string, TransferState>>(new Map())
|
const [transfers, setTransfers] = useState<Map<string, TransferEntry>>(new Map())
|
||||||
const timersRef = useRef<number[]>([])
|
/** auto-dismiss timers, keyed by transfer id (never more than one per run) */
|
||||||
|
const timersRef = useRef<Map<string, number>>(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(() => {
|
useEffect(() => {
|
||||||
const off = window.api.onTransferProgress((e: TransferProgressEvent) => {
|
const off = window.api.onTransferProgress((e: TransferProgressEvent) => {
|
||||||
setTransfers((prev) => {
|
setTransfers((prev) => {
|
||||||
const next = new Map(prev)
|
const next = new Map(prev)
|
||||||
|
const existing = next.get(e.transferId)
|
||||||
if (e.file) {
|
if (e.file) {
|
||||||
next.set(e.transferId, {
|
next.set(e.transferId, {
|
||||||
kind: e.kind,
|
kind: e.kind,
|
||||||
@@ -34,42 +52,51 @@ export function TransferPanel(): React.JSX.Element | null {
|
|||||||
error: e.error
|
error: e.error
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// terminal event for the whole transfer
|
// Terminal event for the whole transfer. It can arrive without any
|
||||||
const existing = next.get(e.transferId)
|
// per-file progress first (e.g. the remote open was denied), so a
|
||||||
if (existing) {
|
// missing entry is created rather than dropped — otherwise the
|
||||||
if (e.state === 'error') {
|
// failure would be visible nowhere.
|
||||||
next.set(e.transferId, { ...existing, state: 'error', error: e.error })
|
const base: TransferEntry = existing ?? {
|
||||||
} else if (e.state === 'cancelled') {
|
kind: e.kind,
|
||||||
next.set(e.transferId, { ...existing, state: 'cancelled' })
|
file: '',
|
||||||
} else {
|
bytes: e.bytes,
|
||||||
next.set(e.transferId, { ...existing, state: 'done' })
|
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 next
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
return () => {
|
return off
|
||||||
off()
|
|
||||||
for (const t of timersRef.current) window.clearTimeout(t)
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// auto-dismiss finished entries after 3s
|
// auto-dismiss finished entries after 3s
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
for (const [id, t] of transfers) {
|
for (const [id, tr] of transfers) {
|
||||||
if ((t.state === 'done' || t.state === 'cancelled') && !t.error) {
|
if ((tr.state === 'done' || tr.state === 'cancelled') && !tr.error && !timersRef.current.has(id)) {
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
setTransfers((prev) => {
|
timersRef.current.delete(id)
|
||||||
const next = new Map(prev)
|
dismiss(id)
|
||||||
next.delete(id)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}, 3000)
|
}, 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()]
|
const list = [...transfers.entries()]
|
||||||
if (list.length === 0) return null
|
if (list.length === 0) return null
|
||||||
@@ -100,13 +127,7 @@ export function TransferPanel(): React.JSX.Element | null {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="sftp-transfer-close"
|
className="sftp-transfer-close"
|
||||||
onClick={() =>
|
onClick={() => dismiss(id)}
|
||||||
setTransfers((prev) => {
|
|
||||||
const next = new Map(prev)
|
|
||||||
next.delete(id)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<CloseOutlined />
|
<CloseOutlined />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import type { CommandItem } from '@shared/commands'
|
|||||||
import type { TerminalSettings } from '@shared/settings'
|
import type { TerminalSettings } from '@shared/settings'
|
||||||
import { useSettingsStore } from '@renderer/settings/store'
|
import { useSettingsStore } from '@renderer/settings/store'
|
||||||
import { writeBroadcast } from '@renderer/workspace/broadcastStore'
|
import { writeBroadcast } from '@renderer/workspace/broadcastStore'
|
||||||
import { compileRules, HighlightStream, type CompiledRule } from './highlightEngine'
|
import { compileRules, HighlightStream } from './highlightEngine'
|
||||||
import { findUrls } from './urlLinks'
|
import { findUrls } from './urlLinks'
|
||||||
import { subscribePtyData, subscribePtyExit } from './ptyDispatcher'
|
import { subscribePtyData, subscribePtyExit } from './ptyDispatcher'
|
||||||
import { cdArgument, conemuCwd } from './cwdTracker'
|
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
|
* handled here; escape sequences (\x1b) and the leading non-printable control
|
||||||
* bytes on a chunk — like \x1b[K clear-line — never enter the buffer.
|
* 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
|
* readline's line-editing keys (Ctrl+U/W/K, Ctrl+A/E/L/Y, …) are handled too:
|
||||||
* Ctrl+U kill + full-command write). The very next onData chunk is the shell's
|
* the shell applies them to its own line and never echoes the resulting edit
|
||||||
* redraw echoing the accepted line, so instead of appending onto the stale
|
* back to us, so appending the raw control byte would silently desync this
|
||||||
* pre-accept buffer we *replace* it with what the pty actually echoed. This is
|
* buffer from the shell's line and make the history record the pre-edit text.
|
||||||
* the echo-suppression that keeps the local buffer in sync with the shell and
|
|
||||||
* prevents the accepted text from being duplicated.
|
|
||||||
*/
|
*/
|
||||||
function stepLineBuffer(
|
function stepLineBuffer(
|
||||||
outBuf: React.MutableRefObject<string>,
|
outBuf: React.MutableRefObject<string>,
|
||||||
data: string,
|
data: string
|
||||||
rewrite: boolean
|
|
||||||
): { line: string; submit?: string } {
|
): { line: string; submit?: string } {
|
||||||
const b = outBuf.current
|
const b = outBuf.current
|
||||||
if (!data) return { line: b }
|
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
|
// 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
|
// 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
|
// starts with ESC, which the guards below would drop wholesale. Strip the
|
||||||
@@ -195,6 +173,21 @@ function stepLineBuffer(
|
|||||||
return { line: '' }
|
return { line: '' }
|
||||||
}
|
}
|
||||||
if (first === '\x1b') return { line: b }
|
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
|
outBuf.current = b + data
|
||||||
return { line: outBuf.current }
|
return { line: outBuf.current }
|
||||||
}
|
}
|
||||||
@@ -273,17 +266,12 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
|||||||
const searchTermRef = useRef('')
|
const searchTermRef = useRef('')
|
||||||
const nativePasteRef = useRef<string | null>(null)
|
const nativePasteRef = useRef<string | null>(null)
|
||||||
const onCloseRef = useRef(onClose)
|
const onCloseRef = useRef(onClose)
|
||||||
const compiledRef = useRef<CompiledRule[]>([])
|
|
||||||
const streamRef = useRef<HighlightStream | null>(null)
|
const streamRef = useRef<HighlightStream | null>(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<TerminalSettings['rendererMode'] | null>(null)
|
||||||
// M5: per-session line-capture buffer + completion candidate cache.
|
// M5: per-session line-capture buffer + completion candidate cache.
|
||||||
const lineBufRef = useRef<string>('')
|
const lineBufRef = useRef<string>('')
|
||||||
// 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<CommandItem[]>([])
|
const completionCacheRef = useRef<CommandItem[]>([])
|
||||||
const completionKeyRef = useRef('')
|
const completionKeyRef = useRef('')
|
||||||
const recordingRef = useRef(false)
|
const recordingRef = useRef(false)
|
||||||
@@ -302,7 +290,6 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
|||||||
// latest rules without triggering a terminal rebuild. Only new output is
|
// latest rules without triggering a terminal rebuild. Only new output is
|
||||||
// highlighted — the existing scrollback buffer is left untouched.
|
// highlighted — the existing scrollback buffer is left untouched.
|
||||||
const compiledRules = useMemo(() => compileRules(settings.highlightRules), [settings.highlightRules])
|
const compiledRules = useMemo(() => compileRules(settings.highlightRules), [settings.highlightRules])
|
||||||
compiledRef.current = compiledRules
|
|
||||||
streamRef.current?.setRules(compiledRules)
|
streamRef.current?.setRules(compiledRules)
|
||||||
|
|
||||||
// M5: load completion candidates once per session (history + library cached).
|
// M5: load completion candidates once per session (history + library cached).
|
||||||
@@ -352,7 +339,10 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
|||||||
webglRef.current.dispose()
|
webglRef.current.dispose()
|
||||||
webglRef.current = null
|
webglRef.current = null
|
||||||
}
|
}
|
||||||
if (mode === 'dom') return
|
if (mode === 'dom') {
|
||||||
|
rendererModeRef.current = mode
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const webgl = new WebglAddon()
|
const webgl = new WebglAddon()
|
||||||
webgl.onContextLoss(() => {
|
webgl.onContextLoss(() => {
|
||||||
@@ -366,6 +356,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
|||||||
} catch {
|
} catch {
|
||||||
webglRef.current = null
|
webglRef.current = null
|
||||||
}
|
}
|
||||||
|
rendererModeRef.current = mode
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const scheduleFit = useCallback(() => {
|
const scheduleFit = useCallback(() => {
|
||||||
@@ -386,6 +377,13 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
|||||||
const send = (): void => {
|
const send = (): void => {
|
||||||
const live = termRef.current
|
const live = termRef.current
|
||||||
if (!live || deadRef.current) return
|
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
|
if (live.cols === ptySizeRef.current?.cols && live.rows === ptySizeRef.current?.rows) return
|
||||||
ptySizeRef.current = { cols: live.cols, rows: live.rows }
|
ptySizeRef.current = { cols: live.cols, rows: live.rows }
|
||||||
window.api.resizePty(sessionId, live.cols, live.rows)
|
window.api.resizePty(sessionId, live.cols, live.rows)
|
||||||
@@ -555,8 +553,11 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
|||||||
} catch {
|
} catch {
|
||||||
/* clipboard write denied */
|
/* clipboard write denied */
|
||||||
}
|
}
|
||||||
if (!settings.terminal.copyOnSelect) term.clearSelection()
|
// Read the setting at call time: this runs from the terminal's custom key
|
||||||
}, [settings.terminal.copyOnSelect])
|
// handler, which is attached once per session and would otherwise keep the
|
||||||
|
// copyOnSelect value captured at mount.
|
||||||
|
if (!useSettingsStore.getState().settings.terminal.copyOnSelect) term.clearSelection()
|
||||||
|
}, [])
|
||||||
|
|
||||||
const pasteFromClipboard = useCallback(async () => {
|
const pasteFromClipboard = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -573,11 +574,11 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
|||||||
(selected: CommandItem): void => {
|
(selected: CommandItem): void => {
|
||||||
const cmd = selected.command
|
const cmd = selected.command
|
||||||
// Rewrite the whole shell line (Ctrl+U then `cmd`) instead of appending a
|
// 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
|
// delta, so the pty never has to reconcile our local buffer with its own
|
||||||
// truth for the line (see differenceWrite). The redraw trigger sets the
|
// (see differenceWrite). The local buffer mirrors the rewrite — the shell
|
||||||
// rewrite flag so onData resets the line buffer to the echoed line.
|
// 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)
|
differenceWrite(sessionId, cmd, lineBufRef.current)
|
||||||
diffRewriteRef.current = true
|
|
||||||
lineBufRef.current = cmd
|
lineBufRef.current = cmd
|
||||||
suggestionsRef.current = []
|
suggestionsRef.current = []
|
||||||
selIndexRef.current = 0
|
selIndexRef.current = 0
|
||||||
@@ -792,15 +793,27 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
|||||||
rows.push({ index: row, text: line.translateToString(false) })
|
rows.push({ index: row, text: line.translateToString(false) })
|
||||||
}
|
}
|
||||||
const text = rows.map((r) => r.text).join('')
|
const text = rows.map((r) => 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 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 links: ILink[] = findUrls(text).map((found) => {
|
||||||
const startRow = Math.floor(found.start / cols)
|
|
||||||
const endRow = Math.floor((found.end - 1) / cols)
|
|
||||||
return {
|
return {
|
||||||
range: {
|
range: {
|
||||||
start: { x: (found.start % cols) + 1, y: base + startRow + 1 },
|
start: { x: colAt[found.start] ?? 1, y: (rowAt[found.start] ?? first) + 1 },
|
||||||
end: { x: ((found.end - 1) % cols) + 1, y: base + endRow + 1 }
|
end: { x: colAt[found.end - 1] ?? 1, y: (rowAt[found.end - 1] ?? first) + 1 }
|
||||||
},
|
},
|
||||||
text: found.url,
|
text: found.url,
|
||||||
decorations: { pointerCursor: true, underline: true },
|
decorations: { pointerCursor: true, underline: true },
|
||||||
@@ -822,10 +835,6 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
|||||||
linkProvider,
|
linkProvider,
|
||||||
term.onData((data) => {
|
term.onData((data) => {
|
||||||
if (!deadRef.current) writeBroadcast(sessionId, data)
|
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
|
// Full-screen TUIs (vim, Claude Code, …) own the alternate buffer, their
|
||||||
// own key handling and their own cursor: the line buffer, the command
|
// own key handling and their own cursor: the line buffer, the command
|
||||||
// history and the completion popup have no business there. Interfering
|
// history and the completion popup have no business there. Interfering
|
||||||
@@ -841,8 +850,10 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// M5: line capture → recordCommand + inline completion overlay.
|
// M5: line capture → recordCommand + inline completion overlay. Only user
|
||||||
const step = stepLineBuffer(lineBufRef, data, consumeRewrite)
|
// input reaches onData; the shell's own redraws arrive on the pty path,
|
||||||
|
// which never touches the buffer.
|
||||||
|
const step = stepLineBuffer(lineBufRef, data)
|
||||||
if (typeof step.submit === 'string' && step.submit) {
|
if (typeof step.submit === 'string' && step.submit) {
|
||||||
trackSubmittedLine(step.submit)
|
trackSubmittedLine(step.submit)
|
||||||
}
|
}
|
||||||
@@ -1005,6 +1016,9 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
|||||||
// ---- renderer mode changes: keep the instance, swap renderer, refit ----
|
// ---- renderer mode changes: keep the instance, swap renderer, refit ----
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const next = tSettings.rendererMode
|
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') {
|
if (next === 'dom') {
|
||||||
applyRenderers('dom')
|
applyRenderers('dom')
|
||||||
} else if (!webglRef.current) {
|
} else if (!webglRef.current) {
|
||||||
|
|||||||
@@ -33,6 +33,16 @@ const ESCAPE_RE =
|
|||||||
const MAX_PER_RULE = 300
|
const MAX_PER_RULE = 300
|
||||||
/** Chunks larger than this are returned unmodified (budget guard). */
|
/** Chunks larger than this are returned unmodified (budget guard). */
|
||||||
const MAX_CHUNK = 512 * 1024
|
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. */
|
/** "#rrggbb" or 3-digit "#rgb" → {r,g,b}; any malformed input → white. */
|
||||||
export function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
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
|
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
|
* Apply all rules to a single plain-text run. Consumes from `budgets` (per-rule
|
||||||
* remaining match allowance shared across the whole chunk). Returns the
|
* 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
|
* 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 {
|
function applyRun(text: string, rules: CompiledRule[], budgets: number[]): string {
|
||||||
if (text.length === 0) return ''
|
if (text.length === 0) return ''
|
||||||
|
if (hasLongLine(text)) return text
|
||||||
const claimed: Span[] = []
|
const claimed: Span[] = []
|
||||||
for (let ri = 0; ri < rules.length; ri++) {
|
for (let ri = 0; ri < rules.length; ri++) {
|
||||||
const rule = rules[ri]
|
const rule = rules[ri]
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ function toHex(v: number): string {
|
|||||||
return Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
|
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 */
|
/** t=0 → a, t=1 → b */
|
||||||
export function mix(a: string, b: string, t: number): string | null {
|
export function mix(a: string, b: string, t: number): string | null {
|
||||||
const ca = parseHex(a)
|
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). */
|
* from it flip their overlay direction (white-on-dark vs black-on-light). */
|
||||||
let chromeIsLight = false
|
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
|
/** Derive the app chrome palette (sidebar, tab bars, dividers) from the active
|
||||||
* terminal theme so the whole window follows theme switches instead of only
|
* terminal theme so the whole window follows theme switches instead of only
|
||||||
* the terminal canvas. Values land on :root CSS variables; anything the theme
|
* 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-bg-hover', 'rgba(255, 255, 255, 0.07)')
|
||||||
set('--chrome-tab-ring', 'rgba(255, 255, 255, 0.1)')
|
set('--chrome-tab-ring', 'rgba(255, 255, 255, 0.1)')
|
||||||
set('--chrome-tab-ring-hover', 'rgba(255, 255, 255, 0.2)')
|
set('--chrome-tab-ring-hover', 'rgba(255, 255, 255, 0.2)')
|
||||||
set('--chrome-tab-fg', '#98a2ab')
|
set('--chrome-tab-fg', tabLabel(colors.background, colors.foreground, 0.68))
|
||||||
set('--chrome-tab-fg-hover', '#d0d0d0')
|
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
|
/** 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. */
|
* var(--tab-accent*) in workspace.css. */
|
||||||
export function applyTabAccent(color: string): void {
|
export function applyTabAccent(color: string): void {
|
||||||
const style = document.documentElement.style
|
const style = document.documentElement.style
|
||||||
|
tabAccent = color
|
||||||
// Settings hydrated from an older main process may lack the key; clear the
|
// 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.
|
// vars so the CSS fallbacks apply instead of an invalid "undefined" value.
|
||||||
if (!color) {
|
if (!color) {
|
||||||
|
|||||||
@@ -15,49 +15,79 @@ const MIN_HEIGHT = 120
|
|||||||
export function SshBottomPanel({ sessionId, terminal }: SshBottomPanelProps): React.JSX.Element {
|
export function SshBottomPanel({ sessionId, terminal }: SshBottomPanelProps): React.JSX.Element {
|
||||||
/** Current file-browser height in px. */
|
/** Current file-browser height in px. */
|
||||||
const [filesHeight, setFilesHeight] = useState<number>(220)
|
const [filesHeight, setFilesHeight] = useState<number>(220)
|
||||||
/** Container height captured while a drag is in flight. */
|
/** Divider drag in flight — drives the accent highlight. */
|
||||||
const dragInfoRef = useRef<{ startY: number; startHeight: number; containerH: number } | null>(null)
|
const [resizing, setResizing] = useState(false)
|
||||||
|
const rootRef = useRef<HTMLDivElement>(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<HTMLDivElement>): void => {
|
const clampHeight = useCallback((height: number): number => {
|
||||||
if (e.button !== 0) return
|
return Math.max(MIN_HEIGHT, Math.min(Math.round(containerHRef.current * 0.6), height))
|
||||||
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])
|
|
||||||
|
|
||||||
|
// Re-clamp on container resize: a shrunk window would otherwise leave the file
|
||||||
|
// browser taller than 60% of the panel, collapsing the terminal.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onMove = (e: MouseEvent): void => {
|
const root = rootRef.current
|
||||||
const info = dragInfoRef.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<HTMLDivElement>): 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<HTMLDivElement>): void => {
|
||||||
|
const info = dragRef.current
|
||||||
if (!info) return
|
if (!info) return
|
||||||
const delta = e.clientY - info.startY
|
setFilesHeight(clampHeight(info.startHeight - (e.clientY - info.startY)))
|
||||||
const max = Math.round(info.containerH * 0.6)
|
},
|
||||||
setFilesHeight(Math.min(max, Math.max(MIN_HEIGHT, info.startHeight - delta)))
|
[clampHeight]
|
||||||
}
|
)
|
||||||
const onUp = (): void => {
|
|
||||||
dragInfoRef.current = null
|
const endDrag = useCallback((e: React.PointerEvent<HTMLDivElement>): void => {
|
||||||
}
|
if (!dragRef.current) return
|
||||||
window.addEventListener('mousemove', onMove)
|
dragRef.current = null
|
||||||
window.addEventListener('mouseup', onUp)
|
setResizing(false)
|
||||||
return () => {
|
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
||||||
window.removeEventListener('mousemove', onMove)
|
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||||
window.removeEventListener('mouseup', onUp)
|
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ssh-bottom-panel">
|
<div className="ssh-bottom-panel" ref={rootRef}>
|
||||||
<div className="ssh-bottom-top">{terminal}</div>
|
<div className="ssh-bottom-top">{terminal}</div>
|
||||||
<div
|
<div
|
||||||
className="ssh-bottom-divider"
|
className={'ssh-bottom-divider' + (resizing ? ' is-resizing' : '')}
|
||||||
role="separator"
|
role="separator"
|
||||||
aria-orientation="horizontal"
|
aria-orientation="horizontal"
|
||||||
aria-label={t('ssh.bottomPanel.resize')}
|
aria-label={t('ssh.bottomPanel.resize')}
|
||||||
onMouseDown={onDividerMouseDown}
|
onPointerDown={onDividerPointerDown}
|
||||||
|
onPointerMove={onDividerPointerMove}
|
||||||
|
onPointerUp={endDrag}
|
||||||
|
onPointerCancel={endDrag}
|
||||||
|
onLostPointerCapture={endDrag}
|
||||||
/>
|
/>
|
||||||
<div className="ssh-bottom-files" style={{ height: filesHeight }}>
|
<div className="ssh-bottom-files" style={{ height: filesHeight }}>
|
||||||
<FilePanel sessionId={sessionId} />
|
<FilePanel sessionId={sessionId} />
|
||||||
|
|||||||
@@ -42,17 +42,23 @@ export function TerminalPanel({ params, api, onSessionDead }: TerminalPanelProps
|
|||||||
const [sideOpen, setSideOpen] = useState(true)
|
const [sideOpen, setSideOpen] = useState(true)
|
||||||
|
|
||||||
// M6 broadcast: keep the session in the broadcast registry while mounted so
|
// 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(() => {
|
useEffect(() => {
|
||||||
useBroadcastStore.getState().registerSession({ id: sessionId, title: api.title ?? '', isSsh })
|
useBroadcastStore
|
||||||
return () => useBroadcastStore.getState().unregisterSession(sessionId)
|
.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
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- api is stable; title kept fresh below
|
||||||
}, [sessionId])
|
}, [sessionId])
|
||||||
|
|
||||||
// M6: keep the registered broadcast title in sync with the tab title.
|
// M6: keep the registered broadcast title in sync with the tab title.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const disposable = api.onDidTitleChange((event) => {
|
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()
|
return () => disposable.dispose()
|
||||||
}, [api, sessionId, isSsh])
|
}, [api, sessionId, isSsh])
|
||||||
|
|||||||
@@ -63,12 +63,15 @@ export function TerminalTab({ api, params }: TerminalTabProps): React.JSX.Elemen
|
|||||||
if (key === 'close') {
|
if (key === 'close') {
|
||||||
api.close()
|
api.close()
|
||||||
} else if (key === 'closeOthers') {
|
} 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)
|
if (sibling.api !== api) closePanel(sibling)
|
||||||
}
|
}
|
||||||
} else if (key === 'closeRight') {
|
} else if (key === 'closeRight') {
|
||||||
for (let i = selfIndex + 1; i < panel.panels.length; i++) {
|
for (const target of [...panel.panels].slice(selfIndex + 1)) {
|
||||||
closePanel(panel.panels[i])
|
closePanel(target)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
DockviewApi,
|
DockviewApi,
|
||||||
DockviewPanelApi,
|
DockviewPanelApi,
|
||||||
DockviewReadyEvent,
|
DockviewReadyEvent,
|
||||||
|
DockviewTheme,
|
||||||
IDockviewHeaderActionsProps,
|
IDockviewHeaderActionsProps,
|
||||||
IDockviewPanel,
|
IDockviewPanel,
|
||||||
IDockviewPanelHeaderProps,
|
IDockviewPanelHeaderProps,
|
||||||
@@ -61,6 +62,54 @@ export type { TerminalParams }
|
|||||||
const TERMINAL_COMPONENT = 'terminal'
|
const TERMINAL_COMPONENT = 'terminal'
|
||||||
const TERMINAL_TAB_COMPONENT = 'terminal-tab'
|
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<string>()
|
||||||
|
|
||||||
|
/** 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<TerminalParams>) => (
|
||||||
|
<TerminalPanel {...props} onSessionDead={markSessionDead} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOCKVIEW_TAB_COMPONENTS = {
|
||||||
|
[TERMINAL_TAB_COMPONENT]: (props: IDockviewPanelHeaderProps<TerminalParams>) => (
|
||||||
|
<TerminalTab {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Electron re-throws a main-process rejection as
|
||||||
|
* `Error: Error invoking remote method 'x': Error: <real message>`, 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
|
* "终端 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
|
* 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).
|
* (React state / panel instances stay mounted, so the switch is free).
|
||||||
*
|
*
|
||||||
* Sessions: panels carry a `params.sessionId`. Killing a session is idempotent
|
* 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
|
* up-front (TerminalView `onClose` after the process exits) or when its panel
|
||||||
* is removed. Each dockview registers `onDidRemovePanel` so a panel close in
|
* is removed. Each dockview registers `onDidRemovePanel` so a panel close in
|
||||||
* either workspace kills the bound session.
|
* 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). */
|
/** The currently-visible mode's active panel (broadcast target). */
|
||||||
const activePanelRef = useRef<IDockviewPanel | undefined>(undefined)
|
const activePanelRef = useRef<IDockviewPanel | undefined>(undefined)
|
||||||
|
|
||||||
/** Sessions we already asked to kill — kill once, never twice. */
|
|
||||||
const deadSessionsRef = useRef<Set<string>>(new Set())
|
|
||||||
/** Bind-time disposables for both dockviews, disposed on unmount. */
|
/** Bind-time disposables for both dockviews, disposed on unmount. */
|
||||||
const disposablesRef = useRef<Array<{ dispose(): void }>>([])
|
const disposablesRef = useRef<Array<{ dispose(): void }>>([])
|
||||||
|
|
||||||
@@ -264,6 +311,8 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
|||||||
const [applyOpen, setApplyOpen] = useState(false)
|
const [applyOpen, setApplyOpen] = useState(false)
|
||||||
/** Terminal dockview ready — gates first-run seeding. */
|
/** Terminal dockview ready — gates first-run seeding. */
|
||||||
const [terminalReady, setTerminalReady] = useState(false)
|
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. */
|
/** Local terminal panels surfaced to the sidebar's "终端" section. */
|
||||||
const [localPanels, setLocalPanels] = useState<Array<{ id: string; title: string }>>([])
|
const [localPanels, setLocalPanels] = useState<Array<{ id: string; title: string }>>([])
|
||||||
@@ -283,8 +332,12 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
|||||||
}, [])
|
}, [])
|
||||||
/** one pending SSH connection request (null = idle). */
|
/** one pending SSH connection request (null = idle). */
|
||||||
const [requestedConn, setRequestedConn] = useState<SshConnection | null>(null)
|
const [requestedConn, setRequestedConn] = useState<SshConnection | null>(null)
|
||||||
/** true while openSession is in flight — drives the "连接中…" modal. */
|
/** In-flight openSession attempts (0 = idle); the rail busy dot reads it. */
|
||||||
const [connecting, setConnecting] = useState(false)
|
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. */
|
/** FIFO of pending host-key confirmations — render the head only. */
|
||||||
const [hostKeyQueue, setHostKeyQueue] = useState<HostKeyPromptEvent[]>([])
|
const [hostKeyQueue, setHostKeyQueue] = useState<HostKeyPromptEvent[]>([])
|
||||||
|
|
||||||
@@ -342,16 +395,11 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
|||||||
/** Idempotently kill a pty session. */
|
/** Idempotently kill a pty session. */
|
||||||
const killSession = useCallback((sessionId: string | undefined): void => {
|
const killSession = useCallback((sessionId: string | undefined): void => {
|
||||||
if (!sessionId) return
|
if (!sessionId) return
|
||||||
if (deadSessionsRef.current.has(sessionId)) return
|
if (deadSessions.has(sessionId)) return
|
||||||
deadSessionsRef.current.add(sessionId)
|
deadSessions.add(sessionId)
|
||||||
window.api.killPty(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<string> => {
|
const createSession = useCallback(async (cwd?: string): Promise<string> => {
|
||||||
// `cwd` is the memory hook: a restored pane and a freshly opened one both
|
// `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
|
// 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. */
|
* terminal dockview holds only local sessions, so it is the single source. */
|
||||||
const recomputeLocalPanels = useCallback((): void => {
|
const recomputeLocalPanels = useCallback((): void => {
|
||||||
const current = terminalApiRef.current
|
const current = terminalApiRef.current
|
||||||
if (!current) {
|
const panels = current
|
||||||
setLocalPanels([])
|
? current.panels
|
||||||
return
|
.filter((p) => (p.params as TerminalParams | undefined)?.sessionKind === 'local')
|
||||||
}
|
.map((p) => ({ id: p.id, title: p.title ?? '' }))
|
||||||
const panels = current.panels
|
: []
|
||||||
.filter((p) => (p.params as TerminalParams | undefined)?.sessionKind === 'local')
|
// Keep the previous array when nothing changed: a fresh array on every
|
||||||
.map((p) => ({ id: p.id, title: p.title ?? '' }))
|
// layout event re-renders the sidebar (and the whole workspace) for nothing.
|
||||||
setLocalPanels(panels)
|
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. */
|
/** Recount session panels per dockview into local state. */
|
||||||
@@ -426,6 +479,10 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
|||||||
}
|
}
|
||||||
useCount.delete(sid)
|
useCount.delete(sid)
|
||||||
killSession(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 = [
|
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
|
* Boot pass: pick up where the user left off — layout plus each pane's
|
||||||
* directory — when 设置 → 系统 → 启动时恢复上次会话 is on. Otherwise (or when no
|
* directory — when 设置 → 系统 → 启动时恢复上次会话 is on. Otherwise (or when no
|
||||||
* snapshot exists) seed one terminal. Either way the workspace is never empty.
|
* 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(() => {
|
useEffect(() => {
|
||||||
if (!terminalReady) return
|
if (!terminalReady || !settingsHydrated) return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
void (async (): Promise<void> => {
|
void (async (): Promise<void> => {
|
||||||
const current = terminalApiRef.current
|
const current = terminalApiRef.current
|
||||||
if (!current) return
|
if (!current) return
|
||||||
const restoreOn = useSettingsStore.getState().settings.system.restoreSession !== false
|
try {
|
||||||
const snapshot = restoreOn ? await window.api.getSessionState().catch(() => null) : null
|
const restoreOn = useSettingsStore.getState().settings.system.restoreSession !== false
|
||||||
if (cancelled) return
|
const snapshot = restoreOn ? await window.api.getSessionState().catch(() => null) : null
|
||||||
const restorable = Boolean(snapshot && (snapshot.layouts.terminal || snapshot.panels.length > 0))
|
if (cancelled) return
|
||||||
const restored = snapshot && restorable ? await restoreFromSnapshot(snapshot) : false
|
const restorable = Boolean(snapshot && (snapshot.layouts.terminal || snapshot.panels.length > 0))
|
||||||
if (cancelled) return
|
const restored = snapshot && restorable ? await restoreFromSnapshot(snapshot) : false
|
||||||
if (!restored && !current.panels.some((p) => sessionIdOf(p))) void addTerminal()
|
if (cancelled) return
|
||||||
bootDoneRef.current = true
|
if (!restored && !current.panels.some((p) => sessionIdOf(p))) await addTerminal()
|
||||||
scheduleSave()
|
} 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 () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- boot exactly once, on first ready
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- boot once, on ready + hydrated
|
||||||
}, [terminalReady])
|
}, [terminalReady, settingsHydrated])
|
||||||
|
|
||||||
/** Kill remaining sessions on window close. */
|
/** Kill remaining sessions on window close. */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -649,10 +719,15 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
|||||||
* stream; `connectionId` + any staged `secretOverride` are the only inputs
|
* stream; `connectionId` + any staged `secretOverride` are the only inputs
|
||||||
* the main process needs — no extra IPC channel. New sessions land in the ssh
|
* the main process needs — no extra IPC channel. New sessions land in the ssh
|
||||||
* dockview.
|
* 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(
|
const connect = useCallback(
|
||||||
async (conn: SshConnection, secretOverride: SshSecretOverride): Promise<void> => {
|
async (conn: SshConnection, secretOverride: SshSecretOverride): Promise<void> => {
|
||||||
setConnecting(true)
|
if (connectInFlightRef.current > 0) return
|
||||||
|
connectInFlightRef.current += 1
|
||||||
|
setConnectingCount((count) => count + 1)
|
||||||
try {
|
try {
|
||||||
const result = await window.api.openSession({
|
const result = await window.api.openSession({
|
||||||
kind: 'ssh',
|
kind: 'ssh',
|
||||||
@@ -674,9 +749,10 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
|||||||
// B's sidebar refreshes lastConnectedAt on connect.
|
// B's sidebar refreshes lastConnectedAt on connect.
|
||||||
void sidebarRef.current?.refresh()
|
void sidebarRef.current?.refresh()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(typeof error === 'string' ? error : t('workspace.connect.failed'))
|
message.error(connectFailureMessage(error))
|
||||||
} finally {
|
} finally {
|
||||||
setConnecting(false)
|
connectInFlightRef.current -= 1
|
||||||
|
setConnectingCount((count) => count - 1)
|
||||||
setRequestedConn(null)
|
setRequestedConn(null)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -685,6 +761,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
|||||||
|
|
||||||
const handleConnectRequest = useCallback(
|
const handleConnectRequest = useCallback(
|
||||||
(conn: SshConnection): void => {
|
(conn: SshConnection): void => {
|
||||||
|
if (connectInFlightRef.current > 0) return
|
||||||
// Ask-at-connect connections go through the secret prompt first; saved
|
// Ask-at-connect connections go through the secret prompt first; saved
|
||||||
// credentials must connect IMMEDIATELY — routing them through ConnectFlow
|
// credentials must connect IMMEDIATELY — routing them through ConnectFlow
|
||||||
// would only ever *render* a "connecting" spinner without firing openSession.
|
// would only ever *render* a "connecting" spinner without firing openSession.
|
||||||
@@ -694,6 +771,9 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
|||||||
setRequestedConn(conn)
|
setRequestedConn(conn)
|
||||||
return
|
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, {})
|
void connect(conn, {})
|
||||||
},
|
},
|
||||||
[connect]
|
[connect]
|
||||||
@@ -783,16 +863,35 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
|||||||
* panel's `<TerminalView>` with the new `sessionId` prop exactly the way it
|
* panel's `<TerminalView>` with the new `sessionId` prop exactly the way it
|
||||||
* does when the user switches terminals — its bind effect tears the old xterm
|
* does when the user switches terminals — its bind effect tears the old xterm
|
||||||
* down and replays the new session's output.
|
* 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(
|
const bindPanelSession = useCallback(
|
||||||
async (panel: DockviewPanelApi | undefined, currentSessionId: string, cwd?: string): Promise<void> => {
|
async (
|
||||||
if (!panel) return
|
api: DockviewApi | undefined,
|
||||||
const nextSessionId = await createSession(cwd)
|
panel: DockviewPanelApi | undefined,
|
||||||
panel.updateParameters({ sessionId: nextSessionId, sessionKind: 'local' })
|
currentSessionId: string,
|
||||||
deadSessionsRef.current.delete(currentSessionId)
|
cwd?: string
|
||||||
forgetSessionCwd(currentSessionId)
|
): Promise<void> => {
|
||||||
|
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()
|
panel.api.close()
|
||||||
continue
|
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]
|
[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
|
// Retitle duplicates left over from the old counter bug (the snapshot it
|
||||||
// wrote keeps colliding titles forever otherwise): the first panel keeps
|
// wrote keeps colliding titles forever otherwise): the first panel keeps
|
||||||
// the title, later ones get the lowest free number.
|
// 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<string>()
|
const seenTitles = new Set<string>()
|
||||||
for (const p of [...(terminal?.panels ?? []), ...(ssh?.panels ?? [])]) {
|
for (const p of [...(terminal?.panels ?? []), ...(ssh?.panels ?? [])]) {
|
||||||
|
if ((p.params as TerminalParams | undefined)?.sessionKind === 'ssh') continue
|
||||||
const t = p.title ?? ''
|
const t = p.title ?? ''
|
||||||
if (!t) continue
|
if (!t) continue
|
||||||
if (seenTitles.has(t)) {
|
if (seenTitles.has(t)) {
|
||||||
@@ -1014,19 +1124,11 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
|||||||
<div className={workspaceMode === 'terminal' ? 'workspace-dockview-container' : 'workspace-dockview-container is-hidden'}>
|
<div className={workspaceMode === 'terminal' ? 'workspace-dockview-container' : 'workspace-dockview-container is-hidden'}>
|
||||||
<DockviewReact
|
<DockviewReact
|
||||||
className="dockview-theme-abyss"
|
className="dockview-theme-abyss"
|
||||||
components={{
|
components={DOCKVIEW_COMPONENTS}
|
||||||
[TERMINAL_COMPONENT]: (props: IDockviewPanelProps<TerminalParams>) => (
|
tabComponents={DOCKVIEW_TAB_COMPONENTS}
|
||||||
<TerminalPanel {...props} onSessionDead={markSessionDead} />
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
tabComponents={{
|
|
||||||
[TERMINAL_TAB_COMPONENT]: (props: IDockviewPanelHeaderProps<TerminalParams>) => (
|
|
||||||
<TerminalTab {...props} />
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
rightHeaderActionsComponent={TerminalTabActions}
|
rightHeaderActionsComponent={TerminalTabActions}
|
||||||
onReady={handleTerminalReady}
|
onReady={handleTerminalReady}
|
||||||
theme={{ name: 'Abyss', className: 'dockview-theme-abyss', colorScheme: 'dark' }}
|
theme={DOCKVIEW_THEME}
|
||||||
/>
|
/>
|
||||||
{terminalCount === 0 && (
|
{terminalCount === 0 && (
|
||||||
<div className="workspace-empty-overlay">
|
<div className="workspace-empty-overlay">
|
||||||
@@ -1048,19 +1150,11 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
|||||||
<div className={workspaceMode === 'ssh' ? 'workspace-dockview-container' : 'workspace-dockview-container is-hidden'}>
|
<div className={workspaceMode === 'ssh' ? 'workspace-dockview-container' : 'workspace-dockview-container is-hidden'}>
|
||||||
<DockviewReact
|
<DockviewReact
|
||||||
className="dockview-theme-abyss"
|
className="dockview-theme-abyss"
|
||||||
components={{
|
components={DOCKVIEW_COMPONENTS}
|
||||||
[TERMINAL_COMPONENT]: (props: IDockviewPanelProps<TerminalParams>) => (
|
tabComponents={DOCKVIEW_TAB_COMPONENTS}
|
||||||
<TerminalPanel {...props} onSessionDead={markSessionDead} />
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
tabComponents={{
|
|
||||||
[TERMINAL_TAB_COMPONENT]: (props: IDockviewPanelHeaderProps<TerminalParams>) => (
|
|
||||||
<TerminalTab {...props} />
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
rightHeaderActionsComponent={SshTabActions}
|
rightHeaderActionsComponent={SshTabActions}
|
||||||
onReady={handleSshReady}
|
onReady={handleSshReady}
|
||||||
theme={{ name: 'Abyss', className: 'dockview-theme-abyss', colorScheme: 'dark' }}
|
theme={DOCKVIEW_THEME}
|
||||||
/>
|
/>
|
||||||
{sshCount === 0 && (
|
{sshCount === 0 && (
|
||||||
<div className="workspace-empty-overlay">
|
<div className="workspace-empty-overlay">
|
||||||
|
|||||||
@@ -83,6 +83,16 @@ export default function ZmodemOffers(): null | React.JSX.Element {
|
|||||||
|
|
||||||
const offDone = useCallback(
|
const offDone = useCallback(
|
||||||
(evt: { id: string; ok: boolean; message?: string }) => {
|
(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'))
|
if (evt.ok) message.success(t('ssh.zmodem.done'))
|
||||||
else
|
else
|
||||||
message.error(
|
message.error(
|
||||||
|
|||||||
@@ -2,10 +2,17 @@ import { create } from 'zustand'
|
|||||||
|
|
||||||
/** One open terminal session eligible to be a broadcast target. */
|
/** One open terminal session eligible to be a broadcast target. */
|
||||||
export interface BroadcastSession {
|
export interface BroadcastSession {
|
||||||
/** pty / ssh session id (also the dockview panel id) */
|
/** pty / ssh session id */
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
isSsh: boolean
|
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 {
|
export interface BroadcastState {
|
||||||
@@ -15,10 +22,13 @@ export interface BroadcastState {
|
|||||||
targets: Set<string>
|
targets: Set<string>
|
||||||
/** open terminal sessions, registration order preserved */
|
/** open terminal sessions, registration order preserved */
|
||||||
sessions: BroadcastSession[]
|
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
|
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 */
|
/** add/remove one target; never lets the set drop below 2 while enabled */
|
||||||
toggleTarget: (id: string) => void
|
toggleTarget: (id: string) => void
|
||||||
/** turn broadcast on/off */
|
/** turn broadcast on/off */
|
||||||
@@ -43,29 +53,42 @@ function pruneOnTargetLoss(enabled: boolean, nextTargets: Set<string>): { enable
|
|||||||
return { enabled, targets: nextTargets }
|
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<string, BroadcastSession>()
|
||||||
|
|
||||||
|
/** One entry per session, in first-registration order, latest title wins. */
|
||||||
|
function deriveSessions(): BroadcastSession[] {
|
||||||
|
const bySession = new Map<string, BroadcastSession>()
|
||||||
|
for (const registration of registrations.values()) bySession.set(registration.id, registration)
|
||||||
|
return [...bySession.values()]
|
||||||
|
}
|
||||||
|
|
||||||
export const useBroadcastStore = create<BroadcastState>((set) => ({
|
export const useBroadcastStore = create<BroadcastState>((set) => ({
|
||||||
enabled: false,
|
enabled: false,
|
||||||
targets: new Set(),
|
targets: new Set(),
|
||||||
sessions: [],
|
sessions: [],
|
||||||
|
|
||||||
registerSession: (session) =>
|
registerSession: (session) => {
|
||||||
set((state) => {
|
registrations.set(session.panelId, session)
|
||||||
const exists = state.sessions.some((s) => s.id === session.id)
|
set({ sessions: deriveSessions() })
|
||||||
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] }
|
|
||||||
}),
|
|
||||||
|
|
||||||
unregisterSession: (id) =>
|
unregisterSession: (panelId) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const sessions = state.sessions.filter((s) => s.id !== id)
|
const dropped = registrations.get(panelId)
|
||||||
if (!state.targets.has(id)) return { sessions }
|
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)
|
const targets = new Set(state.targets)
|
||||||
targets.delete(id)
|
targets.delete(dropped.id)
|
||||||
return { sessions, ...pruneOnTargetLoss(state.enabled, targets) }
|
return { sessions, ...pruneOnTargetLoss(state.enabled, targets) }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -74,7 +97,11 @@ export const useBroadcastStore = create<BroadcastState>((set) => ({
|
|||||||
const targets = new Set(state.targets)
|
const targets = new Set(state.targets)
|
||||||
if (targets.has(id)) {
|
if (targets.has(id)) {
|
||||||
targets.delete(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)
|
targets.add(id)
|
||||||
// requires >= 2 targets to become/become-enabled
|
// requires >= 2 targets to become/become-enabled
|
||||||
|
|||||||
Reference in new issue
Block a user