Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 0d6ef5f701 fix(known-hosts): make virtualized grid cards keyboard-focusable
Grid HostItem roots now set data-vault-focus-target + tabIndex like list
mode so VirtualizedHostCollection arrow-key focus works at 30+ hosts.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 06:19:03 +00:00
Cursor Agentand陈大猫 6e858a7e4e merge(base): notesStore sync publish from #2790
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 06:18:52 +00:00
Cursor Agentand陈大猫 1acd80338e fix(notes): publish notesStore sync on vault note updates
updateNotes/updateNoteGroups now publishNotesSnapshot immediately so
AI/notes consumers do not briefly read a stale catalog after a hide
flush. Flush inactive drafts in useLayoutEffect before paint.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 06:18:43 +00:00
Cursor Agentand陈大猫 a10c043705 merge(base): lint + notes hide flush from #2790
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 06:06:00 +00:00
Cursor Agentand陈大猫 abe0846a65 fix: clear lint blockers and flush notes when panels hide
Move AI panel diagnostics persistence into application/state so
components stop importing localStorageAdapter. Flush note drafts when
retained NotesManager mounts become inactive, and silence related lint
warnings in theme/host-key helpers.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 06:05:59 +00:00
Cursor Agentand陈大猫 4cedaf482d test: restore known-hosts column policy assertions
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 05:58:26 +00:00
Cursor Agentand陈大猫 fd545e6577 fix: make virtualized known hosts keyboard-focusable
HostItem cards now set data-vault-focus-target and tabIndex so
VirtualizedHostCollection arrow/Home/End navigation can focus them.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 05:58:12 +00:00
Cursor Agentand陈大猫 12202a1757 merge: clear remaining application→UI imports from base
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 05:51:59 +00:00
Cursor Agentand陈大猫 4cdcf5dc4c fix: move remaining migrated-hook deps out of components
Host-key verification types/helpers now live in domain/, and SFTP column
layout types live in application/state/sftp/columnLayout. Application
hooks no longer import those definitions from the UI layer.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 05:51:58 +00:00
Cursor Agentand陈大猫 780f967177 merge: remove UI imports from migrated AI/system state hooks
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 05:45:12 +00:00
Cursor Agentand陈大猫 69766dcda1 fix: keep migrated state hooks free of UI-layer imports
Move aiChatStreamingSupport into infrastructure/ai and poll list helpers
into domain/systemManager so useAIChatStreaming / useSystemManager no
longer import from components. Leave thin re-exports at the old paths.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 05:45:11 +00:00
Cursor Agentand陈大猫 f98ce74a54 fix: measure vault grid columns from content width
Non-virtual known-hosts/snippets ResizeObservers now subtract scroll
container padding via getElementContentWidth, matching
VirtualizedHostCollection's child-root measure so the 30-item threshold
no longer flips columns near breakpoint edges.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 05:35:14 +00:00
Cursor Agentand陈大猫 d9553d8e71 fix: reset vault reorder drag state on every drop
useVaultItemReorder.handleDropCapture now clears dragging ids in finally
so virtualized known-host cards that unmount mid-drag cannot leave a
stale source id after drops on padding or empty cells.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 05:17:04 +00:00
Cursor Agentand陈大猫 9b234f38b3 fix: finish container-based snippet package grid styling
Apply the shared CSS-var column style to the package grid as well so no
viewport Tailwind breakpoints remain in SnippetsManager.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 05:09:57 +00:00
Cursor Agentand陈大猫 711683a90b fix: drive vault grids from container width, not viewport
Known hosts and snippets non-virtual grids now use the same
container-width column helpers as the virtualizer, so crossing the
virtualization threshold no longer reflows when Vault sidebars shrink
content relative to the viewport.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 05:09:39 +00:00
Cursor Agentand陈大猫 f7222761a2 merge: AI context domain move from base
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 05:08:32 +00:00
Cursor Agentand陈大猫 2e2988cacd fix: drop duplicate AIPanelContext type from TerminalLayerSupport
The type is re-exported from domain; the local definition conflicted.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 05:08:31 +00:00
Cursor Agentand陈大猫 2b9d000a89 fix: move AI terminal session context builders into domain
buildAITerminalSessionInfo and AIPanelContext now live under domain/ so
application hooks no longer import TerminalLayerSupport and create a
UI → application → UI cycle.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 05:07:59 +00:00
Cursor Agentand陈大猫 5187e7ea66 fix: import known-hosts grid column helper
The virtualizer getColumnCount call referenced
getKnownHostsGridColumnCount without importing it.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 05:00:02 +00:00
Cursor Agentand陈大猫 778010a271 fix: stabilize known-host columns and clear snippet drag on drop
Virtualized known hosts now use the same 2/sm3/xl4 column breakpoints as
the CSS grid. Snippet reorder drops reset drag ids in finally so
virtualized unmount mid-drag cannot leave a stale draggingSnippetId.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 04:59:23 +00:00
Cursor Agentand陈大猫 09d2a18ed3 merge: vault import UI boundary + notes pagehide flush
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 04:58:48 +00:00
Cursor Agentand陈大猫 904c59c395 fix: keep vault import hooks UI-free and flush notes on teardown
Move ImportOptions into application/state and inject a notifier so
useVaultImportHandlers no longer imports components. Notes drafts also
flush on pagehide/beforeunload when Electron tears down without React
unmount.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 04:58:42 +00:00
Cursor Agentand陈大猫 d5f4b54ae7 merge: unsaved prompt singleton render registration
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 04:49:01 +00:00
Cursor Agentand陈大猫 25ca616622 fix: register unsaved prompt singleton during render
AppView close handlers call promptUnsavedChanges outside the old
render-prop. Assign the singleton during render so Cmd/Ctrl+W and tab
close never hit the pre-effect null window that auto-cancels.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 04:49:00 +00:00
Cursor Agentand陈大猫 396fb97191 merge: clear stale terminal accent from base PR
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 04:44:22 +00:00
Cursor Agentand陈大猫 fba5059083 fix: clear terminal accent when switching back to theme mode
Re-resolve appearanceTheme by catalog id before applyCustomAccent so a
stale baked custom cursor/selection cannot stick after accentMode returns
to theme while TerminalLayer memo ignores accent props.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 04:44:21 +00:00
Cursor Agentand陈大猫 8e7791a0fc fix: keep snippet virtualizer on the same column policy
VirtualizedHostCollection now accepts getColumnCount so snippets can
mirror the non-virtual CSS grid (max 3 full-width columns; card-width
sizing in split view) instead of the host 4-column layout.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 04:38:44 +00:00
Cursor Agentand陈大猫 dee922385a merge: accent chrome + note rename fixes from base PR
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 04:37:29 +00:00
Cursor Agentand陈大猫 0c8ac89172 fix: keep compose chrome accent live and merge note renames
Subscribe theme-panel focusedAppearance to appearanceChromeStore so
compose-bar chrome updates during color-picker drag even when
resolveFocusedAppearance identity stays stable. Tree renames now merge
title into the post-flush note instead of replacing from a stale row.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 04:37:23 +00:00
Cursor Agentand陈大猫 7763b1ed20 merge: preserve chrome accent from base PR
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 04:29:49 +00:00
Cursor Agentand陈大猫 c03e46fced fix: keep accent in chrome themes without TerminalLayer thrash
Restore accent in globalAppearance for chrome/injection consumers.
Keep resolveFocusedAppearance identity stable across accent drag via
accent ref, while Terminal leaf still re-applies appearanceChromeStore.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 04:29:48 +00:00
Cursor Agentand陈大猫 4e5f2825c6 fix: address Codex feedback on output triggers and AI history memo
- Keep output-trigger scan on setTimeout so hidden/minimized renderers still flush
- Compare sibling workspace updatedAt in scoped AI panel equality for history sort,
  while still ignoring unrelated terminal stream ticks

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 04:22:22 +00:00
Cursor Agentand陈大猫 abf32a23cc fix: keep editor-close hotkey ref fresh during render
Assign handleRequestCloseEditorTabRef in render instead of useEffect so
Cmd/Ctrl+W never hits the App stub before the effect runs.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 04:15:05 +00:00
Cursor Agentand陈大猫 4a85ae32ff fix: keep editor-close hotkey ref fresh during render
Assign handleRequestCloseEditorTabRef in render instead of useEffect so
Cmd/Ctrl+W never hits the App stub before the effect runs.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 04:15:04 +00:00
Cursor Agentand陈大猫 177c574ffc merge: Bugbot/Codex fixes from base PR #2790
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 04:11:28 +00:00
Cursor Agentand陈大猫 66ad27b4bf fix(notes): flush draft before markdown export paths
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 04:11:27 +00:00
Cursor Agentand陈大猫 2afd36ab33 fix: address Bugbot/Codex regressions from perf cleanup
- Keep blank Enter from closing RenameDraftDialog; only reset rename state after a non-empty submit
- Flush note drafts before vault mutations so pending title/body edits are not overwritten
- Re-apply appearanceChromeStore accent on appearanceTheme in Terminal, and keep layer themes unaccented

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 04:11:09 +00:00
Cursor Agentand陈大猫 4717a6ec8e chore(i18n): drop unused knownHosts showingLimited strings
Virtualized Known Hosts no longer caps the visible list, so the
showingLimited copy was dead across locales.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 03:58:47 +00:00
Cursor Agentand陈大猫 b941556b1d perf: virtualize known hosts/snippets lists and consolidate formatBytes
Replace KnownHosts RENDER_LIMIT=100 and large Snippets grids with
VirtualizedHostCollection (>=30), using drop indicators instead of live
grid reorder so drag sources can unmount safely. Centralize byte
formatting in domain/formatBytes with options that preserve each call
site's zero/precision/TB behavior.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 03:56:57 +00:00
Cursor Agentand陈大猫 8c7bc0c706 chore: safe dead-code cleanup without behavior changes
Disable plugin WebContentsView spellcheck, drop unused SFTP/i18n/shim
dead code, centralize side-panel position and auto-update storage keys,
and stop plumbing unused accent props into Terminal panes.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 03:54:14 +00:00
Cursor Agentand陈大猫 7a91b31290 perf(ai): ignore sibling session updatedAt in visible panel memo
Scoped aiSessionIdSetEqual so history chrome only compares updatedAt for
the panel's exact scope (and selected resume). Sibling stream ticks no
longer invalidate unrelated visible AI panels while the full session list
remains available for fuzzy history ranking.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 03:52:01 +00:00
Cursor Agentand陈大猫 885b0c0177 perf: cut UI jank from stringify equality, scan coalesce, and debug storage
Replace connection-log terminalData map JSON.stringify equality with cheap
structural compare, coalesce output-trigger scans via rAF (setTimeout fallback),
route terminal diagnostics and SFTP transfer center reads through
localStorageAdapter, and extract shared FNV-1a helpers for harness hashing.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 03:51:49 +00:00
Cursor Agentand陈大猫 a39af9d7b0 fix: correct import paths after application/state hook migrations
Migrated vault/AI/system hooks still used components/-relative imports;
point them at repo-root domain/components/types/lib instead.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 03:42:40 +00:00
Cursor Agentand陈大猫 a9252d5902 merge: migrate architecture-debt hooks into application/state
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 03:41:31 +00:00
Cursor Agentand陈大猫 e44afc3916 merge: package size, dead code, and consolidation cleanup
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 03:41:28 +00:00
Cursor Agentand陈大猫 2b1bfadb45 merge: electron memory and re-render fixes
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 03:41:26 +00:00
Cursor Agentand陈大猫 5329a47d23 fix: stabilize rAF text batching and diagnostics tests
Polyfill requestAnimationFrame for Node, flush pending text before
steer buffering, bypass rAF while steer is in flight, and stub
global localStorage for AI panel diagnostics adapter tests.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 03:40:50 +00:00
Cursor Agentand陈大猫 162b5a599f chore: package size, dead code, and consolidation cleanup
Remove unused direct deps, exclude Vite-bundled renderer packages from
electron-builder, delete high-confidence dead code/no-op transfer warm
wiring, consolidate cloud provider IDs and KnownHost/SFTP helpers, and
land UI/AGENTS quick wins (rAF text batching, notes draft debounce,
streaming plain text, tempDirBridge, diagnostics adapter, External MCP
setters).

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 03:38:16 +00:00
Cursor Agentand陈大猫 c5308a573d fix(ui): cut unnecessary AppView/VaultView/settings re-renders
Stabilize TopTabs close handler, memo AppHostTreeLayer, exclude accent from
settingsVersion, ignore connectionLogs off the logs section, share lastConnected
host refs, and keep rename dialog drafts local so chrome domain stays stable.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 03:35:24 +00:00
Cursor Agentand陈大猫 f8c220dbd1 Isolate notes and customAccent from TerminalLayer hot props
Route vault notes through notesStore (shellHistoryStore pattern) and
app accent through appearanceChromeStore so note edits and accent-drag
ticks no longer rebuild TerminalLayer. Notes/AI slots and Terminal leaves
subscribe locally; VaultView and open-note callbacks stay unchanged.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 03:33:25 +00:00
Cursor Agentand陈大猫 d43885bdbc fix(electron): reduce baseline memory via spellcheck/GPU/prewarm guards
Disable spellcheck on BrowserWindows and the default session, drop spare
renderers, gate aggressive GPU switches behind NETCATTY_COMPAT_GPU, and make
settings prewarm opt-in via NETCATTY_PREWARM_SETTINGS.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 03:33:24 +00:00
Cursor Agentand陈大猫 c98e688ad6 refactor(sftp): align local bookmarks store API with global bookmarks
Export subscribe/get/set/rehydrate helpers from localSftpBookmarks to match
the globalSftpBookmarks external-store pattern.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 03:29:36 +00:00
Cursor Agentand陈大猫 5c2d60f694 refactor: migrate architecture-debt hooks into application/state
Move SFTP Wave A stores/hooks and related vault/AI/terminal/systemManager
hooks out of components into application/state, update importers, and keep
thin deprecated re-exports at the old paths.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 03:28:56 +00:00
177 changed files with 3299 additions and 2088 deletions

No files matched your search

+1 -9
View File
@@ -291,15 +291,11 @@ function App({ settings }: { settings: SettingsState }) {
draggingSessionId,
setDraggingSessionId,
sessionRenameTarget,
sessionRenameValue,
setSessionRenameValue,
startSessionRename,
renameSessionInline,
submitSessionRename,
resetSessionRename,
workspaceRenameTarget,
workspaceRenameValue,
setWorkspaceRenameValue,
startWorkspaceRename,
submitWorkspaceRename,
resetWorkspaceRename,
@@ -1945,10 +1941,7 @@ function App({ settings }: { settings: SettingsState }) {
resetWorkspaceRename,
resolvedTheme,
sessionRenameTarget,
sessionRenameValue,
setActiveTabId,
setSessionRenameValue,
setWorkspaceRenameValue,
settings,
startSessionRename,
renameSessionInline,
@@ -1960,8 +1953,7 @@ function App({ settings }: { settings: SettingsState }) {
toggleConnectionLogSaved,
updateConnectionLog,
workspaceRenameTarget,
workspaceRenameValue,
}), [accentMode, clearUnsavedConnectionLogs, closeLogView, customAccent, deleteConnectionLog, handleEndSessionDrag, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handleSyncNowManual, isMacClient, logViews, openLogView, orderedTabsWithEditors, orphanSessionsForShell, reorderWorkTabs, resetSessionRename, resetWorkspaceRename, resolvedTheme, sessionRenameTarget, sessionRenameValue, setActiveTabId, setSessionRenameValue, setWorkspaceRenameValue, settings, startSessionRename, renameSessionInline, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, themeById, toggleConnectionLogSaved, updateConnectionLog, workspaceRenameTarget, workspaceRenameValue]);
}), [accentMode, clearUnsavedConnectionLogs, closeLogView, customAccent, deleteConnectionLog, handleEndSessionDrag, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handleSyncNowManual, isMacClient, logViews, openLogView, orderedTabsWithEditors, orphanSessionsForShell, reorderWorkTabs, resetSessionRename, resetWorkspaceRename, resolvedTheme, sessionRenameTarget, setActiveTabId, settings, startSessionRename, renameSessionInline, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, themeById, toggleConnectionLogSaved, updateConnectionLog, workspaceRenameTarget]);
const appDialogsDomain = useMemo(() => ({
addToWorkspaceDialog,
+5
View File
@@ -53,3 +53,8 @@ test('shared host tree forwards work-surface host management callbacks', () => {
assert.match(hostTreeLayerSource, /onNewHost=\{onNewHost\}/);
assert.match(hostTreeLayerSource, /onEditHost=\{onEditHost\}/);
});
test('shared host tree layer is memoized with a custom areEqual', () => {
assert.match(hostTreeLayerSource, /memo\(AppHostTreeLayerInner,\s*appHostTreeLayerAreEqual\)/);
});
+31 -2
View File
@@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import React, { memo, useMemo } from 'react';
import { useActiveTabId } from '../state/activeTabStore';
import type { EditorTabChrome } from '../state/editorTabStore';
@@ -47,7 +47,33 @@ export function getAppHostTreeLayerStyle(surfaceVisible: boolean): React.CSSProp
};
}
export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
function appHostTreeLayerAreEqual(
prev: AppHostTreeLayerProps,
next: AppHostTreeLayerProps,
): boolean {
return prev.enabled === next.enabled
&& prev.hosts === next.hosts
&& prev.customGroups === next.customGroups
&& prev.groupConfigs === next.groupConfigs
&& prev.sessions === next.sessions
&& prev.workspaces === next.workspaces
&& prev.editorTabs === next.editorTabs
&& prev.logViews === next.logViews
&& prev.orderedTabs === next.orderedTabs
&& prev.accentMode === next.accentMode
&& prev.currentTerminalTheme === next.currentTerminalTheme
&& prev.customAccent === next.customAccent
&& prev.followAppTerminalTheme === next.followAppTerminalTheme
&& prev.hostById === next.hostById
&& prev.themeById === next.themeById
&& prev.resolveSessionAppearance === next.resolveSessionAppearance
&& prev.onConnect === next.onConnect
&& prev.onNewHost === next.onNewHost
&& prev.onEditHost === next.onEditHost
&& prev.onCreateLocalTerminal === next.onCreateLocalTerminal;
}
const AppHostTreeLayerInner: React.FC<AppHostTreeLayerProps> = ({
enabled,
hosts,
customGroups,
@@ -154,3 +180,6 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
</div>
);
};
export const AppHostTreeLayer = memo(AppHostTreeLayerInner, appHostTreeLayerAreEqual);
AppHostTreeLayer.displayName = 'AppHostTreeLayer';
+159 -112
View File
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo } from 'react';
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AlertTriangle, Download, Trash2 } from 'lucide-react';
import { activeTabStore, toEditorTabId, useIsEditorTabActive } from '../state/activeTabStore';
import { editorTabStore } from '../state/editorTabStore';
@@ -12,7 +12,7 @@ import { QuickScriptEditorDialog } from '../../components/scripts/QuickScriptEdi
import { AddToWorkspaceDialog } from '../../components/workspace/AddToWorkspaceDialog';
import { KeyboardInteractiveModal } from '../../components/KeyboardInteractiveModal';
import { PassphraseModal } from '../../components/PassphraseModal';
import { UnsavedChangesProvider } from '../../components/editor/UnsavedChangesDialog';
import { UnsavedChangesProvider, promptUnsavedChanges } from '../../components/editor/UnsavedChangesDialog';
import { SnippetExecutionProvider } from '../../components/SnippetExecutionProvider';
import { Button } from '../../components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../../components/ui/dialog';
@@ -66,6 +66,68 @@ const TextEditorTabFallback = ({ tabId }: { tabId: string }) => {
);
};
/** Local draft so keystrokes do not rebuild App chrome domain every character. */
function RenameDraftDialog({
open,
inputId,
initialName,
title,
nameLabel,
placeholder,
cancelLabel,
saveLabel,
onCancel,
onSave,
}: {
open: boolean;
inputId: string;
initialName: string;
title: string;
nameLabel: string;
placeholder: string;
cancelLabel: string;
saveLabel: string;
onCancel: () => void;
onSave: (name: string) => void;
}) {
const [draft, setDraft] = useState(initialName);
useEffect(() => {
if (open) setDraft(initialName);
}, [open, initialName]);
return (
<Dialog open={open} onOpenChange={(nextOpen) => {
if (!nextOpen) onCancel();
}}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="space-y-2 py-2">
<Label htmlFor={inputId}>{nameLabel}</Label>
<Input
id={inputId}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key !== 'Enter') return;
if (!draft.trim()) return;
onSave(draft);
}}
autoFocus
placeholder={placeholder}
/>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onCancel}>{cancelLabel}</Button>
<Button onClick={() => onSave(draft)} disabled={!draft.trim()}>{saveLabel}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export type AppViewProps = {
domains: AppViewDomains;
};
@@ -127,14 +189,14 @@ function AppViewInner({ domains }: AppViewProps) {
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
resolveEmptyVaultConflict, resolvedTheme, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
sessionRenameValue, sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
setNavigateToSection, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
setNavigateToSection, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
setWorkspaceFocusedSession, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
sftpShowHiddenFiles, sftpUseCompressedUpload, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById,
toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog,
readPersistedHosts, readPersistedManagedSources, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources,
updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaceRenameValue, workspaces,
updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaces,
VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper,
} = ctx;
@@ -211,6 +273,70 @@ function AppViewInner({ domains }: AppViewProps) {
pluginViewTabStore.close(tabId);
}, [orderedTabsWithEditors]);
const orderedTabsWithEditorsRef = useRef(orderedTabsWithEditors);
orderedTabsWithEditorsRef.current = orderedTabsWithEditors;
// Stable for TopTabs memo: read ordered tabs via ref; prompt via module singleton.
const handleRequestCloseEditorTab = useCallback(async (id: string): Promise<boolean> => {
const tab = editorTabStore.getTab(id);
if (!tab) return false;
const closeEditorAndActivateNeighbor = () => {
const closingTabId = toEditorTabId(id);
const list = orderedTabsWithEditorsRef.current;
const idx = list.indexOf(closingTabId);
releaseEditorTabSaveCoordinator(id);
editorTabStore.close(id);
if (activeTabStore.getActiveTabId() !== closingTabId) return;
const next = list[idx - 1] ?? list[idx + 1] ?? 'vault';
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
};
const dirty = tab.content !== tab.baselineContent;
if (!dirty) {
closeEditorAndActivateNeighbor();
return true;
}
const choice = await promptUnsavedChanges(tab.fileName);
if (choice === 'cancel') return false;
if (choice === 'discard') {
closeEditorAndActivateNeighbor();
return true;
}
if (choice === 'save') {
const ok = await saveEditorTab(id);
if (!ok) {
const msg = editorTabStore.getTab(id)?.saveError ?? 'Save failed';
toast.error(msg, 'SFTP');
return false;
}
const latest = editorTabStore.getTab(id);
if (!latest || latest.content !== latest.baselineContent) return false;
closeEditorAndActivateNeighbor();
return true;
}
return false;
}, []);
// Keep the hotkey ref current during render so Cmd/Ctrl+W never sees the
// App.tsx stub `() => false` between commit and useEffect.
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
const handleSaveSessionRename = useCallback((name: string) => {
if (!sessionRenameTarget) return;
if (!name.trim()) return;
submitSessionRename(sessionRenameTarget.id, name);
resetSessionRename();
}, [resetSessionRename, sessionRenameTarget, submitSessionRename]);
const handleSaveWorkspaceRename = useCallback((name: string) => {
if (!workspaceRenameTarget) return;
if (!name.trim()) return;
submitWorkspaceRename(workspaceRenameTarget.id, name);
resetWorkspaceRename();
}, [resetWorkspaceRename, submitWorkspaceRename, workspaceRenameTarget]);
useEffect(() => {
const catalog = buildPluginSettingScopeCatalog({
hosts,
@@ -224,54 +350,7 @@ function AppViewInner({ domains }: AppViewProps) {
return (
<SnippetExecutionProvider>
<UnsavedChangesProvider>
{({ prompt }) => {
// Helper: close an editor tab and activate the neighbor (left-preference), or vault.
const closeEditorAndActivateNeighbor = (id: string) => {
const closingTabId = toEditorTabId(id);
const list = orderedTabsWithEditors;
const idx = list.indexOf(closingTabId);
releaseEditorTabSaveCoordinator(id);
editorTabStore.close(id);
if (activeTabStore.getActiveTabId() !== closingTabId) return;
const next = list[idx - 1] ?? list[idx + 1] ?? 'vault';
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
};
// Real dirty-confirm close handler.
const handleRequestCloseEditorTab = async (id: string): Promise<boolean> => {
const tab = editorTabStore.getTab(id);
if (!tab) return false;
const dirty = tab.content !== tab.baselineContent;
if (!dirty) {
closeEditorAndActivateNeighbor(id);
return true;
}
const choice = await prompt(tab.fileName);
if (choice === 'cancel') return false;
if (choice === 'discard') {
closeEditorAndActivateNeighbor(id);
return true;
}
if (choice === 'save') {
const ok = await saveEditorTab(id);
if (!ok) {
const msg = editorTabStore.getTab(id)?.saveError ?? 'Save failed';
toast.error(msg, 'SFTP');
return false;
}
const latest = editorTabStore.getTab(id);
if (!latest || latest.content !== latest.baselineContent) return false;
closeEditorAndActivateNeighbor(id);
return true;
}
return false;
};
// Expose to the hotkey dispatcher (Cmd/Ctrl+W).
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
return (
{() => (
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" data-terminal-appearance-root onContextMenu={handleRootContextMenu}>
<TopTabs
theme={resolvedTheme}
@@ -464,8 +543,6 @@ function AppViewInner({ domains }: AppViewProps) {
identities={identities}
snippets={snippets}
snippetPackages={snippetPackages}
notes={notes}
noteGroups={noteGroups}
sessions={sessions}
workspaces={workspaces}
knownHosts={effectiveKnownHosts}
@@ -529,8 +606,6 @@ function AppViewInner({ domains }: AppViewProps) {
updateHosts={updateTerminalHosts}
updateSnippets={updateSnippets}
updateSnippetPackages={updateSnippetPackages}
updateNotes={updateNotes}
updateNoteGroups={updateNoteGroups}
sftpDefaultViewMode={sftpDefaultViewMode}
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
sftpAutoSync={sftpAutoSync}
@@ -715,59 +790,32 @@ function AppViewInner({ domains }: AppViewProps) {
</LazyLoadBoundary>
)}
<Dialog open={!!sessionRenameTarget} onOpenChange={(open) => {
if (!open) {
resetSessionRename();
}
}}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t('dialog.renameSession.title')}</DialogTitle>
</DialogHeader>
<div className="space-y-2 py-2">
<Label htmlFor="session-name">{t('field.name')}</Label>
<Input
id="session-name"
value={sessionRenameValue}
onChange={(e) => setSessionRenameValue(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') submitSessionRename(); }}
autoFocus
placeholder={t('placeholder.sessionName')}
/>
</div>
<DialogFooter>
<Button variant="ghost" onClick={resetSessionRename}>{t('common.cancel')}</Button>
<Button onClick={submitSessionRename} disabled={!sessionRenameValue.trim()}>{t('common.save')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<RenameDraftDialog
open={!!sessionRenameTarget}
inputId="session-name"
initialName={sessionRenameTarget ? (sessionRenameTarget.customName || sessionRenameTarget.hostLabel) : ''}
title={t('dialog.renameSession.title')}
nameLabel={t('field.name')}
placeholder={t('placeholder.sessionName')}
cancelLabel={t('common.cancel')}
saveLabel={t('common.save')}
onCancel={resetSessionRename}
onSave={handleSaveSessionRename}
/>
<RenameDraftDialog
open={!!workspaceRenameTarget}
inputId="workspace-name"
initialName={workspaceRenameTarget ? workspaceRenameTarget.title : ''}
title={t('dialog.renameWorkspace.title')}
nameLabel={t('field.name')}
placeholder={t('placeholder.workspaceName')}
cancelLabel={t('common.cancel')}
saveLabel={t('common.save')}
onCancel={resetWorkspaceRename}
onSave={handleSaveWorkspaceRename}
/>
<Dialog open={!!workspaceRenameTarget} onOpenChange={(open) => {
if (!open) {
resetWorkspaceRename();
}
}}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t('dialog.renameWorkspace.title')}</DialogTitle>
</DialogHeader>
<div className="space-y-2 py-2">
<Label htmlFor="workspace-name">{t('field.name')}</Label>
<Input
id="workspace-name"
value={workspaceRenameValue}
onChange={(e) => setWorkspaceRenameValue(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') submitWorkspaceRename(); }}
autoFocus
placeholder={t('placeholder.workspaceName')}
/>
</div>
<DialogFooter>
<Button variant="ghost" onClick={resetWorkspaceRename}>{t('common.cancel')}</Button>
<Button onClick={submitWorkspaceRename} disabled={!workspaceRenameValue.trim()}>{t('common.save')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{isCreateWorkspaceOpen && (
<LazyLoadBoundary name="Create workspace" resetKey="create-workspace">
@@ -871,8 +919,7 @@ function AppViewInner({ domains }: AppViewProps) {
</DialogContent>
</Dialog>
</div>
);
}}
)}
</UnsavedChangesProvider>
</SnippetExecutionProvider>
);
+5 -2
View File
@@ -9,7 +9,10 @@ import { sftpTransferCenterStore } from '../state/sftpTransferCenterStore';
import { resumeTransferWithDedicatedSession } from '../state/sftp/dedicatedTransferResume';
import { getSftpTransferResourceKeys, globalSftpTransferScheduler } from '../state/sftp/globalTransferScheduler';
import { hasNewSourceFingerprint } from '../state/sftp/transferProgressMetadata';
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from '../../infrastructure/config/storageKeys';
import {
STORAGE_KEY_AUTO_UPDATE_ENABLED,
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
} from '../../infrastructure/config/storageKeys';
import type { TransferTask } from '../../domain/models';
import { isTerminalBootEpochCurrent } from '../../domain/terminalBootEpoch';
import {
@@ -218,7 +221,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
// Skip "update available" toast if auto-download has already started or completed
if (updateState.autoDownloadStatus !== 'idle') return;
// Don't show automatic notification when auto-update is disabled
if (localStorageAdapter.readString('netcatty_auto_update_enabled_v1') === 'false') return;
if (localStorageAdapter.readString(STORAGE_KEY_AUTO_UPDATE_ENABLED) === 'false') return;
if (updateState.hasUpdate && updateState.latestRelease) {
const version = updateState.latestRelease.version;
toast.info(
+1 -32
View File
@@ -113,8 +113,6 @@ export const enVaultMessages: Messages = {
'knownHosts.empty.title': 'No Known Hosts',
'knownHosts.empty.desc':
"Known hosts are SSH servers you've connected to before. Import from your system's known_hosts file to get started.",
'knownHosts.results.showingLimited':
'Showing {shown} of {total} hosts. Use search to find specific hosts.',
'knownHosts.toast.scanUnavailable': 'System scan is unavailable on this platform.',
'knownHosts.toast.scanNoFile': 'No system known_hosts file found.',
'knownHosts.toast.scanNoEntries': 'No usable entries found in known_hosts.',
@@ -450,13 +448,6 @@ export const enVaultMessages: Messages = {
'settings.tab.sftpFileAssociations': 'SFTP',
'settings.sftp.transferConcurrency': 'Transfer Concurrency',
'settings.sftp.transferConcurrency.desc': 'Maximum number of files transferred in parallel for the same endpoint, including folder contents and separately selected files. Higher values may improve speed but can overwhelm some servers.',
'settings.sftp.transferPoolIdleTtl': 'Transfer connection keep-alive',
'settings.sftp.transferPoolIdleTtl.desc': 'How long dedicated transfer SFTP connections stay warm after idle. Longer values reduce cold-start delay before the next upload or download. Busy transfers are never closed.',
'settings.sftp.transferPoolIdleTtl.1m': '1 minute',
'settings.sftp.transferPoolIdleTtl.5m': '5 minutes',
'settings.sftp.transferPoolIdleTtl.15m': '15 minutes',
'settings.sftp.transferPoolIdleTtl.30m': '30 minutes',
'settings.sftp.transferPoolIdleTtl.never': 'Until app quit',
'settings.ssh.transportIdleTtl': 'SSH connection keep-alive',
'settings.ssh.transportIdleTtl.desc': 'How long a shared SSH connection stays warm after the last terminal, SFTP browse, bulk transfer, or port-forward lease is released. While warm, reconnecting to the same host can skip re-authentication (including MFA). Active sessions and transfers are never closed.',
'settings.ssh.transportIdleTtl.1m': '1 minute',
@@ -913,30 +904,8 @@ export const enVaultMessages: Messages = {
'hostDetails.plugin.unavailable': 'The configured provider is unavailable. Configuration is preserved until the plugin is restored.',
'hostDetails.plugin.useSsh': 'Use SSH instead',
// Host form (legacy modal)
'hostForm.title.edit': 'Edit Host',
'hostForm.title.new': 'New Host',
'hostForm.desc.edit': 'Update connection details for this host',
'hostForm.desc.new': 'Create a new SSH host entry',
'hostForm.field.label': 'Label',
'hostForm.placeholder.label': 'My Production Server',
'hostForm.field.hostname': 'Hostname / IP',
'hostForm.placeholder.hostname': '192.168.1.1',
'hostForm.field.port': 'Port',
'hostForm.field.username': 'Username',
'hostForm.field.osType': 'OS Type',
'hostForm.placeholder.selectOs': 'Select OS',
'hostForm.field.group': 'Group',
'hostForm.placeholder.group': 'e.g. AWS, DigitalOcean',
'hostForm.field.tags': 'Tags',
'hostForm.placeholder.addTag': 'Add a tag...',
'hostForm.auth.method': 'Authentication Method',
'hostForm.auth.password': 'Password',
'hostForm.auth.sshKey': 'SSH Key',
// Host form (legacy keys still referenced by TerminalAuthDialog)
'hostForm.auth.selectKey': 'Select an SSH Key',
'hostForm.auth.noKeys': 'No keys available',
'hostForm.auth.noKeysHint': 'No SSH keys found in Keychain. Please create one first.',
'hostForm.saveHost': 'Save Host',
// Connection logs
'logs.table.date': 'Date',
+1 -32
View File
@@ -150,8 +150,6 @@ export const ruVaultMessages: Messages = {
'knownHosts.empty.title': 'Нет известных хостов',
'knownHosts.empty.desc':
'Известные хосты — это SSH-серверы, к которым вы подключались раньше. Импортируйте системный файл known_hosts, чтобы начать.',
'knownHosts.results.showingLimited':
'Показано {shown} из {total} хостов. Используйте поиск, чтобы найти нужные хосты.',
'knownHosts.toast.scanUnavailable': 'Сканирование системы недоступно на этой платформе.',
'knownHosts.toast.scanNoFile': 'Системный файл known_hosts не найден.',
'knownHosts.toast.scanNoEntries': 'В known_hosts не найдено пригодных записей.',
@@ -486,13 +484,6 @@ export const ruVaultMessages: Messages = {
'settings.tab.sftpFileAssociations': 'SFTP',
'settings.sftp.transferConcurrency': 'Параллелизм передачи',
'settings.sftp.transferConcurrency.desc': 'Максимальное число файлов, одновременно передаваемых для одной цели, включая содержимое папок и отдельно выбранные файлы. Большие значения могут ускорить передачу, но перегрузить некоторые серверы.',
'settings.sftp.transferPoolIdleTtl': 'Удержание transfer-соединений',
'settings.sftp.transferPoolIdleTtl.desc': 'Как долго простаивающие SFTP-соединения пула передачи остаются открытыми. Большее время снижает задержку перед следующей передачей. Активные передачи не закрываются.',
'settings.sftp.transferPoolIdleTtl.1m': '1 минута',
'settings.sftp.transferPoolIdleTtl.5m': '5 минут',
'settings.sftp.transferPoolIdleTtl.15m': '15 минут',
'settings.sftp.transferPoolIdleTtl.30m': '30 минут',
'settings.sftp.transferPoolIdleTtl.never': 'До выхода из приложения',
'settings.ssh.transportIdleTtl': 'Удержание SSH-соединений',
'settings.ssh.transportIdleTtl.desc': 'Как долго общее SSH-соединение остаётся открытым после освобождения последнего lease терминала, SFTP-браузера, пакетной передачи или проброса портов. Пока оно тёплое, повторное подключение к тому же хосту может обойти повторную аутентификацию (включая MFA). Активные сессии и передачи не закрываются.',
'settings.ssh.transportIdleTtl.1m': '1 минута',
@@ -945,30 +936,8 @@ export const ruVaultMessages: Messages = {
'hostDetails.plugin.unavailable': 'Настроенный провайдер недоступен. Конфигурация сохранится до восстановления плагина.',
'hostDetails.plugin.useSsh': 'Использовать SSH',
// Host form (legacy modal)
'hostForm.title.edit': 'Редактировать хост',
'hostForm.title.new': 'Новый хост',
'hostForm.desc.edit': 'Обновите параметры подключения для этого хоста',
'hostForm.desc.new': 'Создайте новую запись SSH-хоста',
'hostForm.field.label': 'Метка',
'hostForm.placeholder.label': 'Мой production-сервер',
'hostForm.field.hostname': 'Имя хоста / IP',
'hostForm.placeholder.hostname': '192.168.1.1',
'hostForm.field.port': 'Порт',
'hostForm.field.username': 'Имя пользователя',
'hostForm.field.osType': 'Тип ОС',
'hostForm.placeholder.selectOs': 'Выберите ОС',
'hostForm.field.group': 'Группа',
'hostForm.placeholder.group': 'например, AWS, DigitalOcean',
'hostForm.field.tags': 'Теги',
'hostForm.placeholder.addTag': 'Добавить тег...',
'hostForm.auth.method': 'Метод аутентификации',
'hostForm.auth.password': 'Пароль',
'hostForm.auth.sshKey': 'SSH-ключ',
// Host form (legacy keys still referenced by TerminalAuthDialog)
'hostForm.auth.selectKey': 'Выберите SSH-ключ',
'hostForm.auth.noKeys': 'Нет доступных ключей',
'hostForm.auth.noKeysHint': 'В связке ключей не найдено SSH-ключей. Сначала создайте один.',
'hostForm.saveHost': 'Сохранить хост',
'vault.import.plugins.title': 'Импортёры плагинов',
'vault.import.plugins.loading': 'Создание безопасного предварительного просмотра…',
'vault.import.plugins.progress': 'Обработано {completed} из {total} {message}',
-1
View File
@@ -732,7 +732,6 @@ export const zhCNCoreMessages: Messages = {
'knownHosts.empty.title': '暂无已知主机',
'knownHosts.empty.desc':
'Known Hosts 是你之前连接过的 SSH server。导入系统的 known_hosts 文件以开始。',
'knownHosts.results.showingLimited': '显示 {shown}/{total} 个主机。使用搜索查找特定主机。',
'knownHosts.toast.scanUnavailable': '当前平台无法扫描系统 known_hosts。',
'knownHosts.toast.scanNoFile': '未找到系统 known_hosts 文件。',
'knownHosts.toast.scanNoEntries': 'known_hosts 中没有可用条目。',
@@ -93,13 +93,6 @@ export const zhCNTerminalMessages: Messages = {
'settings.tab.sftpFileAssociations': 'SFTP',
'settings.sftp.transferConcurrency': '传输并发数',
'settings.sftp.transferConcurrency.desc': '同一目标同时传输的文件数量上限,包括文件夹内文件和单独多选的文件。较高的值可能提高速度,但可能导致某些服务器过载。',
'settings.sftp.transferPoolIdleTtl': '传输连接保活时间',
'settings.sftp.transferPoolIdleTtl.desc': '专用传输 SFTP 连接空闲多久后关闭。时间更长可减少下次上传/下载的冷启动等待;进行中的传输不会被关闭。',
'settings.sftp.transferPoolIdleTtl.1m': '1 分钟',
'settings.sftp.transferPoolIdleTtl.5m': '5 分钟',
'settings.sftp.transferPoolIdleTtl.15m': '15 分钟',
'settings.sftp.transferPoolIdleTtl.30m': '30 分钟',
'settings.sftp.transferPoolIdleTtl.never': '直到退出应用',
'settings.ssh.transportIdleTtl': 'SSH 连接保活时间',
'settings.ssh.transportIdleTtl.desc': '最后一个终端、SFTP 浏览、批量传输或端口转发释放后,共享 SSH 连接再保留多久。保留期间再次打开同主机可跳过重新认证(含 MFA);进行中的会话与传输不会被关闭。',
'settings.ssh.transportIdleTtl.1m': '1 分钟',
+1 -22
View File
@@ -331,29 +331,8 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.plugin.useSsh': '改用 SSH',
// Host form (legacy modal)
'hostForm.title.edit': '编辑主机',
'hostForm.title.new': '新建主机',
'hostForm.desc.edit': '更新该主机的连接信息',
'hostForm.desc.new': '创建一个新的 SSH 主机条目',
'hostForm.field.label': '名称',
'hostForm.placeholder.label': 'My Production Server',
'hostForm.field.hostname': 'Hostname / IP',
'hostForm.placeholder.hostname': '192.168.1.1',
'hostForm.field.port': '端口',
'hostForm.field.username': '用户名',
'hostForm.field.osType': '操作系统类型',
'hostForm.placeholder.selectOs': '选择操作系统',
'hostForm.field.group': '分组',
'hostForm.placeholder.group': '例如:AWS、DigitalOcean',
'hostForm.field.tags': '标签',
'hostForm.placeholder.addTag': '添加标签…',
'hostForm.auth.method': '认证方式',
'hostForm.auth.password': '密码',
'hostForm.auth.sshKey': 'SSH密钥',
// Host form (legacy keys still referenced by TerminalAuthDialog)
'hostForm.auth.selectKey': '选择 SSH密钥',
'hostForm.auth.noKeys': '暂无密钥',
'hostForm.auth.noKeysHint': '钥匙串中未找到 SSH密钥,请先创建一个。',
'hostForm.saveHost': '保存主机',
// Connection logs
'logs.table.date': '日期',
-1
View File
@@ -732,7 +732,6 @@ export const zhTWCoreMessages: Messages = {
'knownHosts.empty.title': '暫無已知主機',
'knownHosts.empty.desc':
'Known Hosts 是你之前連線過的 SSH server。匯入系統的 known_hosts 檔案以開始。',
'knownHosts.results.showingLimited': '顯示 {shown}/{total} 個主機。使用搜索查詢特定主機。',
'knownHosts.toast.scanUnavailable': '目前平台無法掃描系統 known_hosts。',
'knownHosts.toast.scanNoFile': '未找到系統 known_hosts 檔案。',
'knownHosts.toast.scanNoEntries': 'known_hosts 中沒有可用條目。',
@@ -93,13 +93,6 @@ export const zhTWTerminalMessages: Messages = {
'settings.tab.sftpFileAssociations': 'SFTP',
'settings.sftp.transferConcurrency': '傳輸併發數',
'settings.sftp.transferConcurrency.desc': '同一目標同時傳輸的檔案數量上限,包括資料夾內檔案和單獨多選的檔案。較高的值可能提高速度,但可能導致某些伺服器過載。',
'settings.sftp.transferPoolIdleTtl': '傳輸連線保活時間',
'settings.sftp.transferPoolIdleTtl.desc': '專用傳輸 SFTP 連線閒置多久後關閉。時間更長可減少下次上傳/下載的冷啟動等待;進行中的傳輸不會被關閉。',
'settings.sftp.transferPoolIdleTtl.1m': '1 分鐘',
'settings.sftp.transferPoolIdleTtl.5m': '5 分鐘',
'settings.sftp.transferPoolIdleTtl.15m': '15 分鐘',
'settings.sftp.transferPoolIdleTtl.30m': '30 分鐘',
'settings.sftp.transferPoolIdleTtl.never': '直到結束應用',
'settings.ssh.transportIdleTtl': 'SSH 連線保活時間',
'settings.ssh.transportIdleTtl.desc': '最後一個終端、SFTP 瀏覽、批次傳輸或連接埠轉發釋放後,共享 SSH 連線再保留多久。保留期間再次開啟同主機可略過重新驗證(含 MFA);進行中的工作階段與傳輸不會被關閉。',
'settings.ssh.transportIdleTtl.1m': '1 分鐘',
+1 -22
View File
@@ -331,29 +331,8 @@ export const zhTWVaultMessages: Messages = {
'hostDetails.plugin.useSsh': '改用 SSH',
// Host form (legacy modal)
'hostForm.title.edit': '編輯主機',
'hostForm.title.new': '新建主機',
'hostForm.desc.edit': '更新該主機的連線資訊',
'hostForm.desc.new': '建立一個新的 SSH 主機條目',
'hostForm.field.label': '名稱',
'hostForm.placeholder.label': 'My Production Server',
'hostForm.field.hostname': 'Hostname / IP',
'hostForm.placeholder.hostname': '192.168.1.1',
'hostForm.field.port': '埠',
'hostForm.field.username': '使用者名稱',
'hostForm.field.osType': '作業系統型別',
'hostForm.placeholder.selectOs': '選擇作業系統',
'hostForm.field.group': '分組',
'hostForm.placeholder.group': '例如:AWS、DigitalOcean',
'hostForm.field.tags': '標籤',
'hostForm.placeholder.addTag': '新增標籤…',
'hostForm.auth.method': '認證方式',
'hostForm.auth.password': '密碼',
'hostForm.auth.sshKey': 'SSH金鑰',
// Host form (legacy keys still referenced by TerminalAuthDialog)
'hostForm.auth.selectKey': '選擇 SSH金鑰',
'hostForm.auth.noKeys': '暫無金鑰',
'hostForm.auth.noKeysHint': '鑰匙串中未找到 SSH金鑰,請先建立一個。',
'hostForm.saveHost': '儲存主機',
// Connection logs
'logs.table.date': '日期',
+85
View File
@@ -0,0 +1,85 @@
import type React from 'react';
import {
STORAGE_KEY_AI_PANEL_DIAGNOSTIC_HIDE,
STORAGE_KEY_AI_PANEL_DIAGNOSTIC_PROFILE,
} from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
export const AI_PANEL_DIAGNOSTIC_HIDE_KEY = STORAGE_KEY_AI_PANEL_DIAGNOSTIC_HIDE;
export const AI_PANEL_DIAGNOSTIC_PROFILE_KEY = STORAGE_KEY_AI_PANEL_DIAGNOSTIC_PROFILE;
export const AI_PANEL_FORCE_HIDE_ALL_CONTENT = false;
export const AI_PANEL_FORCE_HIDE_SHELL = false;
export type AIPanelDiagnosticPart =
| 'all'
| 'attachments'
| 'header'
| 'history'
| 'input'
| 'markdown'
| 'messages'
| 'recent'
| 'toolcalls';
function readLocalStorageValue(key: string): string {
try {
return localStorageAdapter.readString(key) ?? '';
} catch {
return '';
}
}
export function getAIPanelDiagnosticHiddenParts(): ReadonlySet<string> {
if (AI_PANEL_FORCE_HIDE_ALL_CONTENT) {
return new Set(['all']);
}
const raw = readLocalStorageValue(AI_PANEL_DIAGNOSTIC_HIDE_KEY);
return new Set(
raw
.split(',')
.map((part) => part.trim().toLowerCase())
.filter(Boolean),
);
}
export function isAIPanelDiagnosticPartHidden(
part: AIPanelDiagnosticPart,
hiddenParts = getAIPanelDiagnosticHiddenParts(),
): boolean {
return hiddenParts.has('all') || hiddenParts.has(part);
}
export function isAIPanelDiagnosticsProfilingEnabled(): boolean {
const raw = readLocalStorageValue(AI_PANEL_DIAGNOSTIC_PROFILE_KEY).trim().toLowerCase();
return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
}
export function logAIPanelProfiler(
id: string,
phase: 'mount' | 'update' | 'nested-update',
actualDuration: number,
baseDuration: number,
): void {
if (!isAIPanelDiagnosticsProfilingEnabled()) return;
console.info(
`[AI panel profile] ${id} ${phase}: actual=${actualDuration.toFixed(1)}ms base=${baseDuration.toFixed(1)}ms`,
);
}
export function profileAIPanelCalculation<T>(label: string, calculate: () => T): T {
if (!isAIPanelDiagnosticsProfilingEnabled()) return calculate();
const startedAt = performance.now();
try {
return calculate();
} finally {
const elapsed = performance.now() - startedAt;
console.info(`[AI panel profile] ${label}: ${elapsed.toFixed(1)}ms`);
}
}
export function getAIPanelProfilerProps(id: string): Pick<React.ProfilerProps, 'id' | 'onRender'> {
return {
id,
onRender: logAIPanelProfiler,
};
}
@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getAppearanceChromeSnapshot,
publishAppearanceChromeSnapshot,
subscribeAppearanceChrome,
} from './appearanceChromeStore.ts';
test('appearanceChromeStore notifies subscribers only when accent fields change', () => {
const events: string[] = [];
const unsubscribe = subscribeAppearanceChrome(() => {
const snap = getAppearanceChromeSnapshot();
events.push(`${snap.accentMode}:${snap.customAccent}`);
});
publishAppearanceChromeSnapshot({ accentMode: 'custom', customAccent: '#ff0000' });
assert.equal(events.at(-1), 'custom:#ff0000');
assert.equal(getAppearanceChromeSnapshot().customAccent, '#ff0000');
publishAppearanceChromeSnapshot({ accentMode: 'custom', customAccent: '#ff0000' });
assert.equal(events.length, 1);
publishAppearanceChromeSnapshot({ accentMode: 'custom', customAccent: '#00ff00' });
assert.equal(events.at(-1), 'custom:#00ff00');
publishAppearanceChromeSnapshot({ accentMode: 'theme', customAccent: '#00ff00' });
assert.equal(events.at(-1), 'theme:#00ff00');
unsubscribe();
});
@@ -0,0 +1,68 @@
import { useSyncExternalStore } from 'react';
type Listener = () => void;
export type AppearanceChromeSnapshot = {
accentMode: 'theme' | 'custom';
customAccent: string;
};
const DEFAULT_SNAPSHOT: AppearanceChromeSnapshot = Object.freeze({
accentMode: 'theme',
customAccent: '',
});
/**
* External store for app accent chrome so Terminal leaves can apply custom
* accent without TerminalLayer rebuilding on every color-picker drag tick.
*/
class AppearanceChromeStore {
private snapshot: AppearanceChromeSnapshot = DEFAULT_SNAPSHOT;
private listeners = new Set<Listener>();
getSnapshot = (): AppearanceChromeSnapshot => this.snapshot;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
setSnapshot(next: AppearanceChromeSnapshot): void {
if (
this.snapshot.accentMode === next.accentMode
&& this.snapshot.customAccent === next.customAccent
) {
return;
}
this.snapshot = next;
for (const listener of this.listeners) {
listener();
}
}
}
export const appearanceChromeStore = new AppearanceChromeStore();
export function publishAppearanceChromeSnapshot(
snapshot: AppearanceChromeSnapshot,
): void {
appearanceChromeStore.setSnapshot(snapshot);
}
export function getAppearanceChromeSnapshot(): AppearanceChromeSnapshot {
return appearanceChromeStore.getSnapshot();
}
export function subscribeAppearanceChrome(listener: Listener): () => void {
return appearanceChromeStore.subscribe(listener);
}
export function useAppearanceChromeStore(): AppearanceChromeSnapshot {
return useSyncExternalStore(
subscribeAppearanceChrome,
getAppearanceChromeSnapshot,
getAppearanceChromeSnapshot,
);
}
+101
View File
@@ -0,0 +1,101 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
EMPTY_NOTES_SNAPSHOT,
getEmptyNotesSnapshot,
getNotesActions,
getNotesSnapshot,
publishNotesSnapshot,
registerNotesActions,
subscribeNotes,
subscribeNotesNoop,
} from './notesStore.ts';
test('notesStore notifies subscribers only when snapshot identity changes', () => {
const events: number[] = [];
const unsubscribe = subscribeNotes(() => {
events.push(getNotesSnapshot().notes.length);
});
const firstNotes = [{
id: 'n1',
title: 'One',
content: 'body',
tags: [],
createdAt: 1,
updatedAt: 1,
order: 1000,
}];
const firstGroups = ['Ops'];
publishNotesSnapshot({ notes: firstNotes, noteGroups: firstGroups });
assert.equal(events.at(-1), 1);
assert.equal(getNotesSnapshot().notes, firstNotes);
assert.equal(getNotesSnapshot().noteGroups, firstGroups);
publishNotesSnapshot({ notes: firstNotes, noteGroups: firstGroups });
assert.equal(events.length, 1);
const secondNotes = [
...firstNotes,
{
id: 'n2',
title: 'Two',
content: 'more',
tags: [],
createdAt: 2,
updatedAt: 2,
order: 2000,
},
];
publishNotesSnapshot({ notes: secondNotes, noteGroups: firstGroups });
assert.equal(events.at(-1), 2);
publishNotesSnapshot({ notes: secondNotes, noteGroups: ['Ops', 'DB'] });
assert.equal(events.length, 3);
assert.deepEqual(getNotesSnapshot().noteGroups, ['Ops', 'DB']);
unsubscribe();
});
test('notesStore gated helpers stay empty and never notify', () => {
let called = 0;
const unsub = subscribeNotesNoop(() => {
called += 1;
});
publishNotesSnapshot({
notes: [{
id: 'n1',
title: 'One',
content: 'body',
tags: [],
createdAt: 1,
updatedAt: 1,
order: 1000,
}],
noteGroups: ['Ops'],
});
assert.equal(called, 0);
assert.equal(getEmptyNotesSnapshot(), EMPTY_NOTES_SNAPSHOT);
assert.equal(getEmptyNotesSnapshot().notes.length, 0);
unsub();
});
test('registerNotesActions exposes update handlers', () => {
const calls: string[] = [];
registerNotesActions({
updateNotes: () => {
calls.push('notes');
},
updateNoteGroups: () => {
calls.push('groups');
},
});
const actions = getNotesActions();
assert.ok(actions);
actions!.updateNotes([]);
actions!.updateNoteGroups([]);
assert.deepEqual(calls, ['notes', 'groups']);
registerNotesActions(null);
assert.equal(getNotesActions(), null);
});
+138
View File
@@ -0,0 +1,138 @@
import { useSyncExternalStore } from 'react';
import type { VaultNote } from '../../domain/models';
type Listener = () => void;
export type NotesSnapshot = {
notes: readonly VaultNote[];
noteGroups: readonly string[];
};
export type NotesActions = {
updateNotes: (notes: VaultNote[]) => void;
updateNoteGroups: (groups: string[]) => void;
};
const EMPTY_NOTES: readonly VaultNote[] = Object.freeze([]);
const EMPTY_NOTE_GROUPS: readonly string[] = Object.freeze([]);
export const EMPTY_NOTES_SNAPSHOT: NotesSnapshot = Object.freeze({
notes: EMPTY_NOTES,
noteGroups: EMPTY_NOTE_GROUPS,
});
/**
* External store for vault notes so Notes / AI side-panel consumers can
* subscribe without forcing TerminalLayer re-renders on every note edit.
*/
class NotesStore {
private snapshot: NotesSnapshot = EMPTY_NOTES_SNAPSHOT;
private actions: NotesActions | null = null;
private listeners = new Set<Listener>();
private actionListeners = new Set<Listener>();
getSnapshot = (): NotesSnapshot => this.snapshot;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
setSnapshot(next: NotesSnapshot): void {
if (
this.snapshot.notes === next.notes
&& this.snapshot.noteGroups === next.noteGroups
) {
return;
}
this.snapshot = next;
for (const listener of this.listeners) {
listener();
}
}
getActions = (): NotesActions | null => this.actions;
subscribeActions = (listener: Listener): (() => void) => {
this.actionListeners.add(listener);
return () => {
this.actionListeners.delete(listener);
};
};
setActions(next: NotesActions | null): void {
if (this.actions === next) return;
this.actions = next;
for (const listener of this.actionListeners) {
listener();
}
}
}
export const notesStore = new NotesStore();
export function publishNotesSnapshot(snapshot: NotesSnapshot): void {
notesStore.setSnapshot(snapshot);
}
export function getNotesSnapshot(): NotesSnapshot {
return notesStore.getSnapshot();
}
export function subscribeNotes(listener: Listener): () => void {
return notesStore.subscribe(listener);
}
/** No-op subscribe for gated (hidden) panel mounts. */
export function subscribeNotesNoop(_listener: Listener): () => void {
return () => {};
}
export function getEmptyNotesSnapshot(): NotesSnapshot {
return EMPTY_NOTES_SNAPSHOT;
}
export function registerNotesActions(actions: NotesActions | null): void {
notesStore.setActions(actions);
}
export function getNotesActions(): NotesActions | null {
return notesStore.getActions();
}
export function subscribeNotesActions(listener: Listener): () => void {
return notesStore.subscribeActions(listener);
}
const noopUpdateNotes: NotesActions['updateNotes'] = () => {};
const noopUpdateNoteGroups: NotesActions['updateNoteGroups'] = () => {};
/**
* Subscribe to notes catalog + vault mutation actions for Notes / AI panels.
*/
export function useNotesStore(): {
notes: VaultNote[];
noteGroups: string[];
updateNotes: NotesActions['updateNotes'];
updateNoteGroups: NotesActions['updateNoteGroups'];
} {
const snapshot = useSyncExternalStore(
subscribeNotes,
getNotesSnapshot,
getNotesSnapshot,
);
const actions = useSyncExternalStore(
subscribeNotesActions,
getNotesActions,
getNotesActions,
);
return {
notes: snapshot.notes as VaultNote[],
noteGroups: snapshot.noteGroups as string[],
updateNotes: actions?.updateNotes ?? noopUpdateNotes,
updateNoteGroups: actions?.updateNoteGroups ?? noopUpdateNoteGroups,
};
}
+32
View File
@@ -0,0 +1,32 @@
export type SortField = "name" | "size" | "modified" | "type";
export type SortOrder = "asc" | "desc";
export interface ColumnWidths {
name: number;
modified: number;
size: number;
type: number;
}
export type SftpColumnVisibility = Record<keyof ColumnWidths, boolean>;
export const DEFAULT_SFTP_COLUMN_VISIBILITY: SftpColumnVisibility = {
name: true,
modified: true,
size: true,
type: true,
};
export const normalizeSftpColumnVisibility = (value: unknown): SftpColumnVisibility => {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return DEFAULT_SFTP_COLUMN_VISIBILITY;
}
const stored = value as Partial<Record<keyof ColumnWidths, unknown>>;
return {
name: true,
modified: stored.modified !== false,
size: stored.size !== false,
type: stored.type !== false,
};
};
@@ -1,31 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { resumeCompressedUploadSafely } from "./compressedUploadControl.ts";
test("live compressed resume failure stays paused instead of starting a second transfer", async () => {
const outcome = await resumeCompressedUploadSafely({
transferId: "compressed-live",
reconnectRequired: false,
resume: async () => ({ success: false, reason: "Upload resume is unavailable" }),
});
assert.deepEqual(outcome, { kind: "failed", reason: "Upload resume is unavailable" });
});
test("restored compressed task may restart when its old worker no longer exists", async () => {
const outcome = await resumeCompressedUploadSafely({
transferId: "compressed-restored",
reconnectRequired: true,
resume: async () => ({ success: false, reason: "Compression is not active" }),
});
assert.deepEqual(outcome, { kind: "restart", reason: "Compression is not active" });
});
test("successful compressed resume rejoins the existing job", async () => {
const outcome = await resumeCompressedUploadSafely({
transferId: "compressed-live",
reconnectRequired: false,
resume: async () => ({ success: true }),
});
assert.deepEqual(outcome, { kind: "resumed" });
});
@@ -1,16 +0,0 @@
export type CompressedUploadResumeOutcome =
| { kind: "resumed" }
| { kind: "restart"; reason?: string }
| { kind: "failed"; reason: string };
export async function resumeCompressedUploadSafely(params: {
transferId: string;
reconnectRequired: boolean;
resume?: (transferId: string) => Promise<{ success: boolean; reason?: string }>;
}): Promise<CompressedUploadResumeOutcome> {
const result = await (params.resume?.(params.transferId)
?? { success: false, reason: "Resume unavailable" });
if (result.success) return { kind: "resumed" };
if (params.reconnectRequired) return { kind: "restart", reason: result.reason };
return { kind: "failed", reason: result.reason ?? "Could not resume the compressed upload." };
}
@@ -0,0 +1,83 @@
import { useCallback, useMemo, useSyncExternalStore } from "react";
import type { SftpBookmark } from "../../../domain/models";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { STORAGE_KEY_SFTP_LOCAL_BOOKMARKS } from "../../../infrastructure/config/storageKeys";
import { createSftpBookmark } from "./bookmarkHelpers";
type Listener = () => void;
const listeners = new Set<Listener>();
let snapshot: SftpBookmark[] =
localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS) ?? [];
export function subscribeLocalSftpBookmarks(listener: Listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function getLocalSftpBookmarksSnapshot() {
return snapshot;
}
export function rehydrateLocalSftpBookmarks() {
snapshot = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS) ?? [];
for (const listener of listeners) listener();
}
export function setLocalSftpBookmarks(
next: SftpBookmark[] | ((prev: SftpBookmark[]) => SftpBookmark[]),
) {
snapshot = typeof next === "function" ? next(snapshot) : next;
localStorageAdapter.write(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS, snapshot);
for (const listener of listeners) listener();
}
if (typeof window !== "undefined") {
window.addEventListener("storage", (event) => {
if (event.key === STORAGE_KEY_SFTP_LOCAL_BOOKMARKS) {
rehydrateLocalSftpBookmarks();
}
});
}
interface UseLocalSftpBookmarksParams {
currentPath: string | undefined;
}
export const useLocalSftpBookmarks = ({
currentPath,
}: UseLocalSftpBookmarksParams) => {
const bookmarks = useSyncExternalStore(
subscribeLocalSftpBookmarks,
getLocalSftpBookmarksSnapshot,
getLocalSftpBookmarksSnapshot,
);
const isCurrentPathBookmarked = useMemo(
() => !!currentPath && bookmarks.some((b) => b.path === currentPath),
[currentPath, bookmarks],
);
const toggleBookmark = useCallback(() => {
if (!currentPath) return;
if (isCurrentPathBookmarked) {
setLocalSftpBookmarks((prev) => prev.filter((b) => b.path !== currentPath));
} else {
setLocalSftpBookmarks((prev) => [...prev, createSftpBookmark(currentPath)]);
}
}, [currentPath, isCurrentPathBookmarked]);
const deleteBookmark = useCallback((id: string) => {
setLocalSftpBookmarks((prev) => prev.filter((b) => b.id !== id));
}, []);
return {
bookmarks,
isCurrentPathBookmarked,
toggleBookmark,
deleteBookmark,
};
};
@@ -6,7 +6,7 @@
*/
import { useSyncExternalStore, useEffect } from "react";
import { sftpFocusStore, SftpFocusedSide } from "./useSftpFocusedPane";
import { sftpFocusStore, SftpFocusedSide } from "./sftpFocusStore";
type SftpDialogActionType = "rename" | "delete" | "newFolder" | "newFile" | null;
@@ -1,25 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
DEFAULT_SFTP_TRANSFER_POOL_IDLE_TTL_MS,
isTransferPoolIdleReclaimDisabled,
resolveSftpTransferPoolIdleTtlMs,
} from "../../../infrastructure/config/sftpTransferPool.ts";
test("default pool idle TTL is five minutes", () => {
assert.equal(DEFAULT_SFTP_TRANSFER_POOL_IDLE_TTL_MS, 5 * 60_000);
assert.equal(resolveSftpTransferPoolIdleTtlMs(() => null), DEFAULT_SFTP_TRANSFER_POOL_IDLE_TTL_MS);
});
test("accepts preset values including never-reclaim zero", () => {
assert.equal(resolveSftpTransferPoolIdleTtlMs(() => 60_000), 60_000);
assert.equal(resolveSftpTransferPoolIdleTtlMs(() => 0), 0);
assert.equal(resolveSftpTransferPoolIdleTtlMs(() => 123), DEFAULT_SFTP_TRANSFER_POOL_IDLE_TTL_MS);
});
test("transfer pool idle reclaim helper is deprecated and always reclaim-enabled", () => {
// Channels close when idle; SSH park is separate. The helper always returns false.
assert.equal(isTransferPoolIdleReclaimDisabled(0), false);
assert.equal(isTransferPoolIdleReclaimDisabled(300_000), false);
});
-5
View File
@@ -111,9 +111,4 @@ export interface SftpStateOptions {
* This keeps side-panel tab switches on the already-authenticated SSH transport.
*/
resolveBrowseSourceSessionId?: (hostId: string, host?: Host) => string | undefined;
/**
* @deprecated Transfer channels no longer park independently. SSH keep-alive
* is controlled by sshTransportIdleTtlMs in settings.
*/
transferPoolIdleTtlMs?: number;
}
+2 -9
View File
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from "react";
import type { MutableRefObject } from "react";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import type { Host, Identity, KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
import { createKnownHostFromHostKeyInfo } from "../../../domain/knownHosts";
import type { SftpHostKeyInfo, SftpHostKeyVerificationState, SftpPane } from "./types";
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
import { useSftpHostCredentials } from "./useSftpHostCredentials";
@@ -206,15 +207,7 @@ const createKnownHostFromSftpHostKeyInfo = (
hostKeyInfo: SftpHostKeyInfo,
now = Date.now(),
idSuffix = Math.random().toString(36).slice(2, 11),
): KnownHost => ({
id: hostKeyInfo.knownHostId || `kh-${now}-${idSuffix}`,
hostname: hostKeyInfo.hostname,
port: hostKeyInfo.port || 22,
keyType: hostKeyInfo.keyType,
publicKey: hostKeyInfo.publicKey || `SHA256:${hostKeyInfo.fingerprint}`,
fingerprint: hostKeyInfo.fingerprint,
discoveredAt: now,
});
): KnownHost => createKnownHostFromHostKeyInfo(hostKeyInfo, { now, idSuffix });
export const useSftpConnections = ({
hosts,
@@ -0,0 +1,10 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const source = readFileSync(new URL("./useSftpPaneSorting.ts", import.meta.url), "utf8");
test("useSftpPaneSorting imports column layout from application state", () => {
assert.doesNotMatch(source, /from ["'].*components\//);
assert.match(source, /from ["']\.\/columnLayout["']/);
});
@@ -6,13 +6,13 @@ import {
type SftpColumnVisibility,
type SortField,
type SortOrder,
} from "../utils";
} from "./columnLayout";
import { STORAGE_KEY_SFTP_VISIBLE_COLUMNS } from "../../../infrastructure/config/storageKeys";
import {
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
localStorageAdapter,
} from "../../../infrastructure/persistence/localStorageAdapter";
import { useSftpDirectoriesFirst } from "../../../application/state/sftp/useSftpDirectoriesFirst";
import { useSftpDirectoriesFirst } from "./useSftpDirectoriesFirst";
export interface UseSftpPaneSortingResult {
sortField: SortField;
@@ -2,25 +2,6 @@ import { useEffect, useLayoutEffect, useRef } from "react";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
export function useWarmSftpTransferPool(params: {
hostIds: readonly string[];
activeHostId?: string;
warmTransferPoolForHost?: (hostId: string) => void | Promise<void>;
}) {
const warmRef = useRef(params.warmTransferPoolForHost);
warmRef.current = params.warmTransferPoolForHost;
const hostIdsKey = [...new Set([
...params.hostIds,
...(params.activeHostId ? [params.activeHostId] : []),
])].sort().join("\u0000");
useEffect(() => {
for (const hostId of hostIdsKey.split("\u0000").filter(Boolean)) {
void warmRef.current?.(hostId);
}
}, [hostIdsKey]);
}
export function useReportSftpTransferOwnerActivity(params: {
ownerId: string;
activeTransfersCount: number;
+7 -5
View File
@@ -1,11 +1,13 @@
import { SftpFileEntry, TransferTask } from "../../../domain/models";
import { formatBytes } from "../../../domain/formatBytes";
export const formatFileSize = (bytes: number): string => {
if (bytes === 0) return "--";
const units = ["Bytes", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const size = bytes / Math.pow(1024, i);
return `${size.toFixed(i === 0 ? 0 : 2)} ${units[i]}`;
return formatBytes(bytes, {
zeroDisplay: "--",
includeTB: true,
fractionDigits: 2,
byteUnit: "Bytes",
});
};
/**
+1 -1
View File
@@ -2318,7 +2318,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
const browserPersistence: StorePersistence | undefined = typeof globalThis.localStorage === "undefined"
? undefined
: {
read: () => globalThis.localStorage.getItem(STORAGE_KEY_SFTP_TRANSFER_CENTER),
read: () => localStorageAdapter.readString(STORAGE_KEY_SFTP_TRANSFER_CENTER),
write: (value) => { localStorageAdapter.writeString(STORAGE_KEY_SFTP_TRANSFER_CENTER, value); },
};
-9
View File
@@ -172,15 +172,6 @@ export const useUIFontsLoaded = (): boolean => {
);
};
/**
* Check if a font ID is valid
*/
export const isValidUiFontId = (fontId: string): boolean => {
// Local fonts are always considered valid (they start with 'local-')
if (fontId.startsWith('local-')) return true;
return uiFontStore.getAvailableFonts().some(f => f.id === fontId);
};
/**
* Initialize UI fonts eagerly
*/
@@ -0,0 +1,10 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const source = readFileSync(new URL("./useAIChatStreaming.ts", import.meta.url), "utf8");
test("useAIChatStreaming imports streaming support from infrastructure", () => {
assert.match(source, /from ['"]\.\.\/\.\.\/infrastructure\/ai\/aiChatStreamingSupport['"]/);
assert.doesNotMatch(source, /from ['"].*components\//);
});
@@ -15,26 +15,26 @@ import type {
ExternalAgentConfig,
ProviderConfig,
WebSearchConfig,
} from '../../../infrastructure/ai/types';
import type { ExecutorContext } from '../../../infrastructure/ai/cattyAgent/executor';
import { getAgentRuntime } from '../../../infrastructure/ai/harness/globalAgentRuntime';
} from '../../infrastructure/ai/types';
import type { ExecutorContext } from '../../infrastructure/ai/cattyAgent/executor';
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime';
import type {
TurnSteerInput,
TurnSteerResult,
} from '../../../infrastructure/ai/harness/turnDrivers/types';
import { classifyError } from '../../../infrastructure/ai/errorClassifier';
import { latestAISessionsSnapshot } from '../../../application/state/aiStateSnapshots';
} from '../../infrastructure/ai/harness/turnDrivers/types';
import { classifyError } from '../../infrastructure/ai/errorClassifier';
import { latestAISessionsSnapshot } from './aiStateSnapshots';
import {
generateId,
getNetcattyBridge,
type DefaultTargetSessionHint,
type TerminalSessionInfo,
} from './aiChatStreamingSupport';
} from '../../infrastructure/ai/aiChatStreamingSupport';
import { useAgentCompactionUi } from './useAgentCompactionUi';
export { getNetcattyBridge } from './aiChatStreamingSupport';
export { getNetcattyBridge } from '../../infrastructure/ai/aiChatStreamingSupport';
export type { ActiveCompactionUi } from './useAgentCompactionUi';
export type { DefaultTargetSessionHint } from './aiChatStreamingSupport';
export type { DefaultTargetSessionHint } from '../../infrastructure/ai/aiChatStreamingSupport';
const sharedStreamingSessionIds = new Set<string>();
const sharedAbortControllers = new Map<string, AbortController>();
@@ -62,7 +62,7 @@ export interface UseAIChatStreamingParams {
updateMessageById: (sessionId: string, messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
persistContextCompaction?: (
sessionId: string,
compaction: import('../../../infrastructure/ai/types').AISessionContextCompaction,
compaction: import('../../infrastructure/ai/types').AISessionContextCompaction,
) => void;
}
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import type { ContextPrepareTrigger } from '../../../infrastructure/ai/harness/types';
import { getAgentRuntime } from '../../../infrastructure/ai/harness/globalAgentRuntime';
import { CATTY_COMPACTION_STATUS_KEYS } from '../../../infrastructure/ai/harness/compactionStatusKeys';
import type { ContextPrepareTrigger } from '../../infrastructure/ai/harness/types';
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime';
import { CATTY_COMPACTION_STATUS_KEYS } from '../../infrastructure/ai/harness/compactionStatusKeys';
export interface ActiveCompactionUi {
sessionId: string;
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { buildAITerminalSessionInfo } from '../../components/terminalLayer/TerminalLayerSupport';
import { buildAITerminalSessionInfo } from '../../domain/buildAITerminalSessionInfo';
import { detectLocalOs } from '../../lib/localShell';
import type { Host, PortForwardingRule, TerminalSession } from '../../types';
import { STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED } from '../../infrastructure/config/storageKeys';
@@ -144,12 +144,26 @@ export function readExternalMcpMode(): ExternalMcpMode {
return normalizeExternalMcpMode(localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE));
}
export function writeExternalMcpMode(mode: ExternalMcpMode): ExternalMcpMode {
const normalized = normalizeExternalMcpMode(mode);
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, normalized);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_MODE);
return normalized;
}
export function readExternalMcpIdleTimeoutMinutes(): number {
return normalizeExternalMcpIdleTimeoutMinutes(
localStorageAdapter.readNumber(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES),
);
}
export function writeExternalMcpIdleTimeoutMinutes(minutes: number): number {
const normalized = normalizeExternalMcpIdleTimeoutMinutes(minutes);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES, normalized);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES);
return normalized;
}
/** Whether host_open should surface/focus the main window. Defaults to true (existing behavior). */
export function readExternalMcpFocusOnHostOpen(): boolean {
return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN) ?? true;
@@ -187,6 +201,13 @@ export function readSessionIdleTimeoutMinutes(): number {
);
}
export function writeSessionIdleTimeoutMinutes(minutes: number): number {
const normalized = normalizeSessionIdleTimeoutMinutes(minutes);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES, normalized);
emitAIStateChanged(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES);
return normalized;
}
export function shouldStartExternalMcpOnStartup({
enabled,
mode,
+3
View File
@@ -782,6 +782,9 @@ export function useOutputTriggers({
resetTriggerScanState();
},
schedule: (callback) => {
// Prefer setTimeout over rAF: terminal output can continue while the
// Electron renderer is hidden/minimized, when rAF is throttled or paused.
// Pending scan buffers are capped, so delayed flushes can drop matches.
const timer = globalThis.setTimeout(callback, OUTPUT_TRIGGER_SCAN_DELAY_MS);
return () => globalThis.clearTimeout(timer);
},
@@ -0,0 +1,10 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const source = readFileSync(new URL("./usePortForwardHostKeyVerification.ts", import.meta.url), "utf8");
test("usePortForwardHostKeyVerification stays below the UI layer", () => {
assert.doesNotMatch(source, /from ["'].*components\//);
assert.match(source, /from ["']\.\.\/\.\.\/domain\/portForwardHostKey["']/);
});
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import type { KnownHost } from "../../domain/models";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
import type { HostKeyInfo } from "../../components/terminal/TerminalHostKeyVerification";
import type { HostKeyInfo } from "../../domain/hostKey";
import {
createKnownHostFromPortForwardHostKeyInfo,
enqueuePortForwardHostKeyVerification,
@@ -9,7 +9,7 @@ import {
toPendingPortForwardHostKeyVerification,
type PendingPortForwardHostKeyVerification,
type PortForwardHostKeyRequest,
} from "../../components/port-forwarding/hostKeyVerification";
} from "../../domain/portForwardHostKey";
export interface PortForwardHostKeyVerificationState {
hostKeyInfo: HostKeyInfo;
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../../infrastructure/services/netcattyBridge';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export interface DiskInfo {
capacityKey?: string; // Filesystem or shared-pool identity for deduplication
+15 -6
View File
@@ -619,17 +619,26 @@ export const useSessionState = ({
});
}, []);
const submitWorkspaceRename = useCallback(() => {
const submitWorkspaceRename = useCallback((workspaceId?: string, name?: string) => {
if (workspaceId !== undefined && name !== undefined) {
const trimmed = name.trim();
if (!trimmed) return;
setWorkspaces(prev => prev.map(w => (
w.id === workspaceId ? { ...w, title: trimmed, autoTitle: false } : w
)));
return;
}
setWorkspaceRenameValue(prevValue => {
const name = prevValue.trim();
if (!name) return prevValue;
const trimmed = prevValue.trim();
if (!trimmed) return prevValue;
setWorkspaceRenameTarget(prevTarget => {
if (!prevTarget) return prevTarget;
setWorkspaces(prev => prev.map(w => w.id === prevTarget.id ? { ...w, title: name, autoTitle: false } : w));
setWorkspaces(prev => prev.map(w => w.id === prevTarget.id ? { ...w, title: trimmed, autoTitle: false } : w));
return null;
});
return '';
});
}, []);
+4 -1
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
import { runThemeTransition, type ThemeTransitionMode } from './themeTransition';
import { publishAppearanceChromeSnapshot } from './appearanceChromeStore';
import { SyncConfig, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
import {
DEFAULT_HTTP_NETWORK_PROXY,
@@ -967,6 +968,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
const persistedAppearanceChanged = previousAppearance === null
|| hasPersistedAppearanceChanged(previousAppearance, appearanceRender);
previousAppearanceRenderRef.current = appearanceRender;
// Terminal leaves subscribe here so accent drag does not rebuild TerminalLayer.
publishAppearanceChromeSnapshot({ accentMode, customAccent });
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
const apply = () => applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
const transitionMode = appearanceTransitionModeRef.current;
@@ -1841,7 +1844,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
// Opaque version that changes when any synced setting changes, used by useAutoSync.
// eslint-disable-next-line react-hooks/exhaustive-deps
settingsVersion: useMemo(() => Math.random(), [
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
theme, lightUiThemeId, darkUiThemeId, accentMode,
uiFontFamilyId, uiLanguage, customCSS,
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
customKeyBindings, editorWordWrap,
-11
View File
@@ -399,14 +399,6 @@ export const useSftpState = (
[hosts, identities, keys, openPoolSftpSession, transferKnownHosts, transferPoolKeyCache, transferTerminalSettings],
);
/**
* @deprecated No-op. SSH transport idle park keeps connections warm; opening
* a background transfer channel is unnecessary and could re-trigger MFA.
*/
const warmTransferPoolForHost = useCallback(async (_hostId: string) => {
// Intentionally empty — unified transport registry owns keep-alive.
}, []);
/** True after browse channels were soft-closed while this owner stayed mounted. */
const browseParkedRef = useRef(false);
const browseLifecycleGenRef = useRef(0);
@@ -824,7 +816,6 @@ export const useSftpState = (
rejectHostKeyVerification,
acceptHostKeyVerification,
acceptAndSaveHostKeyVerification,
warmTransferPoolForHost,
};
const methodsRef = useRef(currentMethods);
methodsRef.current = currentMethods;
@@ -907,8 +898,6 @@ export const useSftpState = (
rejectHostKeyVerification: () => methodsRef.current.rejectHostKeyVerification(),
acceptHostKeyVerification: () => methodsRef.current.acceptHostKeyVerification(),
acceptAndSaveHostKeyVerification: () => methodsRef.current.acceptAndSaveHostKeyVerification(),
warmTransferPoolForHost: (...args: Parameters<typeof warmTransferPoolForHost>) =>
methodsRef.current.warmTransferPoolForHost(...args),
activeFileWatchCountRef,
}), [activeFileWatchCountRef]); // activeFileWatchCountRef is a stable ref
@@ -1,10 +1,10 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useI18n } from '../../../application/i18n/I18nProvider';
import type { I18nContextValue } from '../../../application/i18n/I18nProvider';
import { sessionCapabilitiesStore } from '../../../application/state/sessionCapabilitiesStore';
import type { SessionCapabilities } from '../../../domain/systemManager/types';
import type { useSystemManagerBackend } from '../../../application/state/useSystemManagerBackend';
import { nextPollData } from '../listStable';
import { useI18n } from '../i18n/I18nProvider';
import type { I18nContextValue } from '../i18n/I18nProvider';
import { sessionCapabilitiesStore } from './sessionCapabilitiesStore';
import type { SessionCapabilities } from '../../domain/systemManager/types';
import type { useSystemManagerBackend } from './useSystemManagerBackend';
import { nextPollData } from '../../domain/systemManager/pollListStable';
type Backend = ReturnType<typeof useSystemManagerBackend>;
@@ -0,0 +1,10 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const source = readFileSync(new URL("./useTerminalAiContexts.ts", import.meta.url), "utf8");
test("useTerminalAiContexts imports AI context primitives from domain", () => {
assert.match(source, /from ['"]\.\.\/\.\.\/domain\/buildAITerminalSessionInfo['"]/);
assert.doesNotMatch(source, /from ['"].*components\//);
});
@@ -2,11 +2,14 @@ import type React from 'react';
import { useCallback, useMemo, useRef } from 'react';
import { retainStableAiPanelContexts } from '../../domain/aiPanelContextsEqual';
import {
buildAITerminalSessionInfo,
type AIPanelContext,
} from '../../domain/buildAITerminalSessionInfo';
import { collectSessionIds } from '../../domain/workspace';
import type { TerminalContextReader } from '../../domain/terminalContextRead';
import { detectLocalOs } from '../../lib/localShell';
import type { Host, PortForwardingRule, TerminalSession, Workspace } from '../../types';
import { buildAITerminalSessionInfo, type AIPanelContext } from './TerminalLayerSupport';
interface UseTerminalAiContextsOptions {
hosts: Host[];
+22 -7
View File
@@ -1,4 +1,4 @@
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { TerminalTheme } from '../../domain/models';
import {
@@ -42,7 +42,9 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
const [userIntent, setUserIntent] = useState<ThemeUserIntent>(idleThemeUserIntent());
const appearanceSettings = useMemo((): TerminalAppearanceSettings => ({
// Theme-id settings without accent — keeps resolveFocusedAppearance identity
// stable during color-picker drag (TerminalLayer memo depends on it).
const appearanceSettingsBase = useMemo((): Omit<TerminalAppearanceSettings, 'accentMode' | 'customAccent'> => ({
terminalThemeId,
terminalThemeDarkId,
terminalThemeLightId,
@@ -50,8 +52,6 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
resolvedTheme,
lightUiThemeId,
darkUiThemeId,
accentMode,
customAccent,
}), [
terminalThemeId,
terminalThemeDarkId,
@@ -60,9 +60,17 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
resolvedTheme,
lightUiThemeId,
darkUiThemeId,
]);
const accentRef = useRef({ accentMode, customAccent });
accentRef.current = { accentMode, customAccent };
// Chrome / injection consumers need live accent on every settings change.
const appearanceSettings = useMemo((): TerminalAppearanceSettings => ({
...appearanceSettingsBase,
accentMode,
customAccent,
]);
}), [appearanceSettingsBase, accentMode, customAccent]);
const globalAppearance = useMemo(() => resolveGlobalTerminalAppearance({
userIntent,
@@ -70,14 +78,21 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
customThemes,
}), [userIntent, appearanceSettings, customThemes]);
// Read accent from a ref so this callback identity does not churn on drag.
// Callers that invoke it after an accent-only update still get the latest
// accent; Terminal panes also re-apply appearanceChromeStore at the leaf.
const resolveFocusedAppearance = useCallback((hostScope: TerminalAppearanceHostScope): ResolvedAppearance => (
resolveTerminalAppearance({
userIntent,
settings: appearanceSettings,
settings: {
...appearanceSettingsBase,
accentMode: accentRef.current.accentMode,
customAccent: accentRef.current.customAccent,
},
hostScope,
customThemes,
})
), [userIntent, appearanceSettings, customThemes]);
), [userIntent, appearanceSettingsBase, customThemes]);
const applyFollowAppSettingsForPick = useCallback((themeId: string) => {
const update = getFollowAppTerminalThemeSelectionUpdate(themeId);
@@ -0,0 +1,12 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const source = readFileSync(new URL("./useVaultImportHandlers.ts", import.meta.url), "utf8");
test("useVaultImportHandlers stays below the UI layer", () => {
assert.doesNotMatch(source, /from ["'].*components\//);
assert.doesNotMatch(source, /from ["'].*\/toast["']/);
assert.match(source, /notify: VaultImportNotifier/);
assert.match(source, /from ["']\.\/vaultImportOptions["']/);
});
@@ -4,7 +4,7 @@ import {
readRememberedKeyPassphrases,
rememberImportedKeyPassphrase,
resolveDefaultKeyPassphraseAliases,
} from "../../application/defaultKeyPassphrases";
} from "../defaultKeyPassphrases";
import {
countVaultImportDuplicates,
ensureVaultImportPersisted,
@@ -15,12 +15,12 @@ import {
waitForVaultImportProgressPaint,
type VaultHostPersistenceResult,
type VaultImportProgress,
} from "../../application/state/vaultImportProgress";
import { importVaultHostsInWorker } from "../../application/state/vaultImportWorker";
} from "./vaultImportProgress";
import { importVaultHostsInWorker } from "./vaultImportWorker";
import {
type VaultLockHandle,
withVaultImportLock,
} from "../../application/state/vaultManagedImportLock";
} from "./vaultManagedImportLock";
/** Exit the import lock so a concurrent host save can finish, then retry. */
const RETRY_VAULT_IMPORT_AFTER_CONCURRENT_EDIT = Symbol("retry-vault-import-after-concurrent-edit");
@@ -36,14 +36,14 @@ import {
type VaultImportFormat,
} from "../../domain/vaultImport";
import type { GroupConfig, Host, ManagedSource, SSHKey } from "../../types";
import type { ImportOptions } from "./ImportVaultDialog";
import { toast } from "../ui/toast";
import type { VaultImportNotifier, VaultImportOptions } from "./vaultImportOptions";
interface UseVaultImportHandlersOptions {
customGroups: string[];
hosts: Host[];
keys: SSHKey[];
managedSources: ManagedSource[];
notify: VaultImportNotifier;
onReadPersistedHosts: () => Promise<Host[]>;
onUpdateHosts: (hosts: Host[]) => VaultHostPersistenceResult | Promise<VaultHostPersistenceResult>;
onUpdateKeys: (keys: SSHKey[]) => void;
@@ -73,6 +73,7 @@ export function useVaultImportHandlers({
hosts,
keys,
managedSources,
notify,
onReadPersistedHosts,
onUpdateHosts,
onUpdateKeys,
@@ -109,7 +110,7 @@ export function useVaultImportHandlers({
}, [setIsImportOpen]);
const handleImportFileSelected = useCallback(
async (format: VaultImportFormat, files: File[], options?: ImportOptions) => {
async (format: VaultImportFormat, files: File[], options?: VaultImportOptions) => {
const file = files[0];
if (!file) return;
if (importInFlightRef.current) return;
@@ -287,7 +288,7 @@ export function useVaultImportHandlers({
stage: "failed",
error: message,
});
toast.error(
notify.error(
message,
t("vault.import.sshConfig.noFilePath"),
);
@@ -305,7 +306,7 @@ export function useVaultImportHandlers({
stage: "failed",
error: message,
});
toast.error(
notify.error(
message,
t("vault.import.sshConfig.alreadyManaged"),
);
@@ -601,7 +602,7 @@ export function useVaultImportHandlers({
stage: "failed",
error: message,
});
toast.error(
notify.error(
message,
t("vault.import.toast.failedTitle"),
);
@@ -617,7 +618,7 @@ export function useVaultImportHandlers({
skipped,
duplicates,
});
toast.warning(
notify.warning(
t("vault.import.toast.noNewHosts", { format: formatLabel }),
t("vault.import.toast.completedTitle"),
);
@@ -628,7 +629,7 @@ export function useVaultImportHandlers({
rollbackSnapshot = null;
if (isManaged) {
toast.success(
notify.success(
t("vault.import.sshConfig.managedSuccess", { count: totalAffected }),
t("vault.import.toast.completedTitle"),
);
@@ -641,12 +642,12 @@ export function useVaultImportHandlers({
if (hasWarnings) {
const firstIssue = result.issues[0]?.message;
toast.warning(
notify.warning(
firstIssue ? `${details} ${t("vault.import.toast.firstIssue", { issue: firstIssue })}` : details,
t("vault.import.toast.completedTitle"),
);
} else {
toast.success(details, t("vault.import.toast.completedTitle"));
notify.success(details, t("vault.import.toast.completedTitle"));
}
}
updateProgress({
@@ -684,7 +685,7 @@ export function useVaultImportHandlers({
stage: "failed",
error: message,
});
toast.error(message, t("vault.import.toast.failedTitle"));
notify.error(message, t("vault.import.toast.failedTitle"));
} finally {
if (activeImportAbortRef.current === abortController) {
activeImportAbortRef.current = null;
@@ -694,6 +695,7 @@ export function useVaultImportHandlers({
}
},
[
notify,
onReadPersistedHosts,
onCommitVaultImportTransaction,
onReadPersistedManagedSources,
+34 -4
View File
@@ -58,6 +58,7 @@ import {
} from "../../domain/globalHistory";
import {
buildTerminalDataMapFromLogs,
connectionLogTerminalDataMapsEqual,
mergeConnectionLogsFromStorage,
mergeTerminalDataIntoLogs,
mergeTerminalDataMapsForStorage,
@@ -65,6 +66,10 @@ import {
} from "../../domain/connectionLogTerminalData";
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
import { loadSanitizedShellHistory } from "./shellHistoryPersistence";
import {
publishNotesSnapshot,
registerNotesActions,
} from "./notesStore";
import { publishShellHistorySnapshot } from "./shellHistoryStore";
import { setVaultInitialized } from "./vaultInitStore";
import {
@@ -257,7 +262,7 @@ const writeConnectionLogTerminalDataMap = (
localMaps,
buildPersistedLogIds(logs, usePersistedLogIdsFromDisk),
);
if (JSON.stringify(existing) === JSON.stringify(pruned)) return true;
if (connectionLogTerminalDataMapsEqual(existing, pruned)) return true;
return localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA, pruned);
};
@@ -280,9 +285,13 @@ export const useVaultState = () => {
const customGroupsRef = useRef<string[]>([]);
const managedSourcesRef = useRef<ManagedSource[]>([]);
const hostsRef = useRef<Host[]>([]);
const notesRef = useRef<VaultNote[]>([]);
const noteGroupsRef = useRef<string[]>([]);
customGroupsRef.current = customGroups;
managedSourcesRef.current = managedSources;
hostsRef.current = hosts;
notesRef.current = notes;
noteGroupsRef.current = noteGroups;
// Write-version counters prevent out-of-order async writes from overwriting
// newer data. Each update bumps the counter; the .then() callback only
@@ -591,14 +600,20 @@ export const useVaultState = () => {
const updateNotes = useCallback((data: Partial<VaultNote>[]) => {
const cleaned = normalizeVaultNotes(data);
notesRef.current = cleaned;
setNotes(cleaned);
localStorageAdapter.write(STORAGE_KEY_NOTES, cleaned);
// Publish synchronously so Notes→AI handoff does not read a stale catalog
// while React state is still committing (useLayoutEffect republishes too).
publishNotesSnapshot({ notes: cleaned, noteGroups: noteGroupsRef.current });
}, []);
const updateNoteGroups = useCallback((data: unknown) => {
const cleaned = normalizeNoteGroups(data);
noteGroupsRef.current = cleaned;
setNoteGroups(cleaned);
localStorageAdapter.write(STORAGE_KEY_NOTE_GROUPS, cleaned);
publishNotesSnapshot({ notes: notesRef.current, noteGroups: cleaned });
}, []);
const updateCustomGroups = useCallback((
@@ -828,6 +843,18 @@ export const useVaultState = () => {
publishShellHistorySnapshot(shellHistory);
}, [shellHistory]);
// Notes catalog for Notes / AI side panels — keep TerminalLayer off the hot path.
useLayoutEffect(() => {
publishNotesSnapshot({ notes, noteGroups });
}, [notes, noteGroups]);
useLayoutEffect(() => {
registerNotesActions({ updateNotes, updateNoteGroups });
return () => {
registerNotesActions(null);
};
}, [updateNotes, updateNoteGroups]);
const addShellHistoryEntry = useCallback(
(entry: Omit<ShellHistoryEntry, "id" | "timestamp">) => {
setShellHistory((prev) => {
@@ -1375,9 +1402,12 @@ export const useVaultState = () => {
const updateHostLastConnected = useCallback((hostId: string) => {
setHosts((prev) => {
const next = prev.map((h) =>
h.id === hostId ? { ...h, lastConnectedAt: Date.now() } : h,
);
const idx = prev.findIndex((h) => h.id === hostId);
if (idx < 0) return prev;
const now = Date.now();
if (prev[idx].lastConnectedAt === now) return prev;
const next = prev.slice();
next[idx] = { ...prev[idx], lastConnectedAt: now };
const ver = ++hostsWriteVersion.current;
const encryptPromise = encryptHosts(next);
hostsEncryptPendingRef.current = encryptPromise.then(() => undefined);
+17
View File
@@ -0,0 +1,17 @@
import type { VaultImportDestination } from "../../domain/vaultImport";
import type { VaultImportFileEncoding } from "./vaultImportFile";
/** Options for a vault host-file import (kept out of UI modules). */
export type VaultImportOptions = {
managed?: boolean;
filePath?: string;
encoding?: VaultImportFileEncoding;
destination?: VaultImportDestination;
};
/** Notification sink so application/state hooks do not import UI toast. */
export type VaultImportNotifier = {
success: (message: string, title?: string) => void;
error: (message: string, title?: string) => void;
warning: (message: string, title?: string) => void;
};
+2 -2
View File
@@ -49,8 +49,8 @@ interface AIChatPanelContentProps {
handleDeleteSession: (event: React.MouseEvent, sessionId: string) => void;
messages: ChatMessage[];
isStreaming: boolean;
activeCompaction?: import('./ai/hooks/useAgentCompactionUi').ActiveCompactionUi | null;
contextUsage?: import('./ai/hooks/useAgentCompactionUi').AgentContextUsage | null;
activeCompaction?: import('../application/state/useAgentCompactionUi').ActiveCompactionUi | null;
contextUsage?: import('../application/state/useAgentCompactionUi').AgentContextUsage | null;
inputValue: string;
setInputValue: (value: string) => void;
handleSend: () => void;
@@ -252,3 +252,68 @@ test('AI side panel skips re-render when only a sibling scope session object cha
false,
);
});
test('AI side panel skips re-render when sibling stream only bumps updatedAt', () => {
const own = session({
id: 'session-1',
scope: { type: 'terminal', targetId: 'terminal-1' },
updatedAt: 10,
});
const siblingA = session({
id: 'session-2',
scope: { type: 'terminal', targetId: 'terminal-2' },
title: 'Sibling',
updatedAt: 10,
});
const siblingStreamed = session({
id: 'session-2',
scope: { type: 'terminal', targetId: 'terminal-2' },
title: 'Sibling',
updatedAt: 99,
});
const prev = baseProps({
isVisible: true,
scopeType: 'terminal',
scopeTargetId: 'terminal-1',
sessions: [own, siblingA],
});
assert.equal(
aiChatSidePanelPropsAreEqual(prev, {
...prev,
sessions: [own, siblingStreamed],
}),
true,
);
});
test('AI side panel re-renders when sibling history chrome title changes', () => {
const own = session({
id: 'session-1',
scope: { type: 'terminal', targetId: 'terminal-1' },
});
const siblingA = session({
id: 'session-2',
scope: { type: 'terminal', targetId: 'terminal-2' },
title: 'Old',
updatedAt: 10,
});
const siblingRenamed = session({
id: 'session-2',
scope: { type: 'terminal', targetId: 'terminal-2' },
title: 'New',
updatedAt: 10,
});
const prev = baseProps({
isVisible: true,
scopeType: 'terminal',
scopeTargetId: 'terminal-1',
sessions: [own, siblingA],
});
assert.equal(
aiChatSidePanelPropsAreEqual(prev, {
...prev,
sessions: [own, siblingRenamed],
}),
false,
);
});
+17 -6
View File
@@ -49,7 +49,7 @@ import {
getNetcattyBridge,
isAIChatSessionStreaming,
type DefaultTargetSessionHint,
} from './ai/hooks/useAIChatStreaming';
} from '../application/state/useAIChatStreaming';
import { getScopedHistorySessions } from './ai/scopedHistorySessions';
import { aiSessionIdSetEqual, exactScopeAISessionsEqual } from '../domain/aiSessionsForScope';
import { buildExternalAgentHistoryMessagesForBridge } from './ai/externalAgentHistory';
@@ -60,7 +60,7 @@ import { stopAgentTurn } from '../infrastructure/ai/harness/agentStop';
import { getAgentRuntime } from '../infrastructure/ai/harness/globalAgentRuntime';
import { useAIPermissionGrantsState } from '../application/state/useAIPermissionGrantsState';
import { useConversationExport } from './ai/hooks/useConversationExport';
import { useAgentContextUsage } from './ai/hooks/useAgentCompactionUi';
import { useAgentContextUsage } from '../application/state/useAgentCompactionUi';
import type { AIChatSidePanelProps } from './AIChatSidePanel.types';
import {
buildCursorListModelsAgentEnv,
@@ -1599,12 +1599,23 @@ export function aiChatSidePanelPropsAreEqual(
)) {
return false;
}
// History drawer / recent list need create/delete/title/updatedAt chrome.
// Only when the panel is (or becomes) visible so hidden retained panels do
// not re-render on every sibling stream that bumps updatedAt.
// History drawer / recent list need create/delete/title chrome for the full
// list (fuzzy ranking still receives every session). updatedAt is compared
// for this panel's exact scope (+ selected resume) and, for workspace
// panels, sibling workspace timestamps so history sort/relative time stay
// fresh — without letting unrelated terminal stream ticks invalidate.
const prevVisible = prev.isVisible ?? true;
const nextVisible = next.isVisible ?? true;
if ((prevVisible || nextVisible) && !aiSessionIdSetEqual(prev.sessions, next.sessions)) {
if (
(prevVisible || nextVisible)
&& !aiSessionIdSetEqual(
prev.sessions,
next.sessions,
prev.scopeType,
prev.scopeTargetId,
selectedSessionId,
)
) {
return false;
}
@@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { getKnownHostsGridColumnCount } from "./knownHostsGridLayout.ts";
test("known-host grids use container-width column steps", () => {
assert.equal(getKnownHostsGridColumnCount(400), 2);
assert.equal(getKnownHostsGridColumnCount(640), 3);
assert.equal(getKnownHostsGridColumnCount(1000), 3);
assert.equal(getKnownHostsGridColumnCount(1280), 4);
assert.equal(getKnownHostsGridColumnCount(1800), 4);
});
test("virtualized and non-virtual known hosts share container column policy", () => {
const source = readFileSync(new URL("./KnownHostsManager.tsx", import.meta.url), "utf8");
assert.match(source, /getColumnCount=\{\(width, mode\) =>/);
assert.match(source, /getKnownHostsGridColumnCount\(width\)/);
assert.match(source, /getElementContentWidth\(el\)/);
assert.match(source, /gridTemplateColumns: `repeat\(\$\{gridColumns\}, minmax\(0, 1fr\)\)`/);
assert.doesNotMatch(source, /sm:grid-cols-3|xl:grid-cols-4/);
assert.doesNotMatch(source, /getKnownHostsGridColumnCount\(el\.clientWidth\)/);
});
test("virtualized known-host cards expose a focus target for keyboard nav", () => {
const source = readFileSync(new URL("./KnownHostsManager.tsx", import.meta.url), "utf8");
// Grid and list HostItem roots both need a focus target for VirtualizedHostCollection.
assert.equal([...source.matchAll(/data-vault-focus-target/g)].length, 2);
assert.equal([...source.matchAll(/tabIndex=\{0\}/g)].length, 2);
});
+111 -89
View File
@@ -43,7 +43,6 @@ import {
ContextMenuTrigger,
} from "./ui/context-menu";
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
import { ScrollArea } from "./ui/scroll-area";
import { SortDropdown, SortMode } from "./ui/sort-dropdown";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { toast } from "./ui/toast";
@@ -55,8 +54,13 @@ import {
} from "./vault/VaultPageHeader";
import { VaultDeleteConfirmDialog } from "./vault/VaultDeleteConfirmDialog";
import { VaultEntityIcon, vaultPrimaryIconClass } from "./vault/VaultEntityIcon";
import { VirtualizedHostCollection } from "./vault/VirtualizedHostCollection";
import { getKnownHostsGridColumnCount } from "./knownHostsGridLayout";
import { getElementContentWidth } from "./vault/vaultHostGridLayout";
import { useVaultItemReorder } from "./vault/vaultReorderDrag";
const VIRTUALIZE_THRESHOLD = 30;
interface KnownHostsManagerProps {
knownHosts: KnownHost[];
hosts: Host[];
@@ -207,6 +211,8 @@ const HostItem = React.memo<HostItemProps>(
<ContextMenuTrigger asChild>
<div
{...reorderProps}
data-vault-focus-target
tabIndex={0}
className={cn(
reorderProps && "vault-drop-indicator-row",
"group cursor-pointer soft-card elevate rounded-xl h-[68px] px-3 py-2",
@@ -256,6 +262,8 @@ const HostItem = React.memo<HostItemProps>(
<ContextMenuTrigger asChild>
<div
{...reorderProps}
data-vault-focus-target
tabIndex={0}
className={cn(
reorderProps && "vault-drop-indicator-row",
"group flex items-center gap-3 px-3 py-2 h-14 rounded-lg hover:bg-secondary/60 transition-colors cursor-pointer",
@@ -328,8 +336,8 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
const [sortMode, setSortMode] = useState<SortMode>("manual");
const fileInputRef = React.useRef<HTMLInputElement>(null);
const hasScannedRef = React.useRef(false);
const listRef = React.useRef<HTMLDivElement | null>(null);
const RENDER_LIMIT = 100; // Limit rendered items for performance
const scrollRef = React.useRef<HTMLDivElement | null>(null);
const [gridColumns, setGridColumns] = useState(2);
// Define handleScanSystem before useEffect that depends on it
const handleScanSystem = useCallback(async (silent = false) => {
@@ -454,12 +462,25 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
return sortMode === "manual" ? sortByVaultOrder(result) : result;
}, [knownHosts, deferredSearch, sortMode]);
// Limit rendered items for performance
const displayedHosts = useMemo(() => {
return filteredHosts.slice(0, RENDER_LIMIT);
}, [filteredHosts]);
const shouldVirtualize = filteredHosts.length >= VIRTUALIZE_THRESHOLD;
const hasMore = filteredHosts.length > RENDER_LIMIT;
useEffect(() => {
const el = scrollRef.current;
if (!el || viewMode !== "grid" || typeof ResizeObserver === "undefined") return;
const recompute = () => {
setGridColumns(getKnownHostsGridColumnCount(getElementContentWidth(el)));
};
recompute();
const observer = new ResizeObserver(recompute);
observer.observe(el);
return () => observer.disconnect();
}, [viewMode, filteredHosts.length]);
const collectionLayoutKey = [
viewMode,
deferredSearch.trim() ? "search" : "browse",
sortMode,
filteredHosts.length,
].join("|");
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
@@ -541,22 +562,22 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
const openFilePicker = useCallback(() => fileInputRef.current?.click(), []);
const knownHostReorder = useVaultItemReorder({
containerRef: listRef,
containerRef: scrollRef,
viewMode,
dragType: "known-host-id",
targetAttribute: "data-known-host-id",
disabled: deferredSearch.trim().length > 0,
// Virtualized grid cards can unmount mid-drag — use indicators, not live reorder.
preferDropIndicator: shouldVirtualize,
onReorder: (sourceId, targetId, position) => {
onReorder(reorderVaultItems(knownHosts, sourceId, targetId, position));
setSortMode("manual");
},
});
// Memoize the rendered list to prevent re-renders
const renderedItems = useMemo(() => {
return displayedHosts.map((knownHost) => (
const renderKnownHostItem = useCallback(
(knownHost: KnownHost) => (
<HostItem
key={knownHost.id}
knownHost={knownHost}
converted={convertedMap.get(knownHost.id) || false}
viewMode={viewMode}
@@ -564,15 +585,15 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
onDelete={handleDelete}
onConvertToHost={handleConvertToHost}
/>
));
}, [
displayedHosts,
convertedMap,
viewMode,
handleDelete,
handleConvertToHost,
knownHostReorder,
]);
),
[
convertedMap,
viewMode,
handleDelete,
handleConvertToHost,
knownHostReorder,
],
);
return (
<div className="h-full flex flex-col">
@@ -657,74 +678,75 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
</VaultPageHeader>
{/* Content */}
<ScrollArea className="flex-1">
<div
ref={listRef}
className={cn(
"p-4",
viewMode === "grid"
? "grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-4 gap-3"
: "flex flex-col gap-0",
)}
onDragOverCapture={knownHostReorder.handleDragOverCapture}
onDragOver={knownHostReorder.handleDragOver}
onDropCapture={knownHostReorder.handleDropCapture}
onDragEndCapture={knownHostReorder.handleDragEndCapture}
>
{displayedHosts.length === 0 ? (
<div
className={cn(
"flex flex-col items-center justify-center py-16 text-muted-foreground",
viewMode === "grid" && "col-span-full",
)}
>
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
<Shield size={32} className="opacity-60" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">
{t("knownHosts.empty.title")}
</h3>
<p className="text-sm text-center max-w-sm mb-4">
{t("knownHosts.empty.desc")}
</p>
<div className="flex gap-2">
<Button
variant="secondary"
onClick={() => handleScanSystem()}
disabled={isScanning}
>
<RefreshCw
size={14}
className={cn("mr-2", isScanning && "animate-spin")}
/>
{t("knownHosts.action.scanSystem")}
</Button>
<Button variant="outline" onClick={openFilePicker}>
<FolderOpen size={14} className="mr-2" />
{t("knownHosts.action.browseFile")}
</Button>
</div>
<div
ref={scrollRef}
className="flex-1 overflow-auto p-4"
onDragOverCapture={knownHostReorder.handleDragOverCapture}
onDragOver={knownHostReorder.handleDragOver}
onDropCapture={knownHostReorder.handleDropCapture}
onDragEndCapture={knownHostReorder.handleDragEndCapture}
>
{filteredHosts.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
<Shield size={32} className="opacity-60" />
</div>
) : (
<>
{renderedItems}
{hasMore && (
<div
className={cn(
"text-center py-4 text-sm text-muted-foreground",
viewMode === "grid" && "col-span-full",
)}
>
{t("knownHosts.results.showingLimited", {
shown: displayedHosts.length,
total: filteredHosts.length,
})}
</div>
)}
</>
)}
</div>
</ScrollArea>
<h3 className="text-lg font-semibold text-foreground mb-2">
{t("knownHosts.empty.title")}
</h3>
<p className="text-sm text-center max-w-sm mb-4">
{t("knownHosts.empty.desc")}
</p>
<div className="flex gap-2">
<Button
variant="secondary"
onClick={() => handleScanSystem()}
disabled={isScanning}
>
<RefreshCw
size={14}
className={cn("mr-2", isScanning && "animate-spin")}
/>
{t("knownHosts.action.scanSystem")}
</Button>
<Button variant="outline" onClick={openFilePicker}>
<FolderOpen size={14} className="mr-2" />
{t("knownHosts.action.browseFile")}
</Button>
</div>
</div>
) : shouldVirtualize ? (
<VirtualizedHostCollection
items={filteredHosts}
itemKey={(knownHost) => knownHost.id}
scrollRef={scrollRef}
viewMode={viewMode}
layoutKey={collectionLayoutKey}
ariaLabel={t("vault.nav.knownHosts")}
getColumnCount={(width, mode) => (
mode === "grid" ? getKnownHostsGridColumnCount(width) : 1
)}
renderItem={renderKnownHostItem}
/>
) : (
<div
className={cn(
viewMode === "grid"
? "grid gap-3"
: "flex flex-col gap-0",
)}
style={viewMode === "grid" ? {
gridTemplateColumns: `repeat(${gridColumns}, minmax(0, 1fr))`,
} : undefined}
>
{filteredHosts.map((knownHost) => (
<React.Fragment key={knownHost.id}>
{renderKnownHostItem(knownHost)}
</React.Fragment>
))}
</div>
)}
</div>
<VaultDeleteConfirmDialog
open={Boolean(deleteTargetId)}
title={t("vault.deleteConfirm.title", {
+1 -8
View File
@@ -27,7 +27,6 @@ import { useI18n } from "../application/i18n/I18nProvider";
import { useSftpState } from "../application/state/useSftpState";
import {
useReportSftpTransferOwnerActivity,
useWarmSftpTransferPool,
} from "../application/state/sftp/useSftpTransferLifecycle";
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
import {
@@ -61,7 +60,7 @@ import { SftpContextProvider } from "./sftp";
import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks";
import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs";
import { useSftpKeyboardShortcuts } from "./sftp/hooks/useSftpKeyboardShortcuts";
import { sftpFocusStore } from "./sftp/hooks/useSftpFocusedPane";
import { sftpFocusStore } from "../application/state/sftp/sftpFocusStore";
import { keepOnlyPaneSelections } from "./sftp/hooks/selectionScope";
import { KeyBinding, HotkeyScheme } from "../domain/models";
import {
@@ -300,12 +299,6 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
const sftpRef = useRef(sftp);
sftpRef.current = sftp;
useWarmSftpTransferPool({
hostIds: connectedHosts.map((entry) => entry.host.id),
activeHostId: activeHost?.protocol === "serial" ? undefined : activeHost?.id,
warmTransferPoolForHost: sftp.warmTransferPoolForHost,
});
const { getConnectionCacheKey, leftPane } = sftp;
useEffect(() => {
+1 -7
View File
@@ -29,7 +29,6 @@ import { Host, Identity, KnownHost, ProxyProfile, SSHKey, TransferTask } from ".
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
import { useWarmSftpTransferPool } from "../application/state/sftp/useSftpTransferLifecycle";
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
import { toast } from "./ui/toast";
@@ -44,7 +43,7 @@ import { SftpContextProvider, activeTabStore } from "./sftp";
import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks";
import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs";
import { useSftpKeyboardShortcuts } from "./sftp/hooks/useSftpKeyboardShortcuts";
import { sftpFocusStore, SftpFocusedSide, useSftpFocusedSide } from "./sftp/hooks/useSftpFocusedPane";
import { sftpFocusStore, SftpFocusedSide, useSftpFocusedSide } from "../application/state/sftp/sftpFocusStore";
import { keepOnlyActivePaneSelections, keepOnlyPaneSelections } from "./sftp/hooks/selectionScope";
@@ -186,11 +185,6 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
const sftpRef = useRef(sftp);
sftpRef.current = sftp;
useWarmSftpTransferPool({
hostIds: connectedHosts.map((entry) => entry.host.id),
warmTransferPoolForHost: sftp.warmTransferPoolForHost,
});
// Register this useSftpState's writeTextFileByConnection with the bridge so
// the editor tab's save path can reach the active SFTP session. The bridge
// supports multiple simultaneous writers (SftpSidePanel inside terminals
@@ -0,0 +1,36 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { getSnippetsGridColumnCount } from "./snippetsGridLayout.ts";
test("full-width snippet grids use container-width column steps", () => {
assert.equal(getSnippetsGridColumnCount(500, { hasSidePanel: false }), 1);
assert.equal(getSnippetsGridColumnCount(768, { hasSidePanel: false }), 2);
assert.equal(getSnippetsGridColumnCount(1279, { hasSidePanel: false }), 2);
assert.equal(getSnippetsGridColumnCount(1280, { hasSidePanel: false }), 3);
assert.equal(getSnippetsGridColumnCount(2000, { hasSidePanel: false }), 3);
});
test("split-view snippet grids keep the card-width column count", () => {
assert.equal(getSnippetsGridColumnCount(219, { hasSidePanel: true }), 1);
assert.equal(getSnippetsGridColumnCount(452, { hasSidePanel: true }), 2);
assert.equal(getSnippetsGridColumnCount(916, { hasSidePanel: true }), 4);
});
test("virtualized and CSS snippet grids share container column policy", () => {
const source = readFileSync(new URL("./SnippetsManager.tsx", import.meta.url), "utf8");
assert.match(source, /getColumnCount=\{\(width, mode\) =>/);
assert.match(source, /getSnippetsGridColumnCount\(width, \{ hasSidePanel: hasSnippetsSidePanel \}\)/);
assert.match(source, /getSnippetsGridColumnCount\(getElementContentWidth\(el\)/);
assert.match(source, /snippetGridStyle/);
assert.doesNotMatch(source, /md:grid-cols-2|xl:grid-cols-3/);
assert.doesNotMatch(source, /getSnippetsGridColumnCount\(el\.clientWidth/);
});
test("virtualized snippet drops reset drag state without waiting for dragend", () => {
const source = readFileSync(new URL("./SnippetsManager.tsx", import.meta.url), "utf8");
assert.match(source, /handleReorderDrop = useCallback/);
assert.match(source, /finally \{\s*\/\/ Virtualized cards[\s\S]*resetSnippetDragState\(\);/);
assert.match(source, /resetSnippetDragState,/);
});
+187 -150
View File
@@ -49,6 +49,9 @@ import {
vaultSnippetIconClass,
} from './vault/VaultEntityIcon';
import { VaultDeleteConfirmDialog } from './vault/VaultDeleteConfirmDialog';
import { VirtualizedHostCollection } from './vault/VirtualizedHostCollection';
import { getSnippetsGridColumnCount } from './snippetsGridLayout';
import { getElementContentWidth } from './vault/vaultHostGridLayout';
import {
clearVaultDropIndicator as clearSnippetDropIndicator,
getVaultDropIntent as getPackageDropIntent,
@@ -59,6 +62,8 @@ import {
useVaultGridLayoutAnimation,
} from './vault/vaultReorderDrag';
const VIRTUALIZE_THRESHOLD = 30;
interface SnippetsManagerProps {
snippets: Snippet[];
packages: string[];
@@ -521,22 +526,18 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
const prepareGridLayoutAnimation = useVaultGridLayoutAnimation(listRef);
const hasSnippetsSidePanel = rightPanelMode !== 'none';
const splitGridColsRef = useRef(2);
const splitViewGridStyle = hasSnippetsSidePanel && viewMode === 'grid'
const snippetGridStyle = viewMode === 'grid'
? { gridTemplateColumns: 'var(--snippets-grid-cols, repeat(2, minmax(0, 1fr)))' }
: undefined;
useEffect(() => {
const el = listRef.current;
if (!el || typeof ResizeObserver === 'undefined') return;
const GAP = 12;
const MIN_CARD = 220;
const PADDING_X = 32;
if (!el || viewMode !== 'grid' || typeof ResizeObserver === 'undefined') return;
const recompute = () => {
const usable = el.clientWidth - PADDING_X;
if (usable <= 0) return;
const next = Math.max(1, Math.floor((usable + GAP) / (MIN_CARD + GAP)));
const next = getSnippetsGridColumnCount(getElementContentWidth(el), {
hasSidePanel: hasSnippetsSidePanel,
});
if (next === splitGridColsRef.current) return;
splitGridColsRef.current = next;
el.style.setProperty('--snippets-grid-cols', `repeat(${next}, minmax(0, 1fr))`);
@@ -1015,6 +1016,16 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
}, [snippets, selectedPackage, search, sortMode]);
const isSearchActive = search.trim().length > 0;
const shouldVirtualizeSnippets = displayedSnippets.length >= VIRTUALIZE_THRESHOLD;
const snippetCollectionLayoutKey = [
viewMode,
search.trim() ? 'search' : 'browse',
selectedPackage ?? 'root',
displayedPackages.length > 0 && !search.trim() ? 'packages' : 'no-packages',
hasSnippetsSidePanel ? 'panel' : 'full',
sortMode,
displayedSnippets.length,
].join('|');
useEffect(() => {
setSelectedSnippetIds((prev) => {
@@ -1445,6 +1456,11 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
const sourceSnippetId = draggingSnippetIdRef.current || event.dataTransfer.getData('snippet-id');
const position = getDropPosition(target, event.clientX, event.clientY, isGrid);
if (isGrid && sourceSnippetId && sourceSnippetId !== targetSnippetId) {
// Virtualized cards can unmount mid-drag — use drop indicator instead of live reorder.
if (shouldVirtualizeSnippets) {
markSnippetDropIndicator(target, position, 'x');
return;
}
const targetSnippet = snippets.find((snippet) => snippet.id === targetSnippetId);
const sourceSnippet = snippets.find((snippet) => snippet.id === sourceSnippetId);
if (!targetSnippet || !sourceSnippet) return;
@@ -1513,11 +1529,13 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
packages,
parentOfPackage,
prepareGridLayoutAnimation,
shouldVirtualizeSnippets,
snippets,
viewMode,
]);
const handleReorderDrop = useCallback((event: React.DragEvent<HTMLDivElement>) => {
try {
const target = (event.target as Element | null)?.closest('[data-snippet-id], [data-pkg-path]');
clearSnippetDropIndicator();
if (!(target instanceof HTMLElement)) return;
@@ -1577,12 +1595,18 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
lastPreviewReorderRef.current = null;
setSortMode('manual');
}
} finally {
// Virtualized cards can unmount mid-drag so dragend never fires — always
// clear snippet/package drag ids on drop instead of relying on capture end.
resetSnippetDragState();
}
}, [
onBulkSave,
onPackagesChange,
packages,
parentOfPackage,
prepareGridLayoutAnimation,
resetSnippetDragState,
snippets,
viewMode,
]);
@@ -1702,6 +1726,129 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
/>
);
const renderSnippetItem = (snippet: Snippet) => {
const isSelected = selectedSnippetIds.has(snippet.id);
return (
<ContextMenu>
<ContextMenuTrigger>
<div
className={cn(
"vault-drop-indicator-row group cursor-pointer overflow-hidden",
isSelected && (viewMode === 'grid' ? "ring-2 ring-primary/45 bg-primary/5" : "bg-primary/5"),
viewMode === 'grid'
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors"
)}
data-snippet-id={isSearchActive ? undefined : snippet.id}
data-vault-grid-item={`snippet:${snippet.id}`}
data-vault-reorder-grid={viewMode === 'grid' ? 'true' : undefined}
data-vault-reorder-dragging={draggingSnippetId === snippet.id ? 'true' : undefined}
draggable={!isSearchActive && !isMultiSelectMode}
onDragStart={(e) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('snippet-id', snippet.id);
draggingSnippetIdRef.current = snippet.id;
setDraggingSnippetId(snippet.id);
lastPreviewReorderRef.current = null;
}}
onClick={() => {
if (isMultiSelectMode) {
toggleSnippetSelection(snippet.id);
} else {
handleEdit(snippet);
}
}}
>
<div className="flex items-center gap-3 h-full min-w-0">
{isMultiSelectMode && (
<div
className="shrink-0"
onClick={(e) => {
e.stopPropagation();
toggleSnippetSelection(snippet.id);
}}
>
{isSelected ? (
<CheckSquare size={18} className="text-primary" />
) : (
<Square size={18} className="text-muted-foreground" />
)}
</div>
)}
<VaultEntityIcon
className={isScriptSnippet(snippet) ? vaultAutomationScriptIconClass : vaultSnippetIconClass}
icon={isScriptSnippet(snippet) ? (
<Play size={18} />
) : (
<Zap size={18} />
)}
/>
<div className="w-0 flex-1">
<div className="text-sm font-semibold truncate">{snippet.label}</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-[11px] text-muted-foreground font-mono leading-4 truncate">
{flattenSnippetCommandPreview(snippet.command) || t('snippets.commandFallback')}
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
<SnippetCommandTooltipContent
command={snippet.command}
fallback={t('snippets.commandFallback')}
/>
</TooltipContent>
</Tooltip>
</div>
{snippet.shortkey && (
<div className="shrink-0 px-2 py-1 text-[10px] font-mono rounded border border-border bg-muted/50 text-muted-foreground">
{snippet.shortkey}
</div>
)}
{viewMode === 'list' && !isMultiSelectMode && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
onClick={(e) => { e.stopPropagation(); handleEdit(snippet); }}
>
<Edit2 size={14} />
</Button>
)}
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={() => {
const runTargets = getRunnableHostsForSnippet(snippet, hosts);
if (runTargets.length > 0) {
onRunSnippet?.(snippet, runTargets);
return;
}
toast.error(t('scripts.actions.noRunnableHosts'));
}}
disabled={getRunnableHostsForSnippet(snippet, hosts).length === 0}
>
<Play className="mr-2 h-4 w-4" /> {t('action.run')}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => handleEdit(snippet)}>
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
</ContextMenuItem>
<ContextMenuItem onClick={() => handleCopy(snippet.id, snippet.command)}>
<Copy className="mr-2 h-4 w-4" /> {t('action.copy')}
</ContextMenuItem>
<ContextMenuItem onClick={() => exportSingleSnippet(snippet)}>
<Download className="mr-2 h-4 w-4" /> {t('snippets.export.snippet')}
</ContextMenuItem>
<ContextMenuItem className="text-destructive" onClick={() => requestDeleteSnippet(snippet.id)}>
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
};
return (
<TooltipProvider delayDuration={300}>
<div className="flex flex-1 min-h-0 min-w-0 relative">
@@ -1904,15 +2051,10 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
</div>
<div className={cn(
viewMode === 'grid'
? cn(
"grid gap-3",
hasSnippetsSidePanel
? "grid-cols-1"
: "grid-cols-1 md:grid-cols-2 xl:grid-cols-3",
)
? "grid gap-3"
: "flex flex-col gap-0"
)}
style={splitViewGridStyle}
style={snippetGridStyle}
>
{displayedPackages.map((pkg) => (
<ContextMenu key={pkg.path}>
@@ -1983,141 +2125,36 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
{displayedSnippets.length > 0 && (
<div className="space-y-2">
<h3 className={vaultSectionTitleClass}>{t('snippets.section.snippets')}</h3>
<div className={cn(
viewMode === 'grid'
? cn(
"grid gap-3",
hasSnippetsSidePanel
? "grid-cols-1"
: "grid-cols-1 md:grid-cols-2 xl:grid-cols-3",
)
: "flex flex-col gap-0"
{shouldVirtualizeSnippets ? (
<VirtualizedHostCollection
items={displayedSnippets}
itemKey={(snippet) => snippet.id}
scrollRef={listRef}
viewMode={viewMode}
layoutKey={snippetCollectionLayoutKey}
ariaLabel={t('vault.nav.snippets')}
getColumnCount={(width, mode) => (
mode === 'grid'
? getSnippetsGridColumnCount(width, { hasSidePanel: hasSnippetsSidePanel })
: 1
)}
renderItem={renderSnippetItem}
/>
) : (
<div className={cn(
viewMode === 'grid'
? "grid gap-3"
: "flex flex-col gap-0"
)}
style={snippetGridStyle}
>
{displayedSnippets.map((snippet) => (
<React.Fragment key={snippet.id}>
{renderSnippetItem(snippet)}
</React.Fragment>
))}
</div>
)}
style={splitViewGridStyle}
>
{displayedSnippets.map((snippet) => {
const isSelected = selectedSnippetIds.has(snippet.id);
return (
<ContextMenu key={snippet.id}>
<ContextMenuTrigger>
<div
className={cn(
"vault-drop-indicator-row group cursor-pointer overflow-hidden",
isSelected && (viewMode === 'grid' ? "ring-2 ring-primary/45 bg-primary/5" : "bg-primary/5"),
viewMode === 'grid'
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors"
)}
data-snippet-id={isSearchActive ? undefined : snippet.id}
data-vault-grid-item={`snippet:${snippet.id}`}
data-vault-reorder-grid={viewMode === 'grid' ? 'true' : undefined}
data-vault-reorder-dragging={draggingSnippetId === snippet.id ? 'true' : undefined}
draggable={!isSearchActive && !isMultiSelectMode}
onDragStart={(e) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('snippet-id', snippet.id);
draggingSnippetIdRef.current = snippet.id;
setDraggingSnippetId(snippet.id);
lastPreviewReorderRef.current = null;
}}
onClick={() => {
if (isMultiSelectMode) {
toggleSnippetSelection(snippet.id);
} else {
handleEdit(snippet);
}
}}
>
<div className="flex items-center gap-3 h-full min-w-0">
{isMultiSelectMode && (
<div
className="shrink-0"
onClick={(e) => {
e.stopPropagation();
toggleSnippetSelection(snippet.id);
}}
>
{isSelected ? (
<CheckSquare size={18} className="text-primary" />
) : (
<Square size={18} className="text-muted-foreground" />
)}
</div>
)}
<VaultEntityIcon
className={isScriptSnippet(snippet) ? vaultAutomationScriptIconClass : vaultSnippetIconClass}
icon={isScriptSnippet(snippet) ? (
<Play size={18} />
) : (
<Zap size={18} />
)}
/>
<div className="w-0 flex-1">
<div className="text-sm font-semibold truncate">{snippet.label}</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-[11px] text-muted-foreground font-mono leading-4 truncate">
{flattenSnippetCommandPreview(snippet.command) || t('snippets.commandFallback')}
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
<SnippetCommandTooltipContent
command={snippet.command}
fallback={t('snippets.commandFallback')}
/>
</TooltipContent>
</Tooltip>
</div>
{snippet.shortkey && (
<div className="shrink-0 px-2 py-1 text-[10px] font-mono rounded border border-border bg-muted/50 text-muted-foreground">
{snippet.shortkey}
</div>
)}
{viewMode === 'list' && !isMultiSelectMode && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
onClick={(e) => { e.stopPropagation(); handleEdit(snippet); }}
>
<Edit2 size={14} />
</Button>
)}
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={() => {
const runTargets = getRunnableHostsForSnippet(snippet, hosts);
if (runTargets.length > 0) {
onRunSnippet?.(snippet, runTargets);
return;
}
toast.error(t('scripts.actions.noRunnableHosts'));
}}
disabled={getRunnableHostsForSnippet(snippet, hosts).length === 0}
>
<Play className="mr-2 h-4 w-4" /> {t('action.run')}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => handleEdit(snippet)}>
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
</ContextMenuItem>
<ContextMenuItem onClick={() => handleCopy(snippet.id, snippet.command)}>
<Copy className="mr-2 h-4 w-4" /> {t('action.copy')}
</ContextMenuItem>
<ContextMenuItem onClick={() => exportSingleSnippet(snippet)}>
<Download className="mr-2 h-4 w-4" /> {t('snippets.export.snippet')}
</ContextMenuItem>
<ContextMenuItem className="text-destructive" onClick={() => requestDeleteSnippet(snippet.id)}>
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
})}
</div>
</div>
)}
{search.trim() && displayedSnippets.length === 0 && (snippets.length > 0 || displayedPackages.length > 0) && (
+24
View File
@@ -0,0 +1,24 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const source = readFileSync(new URL("./Terminal.tsx", import.meta.url), "utf8");
test("Terminal re-resolves catalog theme before applying store accent", () => {
// appearanceTheme may still carry a baked custom accent while TerminalLayer
// memo ignores accent churn. Switching accentMode back to `theme` must clear
// cursor/selection by starting from the clean catalog theme id.
assert.match(source, /useAppearanceChromeStore\(\)/);
assert.match(
source,
/getBuiltinTerminalThemeById\(appearanceTheme\.id\)/,
);
assert.match(
source,
/applyCustomAccentToTerminalTheme\(resolveBase\(\), accentMode, customAccent\)/,
);
assert.doesNotMatch(
source,
/if \(appearanceTheme\) return appearanceTheme;/,
);
});
+25 -17
View File
@@ -66,6 +66,7 @@ import { useStoredBoolean } from "../application/state/useStoredBoolean";
import { readOptionalStoredStringValue, useStoredString } from "../application/state/useStoredString";
import { useSessionLogBackend } from "../application/state/useSessionLogBackend";
import { useTerminalLayoutSuppressActive } from "../application/state/terminalLayoutSuppressStore";
import { useAppearanceChromeStore } from "../application/state/appearanceChromeStore";
import {
shouldPublishPluginTerminalSessionMountLifecycle,
usePluginTerminalSessionLifecycle,
@@ -260,8 +261,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
fontSize,
terminalTheme,
followAppTerminalTheme = false,
accentMode = "theme",
customAccent = "",
terminalSettings,
sessionId,
workspaceId,
@@ -1264,22 +1263,31 @@ const TerminalComponent: React.FC<TerminalProps> = ({
});
}, [availableFonts, fontFamilyId, hasFontFamilyOverride, host.fontFamily, terminalSettings?.fallbackFont]);
const { accentMode, customAccent } = useAppearanceChromeStore();
const baseEffectiveTheme = useMemo(() => {
if (appearanceTheme) return appearanceTheme;
if (followAppTerminalTheme) {
return applyCustomAccentToTerminalTheme(terminalTheme, accentMode, customAccent);
}
const themeId = resolveHostTerminalThemeId(
{ theme: host.theme, themeOverride: host.themeOverride } as Pick<Host, 'theme' | 'themeOverride'>,
terminalTheme.id,
);
let baseTheme = terminalTheme;
if (themeId) {
const hostTheme = getBuiltinTerminalThemeById(themeId)
|| customThemes.find((t) => t.id === themeId);
if (hostTheme) baseTheme = hostTheme;
}
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
// Always re-apply appearanceChromeStore accent. appearanceTheme from the
// layer may still carry a previously baked custom accent while TerminalLayer
// memo ignores accent churn — re-resolve the catalog theme by id first.
const resolveBase = (): typeof terminalTheme => {
if (appearanceTheme) {
const clean = getBuiltinTerminalThemeById(appearanceTheme.id)
|| customThemes.find((t) => t.id === appearanceTheme.id);
if (clean) return clean;
}
if (followAppTerminalTheme) return terminalTheme;
const themeId = resolveHostTerminalThemeId(
{ theme: host.theme, themeOverride: host.themeOverride } as Pick<Host, 'theme' | 'themeOverride'>,
terminalTheme.id,
);
if (themeId) {
const hostTheme = getBuiltinTerminalThemeById(themeId)
|| customThemes.find((t) => t.id === themeId);
if (hostTheme) return hostTheme;
}
return terminalTheme;
};
return applyCustomAccentToTerminalTheme(resolveBase(), accentMode, customAccent);
}, [accentMode, appearanceTheme, customAccent, customThemes, followAppTerminalTheme, host.theme, host.themeOverride, terminalTheme]);
const resolvedChainHosts =
+26
View File
@@ -217,6 +217,32 @@ test("TerminalLayer ignores shellHistory prop churn (reads shellHistoryStore ins
);
});
test("TerminalLayer ignores notes prop churn (reads notesStore instead)", () => {
assert.equal(
terminalLayerAreEqual(
baseProps as never,
{
...baseProps,
notes: [{ id: "n1", title: "One", content: "body", tags: [], createdAt: 1, updatedAt: 1, order: 1000 }],
noteGroups: ["Ops"],
updateNotes: () => {},
updateNoteGroups: () => {},
} as never,
),
true,
);
});
test("TerminalLayer ignores customAccent prop churn (reads appearanceChromeStore instead)", () => {
assert.equal(
terminalLayerAreEqual(
baseProps as never,
{ ...baseProps, accentMode: "custom", customAccent: "#ff00aa" } as never,
),
true,
);
});
test("TerminalLayer ignores sessions dynamicTitle-only updates", () => {
const sessionsA = [{
id: "s1",
+2 -9
View File
@@ -25,6 +25,7 @@ import { useStoredString } from '../application/state/useStoredString';
import { useStoredNumber } from '../application/state/useStoredNumber';
import { useStoredBoolean } from '../application/state/useStoredBoolean';
import {
STORAGE_KEY_SIDE_PANEL_POSITION,
STORAGE_KEY_SIDE_PANEL_WIDTH,
STORAGE_KEY_TERMINAL_COMPOSE_BAR_OPEN,
} from '../infrastructure/config/storageKeys';
@@ -173,8 +174,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
identities,
snippets,
snippetPackages,
notes,
noteGroups,
openNoteRequest,
onOpenVaultNoteFromChat,
onOpenVaultHostFromChat,
@@ -237,8 +236,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
updateHosts,
updateSnippets,
updateSnippetPackages,
updateNotes,
updateNoteGroups,
sftpDefaultViewMode,
sftpDoubleClickBehavior,
sftpAutoSync,
@@ -555,7 +552,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
{ min: TERMINAL_SIDE_PANEL_MIN_WIDTH, max: TERMINAL_SIDE_PANEL_MAX_WIDTH },
);
const [sidePanelPosition, setSidePanelPosition] = useStoredString<'left' | 'right'>(
'netcatty_side_panel_position',
STORAGE_KEY_SIDE_PANEL_POSITION,
'left',
(v): v is 'left' | 'right' => v === 'left' || v === 'right',
);
@@ -2165,8 +2162,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
programmaticCommandLogRewriteHandlersRef,
snippetPackages,
snippets,
noteGroups,
notes,
onOpenVaultHostFromChat,
onOpenVaultNoteFromChat: handleOpenVaultNoteFromAiPanel,
onOpenVaultSectionFromChat,
@@ -2189,8 +2184,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
TooltipContent,
TooltipTrigger,
updateHosts,
updateNoteGroups,
updateNotes,
updateSnippetPackages,
updateSnippets,
X,
+53 -87
View File
@@ -1,29 +1,29 @@
import test from "node:test";
import assert from "node:assert/strict";
import { vaultViewAreEqual } from "./VaultView.tsx";
import { syncVaultViewMemoSection, vaultViewAreEqual } from "./VaultView.tsx";
const baseProps = {
hosts: [],
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
snippetPackages: [],
notes: [],
noteGroups: [],
customGroups: [],
knownHosts: [],
connectionLogs: [],
sessions: [],
managedSources: [],
groupConfigs: {},
terminalThemeId: "default",
terminalFontSize: 14,
navigateToSection: null,
};
test("VaultView re-renders when an external section navigation request changes", () => {
const baseProps = {
hosts: [],
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
snippetPackages: [],
notes: [],
noteGroups: [],
customGroups: [],
knownHosts: [],
connectionLogs: [],
sessions: [],
managedSources: [],
groupConfigs: {},
terminalThemeId: "default",
terminalFontSize: 14,
navigateToSection: null,
};
assert.equal(
vaultViewAreEqual(
baseProps as never,
@@ -34,26 +34,6 @@ test("VaultView re-renders when an external section navigation request changes",
});
test("VaultView memo does not depend on shellHistory prop identity", () => {
const baseProps = {
hosts: [],
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
snippetPackages: [],
notes: [],
noteGroups: [],
customGroups: [],
knownHosts: [],
connectionLogs: [],
sessions: [],
managedSources: [],
groupConfigs: {},
terminalThemeId: "default",
terminalFontSize: 14,
navigateToSection: null,
};
// History-only updates must not be part of VaultView equality (store path).
assert.equal(
vaultViewAreEqual(baseProps as never, { ...baseProps } as never),
true,
@@ -65,26 +45,6 @@ test("VaultView memo does not depend on shellHistory prop identity", () => {
});
test("VaultView re-renders when proxy profiles change", () => {
const baseProps = {
hosts: [],
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
snippetPackages: [],
notes: [],
noteGroups: [],
customGroups: [],
knownHosts: [],
connectionLogs: [],
sessions: [],
managedSources: [],
groupConfigs: {},
terminalThemeId: "default",
terminalFontSize: 14,
navigateToSection: null,
};
assert.equal(
vaultViewAreEqual(
baseProps as never,
@@ -105,38 +65,12 @@ test("VaultView re-renders when proxy profiles change", () => {
});
test("VaultView re-renders when host-key verification setting changes", () => {
const baseProps = {
hosts: [],
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
snippetPackages: [],
notes: [],
noteGroups: [],
customGroups: [],
knownHosts: [],
connectionLogs: [],
sessions: [],
managedSources: [],
groupConfigs: {},
terminalThemeId: "default",
terminalFontSize: 14,
navigateToSection: null,
terminalSettings: {
verifyHostKeys: true,
keepaliveInterval: 30,
keepaliveCountMax: 10,
},
};
assert.equal(
vaultViewAreEqual(
baseProps as never,
{
...baseProps,
terminalSettings: {
...baseProps.terminalSettings,
verifyHostKeys: false,
},
} as never,
@@ -144,3 +78,35 @@ test("VaultView re-renders when host-key verification setting changes", () => {
false,
);
});
test("VaultView ignores connectionLogs identity when not on logs section", () => {
syncVaultViewMemoSection("hosts");
const nextLogs = [{ id: "log-1" }];
assert.equal(
vaultViewAreEqual(
baseProps as never,
{ ...baseProps, connectionLogs: nextLogs } as never,
),
true,
);
});
test("VaultView compares connectionLogs identity when on logs section", () => {
syncVaultViewMemoSection("logs");
const nextLogs = [{ id: "log-2" }];
assert.equal(
vaultViewAreEqual(
baseProps as never,
{ ...baseProps, connectionLogs: nextLogs } as never,
),
false,
);
assert.equal(
vaultViewAreEqual(
{ ...baseProps, connectionLogs: nextLogs } as never,
{ ...baseProps, connectionLogs: nextLogs } as never,
),
true,
);
syncVaultViewMemoSection("hosts");
});
+21 -3
View File
@@ -147,7 +147,7 @@ import { Badge } from "./ui/badge";
import { HotkeyScheme, KeyBinding } from "../domain/models";
import { VaultViewLayout } from "./vault/VaultViewLayout";
import { useVaultHostCollections } from "./vault/useVaultHostCollections";
import { useVaultImportHandlers } from "./vault/useVaultImportHandlers";
import { useVaultImportHandlers } from "../application/state/useVaultImportHandlers";
import { useVaultGroupDragHandlers } from "./vault/useVaultGroupDragHandlers";
const LazyProtocolSelectDialog = lazy(() => import("./ProtocolSelectDialog"));
@@ -167,6 +167,16 @@ export type VaultSection =
| "knownhosts"
| "logs";
/** Memo section tracker: areEqual ignores connectionLogs unless on logs. */
let vaultViewMemoSection: VaultSection = "hosts";
/** Always stashed from areEqual so skipped updates stay available when opening logs. */
let vaultViewLatestConnectionLogs: ConnectionLog[] | null = null;
/** Test/helper: sync the section used by vaultViewAreEqual. */
export function syncVaultViewMemoSection(section: VaultSection): void {
vaultViewMemoSection = section;
}
const haveSameHostOrderResult = (previous: Host[], next: Host[]) => {
if (previous.length !== next.length) return false;
return next.every((host, index) => {
@@ -365,6 +375,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
const hostsRef = useRef(hosts);
hostsRef.current = hosts;
const [currentSection, setCurrentSection] = useState<VaultSection>("hosts");
vaultViewMemoSection = currentSection;
const resolvedConnectionLogs = vaultViewLatestConnectionLogs ?? connectionLogs;
const [search, setSearch] = useState("");
const [selectedGroupPath, setSelectedGroupPath] = useState<string | null>(
null,
@@ -888,6 +900,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
hosts,
keys,
managedSources,
notify: toast,
onReadPersistedHosts,
onUpdateHosts,
onUpdateKeys,
@@ -1414,7 +1427,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
Clock,
cn,
commitInlineGroupRename,
connectionLogs,
connectionLogs: resolvedConnectionLogs,
connectSelectedHosts,
ContextMenu,
ContextMenuContent,
@@ -1667,6 +1680,11 @@ export const vaultViewAreEqual = (
prev: VaultViewProps,
next: VaultViewProps,
): boolean => {
vaultViewLatestConnectionLogs = next.connectionLogs;
const connectionLogsEqual =
vaultViewMemoSection === "logs"
? prev.connectionLogs === next.connectionLogs
: true;
const isEqual =
prev.hosts === next.hosts &&
prev.keys === next.keys &&
@@ -1679,7 +1697,7 @@ export const vaultViewAreEqual = (
prev.customGroups === next.customGroups &&
prev.knownHosts === next.knownHosts &&
// shellHistory is not a VaultView prop; SnippetsManager reads shellHistoryStore.
prev.connectionLogs === next.connectionLogs &&
connectionLogsEqual &&
prev.sessionCount === next.sessionCount &&
prev.managedSources === next.managedSources &&
prev.groupConfigs === next.groupConfigs &&
+1 -1
View File
@@ -37,7 +37,7 @@ import type { AgentModelPreset, AIPermissionMode, ProviderConfig, UploadedFile }
import { ProviderIconBadge } from '../settings/tabs/ai/ProviderIconBadge';
import { VariableSizeVirtualList, type VariableSizeVirtualListHandle } from '../ui/VariableSizeVirtualList';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import type { AgentContextUsage } from './hooks/useAgentCompactionUi';
import type { AgentContextUsage } from '../../application/state/useAgentCompactionUi';
import {
CHAT_INPUT_DEFAULT_HEIGHT,
CHAT_INPUT_MAX_HEIGHT,
+15 -6
View File
@@ -38,16 +38,24 @@ test("resolved approval state retains only tool calls still present in messages"
assert.deepEqual([...next], [["live-call", false]]);
});
test("assistant content stays plain only when markdown is hidden", () => {
test("assistant content stays plain when markdown is hidden or streaming", () => {
assert.equal(shouldRenderAssistantAsPlainText({
hideMarkdown: false,
}), false);
assert.equal(shouldRenderAssistantAsPlainText({
hideMarkdown: true,
}), true);
assert.equal(shouldRenderAssistantAsPlainText({
hideMarkdown: false,
isStreaming: true,
}), true);
assert.equal(shouldRenderAssistantAsPlainText({
hideMarkdown: false,
isStreaming: false,
}), false);
});
test("ChatMessageList renders markdown for the streaming assistant message", () => {
test("ChatMessageList renders plain text for the streaming assistant message", () => {
const messages: ChatMessage[] = [
{
id: "user-1",
@@ -75,15 +83,16 @@ test("ChatMessageList renders markdown for the streaming assistant message", ()
),
);
assert.match(markup, /data-ai-content="markdown"/);
assert.match(markup, /data-ai-content="plain"/);
assert.match(markup, /streaming-body/);
assert.doesNotMatch(markup, /data-ai-content="plain"/);
assert.doesNotMatch(markup, /data-ai-content="markdown"/);
});
test("streaming markdown keeps the renderer in its animating mode", () => {
test("streaming assistant content skips Streamdown while the turn is live", () => {
const source = readFileSync(new URL("./ChatMessageList.tsx", import.meta.url), "utf8");
assert.match(source, /LazyMessageResponse isAnimating=\{!!isThisStreaming\}/);
assert.match(source, /isStreaming: !!isThisStreaming/);
assert.match(source, /shouldRenderAssistantAsPlainText\(\{[\s\S]*?isStreaming/);
});
test("ChatMessageList hydrates markdown after streaming settles", () => {
+5 -3
View File
@@ -67,7 +67,7 @@ import {
compactionStatusText,
resolveCompactionStatusText,
type ActiveCompactionUi,
} from './hooks/useAgentCompactionUi';
} from '../../application/state/useAgentCompactionUi';
import {
getAIPanelDiagnosticHiddenParts,
getAIPanelProfilerProps,
@@ -113,8 +113,9 @@ export function shouldProvideVaultArtifactNavigation({
export function shouldRenderAssistantAsPlainText(options: {
hideMarkdown: boolean;
isStreaming?: boolean;
}): boolean {
return options.hideMarkdown;
return options.hideMarkdown || !!options.isStreaming;
}
const ASSISTANT_PLAIN_TEXT_CLASS = 'whitespace-pre-wrap break-words text-[13px] leading-[1.45]';
@@ -702,6 +703,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
? <div className={ASSISTANT_PLAIN_TEXT_CLASS}>{message.content}</div>
: shouldRenderAssistantAsPlainText({
hideMarkdown,
isStreaming: !!isThisStreaming,
})
? (
<div
@@ -714,7 +716,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
: (
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Markdown')}>
<div data-ai-content="markdown">
<LazyMessageResponse isAnimating={!!isThisStreaming}>
<LazyMessageResponse isAnimating={false}>
{message.content}
</LazyMessageResponse>
</div>
+10 -5
View File
@@ -2,14 +2,19 @@ import assert from 'node:assert/strict';
import test from 'node:test';
const storage = new Map<string, string>();
const localStorageStub = {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => { storage.set(key, value); },
removeItem: (key: string) => { storage.delete(key); },
};
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: localStorageStub,
});
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
localStorage: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
},
localStorage: localStorageStub,
},
});
+13 -81
View File
@@ -1,81 +1,13 @@
import type React from 'react';
export const AI_PANEL_DIAGNOSTIC_HIDE_KEY = 'netcatty.aiDebug.hide';
export const AI_PANEL_DIAGNOSTIC_PROFILE_KEY = 'netcatty.aiDebug.profile';
export const AI_PANEL_FORCE_HIDE_ALL_CONTENT = false;
export const AI_PANEL_FORCE_HIDE_SHELL = false;
export type AIPanelDiagnosticPart =
| 'all'
| 'attachments'
| 'header'
| 'history'
| 'input'
| 'markdown'
| 'messages'
| 'recent'
| 'toolcalls';
function readLocalStorageValue(key: string): string {
if (typeof window === 'undefined') return '';
try {
return window.localStorage.getItem(key) ?? '';
} catch {
return '';
}
}
export function getAIPanelDiagnosticHiddenParts(): ReadonlySet<string> {
if (AI_PANEL_FORCE_HIDE_ALL_CONTENT) {
return new Set(['all']);
}
const raw = readLocalStorageValue(AI_PANEL_DIAGNOSTIC_HIDE_KEY);
return new Set(
raw
.split(',')
.map((part) => part.trim().toLowerCase())
.filter(Boolean),
);
}
export function isAIPanelDiagnosticPartHidden(
part: AIPanelDiagnosticPart,
hiddenParts = getAIPanelDiagnosticHiddenParts(),
): boolean {
return hiddenParts.has('all') || hiddenParts.has(part);
}
export function isAIPanelDiagnosticsProfilingEnabled(): boolean {
const raw = readLocalStorageValue(AI_PANEL_DIAGNOSTIC_PROFILE_KEY).trim().toLowerCase();
return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
}
export function logAIPanelProfiler(
id: string,
phase: 'mount' | 'update' | 'nested-update',
actualDuration: number,
baseDuration: number,
): void {
if (!isAIPanelDiagnosticsProfilingEnabled()) return;
console.info(
`[AI panel profile] ${id} ${phase}: actual=${actualDuration.toFixed(1)}ms base=${baseDuration.toFixed(1)}ms`,
);
}
export function profileAIPanelCalculation<T>(label: string, calculate: () => T): T {
if (!isAIPanelDiagnosticsProfilingEnabled()) return calculate();
const startedAt = performance.now();
try {
return calculate();
} finally {
const elapsed = performance.now() - startedAt;
console.info(`[AI panel profile] ${label}: ${elapsed.toFixed(1)}ms`);
}
}
export function getAIPanelProfilerProps(id: string): Pick<React.ProfilerProps, 'id' | 'onRender'> {
return {
id,
onRender: logAIPanelProfiler,
};
}
export {
AI_PANEL_DIAGNOSTIC_HIDE_KEY,
AI_PANEL_DIAGNOSTIC_PROFILE_KEY,
AI_PANEL_FORCE_HIDE_ALL_CONTENT,
AI_PANEL_FORCE_HIDE_SHELL,
getAIPanelDiagnosticHiddenParts,
getAIPanelProfilerProps,
isAIPanelDiagnosticPartHidden,
isAIPanelDiagnosticsProfilingEnabled,
logAIPanelProfiler,
profileAIPanelCalculation,
type AIPanelDiagnosticPart,
} from '../../application/state/aiPanelDiagnostics';
+2 -288
View File
@@ -1,288 +1,2 @@
import type { NetcattyBridge } from '../../../infrastructure/ai/cattyAgent/executor';
import type {
OpenAIChatAssistantFields,
ProviderContinuationOptions,
ProviderContinuationSource,
} from '../../../infrastructure/ai/providerContinuation';
/** Shape of a text/text-delta chunk from the Vercel AI SDK stream. */
export interface TextDeltaChunk {
type: 'text' | 'text-delta';
text?: string;
textDelta?: string;
providerMetadata?: unknown;
}
/** Shape of a reasoning chunk from the Vercel AI SDK stream. */
export interface ReasoningChunk {
type: 'reasoning' | 'reasoning-start' | 'reasoning-delta';
text?: string;
textDelta?: string;
delta?: string;
providerMetadata?: unknown;
}
/** Shape of a raw provider chunk from the Vercel AI SDK stream. */
export interface RawChunk {
type: 'raw';
rawValue: unknown;
}
/** Shape of a tool-call chunk from the Vercel AI SDK stream. */
export interface ToolCallChunk {
type: 'tool-call';
toolCallId: string;
toolName: string;
input?: unknown;
args?: unknown;
providerMetadata?: unknown;
}
/** Shape of a tool-result chunk from the Vercel AI SDK stream. */
export interface ToolResultChunk {
type: 'tool-result';
toolCallId: string;
output?: unknown;
result?: unknown;
}
/** Shape of a tool-error chunk from the Vercel AI SDK stream. */
export interface ToolErrorChunk {
type: 'tool-error';
toolCallId: string;
toolName?: string;
error?: unknown;
}
/** Shape of a tool-output-denied chunk from the Vercel AI SDK stream. */
export interface ToolOutputDeniedChunk {
type: 'tool-output-denied';
toolCallId: string;
toolName?: string;
}
/** Nested tool call reference on approval stream chunks. */
export interface StreamChunkToolCallRef {
toolCallId: string;
toolName: string;
input?: unknown;
args?: unknown;
}
/** Shape of a tool-approval-response chunk from the Vercel AI SDK stream. */
export interface ToolApprovalResponseChunk {
type: 'tool-approval-response';
approvalId?: string;
approved?: boolean;
reason?: string;
toolCallId?: string;
toolName?: string;
toolCall?: StreamChunkToolCallRef;
}
/** Resolve toolCallId from flat or nested approval/tool chunks. */
export function resolveStreamChunkToolCallId(chunk: {
toolCallId?: string;
toolCall?: { toolCallId?: string };
}): string | undefined {
return chunk.toolCallId ?? chunk.toolCall?.toolCallId;
}
/** Format tool execution failures for model/UI consumption. */
export function formatToolErrorContent(error: unknown, fallback = 'Tool execution failed.'): string {
if (error instanceof Error) return JSON.stringify({ error: error.message });
if (typeof error === 'string') return JSON.stringify({ error });
if (error != null && typeof error === 'object' && 'error' in error) {
return JSON.stringify(error);
}
return JSON.stringify({ error: fallback });
}
/** Detect tool results that represent errors/denials (e.g. `{ error: "..." }` or `{ ok: false }`) */
export function isToolResultError(output: unknown): boolean {
if (output == null) return false;
if (typeof output === 'object') {
const obj = output as Record<string, unknown>;
// Check for explicit error objects
if ('error' in obj && typeof obj.error === 'string') return true;
if ('ok' in obj && obj.ok === false) return true;
}
// Check stringified JSON (common for tool result wrapping)
if (typeof output === 'string') {
try {
const parsed = JSON.parse(output);
if (parsed && typeof parsed === 'object') {
const parsedObj = parsed as Record<string, unknown>;
if ('error' in parsedObj && typeof parsedObj.error === 'string') return true;
if ('ok' in parsedObj && parsedObj.ok === false) return true;
}
} catch { /* not JSON, not an error */ }
}
return false;
}
/** Shape of an error chunk from the Vercel AI SDK stream. */
export interface ErrorChunk {
type: 'error';
error: unknown;
}
/** Union of all stream chunk shapes we handle. */
export type StreamChunk =
| TextDeltaChunk
| ReasoningChunk
| ToolCallChunk
| ToolResultChunk
| ToolErrorChunk
| ToolOutputDeniedChunk
| ToolApprovalResponseChunk
| ErrorChunk
| RawChunk
| { type: 'reasoning-end' | 'text-start' | 'text-end' | 'start' | 'finish' | 'start-step' | 'finish-step' | 'tool-approval-request'; approvalId?: string; toolCallId?: string; toolName?: string; approved?: boolean; toolCall?: StreamChunkToolCallRef; input?: unknown; args?: unknown };
/** Shape of the netcatty bridge exposed on `window` (panel-specific subset). */
export interface PanelBridge extends NetcattyBridge {
credentialsDecrypt?: (value: string) => Promise<string>;
aiSyncProviders?: (providers: Array<{ id: string; providerId: string; apiKey?: string; baseURL?: string; enabled: boolean }>) => Promise<{ ok: boolean }>;
aiSyncWebSearch?: (apiHost: string | null, apiKey: string | null) => Promise<{ ok: boolean }>;
aiMcpUpdateSessions?: (sessions: TerminalSessionInfo[], chatSessionId?: string) => Promise<unknown>;
aiMcpUpdateAttachments?: (
attachments: Array<{ base64Data?: string; mediaType?: string; filename?: string; filePath?: string }>,
chatSessionId?: string,
) => Promise<unknown>;
aiSdkAgentListModels?: (
sdkBackend: string,
cwd?: string,
providerId?: string,
chatSessionId?: string,
agentEnv?: Record<string, string>,
agentCommand?: string,
codexRuntime?: 'sdk' | 'app-server',
) => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string; thinkingLevels?: string[]; defaultThinkingLevel?: string }>; currentModelId?: string | null; warning?: string; error?: string }>;
aiCattyCancelExec?(chatSessionId: string): Promise<unknown>;
aiSetChatSessionCancelled?(chatSessionId: string, cancelled?: boolean): Promise<{ ok: boolean; error?: string }>;
aiMcpSyncPermissionGrants?(grants: Array<Record<string, unknown>>): Promise<{ ok: boolean; count?: number; error?: string }>;
aiSdkAgentCancel?: (requestId: string, chatSessionId?: string) => Promise<{ ok: boolean; error?: string }>;
aiSdkAgentSteer?: (
requestId: string,
chatSessionId: string,
prompt: string,
images: Array<{ base64Data: string; mediaType: string; filename?: string; filePath?: string }> | undefined,
clientUserMessageId: string,
) => Promise<{
status: 'accepted' | 'not-steerable' | 'busy' | 'inactive' | 'unsupported' | 'cancelled' | 'failed';
message?: string;
turnKind?: 'review' | 'compact';
}>;
aiSdkAgentCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
aiUserSkillsGetStatus?: () => Promise<{
ok: boolean;
skills?: Array<{
id: string;
slug: string;
name: string;
description: string;
status: 'ready' | 'warning';
}>;
}>;
aiUserSkillsBuildContext?: (prompt: string, selectedSkillSlugs?: string[]) => Promise<{ ok: boolean; context?: string; error?: string }>;
[key: string]: ((...args: unknown[]) => unknown) | undefined;
}
/** Terminal session info used throughout the streaming hooks. */
export interface TerminalSessionInfo {
sessionId: string;
hostId: string;
hostname: string;
label: string;
os?: string;
username?: string;
protocol?: string;
shellType?: string;
deviceType?: string;
connected: boolean;
hostChain?: Array<{ hostId: string; label?: string; hostname?: string }>;
activePortForwards?: Array<{
ruleId: string;
label?: string;
type?: string;
localPort?: number;
status?: string;
}>;
}
export interface DefaultTargetSessionHint extends TerminalSessionInfo {
source: 'scope-target' | 'only-connected-in-scope';
}
export interface CattyProviderContinuationContext {
source: ProviderContinuationSource;
openAIChatAssistantFields: Array<OpenAIChatAssistantFields | undefined>;
}
export type AssistantContentPart =
| { type: 'reasoning'; text: string; providerOptions?: ProviderContinuationOptions }
| { type: 'text'; text: string; providerOptions?: ProviderContinuationOptions }
| { type: 'tool-call'; toolCallId: string; toolName: string; input: unknown; providerOptions?: ProviderContinuationOptions };
export function toAssistantModelContent(parts: AssistantContentPart[]): string | AssistantContentPart[] {
if (parts.length === 1 && parts[0].type === 'text' && !parts[0].providerOptions) {
return parts[0].text;
}
return parts;
}
/** Typed accessor for the netcatty bridge on the window object. */
export function getNetcattyBridge(): PanelBridge | undefined {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (window as any).netcatty as PanelBridge | undefined;
}
// ApprovalInfo and PendingApprovalContext removed — approval is now handled
// inside the tool's execute function via the approvalGate module.
export function generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
const USER_SKILLS_CONTEXT_TIMEOUT_MS = 500;
interface UserSkillsContextResult {
ok: boolean;
context?: string;
error?: string;
}
function buildExplicitUserSkillsFallback(selectedUserSkillSlugs?: string[]): string {
if (!selectedUserSkillSlugs?.length) return '';
return `The user explicitly selected these Netcatty user skills for this request: ${selectedUserSkillSlugs.map((slug) => `/${slug}`).join(', ')}. Honor those selections even if their expanded skill content is unavailable.`;
}
export async function resolveUserSkillsContext(
bridge: PanelBridge | undefined,
prompt: string,
selectedUserSkillSlugs?: string[],
): Promise<string> {
if (!bridge?.aiUserSkillsBuildContext) {
return buildExplicitUserSkillsFallback(selectedUserSkillSlugs);
}
const buildContextPromise: Promise<UserSkillsContextResult> = bridge
.aiUserSkillsBuildContext(prompt, selectedUserSkillSlugs)
.catch(() => ({ ok: false, context: '' }));
const hasExplicitSelections = (selectedUserSkillSlugs?.length ?? 0) > 0;
const result = hasExplicitSelections
? await buildContextPromise
: await Promise.race([
buildContextPromise,
new Promise<UserSkillsContextResult>((resolve) =>
setTimeout(() => resolve({ ok: false, context: '' }), USER_SKILLS_CONTEXT_TIMEOUT_MS),
),
]);
return result.context || buildExplicitUserSkillsFallback(selectedUserSkillSlugs);
}
/** @deprecated Import from `@/infrastructure/ai/aiChatStreamingSupport` instead. */
export * from '../../../infrastructure/ai/aiChatStreamingSupport';
@@ -0,0 +1,16 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const source = readFileSync(new URL("./UnsavedChangesDialog.tsx", import.meta.url), "utf8");
test("unsaved prompt singleton registers during render, not only in useEffect", () => {
// AppView close / Cmd+W call promptUnsavedChanges outside the render-prop.
// Assigning only in useEffect leaves a first-paint window where the
// singleton is null and dirty closes silently resolve to "cancel".
assert.match(source, /promptSingleton = prompt;/);
assert.doesNotMatch(
source,
/useEffect\(\(\) => \{\s*promptSingleton = prompt;/,
);
});
+6 -6
View File
@@ -41,12 +41,12 @@ export const UnsavedChangesProvider: React.FC<{
[],
);
// Register the prompt function as the module-level singleton so it can be
// called from outside the React tree (e.g. useSftpViewPaneActions).
useEffect(() => {
promptSingleton = prompt;
return () => { promptSingleton = null; };
}, [prompt]);
// Keep the singleton current during render so AppView close handlers and
// hotkeys never race the post-commit useEffect registration window.
promptSingleton = prompt;
useEffect(() => () => {
promptSingleton = null;
}, []);
// On unmount, resolve any in-flight prompt as "cancel" so awaiting callers don't leak.
useEffect(() => () => {
+10
View File
@@ -0,0 +1,10 @@
/** Container-width column policy (mirrors former 2 / sm:3 / xl:4 breakpoints). */
const KNOWN_HOSTS_SM_MIN_WIDTH = 640;
const KNOWN_HOSTS_XL_MIN_WIDTH = 1280;
export function getKnownHostsGridColumnCount(width: number): number {
if (!Number.isFinite(width) || width <= 0) return 2;
if (width >= KNOWN_HOSTS_XL_MIN_WIDTH) return 4;
if (width >= KNOWN_HOSTS_SM_MIN_WIDTH) return 3;
return 2;
}
+17 -1
View File
@@ -246,12 +246,28 @@ test("NotesManager shows placeholder label for notes without titles", () => {
assert.match(markup, /Note title/);
});
test("NotesManager flushes drafts on pagehide/beforeunload", () => {
const source = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
assert.match(source, /addEventListener\("pagehide"/);
assert.match(source, /addEventListener\("beforeunload"/);
assert.match(source, /flushNoteDraft/);
});
test("NotesManager flushes drafts when retained mounts become inactive", () => {
const source = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
assert.match(source, /isActive\?: boolean/);
assert.match(source, /if \(isActive\) return;/);
assert.match(source, /useLayoutEffect\(\(\) => \{\s*if \(isActive\) return;/);
assert.match(source, /visibilitychange/);
});
test("NotesManager tree rename allows clearing note titles", () => {
const source = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
const renameBlock = source.match(/onRenameCommit=\{\(name\) => \{[\s\S]*?\}\}/)?.[0] ?? "";
assert.match(renameBlock, /saveNote\(\{ \.\.\.note, title: name\.trim\(\)/);
assert.match(renameBlock, /renameNoteFromTree\(note\.id, name\)/);
assert.doesNotMatch(renameBlock, /if \(!title\) return/);
assert.match(source, /note\.id === noteId \? \{ \.\.\.note, title: nextTitle, updatedAt \}/);
});
test("NotesManager sidebar mode renders list without editor by default", () => {
+227 -80
View File
@@ -15,7 +15,7 @@ import {
Upload,
X,
} from "lucide-react";
import React, { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import React, { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "../../application/i18n/I18nProvider";
import { useApplicationBackend } from "../../application/state/useApplicationBackend";
import { useStoredNumber } from "../../application/state/useStoredNumber";
@@ -121,6 +121,8 @@ export interface NotesManagerProps {
onUpdateNoteGroups: (groups: string[]) => void;
onOpenHost?: (host: Host, source?: { noteId: string }) => void;
displayMode?: "full" | "sidebar";
/** When false (hidden retained mount), flush any pending draft immediately. */
isActive?: boolean;
openNoteId?: string | null;
openNoteRequestId?: number | null;
/** Called after a one-shot openNoteId focus request has been applied. */
@@ -340,6 +342,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
onUpdateNoteGroups,
onOpenHost,
displayMode = "full",
isActive = true,
openNoteId = null,
openNoteRequestId = null,
onOpenNoteIdHandled,
@@ -401,6 +404,97 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
return cleaned;
}, [onUpdateNotes]);
const NOTE_DRAFT_DEBOUNCE_MS = 300;
const draftNoteIdRef = useRef<string | null>(null);
const draftTitleRef = useRef<string | null>(null);
const draftContentRef = useRef<string | null>(null);
const draftTimerRef = useRef<number | null>(null);
const [draftNoteId, setDraftNoteId] = useState<string | null>(null);
const [draftTitle, setDraftTitle] = useState<string | null>(null);
const [draftContent, setDraftContent] = useState<string | null>(null);
const clearDraftTimer = useCallback(() => {
if (draftTimerRef.current !== null) {
window.clearTimeout(draftTimerRef.current);
draftTimerRef.current = null;
}
}, []);
const flushNoteDraft = useCallback(() => {
clearDraftTimer();
const noteId = draftNoteIdRef.current;
if (!noteId) return;
const title = draftTitleRef.current;
const content = draftContentRef.current;
draftNoteIdRef.current = null;
draftTitleRef.current = null;
draftContentRef.current = null;
setDraftNoteId(null);
setDraftTitle(null);
setDraftContent(null);
if (title === null && content === null) return;
commitNotes(sortedNotesRef.current.map((note) => {
if (note.id !== noteId) return note;
return {
...note,
...(title !== null ? { title } : {}),
...(content !== null ? { content } : {}),
updatedAt: Date.now(),
};
}));
}, [clearDraftTimer, commitNotes]);
const scheduleNoteDraftFlush = useCallback(() => {
clearDraftTimer();
draftTimerRef.current = window.setTimeout(() => {
draftTimerRef.current = null;
flushNoteDraft();
}, NOTE_DRAFT_DEBOUNCE_MS);
}, [clearDraftTimer, flushNoteDraft]);
const updateNoteDraft = useCallback((noteId: string, fields: { title?: string; content?: string }) => {
if (draftNoteIdRef.current && draftNoteIdRef.current !== noteId) {
flushNoteDraft();
}
draftNoteIdRef.current = noteId;
setDraftNoteId(noteId);
if (fields.title !== undefined) {
draftTitleRef.current = fields.title;
setDraftTitle(fields.title);
}
if (fields.content !== undefined) {
draftContentRef.current = fields.content;
setDraftContent(fields.content);
}
scheduleNoteDraftFlush();
}, [flushNoteDraft, scheduleNoteDraftFlush]);
useEffect(() => () => {
flushNoteDraft();
}, [flushNoteDraft]);
useLayoutEffect(() => {
if (isActive) return;
flushNoteDraft();
}, [flushNoteDraft, isActive]);
useEffect(() => {
const flushOnTeardown = () => {
flushNoteDraft();
};
const flushOnHidden = () => {
if (document.visibilityState === "hidden") flushNoteDraft();
};
window.addEventListener("pagehide", flushOnTeardown);
window.addEventListener("beforeunload", flushOnTeardown);
document.addEventListener("visibilitychange", flushOnHidden);
return () => {
window.removeEventListener("pagehide", flushOnTeardown);
window.removeEventListener("beforeunload", flushOnTeardown);
document.removeEventListener("visibilitychange", flushOnHidden);
};
}, [flushNoteDraft]);
const noteTree = useMemo(() => {
const tree = buildNoteTree(groups, sortedNotes);
return {
@@ -410,6 +504,26 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
}, [groupOrderByPath, groups, sortedNotes]);
const selectedNote = getSelectedVaultNote(sortedNotes, selectedNoteId);
const overlayNote = sortedNotes.find((note) => note.id === overlayNoteId) ?? null;
const selectedNoteView = selectedNote && draftNoteId === selectedNote.id
? {
...selectedNote,
...(draftTitle !== null ? { title: draftTitle } : {}),
...(draftContent !== null ? { content: draftContent } : {}),
}
: selectedNote;
const overlayNoteView = overlayNote && draftNoteId === overlayNote.id
? {
...overlayNote,
...(draftTitle !== null ? { title: draftTitle } : {}),
...(draftContent !== null ? { content: draftContent } : {}),
}
: overlayNote;
useEffect(() => {
if (!draftNoteIdRef.current) return;
if (draftNoteIdRef.current === selectedNoteId || draftNoteIdRef.current === overlayNoteId) return;
flushNoteDraft();
}, [flushNoteDraft, overlayNoteId, selectedNoteId]);
useEffect(() => {
const urls = activeDownloadUrlsRef.current;
@@ -496,8 +610,28 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
const expandAllGroups = () => setExpandedGroups(new Set(allGroupPaths));
const collapseAllGroups = () => setExpandedGroups(new Set());
const saveNote = (nextNote: VaultNote) => {
commitNotes(sortedNotes.map((note) => (note.id === nextNote.id ? nextNote : note)));
/** Flush pending draft, then mutate from the post-flush ref snapshot. */
const commitNotesAfterFlush = useCallback((mutator: (notes: VaultNote[]) => VaultNote[]) => {
flushNoteDraft();
return commitNotes(mutator(sortedNotesRef.current));
}, [commitNotes, flushNoteDraft]);
/** Rename from the tree row: merge title into the post-flush note so an
* unflushed body edit is not discarded by a stale tree-row snapshot. */
const renameNoteFromTree = (noteId: string, title: string) => {
const nextTitle = title.trim();
const updatedAt = Date.now();
commitNotesAfterFlush((notes) => notes.map((note) => (
note.id === noteId ? { ...note, title: nextTitle, updatedAt } : note
)));
};
const saveNoteTitleDraft = (note: VaultNote, title: string) => {
updateNoteDraft(note.id, { title });
};
const saveNoteContentDraft = (note: VaultNote, content: string) => {
updateNoteDraft(note.id, { content });
};
const handleOpenHostFromNote = useCallback((host: Host, noteId: string) => {
@@ -531,10 +665,14 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
};
const addNoteToGroup = (group: string | null) => {
const note = createNote(group, getNextVaultOrder(sortedNotes));
commitNotes([...sortedNotes, note]);
let created: VaultNote | null = null;
commitNotesAfterFlush((notes) => {
created = createNote(group, getNextVaultOrder(notes));
return [...notes, created];
});
if (!created) return;
if (group) expandPath(group);
const nextSelection = getNoteSelectionState(note, isSidebarMode);
const nextSelection = getNoteSelectionState(created, isSidebarMode);
setSelectedNoteId(nextSelection.selectedNoteId);
setSelectedGroup(nextSelection.selectedGroup);
setOverlayNoteId(nextSelection.overlayNoteId);
@@ -591,7 +729,10 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
const result = importMarkdownPayloadsToVaultNotes(
payloads,
sortedNotesRef.current,
(() => {
flushNoteDraft();
return sortedNotesRef.current;
})(),
targetGroup,
);
@@ -623,6 +764,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
}, [
isSidebarMode,
commitNotes,
flushNoteDraft,
selectedGroup,
selectedNote,
t,
@@ -645,10 +787,12 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
}, []);
const exportNoteToMarkdown = useCallback((note: VaultNote) => {
flushNoteDraft();
const latest = sortedNotesRef.current.find((item) => item.id === note.id) ?? note;
try {
const fileName = `${sanitizeNoteExportFileNamePart(note.title, "note")}.md`;
const fileName = `${sanitizeNoteExportFileNamePart(latest.title, "note")}.md`;
downloadNotesBlob(
new Blob([note.content], { type: "text/markdown;charset=utf-8" }),
new Blob([latest.content], { type: "text/markdown;charset=utf-8" }),
fileName,
);
toast.success(t("notes.export.toast.success", { count: 1 }));
@@ -656,9 +800,10 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
logger.error("Failed to export note:", err);
toast.error(t("notes.export.toast.failed"));
}
}, [downloadNotesBlob, t]);
}, [downloadNotesBlob, flushNoteDraft, t]);
const exportNotesToZip = useCallback((scope: VaultNotesExportScope, fileNamePart: string) => {
flushNoteDraft();
try {
const files = buildVaultNoteMarkdownExportFiles(sortedNotesRef.current, scope);
if (files.length === 0) {
@@ -674,7 +819,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
logger.error("Failed to export notes:", err);
toast.error(t("notes.export.toast.failed"));
}
}, [downloadNotesBlob, t]);
}, [downloadNotesBlob, flushNoteDraft, t]);
const exportAllNotes = useCallback(() => {
exportNotesToZip({ type: "all" }, "all");
@@ -703,7 +848,8 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
);
const duplicateNoteById = (noteId: string) => {
const source = sortedNotes.find((note) => note.id === noteId);
flushNoteDraft();
const source = sortedNotesRef.current.find((note) => note.id === noteId);
if (!source) return;
const now = Date.now();
const copy: VaultNote = {
@@ -712,9 +858,9 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
title: `${source.title} (${t("action.copy")})`,
createdAt: now,
updatedAt: now,
order: getNextVaultOrder(sortedNotes),
order: getNextVaultOrder(sortedNotesRef.current),
};
commitNotes([...sortedNotes, copy]);
commitNotes([...sortedNotesRef.current, copy]);
if (copy.group) expandPath(copy.group);
const nextSelection = getNoteSelectionState(copy, isSidebarMode);
setSelectedNoteId(nextSelection.selectedNoteId);
@@ -723,8 +869,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
};
const performDeleteNoteById = (noteId: string) => {
const next = sortedNotes.filter((note) => note.id !== noteId);
commitNotes(next);
const next = commitNotesAfterFlush((notes) => notes.filter((note) => note.id !== noteId));
if (selectedNoteId === noteId) {
const nextSelection = getFallbackNoteSelectionState(next, isSidebarMode);
setSelectedNoteId(nextSelection.selectedNoteId);
@@ -772,12 +917,11 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
const nextGroups = normalizeNoteGroups(
groups.map((item) => replaceNoteGroupPrefix(item, group, nextPath) || ""),
);
const nextNotes = sortedNotes.map((note) => ({
onUpdateNoteGroups(nextGroups);
commitNotesAfterFlush((notes) => notes.map((note) => ({
...note,
group: replaceNoteGroupPrefix(note.group, group, nextPath),
}));
onUpdateNoteGroups(nextGroups);
commitNotes(nextNotes);
})));
setExpandedGroups((current) => {
const next = new Set<string>();
current.forEach((item) => {
@@ -794,7 +938,9 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
const performDeleteGroup = (group: string) => {
onUpdateNoteGroups(groups.filter((item) => !isNoteGroupInside(item, group)));
commitNotes(sortedNotes.map((note) => isNoteGroupInside(note.group, group) ? { ...note, group: undefined } : note));
commitNotesAfterFlush((notes) => notes.map((note) => (
isNoteGroupInside(note.group, group) ? { ...note, group: undefined } : note
)));
if (selectedGroup && isNoteGroupInside(selectedGroup, group)) setSelectedGroup(null);
setEditingGroupPath(null);
};
@@ -844,12 +990,13 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
};
const moveNoteToGroup = (noteId: string, group: string | null) => {
const source = sortedNotes.find((note) => note.id === noteId);
flushNoteDraft();
const source = sortedNotesRef.current.find((note) => note.id === noteId);
if (!source) return;
const nextGroup = group || undefined;
if ((source.group || undefined) === nextGroup) return;
commitNotes(sortedNotes.map((note) => (
commitNotes(sortedNotesRef.current.map((note) => (
note.id === noteId ? { ...note, group: nextGroup, updatedAt: Date.now() } : note
)));
if (group) expandPath(group);
@@ -858,29 +1005,31 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
const reorderNoteToNote = (sourceId: string, targetNote: VaultNote, event: React.DragEvent<HTMLElement>) => {
if (!sourceId || sourceId === targetNote.id) return;
const position = getVaultDropPosition(event.currentTarget, event.clientX, event.clientY);
const movedNotes = sortedNotes.map((note) => (
note.id === sourceId
? { ...note, group: targetNote.group, updatedAt: Date.now() }
: note
));
commitNotes(reorderVaultItems(movedNotes, sourceId, targetNote.id, position));
commitNotesAfterFlush((notes) => {
const movedNotes = notes.map((note) => (
note.id === sourceId
? { ...note, group: targetNote.group, updatedAt: Date.now() }
: note
));
return reorderVaultItems(movedNotes, sourceId, targetNote.id, position);
});
if (targetNote.group) expandPath(targetNote.group);
};
const moveGroupToParent = (group: string, parent: string | null) => {
flushNoteDraft();
const knownGroups = normalizeNoteGroups([
...groups,
...sortedNotes.map((note) => note.group).filter((item): item is string => Boolean(item)),
...sortedNotesRef.current.map((note) => note.group).filter((item): item is string => Boolean(item)),
]);
const nextPath = resolveMovedNoteGroupPath(group, parent, knownGroups);
if (!nextPath) return;
const nextGroups = normalizeNoteGroups(groups.map((item) => replaceNoteGroupPrefix(item, group, nextPath) || ""));
const nextNotes = sortedNotes.map((note) => ({
onUpdateNoteGroups(nextGroups);
commitNotes(sortedNotesRef.current.map((note) => ({
...note,
group: replaceNoteGroupPrefix(note.group, group, nextPath),
}));
onUpdateNoteGroups(nextGroups);
commitNotes(nextNotes);
})));
setExpandedGroups((current) => remapExpandedNoteGroupPaths(current, group, nextPath));
if (selectedGroup && isNoteGroupInside(selectedGroup, group)) {
setSelectedGroup(replaceNoteGroupPrefix(selectedGroup, group, nextPath) ?? null);
@@ -895,10 +1044,11 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
if (!sourceGroup || !targetGroup || sourceGroup === targetGroup) return;
if (targetGroup.startsWith(`${sourceGroup}/`)) return;
flushNoteDraft();
const targetParent = getNoteGroupParentPath(targetGroup);
const knownGroups = normalizeNoteGroups([
...groups,
...sortedNotes.map((note) => note.group).filter((item): item is string => Boolean(item)),
...sortedNotesRef.current.map((note) => note.group).filter((item): item is string => Boolean(item)),
]);
const nextSourceGroup = getNoteGroupParentPath(sourceGroup) === targetParent
? cleanNoteGroupPath(sourceGroup)
@@ -916,16 +1066,13 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
targetGroup,
position,
);
const nextNotes = nextSourceGroup === sourceGroup
? sortedNotes
: sortedNotes.map((note) => ({
...note,
group: replaceNoteGroupPrefix(note.group, sourceGroup, nextSourceGroup),
}));
onUpdateNoteGroups(nextGroups);
if (nextSourceGroup !== sourceGroup) {
commitNotes(nextNotes);
commitNotes(sortedNotesRef.current.map((note) => ({
...note,
group: replaceNoteGroupPrefix(note.group, sourceGroup, nextSourceGroup),
})));
setExpandedGroups((current) => remapExpandedNoteGroupPaths(current, sourceGroup, nextSourceGroup));
if (selectedGroup && isNoteGroupInside(selectedGroup, sourceGroup)) {
setSelectedGroup(replaceNoteGroupPrefix(selectedGroup, sourceGroup, nextSourceGroup) ?? null);
@@ -1092,7 +1239,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
editingInitialName={note.title}
onRenameCommit={(name) => {
setEditingNoteId(null);
saveNote({ ...note, title: name.trim(), updatedAt: Date.now() });
renameNoteFromTree(note.id, name);
}}
onRenameCancel={() => setEditingNoteId(null)}
icon={<FileText size={16} className="mr-2 shrink-0 text-muted-foreground" />}
@@ -1515,41 +1662,41 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
{!isSidebarMode && (
<main className="flex min-w-0 flex-1 flex-col bg-background">
{selectedNote ? (
{selectedNoteView ? (
<>
<div className="flex min-h-[54px] shrink-0 items-center gap-3 px-8 pt-6 pb-1" data-note-title-row>
<div className="min-w-0 flex-1">
<input
className="h-8 w-full bg-transparent text-lg font-semibold leading-8 outline-none placeholder:text-muted-foreground"
value={selectedNote.title}
value={selectedNoteView.title}
placeholder={t("notes.title.placeholder")}
onChange={(event) => saveNote({
...selectedNote,
title: event.target.value,
updatedAt: Date.now(),
})}
onChange={(event) => saveNoteTitleDraft(selectedNoteView, event.target.value)}
onBlur={() => flushNoteDraft()}
/>
</div>
{renderNoteExportButton(selectedNote)}
{renderNoteExportButton(selectedNoteView)}
{renderNoteModeToggle()}
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="min-h-full w-full px-8 pt-2 pb-6">
<LazyLoadBoundary name="Notes editor" resetKey={selectedNote.id}>
<div
className="min-h-full w-full px-8 pt-2 pb-6"
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
flushNoteDraft();
}
}}
>
<LazyLoadBoundary name="Notes editor" resetKey={selectedNoteView.id}>
<Suspense fallback={<InlineMarkdownEditorFallback />}>
<InlineMarkdownEditor
key={selectedNote.id}
value={selectedNote.content}
key={selectedNoteView.id}
value={selectedNoteView.content}
placeholder={t("notes.editor.placeholder")}
editorMode={noteEditorMode}
previewEmptyLabel={t("notes.preview.empty")}
onChange={(content) => saveNote({
...selectedNote,
content,
updatedAt: Date.now(),
})}
onChange={(content) => saveNoteContentDraft(selectedNoteView, content)}
hosts={hosts}
onOpenHost={(host) => handleOpenHostFromNote(host, selectedNote.id)}
onOpenHost={(host) => handleOpenHostFromNote(host, selectedNoteView.id)}
onOpenExternalLink={openExternal}
/>
</Suspense>
@@ -1582,7 +1729,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
)}
</div>
{isSidebarMode && overlayNote && (
{isSidebarMode && overlayNoteView && (
<div className="absolute inset-0 z-30 flex min-h-0 flex-col bg-background text-foreground">
<div className={cn(
TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS,
@@ -1602,7 +1749,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
<TooltipContent side="bottom">{t("common.back")}</TooltipContent>
</Tooltip>
<div className="min-w-0 flex-1 truncate px-1 text-xs font-medium leading-4 text-foreground">
{overlayNote.title || t("notes.title.placeholder")}
{overlayNoteView.title || t("notes.title.placeholder")}
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col bg-background">
@@ -1610,35 +1757,35 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
<div className="min-w-0 flex-1">
<input
className="h-8 w-full bg-transparent text-lg font-semibold leading-8 outline-none placeholder:text-muted-foreground"
value={overlayNote.title}
value={overlayNoteView.title}
placeholder={t("notes.title.placeholder")}
onChange={(event) => saveNote({
...overlayNote,
title: event.target.value,
updatedAt: Date.now(),
})}
onChange={(event) => saveNoteTitleDraft(overlayNoteView, event.target.value)}
onBlur={() => flushNoteDraft()}
/>
</div>
{renderNoteExportButton(overlayNote)}
{renderNoteExportButton(overlayNoteView)}
{renderNoteModeToggle()}
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="min-h-full w-full px-4 pt-2 pb-6">
<LazyLoadBoundary name="Notes editor" resetKey={overlayNote.id}>
<div
className="min-h-full w-full px-4 pt-2 pb-6"
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
flushNoteDraft();
}
}}
>
<LazyLoadBoundary name="Notes editor" resetKey={overlayNoteView.id}>
<Suspense fallback={<InlineMarkdownEditorFallback />}>
<InlineMarkdownEditor
key={overlayNote.id}
value={overlayNote.content}
key={overlayNoteView.id}
value={overlayNoteView.content}
placeholder={t("notes.editor.placeholder")}
editorMode={noteEditorMode}
onChange={(content) => saveNote({
...overlayNote,
content,
updatedAt: Date.now(),
})}
onChange={(content) => saveNoteContentDraft(overlayNoteView, content)}
previewEmptyLabel={t("notes.preview.empty")}
hosts={hosts}
onOpenHost={(host) => handleOpenHostFromNote(host, overlayNote.id)}
onOpenHost={(host) => handleOpenHostFromNote(host, overlayNoteView.id)}
onOpenExternalLink={openExternal}
/>
</Suspense>
@@ -1,56 +1,10 @@
import type { KnownHost } from "../../domain/models";
import { toHostKeyInfo, type HostKeyVerificationRequest } from "../terminal/hostKeyVerification";
import type { HostKeyInfo } from "../terminal/TerminalHostKeyVerification";
export const isPortForwardHostKeySessionId = (sessionId?: string): boolean => {
return typeof sessionId === "string" && sessionId.startsWith("pf-");
};
export type PortForwardHostKeyRequest = HostKeyVerificationRequest & {
requestId: string;
sessionId?: string;
};
export interface PendingPortForwardHostKeyVerification {
requestId: string;
hostKeyInfo: HostKeyInfo;
}
export const toPendingPortForwardHostKeyVerification = (
request: PortForwardHostKeyRequest,
): PendingPortForwardHostKeyVerification | null => {
if (!isPortForwardHostKeySessionId(request.sessionId)) return null;
return {
requestId: request.requestId,
hostKeyInfo: toHostKeyInfo(request),
};
};
export const enqueuePortForwardHostKeyVerification = (
queue: PendingPortForwardHostKeyVerification[],
pending: PendingPortForwardHostKeyVerification,
): PendingPortForwardHostKeyVerification[] => [...queue, pending];
export const removePortForwardHostKeyVerification = (
queue: PendingPortForwardHostKeyVerification[],
requestId: string,
): PendingPortForwardHostKeyVerification[] => {
if (queue[0]?.requestId === requestId) {
return queue.slice(1);
}
return queue.filter((pending) => pending.requestId !== requestId);
};
export const createKnownHostFromPortForwardHostKeyInfo = (
hostKeyInfo: HostKeyInfo,
now = Date.now(),
idSuffix = Math.random().toString(36).slice(2, 11),
): KnownHost => ({
id: hostKeyInfo.knownHostId || `kh-${now}-${idSuffix}`,
hostname: hostKeyInfo.hostname,
port: hostKeyInfo.port || 22,
keyType: hostKeyInfo.keyType,
publicKey: hostKeyInfo.publicKey || `SHA256:${hostKeyInfo.fingerprint}`,
fingerprint: hostKeyInfo.fingerprint,
discoveredAt: now,
});
/** @deprecated Import from `@/domain/portForwardHostKey` instead. */
export {
createKnownHostFromPortForwardHostKeyInfo,
enqueuePortForwardHostKeyVerification,
isPortForwardHostKeySessionId,
removePortForwardHostKeyVerification,
toPendingPortForwardHostKeyVerification,
type PendingPortForwardHostKeyVerification,
type PortForwardHostKeyRequest,
} from "../../domain/portForwardHostKey";
@@ -2,26 +2,19 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Check, Copy, HelpCircle, RefreshCw } from "lucide-react";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import {
normalizeExternalMcpIdleTimeoutMinutes,
normalizeExternalMcpMode,
normalizeSessionIdleTimeoutMinutes,
readExternalMcpFocusOnHostOpen,
readExternalMcpIdleTimeoutMinutes,
readExternalMcpMode,
readExternalMcpSilentSessions,
readSessionIdleTimeoutMinutes,
writeExternalMcpFocusOnHostOpen,
writeExternalMcpIdleTimeoutMinutes,
writeExternalMcpMode,
writeExternalMcpSilentSessions,
writeSessionIdleTimeoutMinutes,
type ExternalMcpMode,
useExternalMcpToggleState,
} from "../../../../application/state/useExternalMcpToggleState";
import {
STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES,
STORAGE_KEY_AI_EXTERNAL_MCP_MODE,
STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES,
} from "../../../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../../../infrastructure/persistence/localStorageAdapter";
import { emitAIStateChanged } from "../../../../application/state/aiStateEvents";
import { cn } from "../../../../lib/utils";
import { Button } from "../../../ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
@@ -328,26 +321,20 @@ export const ExternalMcpCard: React.FC = () => {
}, []);
const setMode = useCallback((nextMode: ExternalMcpMode) => {
const normalized = normalizeExternalMcpMode(nextMode);
const normalized = writeExternalMcpMode(nextMode);
setModeRaw(normalized);
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, normalized);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_MODE);
pushConfig(normalized, idleTimeoutMinutes, sessionIdleTimeoutMinutes);
}, [idleTimeoutMinutes, pushConfig, sessionIdleTimeoutMinutes]);
const setIdleTimeoutMinutes = useCallback((minutes: number) => {
const normalized = normalizeExternalMcpIdleTimeoutMinutes(minutes);
const normalized = writeExternalMcpIdleTimeoutMinutes(minutes);
setIdleTimeoutRaw(normalized);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES, normalized);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES);
pushConfig(mode, normalized, sessionIdleTimeoutMinutes);
}, [mode, pushConfig, sessionIdleTimeoutMinutes]);
const setSessionIdleTimeoutMinutes = useCallback((minutes: number) => {
const normalized = normalizeSessionIdleTimeoutMinutes(minutes);
const normalized = writeSessionIdleTimeoutMinutes(minutes);
setSessionIdleTimeoutRaw(normalized);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES, normalized);
emitAIStateChanged(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES);
pushConfig(mode, idleTimeoutMinutes, normalized);
}, [idleTimeoutMinutes, mode, pushConfig]);
+1 -1
View File
@@ -14,7 +14,7 @@ import type { SftpFileEntry } from "../../types";
import type { SftpPane } from "../../application/state/sftp/types";
import type { SftpTransferSource } from "./SftpContext";
import { sftpListOrderStore } from "./hooks/useSftpListOrderStore";
import type { UseSftpPaneSortingResult } from "./hooks/useSftpPaneSorting";
import type { UseSftpPaneSortingResult } from "../../application/state/sftp/useSftpPaneSorting";
import { buildSftpColumnTemplate, isNavigableDirectory, isSftpColumnMenuKey } from "./utils";
import { isKnownBinaryFile } from "../../lib/sftpFileUtils";
import { SftpFileRow } from "./SftpFileRow";
+1 -1
View File
@@ -12,7 +12,7 @@ import { getParentPath, isWindowsRoot, joinPath, resolveSftpWindowsPathOptions }
import { buildSftpColumnTemplate, filterHiddenFiles, isNavigableDirectory, isSftpColumnMenuKey, sortSftpEntries } from './utils';
import type { SftpTransferSource } from './SftpContext';
import type { SftpPaneTreeViewProps } from './SftpPaneTreeView.types';
import { sftpTreeSelectionStore, useSftpTreeSelectionState } from './hooks/useSftpTreeSelectionStore';
import { sftpTreeSelectionStore, useSftpTreeSelectionState } from '../../application/state/sftp/sftpTreeSelectionStore';
import { sftpKeyboardSelectionStore, sftpTreeEnterStore } from './hooks/useSftpKeyboardShortcuts';
import { useI18n } from '../../application/i18n/I18nProvider';
import { SftpColumnMenuItems } from './SftpColumnMenuItems';
+1 -1
View File
@@ -1,7 +1,7 @@
import type { SftpFileEntry } from '../../types';
import type { SftpPane } from '../../application/state/sftp/types';
import type { SftpTransferSource } from './SftpContext';
import type { UseSftpPaneSortingResult } from './hooks/useSftpPaneSorting';
import type { UseSftpPaneSortingResult } from '../../application/state/sftp/useSftpPaneSorting';
export interface SftpPaneTreeViewProps {
pane: SftpPane;
+5 -5
View File
@@ -25,16 +25,16 @@ import { useSftpPaneDialogs } from "./hooks/useSftpPaneDialogs";
import { useSftpPaneDragAndSelect } from "./hooks/useSftpPaneDragAndSelect";
import { useSftpPaneFiles } from "./hooks/useSftpPaneFiles";
import { useSftpPanePath } from "./hooks/useSftpPanePath";
import { useSftpPaneSorting, type UseSftpPaneSortingResult } from "./hooks/useSftpPaneSorting";
import { useSftpPaneSorting, type UseSftpPaneSortingResult } from "../../application/state/sftp/useSftpPaneSorting";
import { useSftpPaneVirtualList } from "./hooks/useSftpPaneVirtualList";
import { sftpPaneViewModeStore } from "../../application/state/sftp/sftpPaneViewModeStore";
import { useSftpDialogActionHandler } from "./hooks/useSftpDialogAction";
import { useSftpDialogActionHandler } from "../../application/state/sftp/sftpDialogActionStore";
import { useSftpBookmarks } from "./hooks/useSftpBookmarks";
import { useLocalSftpBookmarks } from "./hooks/useLocalSftpBookmarks";
import { useLocalSftpBookmarks } from "../../application/state/sftp/localSftpBookmarks";
import { useGlobalSftpBookmarks } from "./hooks/useGlobalSftpBookmarks";
import { useSftpHostViewMode } from "./hooks/useSftpHostViewMode";
import { useSftpHostViewMode } from "../../application/state/sftp/sftpHostViewModeStore";
import { sftpListOrderStore } from "./hooks/useSftpListOrderStore";
import { sftpTreeSelectionStore } from "./hooks/useSftpTreeSelectionStore";
import { sftpTreeSelectionStore } from "../../application/state/sftp/sftpTreeSelectionStore";
import { sftpClipboardUploadStore } from "./clipboardUpload";
interface TreeReloadRequest {
+1 -1
View File
@@ -1,5 +1,5 @@
import type { SftpStateApi } from "../../../application/state/useSftpState";
import { sftpTreeSelectionStore } from "./useSftpTreeSelectionStore";
import { sftpTreeSelectionStore } from "../../../application/state/sftp/sftpTreeSelectionStore";
export interface SftpSelectionTarget {
side: "left" | "right";
@@ -1,65 +0,0 @@
import { useCallback, useMemo, useSyncExternalStore } from "react";
import type { SftpBookmark } from "../../../domain/models";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { STORAGE_KEY_SFTP_LOCAL_BOOKMARKS } from "../../../infrastructure/config/storageKeys";
import { createSftpBookmark } from "../../../application/state/sftp/bookmarkHelpers";
// ── Shared external store so every hook instance sees the same bookmarks ──
type Listener = () => void;
const listeners = new Set<Listener>();
let snapshot: SftpBookmark[] =
localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS) ?? [];
function subscribe(listener: Listener) {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
function getSnapshot() {
return snapshot;
}
function setBookmarks(next: SftpBookmark[] | ((prev: SftpBookmark[]) => SftpBookmark[])) {
snapshot = typeof next === "function" ? next(snapshot) : next;
localStorageAdapter.write(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS, snapshot);
for (const l of listeners) l();
}
// ── Hook ──
interface UseLocalSftpBookmarksParams {
currentPath: string | undefined;
}
export const useLocalSftpBookmarks = ({
currentPath,
}: UseLocalSftpBookmarksParams) => {
const bookmarks = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const isCurrentPathBookmarked = useMemo(
() => !!currentPath && bookmarks.some((b) => b.path === currentPath),
[currentPath, bookmarks],
);
const toggleBookmark = useCallback(() => {
if (!currentPath) return;
if (isCurrentPathBookmarked) {
setBookmarks((prev) => prev.filter((b) => b.path !== currentPath));
} else {
setBookmarks((prev) => [...prev, createSftpBookmark(currentPath)]);
}
}, [currentPath, isCurrentPathBookmarked]);
const deleteBookmark = useCallback((id: string) => {
setBookmarks((prev) => prev.filter((b) => b.id !== id));
}, []);
return {
bookmarks,
isCurrentPathBookmarked,
toggleBookmark,
deleteBookmark,
};
};
@@ -10,10 +10,10 @@ import type { MutableRefObject } from "react";
import { KeyBinding, matchesKeyBinding } from "../../../domain/models";
import { getParentPath, joinPath, resolveSftpWindowsPathOptions } from "../../../application/state/sftp/utils";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { sftpClipboardStore, SftpClipboardFile } from "./useSftpClipboard";
import { sftpFocusStore } from "./useSftpFocusedPane";
import { sftpDialogActionStore } from "./useSftpDialogAction";
import { sftpTreeSelectionStore } from "./useSftpTreeSelectionStore";
import { sftpClipboardStore, SftpClipboardFile } from "../../../application/state/sftp/sftpClipboardStore";
import { sftpFocusStore } from "../../../application/state/sftp/sftpFocusStore";
import { sftpDialogActionStore } from "../../../application/state/sftp/sftpDialogActionStore";
import { sftpTreeSelectionStore } from "../../../application/state/sftp/sftpTreeSelectionStore";
import { sftpListOrderStore } from "./useSftpListOrderStore";
import { sftpPaneViewModeStore } from "../../../application/state/sftp/sftpPaneViewModeStore";
import { keepOnlyPaneSelections } from "./selectionScope";
+34 -64
View File
@@ -23,7 +23,18 @@ import {
} from 'lucide-react';
import React from 'react';
import type { LucideIcon } from 'lucide-react';
import { formatBytes as formatBytesDomain } from '../../domain/formatBytes';
import { SftpFileEntry } from '../../types';
import {
formatDate as formatDateFromState,
isNavigableDirectory,
} from '../../application/state/sftp/utils';
export { isNavigableDirectory };
/** Accept undefined timestamps from file-list rows. */
export const formatDate = (timestamp: number | undefined): string =>
formatDateFromState(timestamp ?? 0);
// Pre-built icon maps for O(1) lookup in getFileIcon
type IconDef = [LucideIcon, string?];
@@ -177,37 +188,29 @@ const EXTENSION_ICON_MAP = new Map<string, IconDef>([
]);
/**
* Format bytes with appropriate unit (B, KB, MB, GB)
* Format bytes with appropriate unit (B, KB, MB, GB, TB)
*/
export const formatBytes = (bytes: number | string): string => {
const numBytes = typeof bytes === 'string' ? parseInt(bytes, 10) : bytes;
if (isNaN(numBytes) || numBytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(numBytes) / Math.log(1024));
const size = numBytes / Math.pow(1024, i);
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
if (typeof numBytes !== 'number' || isNaN(numBytes)) return '0 B';
return formatBytesDomain(numBytes, {
zeroDisplay: '0 B',
includeTB: true,
fractionDigits: 1,
byteUnit: 'B',
});
};
/**
* Format bytes for transfer display
*/
export const formatTransferBytes = (bytes: number): string => {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const size = bytes / Math.pow(1024, i);
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
};
/**
* Format date as YYYY-MM-DD hh:mm in local timezone
*/
export const formatDate = (timestamp: number | undefined): string => {
if (!timestamp) return '--';
const date = new Date(timestamp);
if (isNaN(date.getTime())) return '--';
const pad = (n: number) => n.toString().padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
return formatBytesDomain(bytes, {
zeroDisplay: '0 B',
includeTB: false,
fractionDigits: 1,
byteUnit: 'B',
});
};
/**
@@ -245,40 +248,15 @@ export const getFileIcon = (entry: SftpFileEntry): React.ReactElement => {
return React.createElement(FileCode, { size: 14 });
};
// Sort configuration types
export type SortField = 'name' | 'size' | 'modified' | 'type';
export type SortOrder = 'asc' | 'desc';
// Column widths type
export interface ColumnWidths {
name: number;
modified: number;
size: number;
type: number;
}
export type SftpColumnVisibility = Record<keyof ColumnWidths, boolean>;
export const DEFAULT_SFTP_COLUMN_VISIBILITY: SftpColumnVisibility = {
name: true,
modified: true,
size: true,
type: true,
};
export const normalizeSftpColumnVisibility = (value: unknown): SftpColumnVisibility => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return DEFAULT_SFTP_COLUMN_VISIBILITY;
}
const stored = value as Partial<Record<keyof ColumnWidths, unknown>>;
return {
name: true,
modified: stored.modified !== false,
size: stored.size !== false,
type: stored.type !== false,
};
};
// Sort / column layout — owned by application/state; re-exported for UI callers.
export {
DEFAULT_SFTP_COLUMN_VISIBILITY,
normalizeSftpColumnVisibility,
type ColumnWidths,
type SftpColumnVisibility,
type SortField,
type SortOrder,
} from '../../application/state/sftp/columnLayout';
export const isSftpColumnMenuKey = (key: string, shiftKey: boolean): boolean =>
key === 'ContextMenu' || (key === 'F10' && shiftKey);
@@ -339,14 +317,6 @@ export const sortSftpEntries = (
return sorted;
};
/**
* Check if an entry is navigable like a directory
* This includes regular directories and symlinks that point to directories
*/
export const isNavigableDirectory = (entry: SftpFileEntry): boolean => {
return entry.type === 'directory' || (entry.type === 'symlink' && entry.linkTarget === 'directory');
};
/**
* Check if a file is hidden
* - Windows: checks the `hidden` attribute (set by localFsBridge)
+26
View File
@@ -0,0 +1,26 @@
const SNIPPETS_GRID_GAP = 12;
const SNIPPETS_GRID_MIN_CARD_WIDTH = 220;
/** Container-width thresholds (mirrors former md / xl column steps). */
const SNIPPETS_GRID_MD_MIN_WIDTH = 768;
const SNIPPETS_GRID_XL_MIN_WIDTH = 1280;
const SNIPPETS_GRID_MAX_COLUMNS = 3;
/** Column policy for snippets — shared by virtualized and CSS-var grids. */
export function getSnippetsGridColumnCount(
width: number,
options: { hasSidePanel: boolean },
): number {
if (!Number.isFinite(width) || width <= 0) return 1;
if (options.hasSidePanel) {
return Math.max(
1,
Math.floor(
(width + SNIPPETS_GRID_GAP)
/ (SNIPPETS_GRID_MIN_CARD_WIDTH + SNIPPETS_GRID_GAP),
),
);
}
if (width >= SNIPPETS_GRID_XL_MIN_WIDTH) return SNIPPETS_GRID_MAX_COLUMNS;
if (width >= SNIPPETS_GRID_MD_MIN_WIDTH) return 2;
return 1;
}
@@ -27,8 +27,8 @@ import {
SystemPanelToolbar,
} from './SystemPanelUi';
import { SystemPanelConfirmDialog } from './SystemPanelConfirmDialog';
import { useAsyncRecordCache } from './hooks/useAsyncRecordCache';
import { usePolling, useStableTranslate } from './hooks/useSystemManager';
import { useAsyncRecordCache } from '../../application/state/systemManager/useAsyncRecordCache';
import { usePolling, useStableTranslate } from '../../application/state/useSystemManager';
import { openInteractiveTerminal } from './openInteractiveTerminal';
import { showSystemManagerError } from './systemManagerToast';
@@ -23,8 +23,8 @@ import {
} from './SystemPanelUi';
import { SystemPanelConfirmDialog } from './SystemPanelConfirmDialog';
import { SystemPanelPromptDialog } from './SystemPanelPromptDialog';
import { useAsyncRecordCache } from './hooks/useAsyncRecordCache';
import { usePolling, useStableTranslate } from './hooks/useSystemManager';
import { useAsyncRecordCache } from '../../application/state/systemManager/useAsyncRecordCache';
import { usePolling, useStableTranslate } from '../../application/state/useSystemManager';
import { showSystemManagerError } from './systemManagerToast';
type Backend = ReturnType<typeof useSystemManagerBackend>;
+1 -1
View File
@@ -20,7 +20,7 @@ import {
SystemPanelStatusBadge,
SystemPanelToolbar,
} from './SystemPanelUi';
import { usePolling, useStableTranslate } from './hooks/useSystemManager';
import { usePolling, useStableTranslate } from '../../application/state/useSystemManager';
type Backend = ReturnType<typeof useSystemManagerBackend>;
@@ -33,7 +33,7 @@ import {
} from './SystemPanelUi';
import { SystemPanelConfirmDialog } from './SystemPanelConfirmDialog';
import { SystemPanelPromptDialog } from './SystemPanelPromptDialog';
import { usePolling, useStableTranslate } from './hooks/useSystemManager';
import { usePolling, useStableTranslate } from '../../application/state/useSystemManager';
import {
getCachedProcessList,
setCachedProcessList,
@@ -18,7 +18,7 @@ import { TmuxManagerTab } from './TmuxManagerTab';
import { WorkspaceSidebarHostHeader } from '../terminalLayer/WorkspaceSidebarHostHeader';
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from '../terminalLayer/terminalSidePanelChrome';
import { SystemPanelEmpty, SystemPanelShell } from './SystemPanelUi';
import { useSessionCapabilities } from './hooks/useSystemManager';
import { useSessionCapabilities } from '../../application/state/useSystemManager';
const SystemPanelChecking = memo(function SystemPanelChecking({
message,
@@ -10,7 +10,7 @@ import React, { memo, useEffect, useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { aggregateMountedDiskUsage } from '../../domain/systemDiskUsage';
import { cn } from '../../lib/utils';
import { useServerStats } from '../terminal/hooks/useServerStats';
import { useServerStats } from '../../application/state/useServerStats';
import { ResourceBar } from './ResourceBar';
import {
SystemPanelEmpty,
Loaded 100 of 177 files, more files were not shown because too many files have changed in this diff. Show more