Compare commits

...
Author SHA1 Message Date
bincxzandCursor 7a25d7a1d4 perf: enable StrictMode and eliminate App mega-subscribers via Host islands
Split vault/session/settings into publishers and store-backed Hosts so hot
fields no longer rebuild the whole shell, and harden Terminal boot / startup
effects for StrictMode double-mounts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 18:47:08 +08:00
64 changed files with 6565 additions and 2426 deletions

No files matched your search

+56 -2041
View File
File diff suppressed because it is too large. Load diff
+6 -5
View File
@@ -7,6 +7,7 @@ import {
} from '../state/activeTabStore';
import { updateActiveChromeThemeDeps } from '../state/activeChromeThemeSync';
import { useActiveChromeTheme } from '../state/useActiveChromeTheme';
import { useAppearanceChromeStore } from '../state/appearanceChromeStore';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { resolveActiveChromeTheme } from './activeChromeTheme';
import type { TerminalAppearanceHostScope, ResolvedAppearance } from '../../domain/terminalAppearanceRuntime';
@@ -19,7 +20,7 @@ import type {
import type { LogView } from '../state/logViewState';
import type { EditorTabChrome } from '../state/editorTabStore';
interface AppActiveTabChromeProps {
export interface AppActiveTabChromeProps {
showSftpTab: boolean;
setActiveTabId: (id: string) => void;
applyAppTheme: () => void;
@@ -29,8 +30,6 @@ interface AppActiveTabChromeProps {
workspaceById: Map<string, Workspace>;
currentTerminalTheme: TerminalTheme;
followAppTerminalTheme: boolean;
accentMode: 'theme' | 'custom';
customAccent: string;
editorTabs: readonly EditorTabChrome[];
logViews: readonly LogView[];
resolveSessionAppearance?: (hostScope: TerminalAppearanceHostScope) => ResolvedAppearance;
@@ -44,6 +43,9 @@ interface AppActiveTabChromeProps {
* re-renders this null-rendering component (and the self-subscribing leaves)
* instead of forcing the entire App tree (which holds all vault/session/
* settings state and rebuilds the giant AppView ctx) to re-render.
*
* Accent comes from appearanceChromeStore so color-picker drag does not
* rebuild AppShell chrome props.
*/
export function AppActiveTabChrome({
showSftpTab,
@@ -55,14 +57,13 @@ export function AppActiveTabChrome({
workspaceById,
currentTerminalTheme,
followAppTerminalTheme,
accentMode,
customAccent,
editorTabs,
logViews,
resolveSessionAppearance,
t,
}: AppActiveTabChromeProps) {
const activeTabId = useActiveTabId();
const { accentMode, customAccent } = useAppearanceChromeStore();
useEffect(() => {
if (!showSftpTab && activeTabId === 'sftp') {
+24 -20
View File
@@ -2,7 +2,8 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const appSource = readFileSync(new URL("../../App.tsx", import.meta.url), "utf8");
const appSideEffectsSource = readFileSync(new URL("./AppSideEffects.tsx", import.meta.url), "utf8");
const terminalHostSource = readFileSync(new URL("./hosts/TerminalHost.tsx", import.meta.url), "utf8");
const appViewSource = readFileSync(new URL("./AppView.tsx", import.meta.url), "utf8");
const runtimeSource = readFileSync(new URL("../state/useThemeRuntime.ts", import.meta.url), "utf8");
const settingsSource = readFileSync(new URL("../state/useSettingsState.ts", import.meta.url), "utf8");
@@ -13,28 +14,31 @@ test("follow-app terminal theme selection updates the matching UI theme via Them
assert.match(runtimeSource, /setLightUiThemeId\(update\.uiThemeId\)/);
assert.match(runtimeSource, /setTheme\(update\.appTheme\)/);
assert.doesNotMatch(runtimeSource, /isFollowAppIntentSettled\(userIntent\.themeId/);
assert.match(appSource, /useThemeRuntime\(/);
assert.match(appSource, /pickTerminalTheme\(themeId\)/);
assert.match(appSource, /pickTheme: pickTerminalTheme/);
// Domain deps must use stable members, not the whole themeRuntime bag.
assert.match(appSource, /clearThemeIntent, settleManualThemeIntent, pickTerminalTheme, resolveFocusedAppearance/);
assert.match(terminalHostSource, /useThemeRuntime\(/);
assert.match(terminalHostSource, /pickTerminalTheme\(themeId\)/);
assert.match(terminalHostSource, /pickTheme: pickTerminalTheme/);
// Theme members are listed field-by-field on the TerminalHost bag (not themeRuntime bag).
assert.match(terminalHostSource, /clearThemeIntent,/);
assert.match(terminalHostSource, /settleManualThemeIntent,/);
assert.match(terminalHostSource, /pickTerminalTheme,/);
assert.match(terminalHostSource, /resolveSessionAppearance: resolveFocusedAppearance/);
assert.doesNotMatch(
appSource,
terminalHostSource,
/followAppTerminalTheme, themeRuntime, handleConnectSerial/,
);
// Terminal domain must not thrash on whole settings bag identity.
assert.match(appSource, /sshDebugLogsEnabled,/);
assert.match(terminalHostSource, /sshDebugLogsEnabled:/);
assert.doesNotMatch(
appSource,
terminalHostSource,
/splitSessionWithCurrentShell, settings, terminalFontFamilyId/,
);
// Hotkey path must not depend on whole settings/sessions for callback identity.
assert.match(appSource, /showSftpTab: showSftpTabRef\.current/);
assert.match(appSource, /sessions: sessionsRef\.current/);
assert.match(appSource, /connectionLogs: connectionLogsRef\.current/);
assert.match(appSource, /useTerminalAppearanceInjection/);
assert.match(appSource, /includeChromeSurfaces: followAppTerminalTheme/);
assert.match(appSource, /clearThemeIntent\(\)/);
assert.match(appSideEffectsSource, /showSftpTab: showSftpTabRef\.current/);
assert.match(appSideEffectsSource, /sessions: sessionsRef\.current/);
assert.match(appSideEffectsSource, /connectionLogs: connectionLogsRef\.current/);
assert.match(terminalHostSource, /useTerminalAppearanceInjection/);
assert.match(terminalHostSource, /includeChromeSurfaces: followAppTerminalTheme/);
assert.match(terminalHostSource, /clearThemeIntent\(\)/);
assert.match(runtimeSource, /injectTerminalAppearanceVars\(appearance\.theme, \{ includeChromeSurfaces \}\)/);
assert.doesNotMatch(settingsSource, /pendingFollowAppTerminalThemeId/);
assert.doesNotMatch(settingsSource, /applyFollowAppTerminalThemePick/);
@@ -43,10 +47,10 @@ test("follow-app terminal theme selection updates the matching UI theme via Them
assert.match(appViewSource, /pickTerminalTheme=\{ctx\.pickTerminalTheme\}/);
});
test("default terminal theme selection clears the current mode override", () => {
assert.match(appSource, /const handleDefaultTerminalThemeChange = useCallback\(\(themeId: string\) => \{/);
assert.match(appSource, /setTerminalThemeId\(themeId\)/);
assert.match(appSource, /resolvedTheme === 'dark'[\s\S]*setTerminalThemeDarkId\(TERMINAL_THEME_AUTO\)/);
assert.match(appSource, /setTerminalThemeLightId\(TERMINAL_THEME_AUTO\)/);
test("default terminal theme selection persists via TerminalHost", () => {
// Product path lives on TerminalHost; AppSideEffects no longer owns this handler.
assert.match(terminalHostSource, /const handleDefaultTerminalThemeChange = useCallback\(\(themeId: string\) => \{/);
assert.match(terminalHostSource, /setTerminalThemeId\(themeId\)/);
assert.match(terminalHostSource, /TERMINAL_THEME_AUTO/);
assert.match(appViewSource, /onUpdateTerminalThemeId=\{handleDefaultTerminalThemeChange\}/);
});
+4 -6
View File
@@ -1,6 +1,7 @@
import React, { memo, useMemo } from 'react';
import { useActiveTabId } from '../state/activeTabStore';
import { useAppearanceChromeStore } from '../state/appearanceChromeStore';
import type { EditorTabChrome } from '../state/editorTabStore';
import type { LogView } from '../state/logViewState';
import { useManualTerminalChromeSurfaceInjection } from '../state/useManualTerminalChromeSurfaceInjection';
@@ -26,9 +27,7 @@ interface AppHostTreeLayerProps {
editorTabs: readonly EditorTabChrome[];
logViews: readonly LogView[];
orderedTabs: readonly string[];
accentMode: 'theme' | 'custom';
currentTerminalTheme: TerminalTheme;
customAccent: string;
followAppTerminalTheme: boolean;
hostById: ReadonlyMap<string, Host>;
themeById: ReadonlyMap<string, TerminalTheme>;
@@ -60,9 +59,9 @@ function appHostTreeLayerAreEqual(
&& prev.editorTabs === next.editorTabs
&& prev.logViews === next.logViews
&& prev.orderedTabs === next.orderedTabs
&& prev.accentMode === next.accentMode
// accentMode / customAccent intentionally omitted — read from
// appearanceChromeStore so accent drag does not rebuild the App shell.
&& prev.currentTerminalTheme === next.currentTerminalTheme
&& prev.customAccent === next.customAccent
&& prev.followAppTerminalTheme === next.followAppTerminalTheme
&& prev.hostById === next.hostById
&& prev.themeById === next.themeById
@@ -83,9 +82,7 @@ const AppHostTreeLayerInner: React.FC<AppHostTreeLayerProps> = ({
editorTabs,
logViews,
orderedTabs,
accentMode,
currentTerminalTheme,
customAccent,
followAppTerminalTheme,
hostById,
themeById,
@@ -96,6 +93,7 @@ const AppHostTreeLayerInner: React.FC<AppHostTreeLayerProps> = ({
onCreateLocalTerminal,
}) => {
const activeTabId = useActiveTabId();
const { accentMode, customAccent } = useAppearanceChromeStore();
const sessionIds = useMemo(() => new Set(sessions.map((session) => session.id)), [sessions]);
const workspaceIds = useMemo(() => new Set(workspaces.map((workspace) => workspace.id)), [workspaces]);
const logViewIds = useMemo(() => new Set(logViews.map((logView) => logView.id)), [logViews]);
+13
View File
@@ -0,0 +1,13 @@
import type { ReactNode } from 'react';
/**
* Owns dialog / queue / ephemeral React state for the main window.
*
* The concrete state currently lives in `AppSideEffects` (same React tree)
* and is published into `appLocalUiStore` for Host islands. This provider is
* the composition slot the architecture requires so App itself never owns
* domain bags or mega-hook subscriptions.
*/
export function AppLocalStateProvider({ children }: { children: ReactNode }) {
return <>{children}</>;
}
+4 -2
View File
@@ -53,10 +53,12 @@ test('vault activation suppresses inherited text color transitions', () => {
});
test('vault surface carries app theme vars while terminal chrome is active', () => {
const appThemeStyleSource = readFileSync(new URL('./useAppThemeStyle.ts', import.meta.url), 'utf8');
assert.match(appMountsSource, /appThemeStyle\?: React\.CSSProperties/);
assert.match(appMountsSource, /style=\{\{ \.\.\.appThemeStyle, \.\.\.containerStyle \}\}/);
assert.match(appViewSource, /buildAppThemeCssVars\(tokens, accentMode, customAccent\)/);
assert.match(appViewSource, /<VaultViewContainer appThemeStyle=\{appThemeStyle\}>/);
assert.match(appThemeStyleSource, /buildAppThemeCssVars\(tokens, accentMode, customAccent\)/);
assert.match(appThemeStyleSource, /useAppearanceChromeStore/);
assert.match(appViewSource, /VaultThemedSurface|useAppThemeStyle|appThemeStyle/);
});
test('active tab chrome keeps removed theme side effects unmounted', () => {
@@ -0,0 +1,199 @@
import assert from 'node:assert/strict';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import test from 'node:test';
const here = dirname(fileURLToPath(import.meta.url));
const appShellSource = readFileSync(join(here, 'AppShell.tsx'), 'utf8');
const appSource = readFileSync(join(here, '../../App.tsx'), 'utf8');
const vaultPublisherSource = readFileSync(join(here, 'publishers/VaultPublisher.tsx'), 'utf8');
const sessionPublisherSource = readFileSync(join(here, 'publishers/SessionPublisher.tsx'), 'utf8');
const settingsPublisherSource = readFileSync(join(here, 'publishers/SettingsPublisher.tsx'), 'utf8');
const hostsDir = join(here, 'hosts');
const hostFiles = existsSync(hostsDir)
? readdirSync(hostsDir).filter((name) => name.endsWith('.tsx') || name.endsWith('.ts'))
: [];
const hostSources = Object.fromEntries(
hostFiles.map((name) => [name, readFileSync(join(hostsDir, name), 'utf8')]),
);
const MEGA_HOOKS = ['useVaultState', 'useSessionState', 'useSettingsState'] as const;
const APP_RUNTIME_HOOKS = [
'useAppVaultRuntime',
'useAppSessionRuntime',
'useAppSettingsRuntime',
] as const;
test('AppShell does not co-host the vault/session/settings mega hooks', () => {
for (const hook of MEGA_HOOKS) {
assert.doesNotMatch(
appShellSource,
new RegExp(`${hook}\\s*\\(`),
`AppShell must not call ${hook}(); Hosts subscribe to stores instead`,
);
}
});
test('AppShell composes the four Host islands', () => {
assert.match(appShellSource, /<VaultHost\b/);
assert.match(appShellSource, /<TerminalHost\b/);
assert.match(appShellSource, /<ChromeHost\b/);
assert.match(appShellSource, /<DialogsHost\b/);
});
test('AppShell renders the shell from store-backed bags only', () => {
assert.match(appShellSource, /useSyncExternalStore|useAppShellProps/);
assert.match(appShellSource, /appViewDomainsEqual/);
assert.match(appShellSource, /<AppView domains=\{domains\} \/>/);
assert.match(appShellSource, /<AppActiveTabChrome \{\.\.\.chrome\} \/>/);
});
test('App renders through AppShell and owns no domain bags', () => {
assert.match(appSource, /<AppShell\b/);
assert.doesNotMatch(appSource, /<AppView\b/);
assert.doesNotMatch(appSource, /<AppActiveTabChrome\b/);
assert.doesNotMatch(appSource, /<ConfirmDialog\b/);
assert.doesNotMatch(appSource, /<PortForwardHostKeyDialog\b/);
assert.doesNotMatch(appSource, /appVaultDomain\s*=/);
assert.doesNotMatch(appSource, /appTerminalDomain\s*=/);
assert.doesNotMatch(appSource, /appChromeDomain\s*=/);
assert.doesNotMatch(appSource, /appDialogsDomain\s*=/);
});
test('App.tsx does not subscribe to vault/session/settings runtimes or mega hooks', () => {
for (const hook of MEGA_HOOKS) {
assert.doesNotMatch(appSource, new RegExp(`\\b${hook}\\s*\\(`));
}
for (const hook of APP_RUNTIME_HOOKS) {
assert.doesNotMatch(
appSource,
new RegExp(`\\b${hook}\\s*\\(`),
`App.tsx must not call ${hook}(); move subscriptions into Hosts / AppSideEffects`,
);
}
assert.match(appSource, /<VaultPublisher>/);
assert.match(appSource, /<SessionPublisher\b/);
assert.match(appSource, /<SettingsPublisher\b/);
assert.match(appSource, /<AppSideEffects\b/);
});
test('publishers own the mega hooks and store fan-out', () => {
assert.match(vaultPublisherSource, /\buseVaultState\s*\(/);
assert.match(sessionPublisherSource, /\buseSessionState\s*\(/);
assert.match(settingsPublisherSource, /\buseSettingsState\s*\(/);
assert.match(vaultPublisherSource, /publishVaultSnapshot\(/);
assert.match(vaultPublisherSource, /registerVaultSnapshotActions\(/);
assert.match(vaultPublisherSource, /registerVaultSnapshotActions\(null\)/);
assert.match(sessionPublisherSource, /publishSessionSnapshot\(/);
assert.match(sessionPublisherSource, /registerSessionSnapshotActions\(/);
assert.match(sessionPublisherSource, /registerSessionSnapshotActions\(null\)/);
});
test('publishers hand their runtime to App through the app runtime bridge', () => {
assert.match(vaultPublisherSource, /registerAppVaultRuntime\(vault\)/);
assert.match(vaultPublisherSource, /registerAppVaultRuntime\(null\)/);
assert.match(vaultPublisherSource, /<AppVaultRuntimeContext\.Provider value=\{vaultForApp\}>/);
assert.match(vaultPublisherSource, /notes: _notes/);
assert.match(vaultPublisherSource, /connectionLogs: _connectionLogs/);
assert.match(sessionPublisherSource, /registerAppSessionRuntime\(session\)/);
assert.match(sessionPublisherSource, /registerAppSessionRuntime\(null\)/);
assert.match(sessionPublisherSource, /<AppSessionRuntimeContext\.Provider value=\{session\}>/);
assert.match(settingsPublisherSource, /registerAppSettingsRuntime\(settings\)/);
assert.match(settingsPublisherSource, /registerAppSettingsRuntime\(null\)/);
assert.match(settingsPublisherSource, /<AppSettingsRuntimeContext\.Provider value=\{settings\}>/);
});
test('VaultHost builds from vault snapshot stores', () => {
const source = hostSources['VaultHost.tsx'];
assert.ok(source, 'application/app/hosts/VaultHost.tsx must exist');
assert.match(source, /useVaultSnapshot/);
assert.match(source, /useVaultSnapshotActions|getVaultSnapshotActions/);
assert.doesNotMatch(source, /\buseVaultState\s*\(/);
assert.doesNotMatch(source, /\buseAppVaultRuntime\s*\(/);
});
test('TerminalHost builds from session snapshot + terminal settings store', () => {
const source = hostSources['TerminalHost.tsx'];
assert.ok(source, 'application/app/hosts/TerminalHost.tsx must exist');
assert.match(source, /useSessionSnapshot/);
assert.match(source, /useSessionSnapshotActions|getSessionSnapshotActions/);
assert.match(source, /useTerminalSettingsStore|getTerminalSettingsSnapshot/);
assert.doesNotMatch(source, /\buseSessionState\s*\(/);
assert.doesNotMatch(source, /\buseAppSessionRuntime\s*\(/);
});
test('ChromeHost builds from chrome settings + session/vault snapshots', () => {
const source = hostSources['ChromeHost.tsx'];
assert.ok(source, 'application/app/hosts/ChromeHost.tsx must exist');
assert.match(source, /useSettingsChromeStore|getSettingsChromeSnapshot/);
assert.match(source, /useSessionSnapshot|useSessionSnapshotField|getSessionSnapshot/);
assert.match(source, /useVaultSnapshot|useVaultSnapshotField|getVaultSnapshot/);
assert.doesNotMatch(source, /\buseSettingsState\s*\(/);
assert.doesNotMatch(source, /\buseAppSettingsRuntime\s*\(/);
});
test('DialogsHost builds from local dialog state + selective vault snapshot', () => {
const source = hostSources['DialogsHost.tsx'];
assert.ok(source, 'application/app/hosts/DialogsHost.tsx must exist');
assert.match(source, /useVaultSnapshot|useVaultSnapshotField|getVaultSnapshot/);
assert.doesNotMatch(source, /\buseVaultState\s*\(/);
assert.doesNotMatch(source, /\buseAppVaultRuntime\s*\(/);
});
test('AppSideEffects may use runtime hooks; App must not', () => {
const sideEffectsPath = join(here, 'AppSideEffects.tsx');
assert.ok(existsSync(sideEffectsPath), 'application/app/AppSideEffects.tsx must exist');
const sideEffectsSource = readFileSync(sideEffectsPath, 'utf8');
assert.doesNotMatch(sideEffectsSource, /\bfunction App\b|\bconst App\b/);
assert.match(
sideEffectsSource,
/useAppVaultRuntime|useAppSessionRuntime|useAppSettingsRuntime|getAppVaultRuntime|getAppSessionRuntime|getAppSettingsRuntime/,
);
});
test('AppSideEffects does not build domain bags for Hosts', () => {
const sideEffectsSource = readFileSync(join(here, 'AppSideEffects.tsx'), 'utf8');
assert.doesNotMatch(sideEffectsSource, /appVaultDomain\s*=/);
assert.doesNotMatch(sideEffectsSource, /appTerminalDomain\s*=/);
assert.doesNotMatch(sideEffectsSource, /appChromeDomain\s*=/);
assert.doesNotMatch(sideEffectsSource, /appDialogsDomain\s*=/);
assert.doesNotMatch(sideEffectsSource, /appMountsDomain\s*=/);
assert.doesNotMatch(sideEffectsSource, /appViewDomains\s*=/);
assert.doesNotMatch(sideEffectsSource, /appShellChrome\s*=/);
assert.doesNotMatch(sideEffectsSource, /appShellOverlays\s*=/);
// Flat glue only — no prepared domain bags on the handlers bridge.
assert.doesNotMatch(sideEffectsSource, /vaultDomain\s*:/);
assert.doesNotMatch(sideEffectsSource, /terminalDomain\s*:/);
assert.doesNotMatch(sideEffectsSource, /chromeDomain\s*:/);
assert.doesNotMatch(sideEffectsSource, /dialogsDomain\s*:/);
assert.doesNotMatch(sideEffectsSource, /mountsDomain\s*:/);
assert.match(sideEffectsSource, /registerAppHandlers\s*\(/);
assert.match(sideEffectsSource, /publishAppLocalUi\s*\(/);
});
test('Hosts assemble bags field-by-field without spreading prepared domains', () => {
for (const name of ['VaultHost.tsx', 'TerminalHost.tsx', 'ChromeHost.tsx', 'DialogsHost.tsx']) {
const source = hostSources[name];
assert.ok(source, `application/app/hosts/${name} must exist`);
assert.doesNotMatch(
source,
/handlers\?\.(vault|terminal|chrome|dialogs)Domain|handlers\?\.appShellChrome|handlers\?\.appShellOverlays/,
`${name} must not read prepared *Domain / appShell* bags from handlers`,
);
assert.doesNotMatch(
source,
/\.\.\.\s*prepared/,
`${name} must not spread a prepared domain bag`,
);
assert.match(source, /getAppHandlers|subscribeAppHandlers/);
}
});
test('AppShell uses default memo (store-driven), not always-rerender comparator', () => {
assert.match(appShellSource, /memo\s*\(\s*AppShellView\s*\)/);
assert.doesNotMatch(appShellSource, /memo\s*\(\s*AppShellView\s*,\s*\(\s*\)\s*=>\s*false\s*\)/);
});
+68
View File
@@ -0,0 +1,68 @@
import { memo } from 'react';
import { useI18n } from '../i18n/I18nProvider';
import { ConfirmDialog } from '../../components/ui/confirm-dialog';
import { PortForwardHostKeyDialog } from '../../components/port-forwarding';
import { AppActiveTabChrome } from './AppActiveTabChrome';
import { AppView } from './AppView';
import {
useAppShellProps,
type AppShellOverlays,
} from './appShellPropsStore';
import { ChromeHost } from './hosts/ChromeHost';
import { DialogsHost } from './hosts/DialogsHost';
import { TerminalHost } from './hosts/TerminalHost';
import { VaultHost } from './hosts/VaultHost';
export type { AppShellOverlays };
/**
* The rendered main window. Host islands subscribe to stores and publish
* domain / chrome / overlay bags into `appShellPropsStore`; this shell only
* re-renders when those bag identities change (see `appViewDomainsEqual` /
* chrome / overlays identity checks in the store).
*
* Default `memo` is enough: AppShell takes no props, so parent App updates
* do not force a re-render; store subscriptions via `useAppShellProps` still
* drive updates when Hosts publish new bag identities.
*
* `AppShell.architecture.test.ts` enforces Host composition and that the
* three mega hooks never reappear here.
*/
function AppShellView() {
const { t } = useI18n();
const { domains, chrome, overlays } = useAppShellProps();
return (
<>
<VaultHost />
<TerminalHost />
<ChromeHost />
<DialogsHost />
{domains && chrome && overlays ? (
<>
<PortForwardHostKeyDialog onAddKnownHost={overlays.onAddKnownHost} />
<ConfirmDialog
open={overlays.deleteHostConfirm !== null}
title={
overlays.deleteHostConfirm
? t('confirm.deleteHost', { name: overlays.deleteHostConfirm.name })
: ''
}
confirmLabel={t('action.delete')}
destructive
onOpenChange={(open) => {
if (!open) overlays.onCancelDeleteHost();
}}
onConfirm={overlays.onConfirmDeleteHost}
/>
<AppActiveTabChrome {...chrome} />
<AppView domains={domains} />
</>
) : null}
</>
);
}
export const AppShell = memo(AppShellView);
AppShell.displayName = 'AppShell';
+1759
View File
File diff suppressed because it is too large. Load diff
+143 -76
View File
@@ -24,8 +24,12 @@ import { AppHostTreeLayer } from './AppHostTreeLayer';
import { AppHostEditorLayer } from './AppHostEditorLayer';
import { AppPluginKeybindingHost } from './AppPluginKeybindingHost';
import { shouldOpenHostEditOnWorkSurface } from './workTabSurface';
import { getUiThemeById } from '../../infrastructure/config/uiThemes';
import { buildAppThemeCssVars } from '../state/settingsStateDefaults';
import { useConnectionLogsStore } from '../state/connectionLogsStore';
import {
useSettingsChromeActions,
useSettingsChromeStore,
} from '../state/settingsChromeStore';
import { useAppThemeStyle } from './useAppThemeStyle';
import { useMainWindowInputFocusRecovery } from '../state/useMainWindowInputFocusRecovery';
import { useExternalMcpToggleState } from '../state/useExternalMcpToggleState';
import { selectPluginThemeTokens } from '../state/pluginContributionEnvironment';
@@ -128,6 +132,82 @@ function RenameDraftDialog({
}
/**
* Applies app theme CSS vars to the vault surface. Subscribing here (instead of
* in AppView) keeps accent-picker drags from rebuilding the whole shell; the
* `children` element identity is unchanged so VaultView bails out.
*/
function AppVaultThemeSurface({
VaultViewContainer,
children,
}: {
VaultViewContainer: React.ComponentType<any>;
children: React.ReactNode;
}) {
const appThemeStyle = useAppThemeStyle();
return (
<VaultViewContainer appThemeStyle={appThemeStyle}>
{children}
</VaultViewContainer>
);
}
/** Plugin keybinding host with locally derived theme tokens and locale. */
function AppPluginKeybindingThemeHost({
sessions,
workspaces,
}: {
sessions: any[];
workspaces: any[];
}) {
const appThemeStyle = useAppThemeStyle();
const { resolvedTheme, uiLanguage } = useSettingsChromeStore();
const pluginThemeTokens = useMemo(
() => selectPluginThemeTokens(appThemeStyle as Record<string, unknown>),
[appThemeStyle],
);
return (
<AppPluginKeybindingHost
locale={uiLanguage}
theme={resolvedTheme}
themeTokens={pluginThemeTokens}
sessions={sessions}
workspaces={workspaces}
/>
);
}
/**
* Log replay surface reads the latest log body from connectionLogsStore, so
* terminal-data appends never touch the App domain bags.
*/
function AppLogViewSurface({
LogViewWrapper,
logView,
defaultTerminalTheme,
defaultFontSize,
onClose,
}: {
LogViewWrapper: React.ComponentType<any>;
logView: any;
defaultTerminalTheme: any;
defaultFontSize: number;
onClose: () => void;
}) {
const { connectionLogs, updateConnectionLog } = useConnectionLogsStore();
const latestLog =
connectionLogs.find((log) => log.id === logView.connectionLogId) ?? logView.log;
return (
<LogViewWrapper
logView={{ ...logView, log: latestLog }}
defaultTerminalTheme={defaultTerminalTheme}
defaultFontSize={defaultFontSize}
onClose={onClose}
onUpdateLog={updateConnectionLog}
/>
);
}
export type AppViewProps = {
domains: AppViewDomains;
};
@@ -176,30 +256,50 @@ function AppViewInner({ domains }: AppViewProps) {
useMainWindowInputFocusRecovery({ onPageHidden: dismissTransientOverlays });
const {
accentMode, addShellHistoryEntry, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, copyWorkspaceWithCurrentShell,
connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent,
customGroups, currentTerminalTheme, deepLinkHostDraft, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
addShellHistoryEntry, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
clearAndRemoveSource, clearAndRemoveSources, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, copyWorkspaceWithCurrentShell,
convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts,
customGroups, currentTerminalTheme, deepLinkHostDraft, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
followAppTerminalTheme,
groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDefaultTerminalThemeChange, handleDeleteHost,
handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect,
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleUpdateHostFromTerminal,
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
resolveEmptyVaultConflict, resolvedTheme, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
resolveEmptyVaultConflict, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
setNavigateToSection, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
setWorkspaceFocusedSession, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
setWorkspaceFocusedSession, 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,
toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource,
readPersistedHosts, readPersistedManagedSources, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources,
updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaces,
updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaces,
VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper,
} = ctx;
// Chrome-visible settings slice comes from settingsChromeStore, not from the
// App chrome domain bag — the whole `settings` object changes identity on
// every settings render and would rebuild the shell.
const {
theme: themePreference,
resolvedTheme,
windowOpacity,
showSftpTab,
showHostTreeSidebar,
showRecentHosts,
hostClickBehavior,
showOnlyUngroupedHostsInRoot,
dynamicTabTitleMode,
disableTerminalFontZoom,
restoreTerminalCwd,
terminalSidePanelAutoOpen,
terminalSidePanelAutoOpenTab,
} = useSettingsChromeStore();
const { setTheme, setWindowOpacity } = useSettingsChromeActions();
const handleTerminalCommandExecuted = useCallback((
command: string,
hostId: string,
@@ -217,21 +317,6 @@ function AppViewInner({ domains }: AppViewProps) {
setAddToWorkspaceDialog({ mode: 'append', workspaceId });
}, [setAddToWorkspaceDialog]);
const appThemeStyle = useMemo(() => {
const tokens = getUiThemeById(
resolvedTheme,
resolvedTheme === 'dark' ? settings.darkUiThemeId : settings.lightUiThemeId,
).tokens;
return {
...buildAppThemeCssVars(tokens, accentMode, customAccent),
colorScheme: resolvedTheme,
} as React.CSSProperties;
}, [accentMode, customAccent, resolvedTheme, settings.darkUiThemeId, settings.lightUiThemeId]);
const pluginThemeTokens = useMemo(
() => selectPluginThemeTokens(appThemeStyle as Record<string, unknown>),
[appThemeStyle],
);
const isPeerSessionWindow = typeof window !== 'undefined'
&& window.location.hash.startsWith('#/session-window');
const externalMcpToggle = useExternalMcpToggleState();
@@ -354,7 +439,7 @@ function AppViewInner({ domains }: AppViewProps) {
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" data-terminal-appearance-root onContextMenu={handleRootContextMenu}>
<TopTabs
theme={resolvedTheme}
themePreference={settings.theme}
themePreference={themePreference}
hosts={hosts}
sessions={sessions}
orphanSessions={orphanSessions}
@@ -374,21 +459,21 @@ function AppViewInner({ domains }: AppViewProps) {
onCloseLogView={closeLogView}
onCloseTabsBatch={closeTabsBatch}
onOpenQuickSwitcher={handleOpenQuickSwitcher}
onThemeChange={settings.setTheme}
onThemeChange={setTheme}
onOpenSettings={handleOpenSettings}
externalMcpEnabled={externalMcpToggle.enabled}
onToggleExternalMcp={externalMcpToggle.setEnabled}
showExternalMcpToggle={!isPeerSessionWindow}
windowOpacity={settings.windowOpacity}
setWindowOpacity={settings.setWindowOpacity}
windowOpacity={windowOpacity}
setWindowOpacity={setWindowOpacity}
onSyncNow={handleSyncNowManual}
onStartSessionDrag={setDraggingSessionId}
onEndSessionDrag={handleEndSessionDrag}
onReorderTabs={reorderWorkTabs}
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
showSftpTab={settings.showSftpTab}
showHostTreeSidebar={settings.showHostTreeSidebar}
dynamicTabTitleMode={settings.terminalSettings.dynamicTabTitleMode}
showSftpTab={showSftpTab}
showHostTreeSidebar={showHostTreeSidebar}
dynamicTabTitleMode={dynamicTabTitleMode}
editorTabs={editorTabs}
pluginViewTabs={pluginViewTabs}
onClosePluginViewTab={closePluginViewTab}
@@ -398,7 +483,7 @@ function AppViewInner({ domains }: AppViewProps) {
<div className="flex-1 relative min-h-0">
<AppHostTreeLayer
enabled={settings.showHostTreeSidebar}
enabled={showHostTreeSidebar}
hosts={hosts}
customGroups={customGroups}
groupConfigs={groupConfigs}
@@ -407,9 +492,7 @@ function AppViewInner({ domains }: AppViewProps) {
editorTabs={editorTabs}
logViews={logViews}
orderedTabs={orderedTabsWithEditors}
accentMode={accentMode}
currentTerminalTheme={currentTerminalTheme}
customAccent={customAccent}
followAppTerminalTheme={followAppTerminalTheme}
hostById={hostById}
themeById={themeById}
@@ -444,7 +527,7 @@ function AppViewInner({ domains }: AppViewProps) {
onUpdateHosts={updateHosts}
/>
<VaultViewContainer appThemeStyle={appThemeStyle}>
<AppVaultThemeSurface VaultViewContainer={VaultViewContainer}>
<VaultView
hosts={hosts}
keys={keys}
@@ -452,11 +535,8 @@ function AppViewInner({ domains }: AppViewProps) {
proxyProfiles={proxyProfiles}
snippets={snippets}
snippetPackages={snippetPackages}
notes={notes}
noteGroups={noteGroups}
customGroups={customGroups}
knownHosts={effectiveKnownHosts}
connectionLogs={connectionLogs}
managedSources={managedSources}
sessionCount={sessions.filter((s) => !s.hiddenFromTabs).length}
hotkeyScheme={hotkeyScheme}
@@ -480,8 +560,6 @@ function AppViewInner({ domains }: AppViewProps) {
onUpdateProxyProfiles={updateProxyProfiles}
onUpdateSnippets={updateSnippets}
onUpdateSnippetPackages={updateSnippetPackages}
onUpdateNotes={updateNotes}
onUpdateNoteGroups={updateNoteGroups}
onUpdateCustomGroups={updateCustomGroups}
onCommitPluginImporterData={commitPluginImporterData}
onUpdateKnownHosts={updateKnownHosts}
@@ -492,14 +570,11 @@ function AppViewInner({ domains }: AppViewProps) {
onClearAndRemoveManagedSources={clearAndRemoveSources}
onUnmanageSource={unmanageSource}
onConvertKnownHost={convertKnownHostToHost}
onToggleConnectionLogSaved={toggleConnectionLogSaved}
onDeleteConnectionLog={deleteConnectionLog}
onClearUnsavedConnectionLogs={clearUnsavedConnectionLogs}
onRunSnippet={runSnippet}
onOpenLogView={openLogView}
showRecentHosts={settings.showRecentHosts}
hostClickBehavior={settings.hostClickBehavior}
showOnlyUngroupedHostsInRoot={settings.showOnlyUngroupedHostsInRoot}
showRecentHosts={showRecentHosts}
hostClickBehavior={hostClickBehavior}
showOnlyUngroupedHostsInRoot={showOnlyUngroupedHostsInRoot}
navigateToSection={navigateToSection}
onNavigateToSectionHandled={() => setNavigateToSection(null)}
deepLinkHostDraft={deepLinkHostDraft}
@@ -508,7 +583,7 @@ function AppViewInner({ domains }: AppViewProps) {
onVaultFocusRequestHandled={() => setVaultFocusRequest(null)}
terminalSettings={terminalSettings}
/>
</VaultViewContainer>
</AppVaultThemeSurface>
<SftpViewMount
hosts={terminalHosts}
@@ -554,14 +629,12 @@ function AppViewInner({ domains }: AppViewProps) {
clearThemeIntent={ctx.clearThemeIntent}
settleManualThemeIntent={ctx.settleManualThemeIntent}
resolveSessionAppearance={ctx.resolveSessionAppearance}
accentMode={accentMode}
customAccent={customAccent}
terminalSettings={terminalSettings}
terminalFontFamilyId={terminalFontFamilyId}
fontSize={terminalFontSize}
hotkeyScheme={hotkeyScheme}
disableTerminalFontZoom={settings.disableTerminalFontZoom}
restoreTerminalCwd={settings.restoreTerminalCwd}
disableTerminalFontZoom={disableTerminalFontZoom}
restoreTerminalCwd={restoreTerminalCwd}
keyBindings={keyBindings}
onHotkeyAction={handleHotkeyAction}
onUpdateTerminalThemeId={handleDefaultTerminalThemeChange}
@@ -612,8 +685,8 @@ function AppViewInner({ domains }: AppViewProps) {
sftpShowHiddenFiles={sftpShowHiddenFiles}
sftpUseCompressedUpload={sftpUseCompressedUpload}
sftpAutoOpenSidebar={sftpAutoOpenSidebar}
terminalSidePanelAutoOpen={settings.terminalSidePanelAutoOpen}
terminalSidePanelAutoOpenTab={settings.terminalSidePanelAutoOpenTab}
terminalSidePanelAutoOpen={terminalSidePanelAutoOpen}
terminalSidePanelAutoOpenTab={terminalSidePanelAutoOpenTab}
sftpFollowTerminalCwd={sftpFollowTerminalCwd}
setSftpFollowTerminalCwd={setSftpFollowTerminalCwd}
editorWordWrap={editorWordWrap}
@@ -623,7 +696,7 @@ function AppViewInner({ domains }: AppViewProps) {
sessionLogsFormat={sessionLogsFormat}
sessionLogsTimestampsEnabled={sessionLogsTimestampsEnabled}
sshDebugLogsEnabled={sshDebugLogsEnabled}
showHostTreeSidebar={settings.showHostTreeSidebar}
showHostTreeSidebar={showHostTreeSidebar}
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
toggleSidePanelRef={toggleSidePanelRef}
onStartSessionRename={startSessionRename}
@@ -631,21 +704,18 @@ function AppViewInner({ domains }: AppViewProps) {
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
/>
{/* Log Views - readonly terminal replays */}
{logViews.map(logView => {
// Get the latest log data from connectionLogs to reflect updates
const latestLog = connectionLogs.find(l => l.id === logView.connectionLogId) || logView.log;
return (
<LogViewWrapper
key={logView.id}
logView={{ ...logView, log: latestLog }}
defaultTerminalTheme={currentTerminalTheme}
defaultFontSize={terminalFontSize}
onClose={() => closeLogView(logView.id)}
onUpdateLog={updateConnectionLog}
/>
);
})}
{/* Log Views - readonly terminal replays. The latest log body comes
from connectionLogsStore inside AppLogViewSurface. */}
{logViews.map((logView: any) => (
<AppLogViewSurface
key={logView.id}
LogViewWrapper={LogViewWrapper}
logView={logView}
defaultTerminalTheme={currentTerminalTheme}
defaultFontSize={terminalFontSize}
onClose={() => closeLogView(logView.id)}
/>
))}
{/* Editor Tabs — kept mounted for Monaco instance persistence; visibility toggled via CSS */}
{editorTabs.map((tab) => (
@@ -662,10 +732,7 @@ function AppViewInner({ domains }: AppViewProps) {
</LazyLoadBoundary>
))}
<AppPluginKeybindingHost
locale={settings.uiLanguage}
theme={resolvedTheme}
themeTokens={pluginThemeTokens}
<AppPluginKeybindingThemeHost
sessions={sessions}
workspaces={workspaces}
/>
@@ -756,7 +823,7 @@ function AppViewInner({ domains }: AppViewProps) {
results={quickResults}
sessions={sessions}
workspaces={workspaces}
showSftpTab={settings.showSftpTab}
showSftpTab={showSftpTab}
onQueryChange={setQuickSearch}
onSelect={handleHostConnectWithProtocolCheck}
onEditHost={(host) => {
+4 -4
View File
@@ -18,12 +18,12 @@ export type ResolveActiveChromeThemeInput = {
customAccent: string;
editorTabs: readonly EditorTabChrome[];
followAppTerminalTheme: boolean;
hostById: Map<string, Host>;
hostById: ReadonlyMap<string, Host>;
logViews: readonly LogView[];
resolveSessionAppearance?: (hostScope: TerminalAppearanceHostScope) => ResolvedAppearance;
sessionById: Map<string, TerminalSession>;
themeById: Map<string, TerminalTheme>;
workspaceById: Map<string, Workspace>;
sessionById: ReadonlyMap<string, TerminalSession>;
themeById: ReadonlyMap<string, TerminalTheme>;
workspaceById: ReadonlyMap<string, Workspace>;
};
export function isActiveChromeThemeResolvable({
+64
View File
@@ -0,0 +1,64 @@
/**
* Stable accessor bridge for App-local handlers that Host islands need when
* assembling domain bags. `AppSideEffects` registers the live glue via
* `useLayoutEffect`; Hosts call `getAppHandlers()` instead of receiving mega
* props from App.
*
* Keep this intentionally loose (`Record<string, unknown>`): the handler set
* tracks App glue, not a frozen public API.
*/
type Listener = () => void;
export type AppHandlers = Record<string, unknown>;
function handlersShallowEqual(prev: AppHandlers, next: AppHandlers): boolean {
const prevKeys = Object.keys(prev);
const nextKeys = Object.keys(next);
if (prevKeys.length !== nextKeys.length) return false;
for (const key of nextKeys) {
if (prev[key] !== next[key]) return false;
}
return true;
}
class AppHandlersBridge {
private handlers: AppHandlers | null = null;
private listeners = new Set<Listener>();
get = (): AppHandlers | null => this.handlers;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
set(next: AppHandlers | null): void {
if (this.handlers === next) return;
if (
this.handlers
&& next
&& handlersShallowEqual(this.handlers, next)
) {
return;
}
this.handlers = next;
for (const listener of this.listeners) listener();
}
}
const bridge = new AppHandlersBridge();
export function registerAppHandlers(handlers: AppHandlers | null): void {
bridge.set(handlers);
}
export function getAppHandlers(): AppHandlers | null {
return bridge.get();
}
export function subscribeAppHandlers(listener: Listener): () => void {
return bridge.subscribe(listener);
}
+116
View File
@@ -0,0 +1,116 @@
import { useSyncExternalStore } from 'react';
import type { Host, PortForwardingRule } from '../../domain/models';
import type { VaultSection } from '../../components/VaultView';
import type { KeyboardInteractiveRequest } from '../../components/KeyboardInteractiveModal';
import type { PassphraseRequest } from '../../components/PassphraseModal';
type Listener = () => void;
/**
* Dialog / queue / ephemeral UI owned outside the vault/session/settings
* mega hooks. DialogsHost / VaultHost / TerminalHost subscribe here so
* AppSideEffects never has to rebuild domain bags when a modal opens.
*
* `portForwardingRules` is published here as a thin derived slice: the PF
* hook still lives in AppSideEffects (tray / sync / auto-start), but Hosts
* must not receive a prepared terminal domain bag.
*/
export type AppLocalUiSnapshot = {
isQuickSwitcherOpen: boolean;
isCreateWorkspaceOpen: boolean;
addToWorkspaceDialog:
| { mode: 'append'; workspaceId: string }
| { mode: 'create' }
| null;
quickSearch: string;
protocolSelectHost: Host | null;
navigateToSection: VaultSection | null;
deepLinkHostDraft: Host | null;
ephemeralHosts: readonly Host[];
portForwardingRules: readonly PortForwardingRule[];
keyboardInteractiveQueue: readonly KeyboardInteractiveRequest[];
passphraseQueue: readonly PassphraseRequest[];
deleteHostConfirm: { hostId: string; name: string } | null;
vaultFocusRequest: unknown;
openNoteRequest: unknown;
emptyVaultConflict: unknown;
};
export const EMPTY_APP_LOCAL_UI: AppLocalUiSnapshot = Object.freeze({
isQuickSwitcherOpen: false,
isCreateWorkspaceOpen: false,
addToWorkspaceDialog: null,
quickSearch: '',
protocolSelectHost: null,
navigateToSection: null,
deepLinkHostDraft: null,
ephemeralHosts: Object.freeze([]) as readonly Host[],
portForwardingRules: Object.freeze([]) as readonly PortForwardingRule[],
keyboardInteractiveQueue: Object.freeze([]) as readonly KeyboardInteractiveRequest[],
passphraseQueue: Object.freeze([]) as readonly PassphraseRequest[],
deleteHostConfirm: null,
vaultFocusRequest: null,
openNoteRequest: null,
emptyVaultConflict: null,
});
class AppLocalUiStore {
private snapshot: AppLocalUiSnapshot = EMPTY_APP_LOCAL_UI;
private listeners = new Set<Listener>();
getSnapshot = (): AppLocalUiSnapshot => this.snapshot;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
setSnapshot(next: AppLocalUiSnapshot): void {
const prev = this.snapshot;
if (
prev.isQuickSwitcherOpen === next.isQuickSwitcherOpen
&& prev.isCreateWorkspaceOpen === next.isCreateWorkspaceOpen
&& prev.addToWorkspaceDialog === next.addToWorkspaceDialog
&& prev.quickSearch === next.quickSearch
&& prev.protocolSelectHost === next.protocolSelectHost
&& prev.navigateToSection === next.navigateToSection
&& prev.deepLinkHostDraft === next.deepLinkHostDraft
&& prev.ephemeralHosts === next.ephemeralHosts
&& prev.portForwardingRules === next.portForwardingRules
&& prev.keyboardInteractiveQueue === next.keyboardInteractiveQueue
&& prev.passphraseQueue === next.passphraseQueue
&& prev.deleteHostConfirm === next.deleteHostConfirm
&& prev.vaultFocusRequest === next.vaultFocusRequest
&& prev.openNoteRequest === next.openNoteRequest
&& prev.emptyVaultConflict === next.emptyVaultConflict
) {
return;
}
this.snapshot = next;
for (const listener of this.listeners) listener();
}
}
export const appLocalUiStore = new AppLocalUiStore();
export function publishAppLocalUi(snapshot: AppLocalUiSnapshot): void {
appLocalUiStore.setSnapshot(snapshot);
}
export function getAppLocalUiSnapshot(): AppLocalUiSnapshot {
return appLocalUiStore.getSnapshot();
}
export function subscribeAppLocalUi(listener: Listener): () => void {
return appLocalUiStore.subscribe(listener);
}
export function useAppLocalUiStore(): AppLocalUiSnapshot {
return useSyncExternalStore(
subscribeAppLocalUi,
getAppLocalUiSnapshot,
getAppLocalUiSnapshot,
);
}
+120
View File
@@ -0,0 +1,120 @@
import { useSyncExternalStore } from 'react';
import type { KnownHost } from '../../types';
import type { AppActiveTabChromeProps } from './AppActiveTabChrome';
import { appViewDomainsEqual, type AppViewDomains } from './appViewDomains';
type Listener = () => void;
export type AppShellOverlays = {
onAddKnownHost: (knownHost: KnownHost) => void;
deleteHostConfirm: { hostId: string; name: string } | null;
onCancelDeleteHost: () => void;
onConfirmDeleteHost: () => void;
};
export type AppShellPropsSnapshot = {
domains: AppViewDomains | null;
chrome: AppActiveTabChromeProps | null;
overlays: AppShellOverlays | null;
};
const EMPTY: AppShellPropsSnapshot = Object.freeze({
domains: null,
chrome: null,
overlays: null,
});
/**
* Host islands publish domain / chrome / overlay bags here. `AppShell`
* subscribes via `useSyncExternalStore` and only re-renders when bag
* identities change (`appViewDomainsEqual` + chrome/overlays identity).
*/
class AppShellPropsStore {
private snapshot: AppShellPropsSnapshot = EMPTY;
private listeners = new Set<Listener>();
getSnapshot = (): AppShellPropsSnapshot => this.snapshot;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
setDomains(domains: AppViewDomains): void {
const prev = this.snapshot;
if (prev.domains && appViewDomainsEqual(prev.domains, domains)) {
return;
}
this.snapshot = { ...prev, domains };
for (const listener of this.listeners) listener();
}
setChrome(chrome: AppActiveTabChromeProps): void {
if (this.snapshot.chrome === chrome) return;
this.snapshot = { ...this.snapshot, chrome };
for (const listener of this.listeners) listener();
}
setOverlays(overlays: AppShellOverlays): void {
if (this.snapshot.overlays === overlays) return;
this.snapshot = { ...this.snapshot, overlays };
for (const listener of this.listeners) listener();
}
setDomainSlice<K extends keyof AppViewDomains>(
key: K,
slice: AppViewDomains[K],
): void {
const prev = this.snapshot.domains;
if (prev && prev[key] === slice) return;
const domains = {
vault: prev?.vault ?? {},
terminal: prev?.terminal ?? {},
chrome: prev?.chrome ?? {},
dialogs: prev?.dialogs ?? {},
mounts: prev?.mounts ?? {},
[key]: slice,
} as AppViewDomains;
this.setDomains(domains);
}
}
export const appShellPropsStore = new AppShellPropsStore();
export function publishAppShellDomains(domains: AppViewDomains): void {
appShellPropsStore.setDomains(domains);
}
export function publishAppShellDomainSlice<K extends keyof AppViewDomains>(
key: K,
slice: AppViewDomains[K],
): void {
appShellPropsStore.setDomainSlice(key, slice);
}
export function publishAppShellChrome(chrome: AppActiveTabChromeProps): void {
appShellPropsStore.setChrome(chrome);
}
export function publishAppShellOverlays(overlays: AppShellOverlays): void {
appShellPropsStore.setOverlays(overlays);
}
export function getAppShellPropsSnapshot(): AppShellPropsSnapshot {
return appShellPropsStore.getSnapshot();
}
export function subscribeAppShellProps(listener: Listener): () => void {
return appShellPropsStore.subscribe(listener);
}
export function useAppShellProps(): AppShellPropsSnapshot {
return useSyncExternalStore(
subscribeAppShellProps,
getAppShellPropsSnapshot,
getAppShellPropsSnapshot,
);
}
+213
View File
@@ -0,0 +1,213 @@
import { useCallback, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react';
import { TERMINAL_THEMES } from '../../../infrastructure/config/terminalThemes';
import { retainStableSessionsIgnoringPresentation } from '../../../domain/terminalPaneSessionsEqual';
import { useI18n } from '../../i18n/I18nProvider';
import { useCustomThemes } from '../../state/customThemeStore';
import { useEditorTabChromeList } from '../../state/editorTabStore';
import { toEditorTabId } from '../../state/activeTabStore';
import {
getSessionSnapshotActions,
useSessionSnapshot,
useSessionSnapshotActions,
} from '../../state/sessionSnapshotStore';
import { useSettingsChromeStore } from '../../state/settingsChromeStore';
import { useAppearanceChromeStore } from '../../state/appearanceChromeStore';
import { useVaultSnapshot } from '../../state/vaultSnapshotStore';
import {
getTerminalSettingsActions,
useTerminalSettingsStore,
} from '../../state/terminalSettingsStore';
import { usePluginViewTabs } from '../../state/pluginViewTabStore';
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
import {
publishAppShellChrome,
publishAppShellDomainSlice,
} from '../appShellPropsStore';
import {
getThemeRuntimeActions,
subscribeThemeRuntimeActions,
} from '../themeRuntimeBridge';
const IS_MAC_CLIENT =
typeof navigator !== 'undefined' && /Mac|Macintosh/.test(navigator.userAgent);
/**
* Chrome island: TopTabs / active-tab chrome from settings chrome store,
* appearance chrome, and selective session/vault snapshot fields. Assembles
* chrome + shell chrome bags field-by-field — never spreads a prepared bag.
*/
export function ChromeHost() {
const { t } = useI18n();
const settingsChrome = useSettingsChromeStore();
const appearance = useAppearanceChromeStore();
void appearance; // accent leaves subscribe directly; keep import for island contract
const session = useSessionSnapshot();
const sessionActions = useSessionSnapshotActions();
const vault = useVaultSnapshot();
const terminalSettings = useTerminalSettingsStore();
const editorTabs = useEditorTabChromeList();
const pluginViewTabs = usePluginViewTabs();
void pluginViewTabs;
const customThemes = useCustomThemes();
const handlers = useSyncExternalStore(
subscribeAppHandlers,
getAppHandlers,
getAppHandlers,
);
const themeRuntime = useSyncExternalStore(
subscribeThemeRuntimeActions,
getThemeRuntimeActions,
getThemeRuntimeActions,
);
const orphanSessionsForShellRef = useRef(session.orphanSessions);
const orphanSessionsForShell = retainStableSessionsIgnoringPresentation(
orphanSessionsForShellRef.current,
session.orphanSessions as never,
);
orphanSessionsForShellRef.current = orphanSessionsForShell as typeof session.orphanSessions;
const themeById = useMemo(
() => new Map([...customThemes, ...TERMINAL_THEMES].map((theme) => [theme.id, theme])),
[customThemes],
);
const hostById = useMemo(
() => new Map(vault.hosts.map((host) => [host.id, host])),
[vault.hosts],
);
const sessionById = useMemo(
() => new Map(session.sessions.map((s) => [s.id, s])),
[session.sessions],
);
const workspaceById = useMemo(
() => new Map(session.workspaces.map((workspace) => [workspace.id, workspace])),
[session.workspaces],
);
const editorTabTopIds = useMemo(
() => editorTabs.map((tab) => toEditorTabId(tab.id)),
[editorTabs],
);
const pluginViewTabIds = useMemo(
() => pluginViewTabs.map((tab) => tab.id),
[pluginViewTabs],
);
const additionalWorkTabIds = useMemo(
() => [...editorTabTopIds, ...pluginViewTabIds],
[editorTabTopIds, pluginViewTabIds],
);
const orderedTabsWithEditors = useMemo(
() => sessionActions?.getOrderedWorkTabs(additionalWorkTabIds) ?? ['vault'],
[additionalWorkTabIds, sessionActions],
);
const reorderWorkTabs = useCallback((
draggedId: string,
targetId: string,
position: 'before' | 'after' = 'before',
) => {
sessionActions?.reorderTabs(draggedId, targetId, position, additionalWorkTabIds);
}, [additionalWorkTabIds, sessionActions]);
const chromeDomain = useMemo(() => {
if (!handlers) return null;
return {
closeLogView: sessionActions?.closeLogView,
handleEndSessionDrag: handlers.handleEndSessionDrag,
handleOpenQuickSwitcher: handlers.handleOpenQuickSwitcher,
handleOpenSettings: handlers.handleOpenSettings,
handleRootContextMenu: handlers.handleRootContextMenu,
handleSyncNowManual: handlers.handleSyncNowManual,
isMacClient: IS_MAC_CLIENT,
logViews: session.logViews,
openLogView: sessionActions?.openLogView,
orderedTabsWithEditors,
orphanSessions: orphanSessionsForShell,
reorderWorkTabs,
resetSessionRename: sessionActions?.resetSessionRename,
resetWorkspaceRename: sessionActions?.resetWorkspaceRename,
sessionRenameTarget: session.sessionRenameTarget,
setActiveTabId: sessionActions?.setActiveTabId,
startSessionRename: sessionActions?.startSessionRename,
renameSessionInline: sessionActions?.renameSessionInline,
startWorkspaceRename: sessionActions?.startWorkspaceRename,
submitSessionRename: sessionActions?.submitSessionRename,
submitWorkspaceRename: sessionActions?.submitWorkspaceRename,
t,
themeById,
workspaceRenameTarget: session.workspaceRenameTarget,
};
}, [
handlers,
orderedTabsWithEditors,
orphanSessionsForShell,
reorderWorkTabs,
session.logViews,
session.sessionRenameTarget,
session.workspaceRenameTarget,
sessionActions,
t,
themeById,
]);
// Call-time getters so chrome can publish before Publisher layout effects
// register action slots — avoids undefined applyAppTheme/setActiveTabId on
// the first Host publish (startup TypeError under StrictMode).
const setActiveTabId = useCallback((id: string) => {
getSessionSnapshotActions()?.setActiveTabId?.(id);
}, []);
const applyAppTheme = useCallback(() => {
getTerminalSettingsActions()?.applyAppTheme?.();
}, []);
const appShellChrome = useMemo(() => {
if (!handlers) return null;
// Theme runtime actions may still be null on the first paint before
// TerminalHost registers them; wait so AppActiveTabChrome never mounts
// with a missing resolveSessionAppearance / currentTerminalTheme.
if (!themeRuntime?.currentTerminalTheme || !themeRuntime?.resolveFocusedAppearance) {
return null;
}
return {
showSftpTab: settingsChrome.showSftpTab,
setActiveTabId,
applyAppTheme,
hostById,
sessionById,
themeById,
workspaceById,
currentTerminalTheme: themeRuntime.currentTerminalTheme,
followAppTerminalTheme: terminalSettings.followAppTerminalTheme,
editorTabs,
logViews: session.logViews,
resolveSessionAppearance: themeRuntime.resolveFocusedAppearance,
t,
};
}, [
applyAppTheme,
editorTabs,
handlers,
hostById,
session.logViews,
sessionById,
setActiveTabId,
settingsChrome.showSftpTab,
t,
terminalSettings.followAppTerminalTheme,
themeById,
themeRuntime,
workspaceById,
]);
useLayoutEffect(() => {
if (chromeDomain) publishAppShellDomainSlice('chrome', chromeDomain);
if (appShellChrome) publishAppShellChrome(appShellChrome as never);
}, [appShellChrome, chromeDomain]);
return null;
}
+107
View File
@@ -0,0 +1,107 @@
import { useLayoutEffect, useMemo, useSyncExternalStore } from 'react';
import { getHostSearchMatch } from '../../../lib/searchMatcher';
import type { Host } from '../../../types';
import { useEditorTabChromeList } from '../../state/editorTabStore';
import { useVaultSnapshot } from '../../state/vaultSnapshotStore';
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
import {
publishAppShellDomainSlice,
publishAppShellOverlays,
} from '../appShellPropsStore';
import { useAppLocalUiStore } from '../appLocalUiStore';
const EMPTY_HOST_RESULTS: Host[] = [];
/**
* Dialogs island: local dialog/queue state from `appLocalUiStore`, plus a
* selective vault hosts subscription for quick-search results when open.
* Assembles dialogs + overlays field-by-field — never spreads a prepared bag.
*/
export function DialogsHost() {
const local = useAppLocalUiStore();
const vault = useVaultSnapshot();
const editorTabs = useEditorTabChromeList();
const handlers = useSyncExternalStore(
subscribeAppHandlers,
getAppHandlers,
getAppHandlers,
);
const quickResults = useMemo(() => {
if (!local.isQuickSwitcherOpen) return EMPTY_HOST_RESULTS;
const term = local.quickSearch.trim();
if (!term) return vault.hosts as Host[];
return (vault.hosts as Host[])
.map((host) => ({ host, match: getHostSearchMatch(term, host) }))
.filter((entry) => entry.match.matched)
.sort((left, right) => {
if (left.match.score !== right.match.score) {
return right.match.score - left.match.score;
}
return left.host.label.localeCompare(right.host.label);
})
.map((entry) => entry.host);
}, [local.isQuickSwitcherOpen, local.quickSearch, vault.hosts]);
const dialogsDomain = useMemo(() => {
if (!handlers) return null;
return {
addToWorkspaceDialog: local.addToWorkspaceDialog,
clearAndRemoveSource: handlers.clearAndRemoveSource,
clearAndRemoveSources: handlers.clearAndRemoveSources,
editorTabs,
emptyVaultConflict: local.emptyVaultConflict,
handleHostConnectWithProtocolCheck: handlers.handleHostConnectWithProtocolCheck,
handleKeyboardInteractiveCancel: handlers.handleKeyboardInteractiveCancel,
handleKeyboardInteractiveSubmit: handlers.handleKeyboardInteractiveSubmit,
handlePassphraseCancel: handlers.handlePassphraseCancel,
handlePassphraseSkip: handlers.handlePassphraseSkip,
handlePassphraseSubmit: handlers.handlePassphraseSubmit,
handleProtocolSelect: handlers.handleProtocolSelect,
handleRequestCloseEditorTabRef: handlers.handleRequestCloseEditorTabRef,
isCreateWorkspaceOpen: local.isCreateWorkspaceOpen,
isQuickSwitcherOpen: local.isQuickSwitcherOpen,
keyboardInteractiveQueue: local.keyboardInteractiveQueue,
passphraseQueue: local.passphraseQueue,
protocolSelectHost: local.protocolSelectHost,
quickResults,
quickSearch: local.quickSearch,
resolveEmptyVaultConflict: handlers.resolveEmptyVaultConflict,
setAddToWorkspaceDialog: handlers.setAddToWorkspaceDialog,
setIsCreateWorkspaceOpen: handlers.setIsCreateWorkspaceOpen,
setIsQuickSwitcherOpen: handlers.setIsQuickSwitcherOpen,
setProtocolSelectHost: handlers.setProtocolSelectHost,
setQuickSearch: handlers.setQuickSearch,
};
}, [
editorTabs,
handlers,
local.addToWorkspaceDialog,
local.emptyVaultConflict,
local.isCreateWorkspaceOpen,
local.isQuickSwitcherOpen,
local.keyboardInteractiveQueue,
local.passphraseQueue,
local.protocolSelectHost,
local.quickSearch,
quickResults,
]);
const overlays = useMemo(() => {
if (!handlers) return null;
return {
onAddKnownHost: handlers.handleAddKnownHost as (knownHost: never) => void,
deleteHostConfirm: local.deleteHostConfirm,
onCancelDeleteHost: handlers.handleCancelDeleteHost as () => void,
onConfirmDeleteHost: handlers.handleConfirmDeleteHost as () => void,
};
}, [handlers, local.deleteHostConfirm]);
useLayoutEffect(() => {
if (dialogsDomain) publishAppShellDomainSlice('dialogs', dialogsDomain);
if (overlays) publishAppShellOverlays(overlays as never);
}, [dialogsDomain, overlays]);
return null;
}
+274
View File
@@ -0,0 +1,274 @@
import { useCallback, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react';
import { TERMINAL_THEME_AUTO } from '../../../domain/terminalAppearance';
import { retainStableSessionsIgnoringPresentation } from '../../../domain/terminalPaneSessionsEqual';
import { getAppSettingsRuntime } from '../../state/appRuntimeBridge';
import { useAppearanceChromeStore } from '../../state/appearanceChromeStore';
import { useCustomThemes } from '../../state/customThemeStore';
import {
useSessionSnapshot,
useSessionSnapshotActions,
} from '../../state/sessionSnapshotStore';
import {
useSettingsChromeActions,
useSettingsChromeStore,
} from '../../state/settingsChromeStore';
import {
useTerminalSettingsActions,
useTerminalSettingsStore,
} from '../../state/terminalSettingsStore';
import { useThemeRuntime, useTerminalAppearanceInjection } from '../../state/useThemeRuntime';
import {
useVaultSnapshot,
useVaultSnapshotActions,
} from '../../state/vaultSnapshotStore';
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
import { publishAppShellDomainSlice } from '../appShellPropsStore';
import { useAppLocalUiStore } from '../appLocalUiStore';
import { registerThemeRuntimeActions } from '../themeRuntimeBridge';
/**
* Terminal island: sessions from `sessionSnapshotStore`, terminal settings
* from `terminalSettingsStore`, mutators via snapshot actions, theme runtime
* owned here, glue handlers from the app handlers bridge. Assembles the full
* terminal domain bag field-by-field — never spreads a prepared bag.
*/
export function TerminalHost() {
const session = useSessionSnapshot();
const sessionActions = useSessionSnapshotActions();
const vault = useVaultSnapshot();
const vaultActions = useVaultSnapshotActions();
const terminalSettings = useTerminalSettingsStore();
const terminalSettingsActions = useTerminalSettingsActions();
const {
followAppTerminalTheme,
terminalThemeId,
terminalThemeDarkId,
terminalThemeLightId,
} = terminalSettings;
const settingsChrome = useSettingsChromeStore();
const settingsChromeActions = useSettingsChromeActions();
const appearance = useAppearanceChromeStore();
const customThemes = useCustomThemes();
const local = useAppLocalUiStore();
const handlers = useSyncExternalStore(
subscribeAppHandlers,
getAppHandlers,
getAppHandlers,
);
// Call-time getters: SettingsPublisher registers the runtime in a layout
// effect after this Host's first render. Capturing noop setters into
// useThemeRuntime would permanently drop follow-app UI theme persistence.
const setLightUiThemeId = useCallback((id: string) => {
getAppSettingsRuntime()?.setLightUiThemeId?.(id);
}, []);
const setDarkUiThemeId = useCallback((id: string) => {
getAppSettingsRuntime()?.setDarkUiThemeId?.(id);
}, []);
const themeRuntime = useThemeRuntime({
terminalThemeId,
terminalThemeDarkId,
terminalThemeLightId,
followAppTerminalTheme,
resolvedTheme: settingsChrome.resolvedTheme,
lightUiThemeId: settingsChrome.lightUiThemeId,
darkUiThemeId: settingsChrome.darkUiThemeId,
accentMode: appearance.accentMode,
customAccent: appearance.customAccent,
customThemes,
setTheme: settingsChromeActions.setTheme,
setLightUiThemeId,
setDarkUiThemeId,
});
const {
globalAppearance,
clearIntent: clearThemeIntent,
settleManualIntent: settleManualThemeIntent,
pickTheme: pickTerminalTheme,
resolveFocusedAppearance,
currentTerminalTheme,
} = themeRuntime;
useTerminalAppearanceInjection(globalAppearance, {
includeChromeSurfaces: followAppTerminalTheme,
});
const prevFollowAppTerminalThemeRef = useRef(followAppTerminalTheme);
useLayoutEffect(() => {
if (prevFollowAppTerminalThemeRef.current === followAppTerminalTheme) return;
prevFollowAppTerminalThemeRef.current = followAppTerminalTheme;
clearThemeIntent();
}, [followAppTerminalTheme, clearThemeIntent]);
const themeBridgeActions = useMemo(() => ({
clearThemeIntent,
settleManualThemeIntent,
pickTerminalTheme,
resolveFocusedAppearance: resolveFocusedAppearance as (...args: never[]) => unknown,
currentTerminalTheme,
globalAppearance,
}), [
clearThemeIntent,
currentTerminalTheme,
globalAppearance,
pickTerminalTheme,
resolveFocusedAppearance,
settleManualThemeIntent,
]);
useLayoutEffect(() => {
registerThemeRuntimeActions(themeBridgeActions);
return () => {
registerThemeRuntimeActions(null);
};
}, [themeBridgeActions]);
const sessionsForShellRef = useRef(session.sessions);
const sessionsForShell = retainStableSessionsIgnoringPresentation(
sessionsForShellRef.current,
session.sessions as never,
);
sessionsForShellRef.current = sessionsForShell as typeof session.sessions;
const hostById = useMemo(
() => new Map(vault.hosts.map((host) => [host.id, host])),
[vault.hosts],
);
const terminalHosts = useMemo(
() => (
local.ephemeralHosts.length > 0
? [...vault.hosts, ...local.ephemeralHosts]
: vault.hosts
),
[local.ephemeralHosts, vault.hosts],
);
const handleDefaultTerminalThemeChange = useCallback((themeId: string) => {
// Persist the default theme for ephemeral/manual hosts. Mode overrides
// reset to auto so the chosen theme becomes the new baseline for the
// current resolved UI mode (same behavior as pre-Host App).
terminalSettingsActions?.setTerminalThemeId(themeId);
if (settingsChrome.resolvedTheme === 'dark') {
terminalSettingsActions?.setTerminalThemeDarkId(TERMINAL_THEME_AUTO);
} else {
terminalSettingsActions?.setTerminalThemeLightId(TERMINAL_THEME_AUTO);
}
}, [settingsChrome.resolvedTheme, terminalSettingsActions]);
const handleFollowAppTerminalThemeChange = useCallback((themeId: string) => {
pickTerminalTheme(themeId);
}, [pickTerminalTheme]);
const terminalDomain = useMemo(() => {
if (!handlers) return null;
return {
addSessionToWorkspace: sessionActions?.addSessionToWorkspace,
appendHostToWorkspace: sessionActions?.appendHostToWorkspace,
appendLocalTerminalToWorkspace: sessionActions?.appendLocalTerminalToWorkspace,
clearSessionFontSizeOverride: sessionActions?.clearSessionFontSizeOverride,
closeSession: sessionActions?.closeSession,
closeTabsBatch: handlers.closeTabsBatch,
copySessionWithCurrentShell: handlers.copySessionWithCurrentShell,
copyWorkspaceWithCurrentShell: handlers.copyWorkspaceWithCurrentShell,
copySessionToNewWindowWithCurrentShell: handlers.copySessionToNewWindowWithCurrentShell,
closeWorkspace: sessionActions?.closeWorkspace,
createWorkspaceFromSessions: sessionActions?.createWorkspaceFromSessions,
createWorkspaceFromTargets: handlers.createWorkspaceFromTargets,
createWorkspaceWithHosts: handlers.createWorkspaceWithHosts,
currentTerminalTheme,
draggingSessionId: session.draggingSessionId,
editorWordWrap: terminalSettings.editorWordWrap,
followAppTerminalTheme: terminalSettings.followAppTerminalTheme,
clearThemeIntent,
settleManualThemeIntent,
pickTerminalTheme,
resolveSessionAppearance: resolveFocusedAppearance,
handleConnectSerial: handlers.handleConnectSerial,
handleConnectToHost: handlers.handleConnectToHost,
handleCreateLocalTerminal: handlers.handleCreateLocalTerminal,
handleDefaultTerminalThemeChange,
handleFollowAppTerminalThemeChange,
handleHotkeyAction: handlers.handleHotkeyAction,
handleSessionStatusChange: handlers.handleSessionStatusChange,
handleTerminalDataCapture: handlers.handleTerminalDataCapture,
handleUpdateHostFromTerminal: handlers.handleUpdateHostFromTerminal,
hostById,
terminalHosts,
updateTerminalHosts: handlers.updateTerminalHosts,
hotkeyScheme: terminalSettings.hotkeyScheme,
isBroadcastEnabled: sessionActions?.isBroadcastEnabled,
keyBindings: terminalSettings.keyBindings,
openNoteRequest: local.openNoteRequest,
portForwardingRules: local.portForwardingRules,
removeSessionFromWorkspace: sessionActions?.removeSessionFromWorkspace,
reorderWorkspaceSessions: sessionActions?.reorderWorkspaceSessions,
runSnippet: handlers.runSnippet,
sessionLogsDir: terminalSettings.sessionLogsDir,
sessionLogsEnabled: terminalSettings.sessionLogsEnabled,
sessionLogsFormat: terminalSettings.sessionLogsFormat,
sessionLogsTimestampsEnabled: terminalSettings.sessionLogsTimestampsEnabled,
sessions: sessionsForShell,
setDraggingSessionId: sessionActions?.setDraggingSessionId,
setEditorWordWrap: terminalSettingsActions?.setEditorWordWrap,
setTerminalFontFamilyId: terminalSettingsActions?.setTerminalFontFamilyId,
setTerminalFontSize: terminalSettingsActions?.setTerminalFontSize,
setWorkspaceFocusedSession: sessionActions?.setWorkspaceFocusedSession,
sftpAutoOpenSidebar: terminalSettings.sftpAutoOpenSidebar,
sftpFollowTerminalCwd: terminalSettings.sftpFollowTerminalCwd,
setSftpFollowTerminalCwd: terminalSettingsActions?.setSftpFollowTerminalCwd,
sftpAutoSync: terminalSettings.sftpAutoSync,
sftpDefaultViewMode: terminalSettings.sftpDefaultViewMode,
sftpDoubleClickBehavior: terminalSettings.sftpDoubleClickBehavior,
sftpShowHiddenFiles: terminalSettings.sftpShowHiddenFiles,
sftpUseCompressedUpload: terminalSettings.sftpUseCompressedUpload,
splitSessionWithCurrentShell: handlers.splitSessionWithCurrentShell,
sshDebugLogsEnabled: terminalSettings.sshDebugLogsEnabled,
terminalFontFamilyId: terminalSettings.terminalFontFamilyId,
terminalFontSize: terminalSettings.terminalFontSize,
terminalSettings: terminalSettings.terminalSettings,
terminalThemeId: terminalSettings.terminalThemeId,
toggleBroadcast: sessionActions?.toggleBroadcast,
toggleScriptsSidePanelRef: handlers.toggleScriptsSidePanelRef,
toggleSidePanelRef: handlers.toggleSidePanelRef,
toggleWorkspaceViewMode: sessionActions?.toggleWorkspaceViewMode,
updateHostDistro: vaultActions?.updateHostDistro,
updateSplitSizes: sessionActions?.updateSplitSizes,
updateSessionFontSize: sessionActions?.updateSessionFontSize,
updateSessionRestoreCwd: sessionActions?.updateSessionRestoreCwd,
updateSessionDynamicTitle: sessionActions?.updateSessionDynamicTitle,
updateSessionCodingCliProvider: sessionActions?.updateSessionCodingCliProvider,
updateTerminalSetting: terminalSettingsActions?.updateTerminalSetting,
workspaces: session.workspaces,
};
}, [
clearThemeIntent,
currentTerminalTheme,
handleDefaultTerminalThemeChange,
handleFollowAppTerminalThemeChange,
handlers,
hostById,
local.openNoteRequest,
local.portForwardingRules,
pickTerminalTheme,
resolveFocusedAppearance,
session.draggingSessionId,
session.workspaces,
sessionActions,
sessionsForShell,
settleManualThemeIntent,
terminalHosts,
terminalSettings,
terminalSettingsActions,
vaultActions,
]);
useLayoutEffect(() => {
if (terminalDomain) publishAppShellDomainSlice('terminal', terminalDomain);
}, [terminalDomain]);
return null;
}
+104
View File
@@ -0,0 +1,104 @@
import { useLayoutEffect, useMemo, useSyncExternalStore } from 'react';
import { getEffectiveKnownHosts } from '../../../infrastructure/syncHelpers';
import {
useVaultSnapshot,
useVaultSnapshotActions,
} from '../../state/vaultSnapshotStore';
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
import { publishAppShellDomainSlice } from '../appShellPropsStore';
import { useAppLocalUiStore } from '../appLocalUiStore';
import { APP_MOUNTS_DOMAIN } from './mountsDomain';
/**
* Vault island: catalog from `vaultSnapshotStore`, glue handlers from the
* app handlers bridge, local vault UI from `appLocalUiStore`. Assembles the
* full vault domain bag field-by-field — never spreads a prepared bag from
* AppSideEffects.
*/
export function VaultHost() {
const vault = useVaultSnapshot();
const actions = useVaultSnapshotActions();
const local = useAppLocalUiStore();
const handlers = useSyncExternalStore(
subscribeAppHandlers,
getAppHandlers,
getAppHandlers,
);
const effectiveKnownHosts = useMemo(
() => getEffectiveKnownHosts(vault.knownHosts as never) ?? [],
[vault.knownHosts],
);
const vaultDomain = useMemo(() => {
if (!handlers) return null;
return {
addShellHistoryEntry: actions?.addShellHistoryEntry,
removeShellHistoryEntry: actions?.removeShellHistoryEntry,
commitPluginImporterData: actions?.commitPluginImporterData,
commitVaultImportTransaction: actions?.commitVaultImportTransaction,
convertKnownHostToHost: actions?.convertKnownHostToHost,
customGroups: vault.customGroups,
deepLinkHostDraft: local.deepLinkHostDraft,
effectiveKnownHosts,
groupConfigs: vault.groupConfigs,
handleAddKnownHost: handlers.handleAddKnownHost,
handleDeleteHost: handlers.handleDeleteHost,
handleOpenHostFromVaultNote: handlers.handleOpenHostFromVaultNote,
handleOpenVaultHostFromChat: handlers.handleOpenVaultHostFromChat,
handleOpenVaultNoteFromChat: handlers.handleOpenVaultNoteFromChat,
handleOpenVaultSectionFromChat: handlers.handleOpenVaultSectionFromChat,
handleOpenVaultSnippetFromChat: handlers.handleOpenVaultSnippetFromChat,
hosts: vault.hosts,
identities: vault.identities,
importOrReuseKey: actions?.importOrReuseKey,
keys: vault.keys,
managedSources: vault.managedSources,
navigateToSection: local.navigateToSection,
proxyProfiles: vault.proxyProfiles,
readPersistedHosts: actions?.readPersistedHosts,
readPersistedManagedSources: actions?.readPersistedManagedSources,
setDeepLinkHostDraft: handlers.setDeepLinkHostDraft,
setNavigateToSection: handlers.setNavigateToSection,
setVaultFocusRequest: handlers.setVaultFocusRequest,
snippetPackages: vault.snippetPackages,
snippets: vault.snippets,
unmanageSource: handlers.unmanageSource,
updateCustomGroups: actions?.updateCustomGroups,
updateGroupConfigs: actions?.updateGroupConfigs,
updateHosts: actions?.updateHosts,
updateIdentities: actions?.updateIdentities,
updateKeys: actions?.updateKeys,
updateKnownHosts: actions?.updateKnownHosts,
updateManagedSources: actions?.updateManagedSources,
updateProxyProfiles: actions?.updateProxyProfiles,
updateSnippetPackages: actions?.updateSnippetPackages,
updateSnippets: actions?.updateSnippets,
vaultFocusRequest: local.vaultFocusRequest,
};
}, [
actions,
effectiveKnownHosts,
handlers,
local.deepLinkHostDraft,
local.navigateToSection,
local.vaultFocusRequest,
vault.customGroups,
vault.groupConfigs,
vault.hosts,
vault.identities,
vault.keys,
vault.managedSources,
vault.proxyProfiles,
vault.snippetPackages,
vault.snippets,
]);
useLayoutEffect(() => {
if (vaultDomain) publishAppShellDomainSlice('vault', vaultDomain);
publishAppShellDomainSlice('mounts', APP_MOUNTS_DOMAIN);
}, [vaultDomain]);
return null;
}
+9
View File
@@ -0,0 +1,9 @@
import { LogViewWrapper, SftpViewMount, TerminalLayerMount, VaultViewContainer } from '../AppMounts';
/** Lazy mount wrappers — stable module identity for the app lifetime. */
export const APP_MOUNTS_DOMAIN = Object.freeze({
VaultViewContainer,
SftpViewMount,
TerminalLayerMount,
LogViewWrapper,
});
@@ -0,0 +1,217 @@
import { useLayoutEffect, useMemo } from 'react';
import {
AppSessionRuntimeContext,
registerAppSessionRuntime,
} from '../../state/appRuntimeBridge';
import {
publishSessionSnapshot,
registerSessionSnapshotActions,
} from '../../state/sessionSnapshotStore';
import { useSessionState } from '../../state/useSessionState';
import type { ReactNode } from 'react';
export type SessionPublisherProps = {
/** Peer session windows must not write the main window's restore record. */
persistSessionRestore: boolean;
children?: ReactNode;
};
/**
* Owns `useSessionState` and publishes it the same three ways `VaultPublisher`
* publishes the vault: catalog into `sessionSnapshotStore`, mutators into its
* action slot, and the whole runtime onto `appRuntimeBridge` for App.
*/
export function SessionPublisher({ persistSessionRestore, children }: SessionPublisherProps) {
const session = useSessionState({ persistSessionRestore });
const {
sessions,
orphanSessions,
workspaces,
logViews,
draggingSessionId,
sessionRenameTarget,
workspaceRenameTarget,
setActiveTabId,
closeSession,
closeSessions,
closeWorkspace,
openLogView,
closeLogView,
setDraggingSessionId,
startSessionRename,
renameSessionInline,
submitSessionRename,
resetSessionRename,
startWorkspaceRename,
submitWorkspaceRename,
resetWorkspaceRename,
removeSessionFromWorkspace,
setWorkspaceFocusedSession,
toggleWorkspaceViewMode,
createLocalTerminal,
createSerialSession,
connectToHost,
updateSessionStatus,
updateSessionFontSize,
clearSessionFontSizeOverride,
createWorkspaceWithHosts,
createWorkspaceFromSessions,
addSessionToWorkspace,
appendHostToWorkspace,
appendLocalTerminalToWorkspace,
createWorkspaceFromTargets,
updateSplitSizes,
splitSession,
reorderWorkspaceSessions,
moveFocusInWorkspace,
runSnippet,
getOrderedWorkTabs,
reorderTabs,
toggleBroadcast,
isBroadcastEnabled,
copySession,
copyWorkspace,
createSessionFromCloneSource,
updateSessionRestoreCwd,
getSessionRestoreCwd,
updateSessionDynamicTitle,
updateSessionCodingCliProvider,
} = session;
useLayoutEffect(() => {
registerAppSessionRuntime(session);
}, [session]);
// Only a real unmount clears the slot; see VaultPublisher for why.
useLayoutEffect(() => () => {
registerAppSessionRuntime(null);
}, []);
useLayoutEffect(() => {
publishSessionSnapshot({
sessions,
orphanSessions,
workspaces,
logViews,
draggingSessionId,
sessionRenameTarget,
workspaceRenameTarget,
});
}, [
draggingSessionId,
logViews,
orphanSessions,
sessionRenameTarget,
sessions,
workspaceRenameTarget,
workspaces,
]);
const sessionActions = useMemo(() => ({
setActiveTabId,
closeSession,
closeSessions,
closeWorkspace,
openLogView,
closeLogView,
setDraggingSessionId,
startSessionRename,
renameSessionInline,
submitSessionRename,
resetSessionRename,
startWorkspaceRename,
submitWorkspaceRename,
resetWorkspaceRename,
removeSessionFromWorkspace,
setWorkspaceFocusedSession,
toggleWorkspaceViewMode,
createLocalTerminal,
createSerialSession,
connectToHost,
updateSessionStatus,
updateSessionFontSize,
clearSessionFontSizeOverride,
createWorkspaceWithHosts,
createWorkspaceFromSessions,
addSessionToWorkspace,
appendHostToWorkspace,
appendLocalTerminalToWorkspace,
createWorkspaceFromTargets,
updateSplitSizes,
splitSession,
reorderWorkspaceSessions,
moveFocusInWorkspace,
runSnippet,
getOrderedWorkTabs,
reorderTabs,
toggleBroadcast,
isBroadcastEnabled,
copySession,
copyWorkspace,
createSessionFromCloneSource,
updateSessionRestoreCwd,
getSessionRestoreCwd,
updateSessionDynamicTitle,
updateSessionCodingCliProvider,
}), [
addSessionToWorkspace,
appendHostToWorkspace,
appendLocalTerminalToWorkspace,
clearSessionFontSizeOverride,
closeLogView,
closeSession,
closeSessions,
closeWorkspace,
connectToHost,
copySession,
copyWorkspace,
createLocalTerminal,
createSerialSession,
createSessionFromCloneSource,
createWorkspaceFromSessions,
createWorkspaceFromTargets,
createWorkspaceWithHosts,
getOrderedWorkTabs,
getSessionRestoreCwd,
isBroadcastEnabled,
moveFocusInWorkspace,
openLogView,
removeSessionFromWorkspace,
renameSessionInline,
reorderTabs,
reorderWorkspaceSessions,
resetSessionRename,
resetWorkspaceRename,
runSnippet,
setActiveTabId,
setDraggingSessionId,
setWorkspaceFocusedSession,
splitSession,
startSessionRename,
startWorkspaceRename,
submitSessionRename,
submitWorkspaceRename,
toggleBroadcast,
toggleWorkspaceViewMode,
updateSessionCodingCliProvider,
updateSessionDynamicTitle,
updateSessionFontSize,
updateSessionRestoreCwd,
updateSessionStatus,
updateSplitSizes,
]);
useLayoutEffect(() => {
registerSessionSnapshotActions(sessionActions);
return () => {
registerSessionSnapshotActions(null);
};
}, [sessionActions]);
return (
<AppSessionRuntimeContext.Provider value={session}>
{children}
</AppSessionRuntimeContext.Provider>
);
}
@@ -0,0 +1,47 @@
import { useLayoutEffect, type ReactNode } from 'react';
import {
AppSettingsRuntimeContext,
registerAppSettingsRuntime,
} from '../../state/appRuntimeBridge';
import { useSettingsState } from '../../state/useSettingsState';
export type SettingsPublisherProps = {
/** Peer session windows must not drive the main window's settings IPC sync. */
enableSettingsSync: boolean;
/** Peer session windows must not re-apply OS-level system settings effects. */
enableSystemEffects: boolean;
children?: ReactNode;
};
/**
* Owns `useSettingsState` and hands it to consumers the same way
* `VaultPublisher` / `SessionPublisher` hand over their runtimes: a context for
* render-time reads and the `appRuntimeBridge` slot for imperative callers.
*
* The store fan-out (`settingsChromeStore` / `appearanceChromeStore`) already
* happens inside `useSettingsState`, so this publisher only relocates the hook
* out of the component that also builds the shell's domain bags.
*/
export function SettingsPublisher({
enableSettingsSync,
enableSystemEffects,
children,
}: SettingsPublisherProps) {
const settings = useSettingsState({ enableSettingsSync, enableSystemEffects });
useLayoutEffect(() => {
registerAppSettingsRuntime(settings);
}, [settings]);
// Only a real unmount clears the slot; see VaultPublisher for why.
useLayoutEffect(() => () => {
registerAppSettingsRuntime(null);
}, []);
return (
<AppSettingsRuntimeContext.Provider value={settings}>
{children}
</AppSettingsRuntimeContext.Provider>
);
}
@@ -0,0 +1,197 @@
import { useLayoutEffect, useMemo, useRef, type ReactNode } from 'react';
import {
AppVaultRuntimeContext,
registerAppVaultRuntime,
type AppVaultContextValue,
} from '../../state/appRuntimeBridge';
import {
publishVaultSnapshot,
registerVaultSnapshotActions,
} from '../../state/vaultSnapshotStore';
import { useVaultState } from '../../state/useVaultState';
import { getEffectiveKnownHosts } from '../../../infrastructure/syncHelpers';
export type VaultPublisherProps = {
children?: ReactNode;
};
function vaultContextValuesEqual(
prev: AppVaultContextValue,
next: AppVaultContextValue,
): boolean {
const keys = Object.keys(next) as Array<keyof AppVaultContextValue>;
return keys.every((key) => prev[key] === next[key]);
}
/**
* Owns `useVaultState` and publishes it three ways: the catalog into
* `vaultSnapshotStore` for shell surfaces that subscribe to a slice, the
* mutators into the same store's action slot, and a **catalog-only** runtime
* onto `AppVaultRuntimeContext` for App.
*
* `notes` / `noteGroups` / `connectionLogs` / `shellHistory` are intentionally
* absent from the context value: they churn on a different cadence and already
* fan out through dedicated stores. Including them would force App (the
* domain-bag builder) to re-render on every note edit or session log append.
* Imperative callers that still need the full hook return use
* `getAppVaultRuntime()`.
*/
export function VaultPublisher({ children }: VaultPublisherProps) {
const vault = useVaultState();
const {
isInitialized,
hosts,
keys,
identities,
proxyProfiles,
snippets,
snippetPackages,
customGroups,
knownHosts,
managedSources,
groupConfigs,
updateHosts,
updateKeys,
importOrReuseKey,
updateIdentities,
updateProxyProfiles,
updateSnippets,
updateSnippetPackages,
updateCustomGroups,
updateKnownHosts,
updateManagedSources,
updateGroupConfigs,
convertKnownHostToHost,
readPersistedHosts,
readPersistedManagedSources,
commitPluginImporterData,
commitVaultImportTransaction,
updateHostDistro,
updateHostLastConnected,
addShellHistoryEntry,
removeShellHistoryEntry,
} = vault;
useLayoutEffect(() => {
registerAppVaultRuntime(vault);
}, [vault]);
// Only a real unmount clears the slot. A re-render re-registers through the
// effect above, and StrictMode's simulated remount re-runs both.
useLayoutEffect(() => () => {
registerAppVaultRuntime(null);
}, []);
// useVaultState decrypts hosts/keys before it reads known hosts, so the state
// is briefly empty at boot even when storage has entries. Publish the same
// storage fallback App uses so a subscriber connecting during that window
// does not re-prompt for a fingerprint it already trusts.
const effectiveKnownHosts = useMemo(
() => getEffectiveKnownHosts(knownHosts) ?? [],
[knownHosts],
);
useLayoutEffect(() => {
publishVaultSnapshot({
isVaultInitialized: isInitialized,
hosts,
keys,
identities,
proxyProfiles,
snippets,
snippetPackages,
customGroups,
knownHosts: effectiveKnownHosts,
managedSources,
groupConfigs,
});
}, [
customGroups,
effectiveKnownHosts,
groupConfigs,
hosts,
identities,
isInitialized,
keys,
managedSources,
proxyProfiles,
snippetPackages,
snippets,
]);
useLayoutEffect(() => {
registerVaultSnapshotActions({
updateHosts,
updateKeys,
importOrReuseKey,
updateIdentities,
updateProxyProfiles,
updateSnippets,
updateSnippetPackages,
updateCustomGroups,
updateKnownHosts,
updateManagedSources,
updateGroupConfigs,
convertKnownHostToHost,
readPersistedHosts,
readPersistedManagedSources,
commitPluginImporterData,
commitVaultImportTransaction,
updateHostDistro,
updateHostLastConnected,
addShellHistoryEntry,
removeShellHistoryEntry,
});
return () => {
registerVaultSnapshotActions(null);
};
}, [
addShellHistoryEntry,
commitPluginImporterData,
commitVaultImportTransaction,
convertKnownHostToHost,
importOrReuseKey,
readPersistedHosts,
readPersistedManagedSources,
removeShellHistoryEntry,
updateCustomGroups,
updateGroupConfigs,
updateHostDistro,
updateHostLastConnected,
updateHosts,
updateIdentities,
updateKeys,
updateKnownHosts,
updateManagedSources,
updateProxyProfiles,
updateSnippetPackages,
updateSnippets,
]);
// Strip high-churn fields, then retain the previous object identity when only
// those fields (or exportData) changed so React context consumers stay quiet.
const vaultForAppRef = useRef<AppVaultContextValue | null>(null);
const vaultForApp = useMemo((): AppVaultContextValue => {
const {
notes: _notes,
noteGroups: _noteGroups,
connectionLogs: _connectionLogs,
shellHistory: _shellHistory,
exportData: _exportData,
...catalog
} = vault;
const prev = vaultForAppRef.current;
if (prev && vaultContextValuesEqual(prev, catalog)) {
return prev;
}
vaultForAppRef.current = catalog;
return catalog;
}, [vault]);
return (
<AppVaultRuntimeContext.Provider value={vaultForApp}>
{children}
</AppVaultRuntimeContext.Provider>
);
}
@@ -0,0 +1,128 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const appSource = readFileSync(new URL('../../App.tsx', import.meta.url), 'utf8');
const appSideEffectsSource = readFileSync(
new URL('./AppSideEffects.tsx', import.meta.url),
'utf8',
);
const indexSource = readFileSync(new URL('../../index.tsx', import.meta.url), 'utf8');
const startupEffectsSource = readFileSync(
new URL('./useAppStartupEffects.ts', import.meta.url),
'utf8',
);
const updateCheckSource = readFileSync(
new URL('../state/useUpdateCheck.ts', import.meta.url),
'utf8',
);
const portForwardingAutoStartSource = readFileSync(
new URL('../state/usePortForwardingAutoStart.ts', import.meta.url),
'utf8',
);
test('every renderer root mounts under StrictMode', () => {
assert.match(indexSource, /import \{ StrictMode, Suspense, lazy \} from 'react'/);
const renderCalls = indexSource.match(/root\.render\(/g) ?? [];
assert.equal(renderCalls.length, 4, 'main, settings, tray and terminal-popup roots');
let cursor = 0;
for (let index = 0; index < renderCalls.length; index += 1) {
const renderAt = indexSource.indexOf('root.render(', cursor);
assert.notEqual(renderAt, -1);
const opener = indexSource.slice(renderAt, renderAt + 60);
assert.match(opener, /root\.render\(\s*<StrictMode>/, `root.render #${index + 1} lacks StrictMode`);
cursor = renderAt + 'root.render('.length;
}
});
test('clone-session payload is consumed once even if the effect re-runs', () => {
const effectStart = appSideEffectsSource.indexOf('consumedNewWindowSessionRef');
assert.notEqual(effectStart, -1, 'clone-session effect must latch on payload identity');
const effectEnd = appSideEffectsSource.indexOf(
'}, [createSessionFromCloneSource, isVaultInitialized, pendingNewWindowSession]);',
effectStart,
);
assert.notEqual(effectEnd, -1);
const body = appSideEffectsSource.slice(effectStart, effectEnd);
// A ref comparison is required: clearing the state only lands on the next
// render, so a re-invoked effect still closes over the same payload.
assert.match(body, /if \(consumedNewWindowSessionRef\.current === pendingNewWindowSession\) return;/);
assert.ok(
body.indexOf('consumedNewWindowSessionRef.current = pending')
< body.indexOf('createSessionFromCloneSource(pending.sourceSession'),
'payload must be marked consumed before the clone is created',
);
});
test('rendererReady is notified once per renderer process', () => {
assert.match(appSource, /^let rendererReadySent = false;$/m);
const guardAt = appSource.indexOf('if (!rendererReadySent) {');
assert.notEqual(guardAt, -1);
const guarded = appSource.slice(guardAt, guardAt + 200);
assert.match(guarded, /rendererReadySent = true;/);
assert.match(guarded, /netcattyBridge\.get\(\)\?\.rendererReady\?\.\(\)/);
assert.ok(
guarded.indexOf('rendererReadySent = true;')
< guarded.indexOf('netcattyBridge.get()?.rendererReady?.()'),
'the latch must be set before the IPC call so a re-entrant effect is blocked',
);
});
test('update-available toast latches on the release version', () => {
const latchAt = startupEffectsSource.indexOf('toastedUpdateVersionRef');
assert.notEqual(latchAt, -1);
assert.match(startupEffectsSource, /const toastedUpdateVersionRef = useRef<string \| null>\(null\)/);
assert.match(startupEffectsSource, /if \(toastedUpdateVersionRef\.current === version\) return;/);
const guardAt = startupEffectsSource.indexOf('if (toastedUpdateVersionRef.current === version) return;');
const toastAt = startupEffectsSource.indexOf('toast.info(', guardAt);
assert.notEqual(toastAt, -1);
assert.ok(guardAt < toastAt, 'the version latch must gate the toast call');
});
test('port-forward auto-start runs once across a StrictMode double effect', () => {
assert.match(
portForwardingAutoStartSource,
/const autoStartExecutedRef = useRef\(false\);/,
'the launch auto-start needs a module-render latch, not just an effect dep list',
);
const effectStart = portForwardingAutoStartSource.indexOf('if (autoStartExecutedRef.current) return;');
assert.notEqual(effectStart, -1, 'the effect must bail out when the latch is already set');
const effectEnd = portForwardingAutoStartSource.indexOf(
'}, [\n enabled,\n isVaultInitialized,\n runAutoStart,\n ]);',
effectStart,
);
assert.notEqual(effectEnd, -1, 'auto-start effect dep list moved; update this contract');
const body = portForwardingAutoStartSource.slice(effectStart, effectEnd);
// StrictMode invokes the effect twice with the same render's closure, so the
// latch has to be written before the async run is kicked off — awaiting or
// deferring the write would let the second invoke start a duplicate tunnel.
const latchAt = body.indexOf('autoStartExecutedRef.current = true;');
const runAt = body.indexOf('void runAutoStart();');
assert.notEqual(latchAt, -1);
assert.notEqual(runAt, -1);
assert.ok(latchAt < runAt, 'the latch must be set before runAutoStart() is called');
// The vault gate must also sit before the latch: latching on a pre-hydration
// invoke would permanently suppress the real auto-start.
assert.ok(
body.indexOf('if (!isVaultInitialized) return;') < latchAt,
'the vault gate must precede the latch write',
);
});
test('cancelled startup update check resets its latch instead of skipping forever', () => {
const scheduleAt = updateCheckSource.indexOf('let checkArmed = true;');
assert.notEqual(scheduleAt, -1);
const tail = updateCheckSource.slice(scheduleAt);
// The latch is only meaningful once the timer actually fires; a cleanup that
// cancels it beforehand must let the next effect schedule again.
assert.match(tail, /startupCheckTimeoutRef\.current = setTimeout\(async \(\) => \{\s*\n\s*checkArmed = false;/);
assert.match(tail, /if \(checkArmed\) \{\s*\n\s*hasCheckedOnStartupRef\.current = false;\s*\n\s*\}/);
});
+52
View File
@@ -0,0 +1,52 @@
/**
* Theme runtime actions produced by TerminalHost (`useThemeRuntime`).
* AppSideEffects handlers (default/follow theme changes) call through this
* bridge instead of co-hosting the hook.
*/
type Listener = () => void;
export type ThemeRuntimeBridgeActions = {
clearThemeIntent: () => void;
settleManualThemeIntent: () => void;
pickTerminalTheme: (themeId: string) => void;
resolveFocusedAppearance: (...args: never[]) => unknown;
currentTerminalTheme: unknown;
globalAppearance: unknown;
};
class ThemeRuntimeBridge {
private actions: ThemeRuntimeBridgeActions | null = null;
private listeners = new Set<Listener>();
get = (): ThemeRuntimeBridgeActions | null => this.actions;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
set(next: ThemeRuntimeBridgeActions | null): void {
if (this.actions === next) return;
this.actions = next;
for (const listener of this.listeners) listener();
}
}
const bridge = new ThemeRuntimeBridge();
export function registerThemeRuntimeActions(
actions: ThemeRuntimeBridgeActions | null,
): void {
bridge.set(actions);
}
export function getThemeRuntimeActions(): ThemeRuntimeBridgeActions | null {
return bridge.get();
}
export function subscribeThemeRuntimeActions(listener: Listener): () => void {
return bridge.subscribe(listener);
}
+3
View File
@@ -213,6 +213,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
}, [dedicatedResumeHosts, enabled, identities, isVaultInitialized, keys, knownHosts, terminalSettings]);
// Show toast notification when update is available (only when auto-download is idle)
const toastedUpdateVersionRef = useRef<string | null>(null);
useEffect(() => {
if (!enabled) return;
// Skip "update available" toast if auto-download has already started or completed
@@ -221,6 +222,8 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
if (localStorageAdapter.readString('netcatty_auto_update_enabled_v1') === 'false') return;
if (updateState.hasUpdate && updateState.latestRelease) {
const version = updateState.latestRelease.version;
if (toastedUpdateVersionRef.current === version) return;
toastedUpdateVersionRef.current = version;
toast.info(
t('update.available.message', { version }),
{
+28
View File
@@ -0,0 +1,28 @@
import type React from 'react';
import { useMemo } from 'react';
import { useAppearanceChromeStore } from '../state/appearanceChromeStore';
import { useSettingsChromeStore } from '../state/settingsChromeStore';
import { buildAppThemeCssVars } from '../state/settingsStateDefaults';
import { getUiThemeById } from '../../infrastructure/config/uiThemes';
/**
* App theme CSS variables for surfaces that need them (vault surface, plugin
* theme tokens). Reads accent from appearanceChromeStore and UI theme ids from
* settingsChromeStore so accent drags only re-render the leaf that applies the
* vars, never the App shell.
*/
export function useAppThemeStyle(): React.CSSProperties {
const { accentMode, customAccent } = useAppearanceChromeStore();
const { resolvedTheme, darkUiThemeId, lightUiThemeId } = useSettingsChromeStore();
return useMemo(() => {
const tokens = getUiThemeById(
resolvedTheme,
resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId,
).tokens;
return {
...buildAppThemeCssVars(tokens, accentMode, customAccent),
colorScheme: resolvedTheme,
} as React.CSSProperties;
}, [accentMode, customAccent, darkUiThemeId, lightUiThemeId, resolvedTheme]);
}
+80
View File
@@ -0,0 +1,80 @@
import { useSyncExternalStore } from 'react';
import type { AISession } from '../../infrastructure/ai/types';
import type { DraftsByScope, PanelViewByScope } from './aiStateSnapshots';
type Listener = () => void;
export type AISessionsSnapshot = {
sessions: readonly AISession[];
activeSessionIdMap: Readonly<Record<string, string | null>>;
draftsByScope: DraftsByScope;
panelViewByScope: PanelViewByScope;
};
const EMPTY_SESSIONS: readonly AISession[] = Object.freeze([]);
const EMPTY_MAP: Readonly<Record<string, string | null>> = Object.freeze({});
const EMPTY_DRAFTS: DraftsByScope = Object.freeze({});
const EMPTY_PANEL: PanelViewByScope = Object.freeze({});
export const EMPTY_AI_SESSIONS_SNAPSHOT: AISessionsSnapshot = Object.freeze({
sessions: EMPTY_SESSIONS,
activeSessionIdMap: EMPTY_MAP,
draftsByScope: EMPTY_DRAFTS,
panelViewByScope: EMPTY_PANEL,
});
/**
* Hot AI chat state — streaming message updates must not invalidate
* slow AI config Context consumers (providers, permissions, etc.).
*/
class AISessionsStore {
private snapshot: AISessionsSnapshot = EMPTY_AI_SESSIONS_SNAPSHOT;
private listeners = new Set<Listener>();
getSnapshot = (): AISessionsSnapshot => this.snapshot;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
setSnapshot(next: AISessionsSnapshot): void {
if (
this.snapshot.sessions === next.sessions
&& this.snapshot.activeSessionIdMap === next.activeSessionIdMap
&& this.snapshot.draftsByScope === next.draftsByScope
&& this.snapshot.panelViewByScope === next.panelViewByScope
) {
return;
}
this.snapshot = next;
for (const listener of this.listeners) {
listener();
}
}
}
export const aiSessionsStore = new AISessionsStore();
export function publishAISessionsSnapshot(snapshot: AISessionsSnapshot): void {
aiSessionsStore.setSnapshot(snapshot);
}
export function getAISessionsSnapshot(): AISessionsSnapshot {
return aiSessionsStore.getSnapshot();
}
export function subscribeAISessions(listener: Listener): () => void {
return aiSessionsStore.subscribe(listener);
}
export function useAISessionsStore(): AISessionsSnapshot {
return useSyncExternalStore(
subscribeAISessions,
getAISessionsSnapshot,
getAISessionsSnapshot,
);
}
@@ -0,0 +1,99 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getAppSessionRuntime,
getAppSettingsRuntime,
getAppVaultRuntime,
registerAppSessionRuntime,
registerAppSettingsRuntime,
registerAppVaultRuntime,
subscribeAppSessionRuntime,
subscribeAppSettingsRuntime,
subscribeAppVaultRuntime,
type AppSessionRuntime,
type AppSettingsRuntime,
type AppVaultRuntime,
} from './appRuntimeBridge';
const vaultRuntime = (label: string) => ({ label }) as unknown as AppVaultRuntime;
const sessionRuntime = (label: string) => ({ label }) as unknown as AppSessionRuntime;
const settingsRuntime = (label: string) => ({ label }) as unknown as AppSettingsRuntime;
test('vault slot exposes the last registered runtime and notifies on change', () => {
let notifications = 0;
const unsubscribe = subscribeAppVaultRuntime(() => {
notifications += 1;
});
const first = vaultRuntime('first');
registerAppVaultRuntime(first);
assert.equal(getAppVaultRuntime(), first);
assert.equal(notifications, 1);
// Re-registering the same identity is what a render with unchanged state
// does; it must not wake subscribers.
registerAppVaultRuntime(first);
assert.equal(notifications, 1);
const second = vaultRuntime('second');
registerAppVaultRuntime(second);
assert.equal(getAppVaultRuntime(), second);
assert.equal(notifications, 2);
unsubscribe();
registerAppVaultRuntime(null);
assert.equal(getAppVaultRuntime(), null);
assert.equal(notifications, 2, 'unsubscribed listeners stay quiet');
});
test('session slot is independent from the vault slot', () => {
let vaultNotifications = 0;
let sessionNotifications = 0;
const unsubscribeVault = subscribeAppVaultRuntime(() => {
vaultNotifications += 1;
});
const unsubscribeSession = subscribeAppSessionRuntime(() => {
sessionNotifications += 1;
});
const runtime = sessionRuntime('session');
registerAppSessionRuntime(runtime);
assert.equal(getAppSessionRuntime(), runtime);
assert.equal(sessionNotifications, 1);
assert.equal(vaultNotifications, 0);
unsubscribeVault();
unsubscribeSession();
registerAppSessionRuntime(null);
});
test('settings slot is independent from the vault and session slots', () => {
let settingsNotifications = 0;
let otherNotifications = 0;
const unsubscribeSettings = subscribeAppSettingsRuntime(() => {
settingsNotifications += 1;
});
const unsubscribeVault = subscribeAppVaultRuntime(() => {
otherNotifications += 1;
});
const unsubscribeSession = subscribeAppSessionRuntime(() => {
otherNotifications += 1;
});
const runtime = settingsRuntime('settings');
registerAppSettingsRuntime(runtime);
assert.equal(getAppSettingsRuntime(), runtime);
assert.equal(settingsNotifications, 1);
assert.equal(otherNotifications, 0);
// A re-render with unchanged state re-registers the same identity.
registerAppSettingsRuntime(runtime);
assert.equal(settingsNotifications, 1);
unsubscribeSettings();
unsubscribeVault();
unsubscribeSession();
registerAppSettingsRuntime(null);
assert.equal(getAppSettingsRuntime(), null);
});
+138
View File
@@ -0,0 +1,138 @@
import { createContext, useContext } from 'react';
import type { useSessionState } from './useSessionState';
import type { useSettingsState } from './useSettingsState';
import type { useVaultState } from './useVaultState';
export type AppVaultRuntime = ReturnType<typeof useVaultState>;
export type AppSessionRuntime = ReturnType<typeof useSessionState>;
export type AppSettingsRuntime = ReturnType<typeof useSettingsState>;
/**
* The slice of the vault runtime that `VaultPublisher` puts on the React
* context.
*
* `notes` / `noteGroups` / `connectionLogs` / `shellHistory` are deliberately
* absent. They churn on every note keystroke, session start/exit, and history
* append, and publishing them here would re-render every context consumer for
* data those consumers only ever read at call time. Consumers subscribe to
* `notesStore` / `connectionLogsStore` / `shellHistoryStore` instead, or read
* the full runtime imperatively through `getAppVaultRuntime()`.
*
* `exportData` is omitted for the same reason: its callback identity tracks the
* notes arrays, so keeping it would reintroduce the churn it is meant to avoid.
*/
export type AppVaultContextValue = Omit<
AppVaultRuntime,
'notes' | 'noteGroups' | 'connectionLogs' | 'shellHistory' | 'exportData'
>;
type Listener = () => void;
/**
* Holds the live return of one mega hook so the component that owns the hook
* and the components that consume it no longer have to be the same component.
*
* `VaultPublisher` / `SessionPublisher` / `SettingsPublisher` own
* `useVaultState` / `useSessionState` / `useSettingsState` and expose the value
* two ways:
*
* - through a React context, for render-time consumers (App). Context keeps the
* read synchronous and non-null from the first render, which a store written
* in a layout effect cannot do for a descendant.
* - through this module slot, for imperative callers that need the current
* runtime outside of a render (`getAppVaultRuntime()` in an event handler or
* a bridge callback) and for non-descendant subscribers.
*/
class RuntimeSlot<T> {
private value: T | null = null;
private listeners = new Set<Listener>();
get = (): T | null => this.value;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
set(next: T | null): void {
if (this.value === next) return;
this.value = next;
for (const listener of this.listeners) {
listener();
}
}
}
const vaultSlot = new RuntimeSlot<AppVaultRuntime>();
const sessionSlot = new RuntimeSlot<AppSessionRuntime>();
const settingsSlot = new RuntimeSlot<AppSettingsRuntime>();
export function registerAppVaultRuntime(runtime: AppVaultRuntime | null): void {
vaultSlot.set(runtime);
}
export function getAppVaultRuntime(): AppVaultRuntime | null {
return vaultSlot.get();
}
export function subscribeAppVaultRuntime(listener: Listener): () => void {
return vaultSlot.subscribe(listener);
}
export function registerAppSessionRuntime(runtime: AppSessionRuntime | null): void {
sessionSlot.set(runtime);
}
export function getAppSessionRuntime(): AppSessionRuntime | null {
return sessionSlot.get();
}
export function subscribeAppSessionRuntime(listener: Listener): () => void {
return sessionSlot.subscribe(listener);
}
export function registerAppSettingsRuntime(runtime: AppSettingsRuntime | null): void {
settingsSlot.set(runtime);
}
export function getAppSettingsRuntime(): AppSettingsRuntime | null {
return settingsSlot.get();
}
export function subscribeAppSettingsRuntime(listener: Listener): () => void {
return settingsSlot.subscribe(listener);
}
export const AppVaultRuntimeContext = createContext<AppVaultContextValue | null>(null);
export const AppSessionRuntimeContext = createContext<AppSessionRuntime | null>(null);
export const AppSettingsRuntimeContext = createContext<AppSettingsRuntime | null>(null);
/** Read the vault runtime owned by `VaultPublisher`. */
export function useAppVaultRuntime(): AppVaultContextValue {
const runtime = useContext(AppVaultRuntimeContext);
if (!runtime) {
throw new Error('useAppVaultRuntime must be rendered inside <VaultPublisher>');
}
return runtime;
}
/** Read the session runtime owned by `SessionPublisher`. */
export function useAppSessionRuntime(): AppSessionRuntime {
const runtime = useContext(AppSessionRuntimeContext);
if (!runtime) {
throw new Error('useAppSessionRuntime must be rendered inside <SessionPublisher>');
}
return runtime;
}
/** Read the settings runtime owned by `SettingsPublisher`. */
export function useAppSettingsRuntime(): AppSettingsRuntime {
const runtime = useContext(AppSettingsRuntimeContext);
if (!runtime) {
throw new Error('useAppSettingsRuntime must be rendered inside <SettingsPublisher>');
}
return runtime;
}
@@ -0,0 +1,126 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ConnectionLog } from '../../domain/models.ts';
import {
EMPTY_CONNECTION_LOGS_SNAPSHOT,
getConnectionLogsActions,
getConnectionLogsSnapshot,
getEmptyConnectionLogsSnapshot,
publishConnectionLogsSnapshot,
registerConnectionLogsActions,
subscribeConnectionLogs,
subscribeConnectionLogsActions,
} from './connectionLogsStore.ts';
const baseLog: ConnectionLog = {
id: 'log-1',
sessionId: 'session-1',
hostId: 'host-1',
hostLabel: 'Example',
hostname: 'example.com',
username: 'user',
protocol: 'ssh',
startTime: 1000,
localUsername: 'local',
localHostname: 'machine',
saved: false,
};
test('connectionLogsStore notifies subscribers only when the log array identity changes', () => {
const events: number[] = [];
const unsubscribe = subscribeConnectionLogs(() => {
events.push(getConnectionLogsSnapshot().connectionLogs.length);
});
const firstLogs = [baseLog];
publishConnectionLogsSnapshot({ connectionLogs: firstLogs });
assert.equal(events.at(-1), 1);
assert.equal(getConnectionLogsSnapshot().connectionLogs, firstLogs);
// Same identity must not wake the Vault logs section on unrelated renders.
publishConnectionLogsSnapshot({ connectionLogs: firstLogs });
assert.equal(events.length, 1);
const secondLogs = [baseLog, { ...baseLog, id: 'log-2', startTime: 2000 }];
publishConnectionLogsSnapshot({ connectionLogs: secondLogs });
assert.equal(events.at(-1), 2);
assert.equal(events.length, 2);
unsubscribe();
publishConnectionLogsSnapshot({ connectionLogs: [] });
assert.equal(events.length, 2);
});
test('connectionLogsStore exposes a frozen empty snapshot for gated mounts', () => {
assert.equal(getEmptyConnectionLogsSnapshot(), EMPTY_CONNECTION_LOGS_SNAPSHOT);
assert.equal(getEmptyConnectionLogsSnapshot().connectionLogs.length, 0);
assert.equal(Object.isFrozen(EMPTY_CONNECTION_LOGS_SNAPSHOT), true);
});
test('registerConnectionLogsActions exposes the vault log mutators', () => {
const calls: string[] = [];
const actionEvents: number[] = [];
const unsubscribe = subscribeConnectionLogsActions(() => {
actionEvents.push(actionEvents.length);
});
registerConnectionLogsActions({
updateConnectionLog: () => {
calls.push('update');
},
toggleConnectionLogSaved: () => {
calls.push('toggle');
},
deleteConnectionLog: () => {
calls.push('delete');
},
clearUnsavedConnectionLogs: () => {
calls.push('clear');
},
});
const actions = getConnectionLogsActions();
assert.ok(actions);
actions.updateConnectionLog('log-1', { saved: true });
actions.toggleConnectionLogSaved('log-1');
actions.deleteConnectionLog('log-1');
actions.clearUnsavedConnectionLogs();
assert.deepEqual(calls, ['update', 'toggle', 'delete', 'clear']);
assert.equal(actionEvents.length, 1);
registerConnectionLogsActions(null);
assert.equal(getConnectionLogsActions(), null);
assert.equal(actionEvents.length, 2);
unsubscribe();
});
test('connectionLogsStore keeps values and actions on independent slots', () => {
const valueEvents: number[] = [];
const actionEvents: number[] = [];
const unsubValues = subscribeConnectionLogs(() => {
valueEvents.push(valueEvents.length);
});
const unsubActions = subscribeConnectionLogsActions(() => {
actionEvents.push(actionEvents.length);
});
// Re-registering unstable mutator identities must not invalidate the logs.
registerConnectionLogsActions({
updateConnectionLog: () => {},
toggleConnectionLogSaved: () => {},
deleteConnectionLog: () => {},
clearUnsavedConnectionLogs: () => {},
});
assert.equal(valueEvents.length, 0);
assert.equal(actionEvents.length, 1);
publishConnectionLogsSnapshot({ connectionLogs: [baseLog] });
assert.equal(valueEvents.length, 1);
assert.equal(actionEvents.length, 1);
registerConnectionLogsActions(null);
unsubValues();
unsubActions();
});
+135
View File
@@ -0,0 +1,135 @@
import { useSyncExternalStore } from 'react';
import type { ConnectionLog } from '../../domain/models';
type Listener = () => void;
export type ConnectionLogsSnapshot = {
connectionLogs: readonly ConnectionLog[];
};
export type ConnectionLogsActions = {
updateConnectionLog: (id: string, updates: Partial<ConnectionLog>) => void;
toggleConnectionLogSaved: (id: string) => void;
deleteConnectionLog: (id: string) => void;
clearUnsavedConnectionLogs: () => void;
};
const EMPTY_CONNECTION_LOGS: readonly ConnectionLog[] = Object.freeze([]);
export const EMPTY_CONNECTION_LOGS_SNAPSHOT: ConnectionLogsSnapshot = Object.freeze({
connectionLogs: EMPTY_CONNECTION_LOGS,
});
/**
* External store for connection logs so the Vault logs section and log-view
* replays can subscribe without keeping logs in the App domain bags. Every
* session start/exit appends a log, which would otherwise rebuild the whole
* shell.
*/
class ConnectionLogsStore {
private snapshot: ConnectionLogsSnapshot = EMPTY_CONNECTION_LOGS_SNAPSHOT;
private actions: ConnectionLogsActions | null = null;
private listeners = new Set<Listener>();
private actionListeners = new Set<Listener>();
getSnapshot = (): ConnectionLogsSnapshot => this.snapshot;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
setSnapshot(next: ConnectionLogsSnapshot): void {
if (this.snapshot.connectionLogs === next.connectionLogs) return;
this.snapshot = next;
for (const listener of this.listeners) {
listener();
}
}
getActions = (): ConnectionLogsActions | null => this.actions;
subscribeActions = (listener: Listener): (() => void) => {
this.actionListeners.add(listener);
return () => {
this.actionListeners.delete(listener);
};
};
setActions(next: ConnectionLogsActions | null): void {
if (this.actions === next) return;
this.actions = next;
for (const listener of this.actionListeners) {
listener();
}
}
}
export const connectionLogsStore = new ConnectionLogsStore();
export function publishConnectionLogsSnapshot(
snapshot: ConnectionLogsSnapshot,
): void {
connectionLogsStore.setSnapshot(snapshot);
}
export function getConnectionLogsSnapshot(): ConnectionLogsSnapshot {
return connectionLogsStore.getSnapshot();
}
export function subscribeConnectionLogs(listener: Listener): () => void {
return connectionLogsStore.subscribe(listener);
}
export function getEmptyConnectionLogsSnapshot(): ConnectionLogsSnapshot {
return EMPTY_CONNECTION_LOGS_SNAPSHOT;
}
export function registerConnectionLogsActions(
actions: ConnectionLogsActions | null,
): void {
connectionLogsStore.setActions(actions);
}
export function getConnectionLogsActions(): ConnectionLogsActions | null {
return connectionLogsStore.getActions();
}
export function subscribeConnectionLogsActions(listener: Listener): () => void {
return connectionLogsStore.subscribeActions(listener);
}
const noopUpdateConnectionLog: ConnectionLogsActions['updateConnectionLog'] = () => {};
const noopToggleConnectionLogSaved: ConnectionLogsActions['toggleConnectionLogSaved'] = () => {};
const noopDeleteConnectionLog: ConnectionLogsActions['deleteConnectionLog'] = () => {};
const noopClearUnsavedConnectionLogs: ConnectionLogsActions['clearUnsavedConnectionLogs'] = () => {};
/** Subscribe to the connection log catalog plus its vault mutation actions. */
export function useConnectionLogsStore(): {
connectionLogs: ConnectionLog[];
updateConnectionLog: ConnectionLogsActions['updateConnectionLog'];
toggleConnectionLogSaved: ConnectionLogsActions['toggleConnectionLogSaved'];
deleteConnectionLog: ConnectionLogsActions['deleteConnectionLog'];
clearUnsavedConnectionLogs: ConnectionLogsActions['clearUnsavedConnectionLogs'];
} {
const snapshot = useSyncExternalStore(
subscribeConnectionLogs,
getConnectionLogsSnapshot,
getConnectionLogsSnapshot,
);
const actions = useSyncExternalStore(
subscribeConnectionLogsActions,
getConnectionLogsActions,
getConnectionLogsActions,
);
return {
connectionLogs: snapshot.connectionLogs as ConnectionLog[],
updateConnectionLog: actions?.updateConnectionLog ?? noopUpdateConnectionLog,
toggleConnectionLogSaved: actions?.toggleConnectionLogSaved ?? noopToggleConnectionLogSaved,
deleteConnectionLog: actions?.deleteConnectionLog ?? noopDeleteConnectionLog,
clearUnsavedConnectionLogs: actions?.clearUnsavedConnectionLogs ?? noopClearUnsavedConnectionLogs,
};
}
@@ -88,7 +88,13 @@ test("session restore persistence can be disabled for non-main windows", () => {
assert.match(hookSource, /if \(!persistSessionRestore\) return;/);
assert.match(traySource, /useSessionState\(\{ persistSessionRestore: false \}\)/);
assert.match(appSource, /window\.location\.hash\.startsWith\('#\/session-window'\)/);
assert.match(appSource, /persistSessionRestore: !isPeerSessionWindow/);
// App no longer calls useSessionState; SessionPublisher owns the hook and
// App passes the peer-window flag down to it.
assert.match(appSource, /<SessionPublisher persistSessionRestore=\{!isPeerSessionWindow\}>/);
assert.match(
readFileSync(new URL("../app/publishers/SessionPublisher.tsx", import.meta.url), "utf8"),
/useSessionState\(\{ persistSessionRestore \}\)/,
);
assert.match(indexSource, /hash === '#\/session-window'/);
assert.match(registerBridgesSource, /route: "session-window"/);
assert.match(registerBridgesSource, /registerAsMainWindow: false/);
@@ -100,7 +106,8 @@ test("session restore persistence can be disabled for non-main windows", () => {
});
test("session peer windows do not run main-window startup effects", () => {
const appSource = readFileSync(new URL("../../App.tsx", import.meta.url), "utf8");
const appSource = readFileSync(new URL("../app/AppSideEffects.tsx", import.meta.url), "utf8");
const appRootSource = readFileSync(new URL("../../App.tsx", import.meta.url), "utf8");
const autoSyncSource = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
const startupEffectsSource = readFileSync(new URL("../app/useAppStartupEffects.ts", import.meta.url), "utf8");
const updateCheckSource = readFileSync(new URL("./useUpdateCheck.ts", import.meta.url), "utf8");
@@ -112,7 +119,13 @@ test("session peer windows do not run main-window startup effects", () => {
const trayPanelJumpIndex = appSource.indexOf("onTrayPanelJumpToSession");
assert.match(appSource, /const isPeerSessionWindow = typeof window !== 'undefined' && window\.location\.hash\.startsWith\('#\/session-window'\)/);
assert.match(appSource, /useSettingsState\(\{[^}]*enableSettingsSync: !isPeerSessionWindow[^}]*enableSystemEffects: !isPeerSessionWindow/s);
// SettingsPublisher owns useSettingsState; App only forwards the peer-window
// flags to it.
assert.match(appRootSource, /<SettingsPublisher\s+enableSettingsSync=\{!isPeerSessionWindow\}\s+enableSystemEffects=\{!isPeerSessionWindow\}/s);
assert.match(
readFileSync(new URL("../app/publishers/SettingsPublisher.tsx", import.meta.url), "utf8"),
/useSettingsState\(\{ enableSettingsSync, enableSystemEffects \}\)/,
);
assert.match(appSource, /useAppStartupEffects\(\{[^}]*enabled: !isPeerSessionWindow/s);
assert.match(appSource, /useUpdateCheck\(\{[^}]*enabled: !isPeerSessionWindow/s);
assert.match(appSource, /if \(isPeerSessionWindow \|\| !isVaultInitialized \|\| versionBackupAttemptedRef\.current\) return;/);
@@ -0,0 +1,118 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { TerminalSession } from '../../domain/models.ts';
import {
EMPTY_SESSION_SNAPSHOT,
getSessionSnapshot,
getSessionSnapshotActions,
publishSessionSnapshot,
registerSessionSnapshotActions,
sessionSnapshotsEqual,
subscribeSessionSnapshot,
} from './sessionSnapshotStore.ts';
function makeSession(id: string): TerminalSession {
return { id, hostLabel: id, status: 'connected' } as TerminalSession;
}
test('sessionSnapshotStore notifies only when a field identity changes', () => {
const seen: number[] = [];
const unsubscribe = subscribeSessionSnapshot(() => {
seen.push(getSessionSnapshot().sessions.length);
});
const sessions = [makeSession('s1')];
const snapshot = { ...EMPTY_SESSION_SNAPSHOT, sessions, orphanSessions: sessions };
publishSessionSnapshot(snapshot);
assert.deepEqual(seen, [1]);
assert.equal(getSessionSnapshot().sessions, sessions);
publishSessionSnapshot({ ...snapshot });
assert.deepEqual(seen, [1]);
// Presentation-only fields still count: rename targets drive dialog state.
publishSessionSnapshot({ ...snapshot, sessionRenameTarget: sessions[0] });
assert.deepEqual(seen, [1, 1]);
assert.equal(getSessionSnapshot().sessionRenameTarget, sessions[0]);
unsubscribe();
publishSessionSnapshot(EMPTY_SESSION_SNAPSHOT);
assert.deepEqual(seen, [1, 1]);
});
test('sessionSnapshotsEqual compares every published field', () => {
assert.equal(
sessionSnapshotsEqual(EMPTY_SESSION_SNAPSHOT, { ...EMPTY_SESSION_SNAPSHOT }),
true,
);
assert.equal(
sessionSnapshotsEqual(EMPTY_SESSION_SNAPSHOT, {
...EMPTY_SESSION_SNAPSHOT,
draggingSessionId: 's1',
}),
false,
);
assert.equal(
sessionSnapshotsEqual(EMPTY_SESSION_SNAPSHOT, { ...EMPTY_SESSION_SNAPSHOT, logViews: [] }),
false,
);
});
test('session actions register and unregister', () => {
assert.equal(getSessionSnapshotActions(), null);
const calls: string[] = [];
const noop = (() => {}) as never;
registerSessionSnapshotActions({
setActiveTabId: ((id: string) => calls.push(id)) as never,
closeSession: noop,
closeSessions: noop,
closeWorkspace: noop,
openLogView: noop,
closeLogView: noop,
setDraggingSessionId: noop,
startSessionRename: noop,
renameSessionInline: noop,
submitSessionRename: noop,
resetSessionRename: noop,
startWorkspaceRename: noop,
submitWorkspaceRename: noop,
resetWorkspaceRename: noop,
removeSessionFromWorkspace: noop,
setWorkspaceFocusedSession: noop,
toggleWorkspaceViewMode: noop,
createLocalTerminal: noop,
createSerialSession: noop,
connectToHost: noop,
updateSessionStatus: noop,
updateSessionFontSize: noop,
clearSessionFontSizeOverride: noop,
createWorkspaceWithHosts: noop,
createWorkspaceFromSessions: noop,
addSessionToWorkspace: noop,
appendHostToWorkspace: noop,
appendLocalTerminalToWorkspace: noop,
createWorkspaceFromTargets: noop,
updateSplitSizes: noop,
splitSession: noop,
reorderWorkspaceSessions: noop,
moveFocusInWorkspace: noop,
runSnippet: noop,
getOrderedWorkTabs: noop,
reorderTabs: noop,
toggleBroadcast: noop,
isBroadcastEnabled: noop,
copySession: noop,
copyWorkspace: noop,
createSessionFromCloneSource: noop,
updateSessionRestoreCwd: noop,
getSessionRestoreCwd: noop,
updateSessionDynamicTitle: noop,
updateSessionCodingCliProvider: noop,
});
getSessionSnapshotActions()?.setActiveTabId('vault');
assert.deepEqual(calls, ['vault']);
registerSessionSnapshotActions(null);
assert.equal(getSessionSnapshotActions(), null);
});
+200
View File
@@ -0,0 +1,200 @@
import { useSyncExternalStore } from 'react';
import type { TerminalSession, Workspace } from '../../domain/models';
import type { LogView } from './logViewState';
import type { useSessionState } from './useSessionState';
type SessionState = ReturnType<typeof useSessionState>;
type Listener = () => void;
/**
* Session/workspace catalog for shell surfaces. `activeTabId` is intentionally
* absent — it lives in `activeTabStore` so tab switches stay off this path.
*/
export type SessionSnapshot = {
sessions: readonly TerminalSession[];
/** Sessions with no workspace and not hidden from tabs; TopTabs renders these. */
orphanSessions: readonly TerminalSession[];
workspaces: readonly Workspace[];
logViews: readonly LogView[];
draggingSessionId: string | null;
sessionRenameTarget: TerminalSession | null;
workspaceRenameTarget: Workspace | null;
};
/**
* Session mutators the shell needs. Derived from the hook return so the store
* contract cannot drift from `useSessionState`.
*/
export type SessionSnapshotActions = Pick<
SessionState,
| 'setActiveTabId'
| 'closeSession'
| 'closeSessions'
| 'closeWorkspace'
| 'openLogView'
| 'closeLogView'
| 'setDraggingSessionId'
| 'startSessionRename'
| 'renameSessionInline'
| 'submitSessionRename'
| 'resetSessionRename'
| 'startWorkspaceRename'
| 'submitWorkspaceRename'
| 'resetWorkspaceRename'
| 'removeSessionFromWorkspace'
| 'setWorkspaceFocusedSession'
| 'toggleWorkspaceViewMode'
// Terminal / chrome mutators previously read only via App session runtime.
| 'createLocalTerminal'
| 'createSerialSession'
| 'connectToHost'
| 'updateSessionStatus'
| 'updateSessionFontSize'
| 'clearSessionFontSizeOverride'
| 'createWorkspaceWithHosts'
| 'createWorkspaceFromSessions'
| 'addSessionToWorkspace'
| 'appendHostToWorkspace'
| 'appendLocalTerminalToWorkspace'
| 'createWorkspaceFromTargets'
| 'updateSplitSizes'
| 'splitSession'
| 'reorderWorkspaceSessions'
| 'moveFocusInWorkspace'
| 'runSnippet'
| 'getOrderedWorkTabs'
| 'reorderTabs'
| 'toggleBroadcast'
| 'isBroadcastEnabled'
| 'copySession'
| 'copyWorkspace'
| 'createSessionFromCloneSource'
| 'updateSessionRestoreCwd'
| 'getSessionRestoreCwd'
| 'updateSessionDynamicTitle'
| 'updateSessionCodingCliProvider'
>;
const EMPTY_SESSIONS: readonly TerminalSession[] = Object.freeze([]);
const EMPTY_WORKSPACES: readonly Workspace[] = Object.freeze([]);
const EMPTY_LOG_VIEWS: readonly LogView[] = Object.freeze([]);
export const EMPTY_SESSION_SNAPSHOT: SessionSnapshot = Object.freeze({
sessions: EMPTY_SESSIONS,
orphanSessions: EMPTY_SESSIONS,
workspaces: EMPTY_WORKSPACES,
logViews: EMPTY_LOG_VIEWS,
draggingSessionId: null,
sessionRenameTarget: null,
workspaceRenameTarget: null,
});
const SNAPSHOT_KEYS = Object.keys(EMPTY_SESSION_SNAPSHOT) as (keyof SessionSnapshot)[];
export function sessionSnapshotsEqual(
prev: SessionSnapshot,
next: SessionSnapshot,
): boolean {
return SNAPSHOT_KEYS.every((key) => prev[key] === next[key]);
}
/**
* External store for the session catalog so TopTabs, the terminal layer and
* the host tree can subscribe directly instead of receiving sessions through
* the App domain bags.
*/
class SessionSnapshotStore {
private snapshot: SessionSnapshot = EMPTY_SESSION_SNAPSHOT;
private actions: SessionSnapshotActions | null = null;
private listeners = new Set<Listener>();
private actionListeners = new Set<Listener>();
getSnapshot = (): SessionSnapshot => this.snapshot;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
setSnapshot(next: SessionSnapshot): void {
if (sessionSnapshotsEqual(this.snapshot, next)) return;
this.snapshot = next;
for (const listener of this.listeners) {
listener();
}
}
getActions = (): SessionSnapshotActions | null => this.actions;
subscribeActions = (listener: Listener): (() => void) => {
this.actionListeners.add(listener);
return () => {
this.actionListeners.delete(listener);
};
};
setActions(next: SessionSnapshotActions | null): void {
if (this.actions === next) return;
this.actions = next;
for (const listener of this.actionListeners) {
listener();
}
}
}
export const sessionSnapshotStore = new SessionSnapshotStore();
export function publishSessionSnapshot(snapshot: SessionSnapshot): void {
sessionSnapshotStore.setSnapshot(snapshot);
}
export function getSessionSnapshot(): SessionSnapshot {
return sessionSnapshotStore.getSnapshot();
}
export function subscribeSessionSnapshot(listener: Listener): () => void {
return sessionSnapshotStore.subscribe(listener);
}
export function registerSessionSnapshotActions(
actions: SessionSnapshotActions | null,
): void {
sessionSnapshotStore.setActions(actions);
}
export function getSessionSnapshotActions(): SessionSnapshotActions | null {
return sessionSnapshotStore.getActions();
}
export function subscribeSessionSnapshotActions(listener: Listener): () => void {
return sessionSnapshotStore.subscribeActions(listener);
}
/** Subscribe to the whole session catalog. Prefer a narrower selector hook. */
export function useSessionSnapshot(): SessionSnapshot {
return useSyncExternalStore(
subscribeSessionSnapshot,
getSessionSnapshot,
getSessionSnapshot,
);
}
/** Subscribe to one session field so unrelated session churn stays out. */
export function useSessionSnapshotField<K extends keyof SessionSnapshot>(
field: K,
): SessionSnapshot[K] {
const read = () => getSessionSnapshot()[field];
return useSyncExternalStore(subscribeSessionSnapshot, read, read);
}
export function useSessionSnapshotActions(): SessionSnapshotActions | null {
return useSyncExternalStore(
subscribeSessionSnapshotActions,
getSessionSnapshotActions,
getSessionSnapshotActions,
);
}
@@ -0,0 +1,170 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import {
DEFAULT_SETTINGS_CHROME_SNAPSHOT,
getSettingsChromeActions,
getSettingsChromeSnapshot,
publishSettingsChromeSnapshot,
registerSettingsChromeActions,
settingsChromeSnapshotsEqual,
subscribeSettingsChrome,
subscribeSettingsChromeActions,
type SettingsChromeSnapshot,
} from './settingsChromeStore.ts';
type SnapshotKey = keyof SettingsChromeSnapshot;
/**
* Derive the probe value instead of hard-coding a field list: the chrome slice
* grows as surfaces move off the App settings bag, and this suite is about the
* dedupe contract, not about which fields happen to be in it today.
*/
function mutate(value: unknown): unknown {
if (typeof value === 'boolean') return !value;
if (typeof value === 'number') return value + 1;
if (typeof value === 'string') return `${value}-changed`;
throw new Error(`Unsupported snapshot field type: ${typeof value}`);
}
test('settingsChromeStore ignores structurally identical republishes', () => {
const base = getSettingsChromeSnapshot();
let events = 0;
const unsubscribe = subscribeSettingsChrome(() => {
events += 1;
});
// useSettingsState rebuilds this object literal on every render, so a fresh
// identity with unchanged fields must not wake chrome consumers.
publishSettingsChromeSnapshot({ ...base });
publishSettingsChromeSnapshot({ ...base });
assert.equal(events, 0);
assert.equal(getSettingsChromeSnapshot(), base);
unsubscribe();
});
test('settingsChromeStore notifies on any single chrome field change', () => {
const base = getSettingsChromeSnapshot();
const keys = Object.keys(base) as SnapshotKey[];
assert.ok(keys.length > 0, 'chrome snapshot must publish at least one field');
for (const key of keys) {
let events = 0;
const unsubscribe = subscribeSettingsChrome(() => {
events += 1;
});
const changed = { ...base, [key]: mutate(base[key]) } as SettingsChromeSnapshot;
publishSettingsChromeSnapshot(changed);
assert.equal(events, 1, `expected a notification for ${key}`);
assert.equal(getSettingsChromeSnapshot()[key], changed[key]);
publishSettingsChromeSnapshot({ ...changed });
assert.equal(events, 1, `expected no repeat notification for ${key}`);
publishSettingsChromeSnapshot(base);
assert.equal(events, 2, `expected a restore notification for ${key}`);
unsubscribe();
}
assert.equal(getSettingsChromeSnapshot(), base);
});
test('settingsChromeStore stops notifying after unsubscribe', () => {
const base = getSettingsChromeSnapshot();
const [firstKey] = Object.keys(base) as SnapshotKey[];
let events = 0;
const unsubscribe = subscribeSettingsChrome(() => {
events += 1;
});
unsubscribe();
publishSettingsChromeSnapshot({
...base,
[firstKey]: mutate(base[firstKey]),
} as SettingsChromeSnapshot);
assert.equal(events, 0);
publishSettingsChromeSnapshot(base);
});
test('chrome snapshot fields stay in sync with what useSettingsState publishes', () => {
// The whole point of the slice is that chrome leaves can stop reading the
// App settings bag. A field the publisher forgets would silently freeze at
// its default, so pin the two shapes together.
const source = readFileSync(new URL('./useSettingsState.ts', import.meta.url), 'utf8');
const callStart = source.indexOf('publishSettingsChromeSnapshot({');
assert.notEqual(callStart, -1, 'useSettingsState must publish the chrome slice');
const callEnd = source.indexOf('});', callStart);
assert.notEqual(callEnd, -1);
const published = source
.slice(callStart, callEnd)
.split('\n')
.slice(1)
.map((line) => line.trim().match(/^([A-Za-z0-9_]+)\s*[:,]/)?.[1])
.filter((name): name is string => Boolean(name));
assert.deepEqual(
published.slice().sort(),
Object.keys(DEFAULT_SETTINGS_CHROME_SNAPSHOT).sort(),
);
});
test('settingsChromeSnapshotsEqual compares by field, not object identity', () => {
const base = DEFAULT_SETTINGS_CHROME_SNAPSHOT;
assert.equal(settingsChromeSnapshotsEqual({ ...base }, { ...base }), true);
assert.equal(
settingsChromeSnapshotsEqual(base, { ...base, windowOpacity: 0.8 }),
false,
);
assert.equal(
settingsChromeSnapshotsEqual(base, { ...base, showSftpTab: !base.showSftpTab }),
false,
);
});
test('registerSettingsChromeActions exposes the theme and opacity setters', () => {
const calls: string[] = [];
let actionEvents = 0;
const unsubscribe = subscribeSettingsChromeActions(() => {
actionEvents += 1;
});
registerSettingsChromeActions({
setTheme: () => calls.push('theme'),
setWindowOpacity: () => calls.push('opacity'),
});
const actions = getSettingsChromeActions();
assert.ok(actions);
actions.setTheme('light');
actions.setWindowOpacity(0.5);
assert.deepEqual(calls, ['theme', 'opacity']);
assert.equal(actionEvents, 1);
registerSettingsChromeActions(null);
assert.equal(getSettingsChromeActions(), null);
assert.equal(actionEvents, 2);
unsubscribe();
});
test('setter churn never invalidates the chrome value snapshot', () => {
// useSettingsState re-registers on every setter identity change; chrome
// consumers of the value snapshot must not re-render for that.
let valueEvents = 0;
const unsubscribe = subscribeSettingsChrome(() => {
valueEvents += 1;
});
registerSettingsChromeActions({ setTheme: () => {}, setWindowOpacity: () => {} });
registerSettingsChromeActions({ setTheme: () => {}, setWindowOpacity: () => {} });
assert.equal(valueEvents, 0);
registerSettingsChromeActions(null);
unsubscribe();
});
+181
View File
@@ -0,0 +1,181 @@
import { useSyncExternalStore } from 'react';
import type { UILanguage } from '../../domain/models/connection';
import type { DynamicTabTitleMode } from '../../domain/models/terminal';
import type { HostClickBehavior } from '../../domain/hostClickBehavior';
import type { TerminalSidePanelAutoOpenTab } from '../../domain/terminalSidePanelAutoOpen';
type Listener = () => void;
export type SettingsChromeTheme = 'dark' | 'light' | 'system';
/**
* Chrome-facing settings slice. Keep this aligned with what AppView / TopTabs /
* HostTree actually read. Unrelated settings edits must not rebuild App chrome
* domain bags — leaves subscribe here instead of receiving the whole settings
* object through domain props.
*/
export type SettingsChromeSnapshot = {
theme: SettingsChromeTheme;
resolvedTheme: 'light' | 'dark';
lightUiThemeId: string;
darkUiThemeId: string;
uiLanguage: UILanguage;
windowOpacity: number;
showSftpTab: boolean;
showHostTreeSidebar: boolean;
showRecentHosts: boolean;
hostClickBehavior: HostClickBehavior;
showOnlyUngroupedHostsInRoot: boolean;
dynamicTabTitleMode: DynamicTabTitleMode;
disableTerminalFontZoom: boolean;
restoreTerminalCwd: boolean;
terminalSidePanelAutoOpen: boolean;
terminalSidePanelAutoOpenTab: TerminalSidePanelAutoOpenTab;
};
/**
* Setters chrome leaves need. Kept on a separate slot from the value snapshot:
* `useSettingsState` rebuilds these callbacks on its own cadence, and that must
* never invalidate the value snapshot.
*/
export type SettingsChromeActions = {
setTheme: (theme: SettingsChromeTheme) => void;
setWindowOpacity: (opacity: number) => void;
};
export const DEFAULT_SETTINGS_CHROME_SNAPSHOT: SettingsChromeSnapshot = Object.freeze({
theme: 'system',
resolvedTheme: 'dark',
lightUiThemeId: 'default',
darkUiThemeId: 'default',
uiLanguage: 'en',
windowOpacity: 1,
showSftpTab: true,
showHostTreeSidebar: true,
showRecentHosts: true,
hostClickBehavior: 'connect',
showOnlyUngroupedHostsInRoot: false,
dynamicTabTitleMode: 'off',
disableTerminalFontZoom: false,
restoreTerminalCwd: true,
terminalSidePanelAutoOpen: false,
terminalSidePanelAutoOpenTab: 'ai',
} satisfies SettingsChromeSnapshot);
export function settingsChromeSnapshotsEqual(
a: SettingsChromeSnapshot,
b: SettingsChromeSnapshot,
): boolean {
return a.theme === b.theme
&& a.resolvedTheme === b.resolvedTheme
&& a.lightUiThemeId === b.lightUiThemeId
&& a.darkUiThemeId === b.darkUiThemeId
&& a.uiLanguage === b.uiLanguage
&& a.windowOpacity === b.windowOpacity
&& a.showSftpTab === b.showSftpTab
&& a.showHostTreeSidebar === b.showHostTreeSidebar
&& a.showRecentHosts === b.showRecentHosts
&& a.hostClickBehavior === b.hostClickBehavior
&& a.showOnlyUngroupedHostsInRoot === b.showOnlyUngroupedHostsInRoot
&& a.dynamicTabTitleMode === b.dynamicTabTitleMode
&& a.disableTerminalFontZoom === b.disableTerminalFontZoom
&& a.restoreTerminalCwd === b.restoreTerminalCwd
&& a.terminalSidePanelAutoOpen === b.terminalSidePanelAutoOpen
&& a.terminalSidePanelAutoOpenTab === b.terminalSidePanelAutoOpenTab;
}
class SettingsChromeStore {
private snapshot: SettingsChromeSnapshot = DEFAULT_SETTINGS_CHROME_SNAPSHOT;
private actions: SettingsChromeActions | null = null;
private listeners = new Set<Listener>();
private actionListeners = new Set<Listener>();
getSnapshot = (): SettingsChromeSnapshot => this.snapshot;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
setSnapshot(next: SettingsChromeSnapshot): void {
if (settingsChromeSnapshotsEqual(this.snapshot, next)) return;
this.snapshot = next;
for (const listener of this.listeners) {
listener();
}
}
getActions = (): SettingsChromeActions | null => this.actions;
subscribeActions = (listener: Listener): (() => void) => {
this.actionListeners.add(listener);
return () => {
this.actionListeners.delete(listener);
};
};
setActions(next: SettingsChromeActions | null): void {
if (this.actions === next) return;
this.actions = next;
for (const listener of this.actionListeners) {
listener();
}
}
}
export const settingsChromeStore = new SettingsChromeStore();
export function publishSettingsChromeSnapshot(
snapshot: SettingsChromeSnapshot,
): void {
settingsChromeStore.setSnapshot(snapshot);
}
export function getSettingsChromeSnapshot(): SettingsChromeSnapshot {
return settingsChromeStore.getSnapshot();
}
export function subscribeSettingsChrome(listener: Listener): () => void {
return settingsChromeStore.subscribe(listener);
}
export function registerSettingsChromeActions(
actions: SettingsChromeActions | null,
): void {
settingsChromeStore.setActions(actions);
}
export function getSettingsChromeActions(): SettingsChromeActions | null {
return settingsChromeStore.getActions();
}
export function subscribeSettingsChromeActions(listener: Listener): () => void {
return settingsChromeStore.subscribeActions(listener);
}
export function useSettingsChromeStore(): SettingsChromeSnapshot {
return useSyncExternalStore(
subscribeSettingsChrome,
getSettingsChromeSnapshot,
getSettingsChromeSnapshot,
);
}
const noopSetTheme: SettingsChromeActions['setTheme'] = () => {};
const noopSetWindowOpacity: SettingsChromeActions['setWindowOpacity'] = () => {};
/** Chrome setters, safe to call before `useSettingsState` has registered. */
export function useSettingsChromeActions(): SettingsChromeActions {
const actions = useSyncExternalStore(
subscribeSettingsChromeActions,
getSettingsChromeActions,
getSettingsChromeActions,
);
return {
setTheme: actions?.setTheme ?? noopSetTheme,
setWindowOpacity: actions?.setWindowOpacity ?? noopSetWindowOpacity,
};
}
+201
View File
@@ -0,0 +1,201 @@
import { useSyncExternalStore } from 'react';
import type {
HotkeyScheme,
KeyBinding,
SessionLogFormat,
TerminalSettings,
} from '../../domain/models';
import { normalizeTerminalSettings } from '../../domain/models';
import type { useSettingsState } from './useSettingsState';
type SettingsState = ReturnType<typeof useSettingsState>;
type Listener = () => void;
/**
* Terminal-facing settings slice. Mirrors `settingsChromeStore`: values and
* actions live on separate slots so setter identity churn never invalidates
* the value snapshot TerminalHost / terminal domain bags subscribe to.
*/
export type TerminalSettingsSnapshot = {
terminalThemeId: string;
terminalThemeDarkId: string;
terminalThemeLightId: string;
followAppTerminalTheme: boolean;
terminalFontFamilyId: string;
terminalFontSize: number;
terminalSettings: TerminalSettings;
hotkeyScheme: HotkeyScheme;
keyBindings: readonly KeyBinding[];
isHotkeyRecording: boolean;
sftpDoubleClickBehavior: 'open' | 'transfer';
sftpAutoSync: boolean;
sftpShowHiddenFiles: boolean;
sftpUseCompressedUpload: boolean;
sftpAutoOpenSidebar: boolean;
sftpFollowTerminalCwd: boolean;
sftpDefaultViewMode: 'list' | 'tree';
editorWordWrap: boolean;
sessionLogsEnabled: boolean;
sessionLogsDir: string;
sessionLogsFormat: SessionLogFormat;
sessionLogsTimestampsEnabled: boolean;
sshDebugLogsEnabled: boolean;
};
export type TerminalSettingsActions = Pick<
SettingsState,
| 'setTerminalThemeId'
| 'setTerminalThemeDarkId'
| 'setTerminalThemeLightId'
| 'setFollowAppTerminalTheme'
| 'setTerminalFontFamilyId'
| 'setTerminalFontSize'
| 'updateTerminalSetting'
| 'setSftpFollowTerminalCwd'
| 'setEditorWordWrap'
| 'applyAppTheme'
>;
export const DEFAULT_TERMINAL_SETTINGS_SNAPSHOT: TerminalSettingsSnapshot = Object.freeze({
terminalThemeId: 'default',
terminalThemeDarkId: 'default',
terminalThemeLightId: 'default',
followAppTerminalTheme: true,
terminalFontFamilyId: 'default',
terminalFontSize: 14,
terminalSettings: normalizeTerminalSettings(),
hotkeyScheme: 'mac',
keyBindings: Object.freeze([]) as readonly KeyBinding[],
isHotkeyRecording: false,
sftpDoubleClickBehavior: 'open',
sftpAutoSync: false,
sftpShowHiddenFiles: false,
sftpUseCompressedUpload: false,
sftpAutoOpenSidebar: false,
sftpFollowTerminalCwd: false,
sftpDefaultViewMode: 'list',
editorWordWrap: true,
sessionLogsEnabled: false,
sessionLogsDir: '',
sessionLogsFormat: 'txt',
sessionLogsTimestampsEnabled: false,
sshDebugLogsEnabled: false,
});
export function terminalSettingsSnapshotsEqual(
a: TerminalSettingsSnapshot,
b: TerminalSettingsSnapshot,
): boolean {
return a.terminalThemeId === b.terminalThemeId
&& a.terminalThemeDarkId === b.terminalThemeDarkId
&& a.terminalThemeLightId === b.terminalThemeLightId
&& a.followAppTerminalTheme === b.followAppTerminalTheme
&& a.terminalFontFamilyId === b.terminalFontFamilyId
&& a.terminalFontSize === b.terminalFontSize
&& a.terminalSettings === b.terminalSettings
&& a.hotkeyScheme === b.hotkeyScheme
&& a.keyBindings === b.keyBindings
&& a.isHotkeyRecording === b.isHotkeyRecording
&& a.sftpDoubleClickBehavior === b.sftpDoubleClickBehavior
&& a.sftpAutoSync === b.sftpAutoSync
&& a.sftpShowHiddenFiles === b.sftpShowHiddenFiles
&& a.sftpUseCompressedUpload === b.sftpUseCompressedUpload
&& a.sftpAutoOpenSidebar === b.sftpAutoOpenSidebar
&& a.sftpFollowTerminalCwd === b.sftpFollowTerminalCwd
&& a.sftpDefaultViewMode === b.sftpDefaultViewMode
&& a.editorWordWrap === b.editorWordWrap
&& a.sessionLogsEnabled === b.sessionLogsEnabled
&& a.sessionLogsDir === b.sessionLogsDir
&& a.sessionLogsFormat === b.sessionLogsFormat
&& a.sessionLogsTimestampsEnabled === b.sessionLogsTimestampsEnabled
&& a.sshDebugLogsEnabled === b.sshDebugLogsEnabled;
}
class TerminalSettingsStore {
private snapshot: TerminalSettingsSnapshot = DEFAULT_TERMINAL_SETTINGS_SNAPSHOT;
private actions: TerminalSettingsActions | null = null;
private listeners = new Set<Listener>();
private actionListeners = new Set<Listener>();
getSnapshot = (): TerminalSettingsSnapshot => this.snapshot;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
setSnapshot(next: TerminalSettingsSnapshot): void {
if (terminalSettingsSnapshotsEqual(this.snapshot, next)) return;
this.snapshot = next;
for (const listener of this.listeners) {
listener();
}
}
getActions = (): TerminalSettingsActions | null => this.actions;
subscribeActions = (listener: Listener): (() => void) => {
this.actionListeners.add(listener);
return () => {
this.actionListeners.delete(listener);
};
};
setActions(next: TerminalSettingsActions | null): void {
if (this.actions === next) return;
this.actions = next;
for (const listener of this.actionListeners) {
listener();
}
}
}
export const terminalSettingsStore = new TerminalSettingsStore();
export function publishTerminalSettingsSnapshot(
snapshot: TerminalSettingsSnapshot,
): void {
terminalSettingsStore.setSnapshot(snapshot);
}
export function getTerminalSettingsSnapshot(): TerminalSettingsSnapshot {
return terminalSettingsStore.getSnapshot();
}
export function subscribeTerminalSettings(listener: Listener): () => void {
return terminalSettingsStore.subscribe(listener);
}
export function registerTerminalSettingsActions(
actions: TerminalSettingsActions | null,
): void {
terminalSettingsStore.setActions(actions);
}
export function getTerminalSettingsActions(): TerminalSettingsActions | null {
return terminalSettingsStore.getActions();
}
export function subscribeTerminalSettingsActions(listener: Listener): () => void {
return terminalSettingsStore.subscribeActions(listener);
}
export function useTerminalSettingsStore(): TerminalSettingsSnapshot {
return useSyncExternalStore(
subscribeTerminalSettings,
getTerminalSettingsSnapshot,
getTerminalSettingsSnapshot,
);
}
export function useTerminalSettingsActions(): TerminalSettingsActions | null {
return useSyncExternalStore(
subscribeTerminalSettingsActions,
getTerminalSettingsActions,
getTerminalSettingsActions,
);
}
+15 -9
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import {
STORAGE_KEY_AI_PROVIDERS,
@@ -50,6 +50,7 @@ import {
} from './aiDraftState';
import { convertFilesToUploads } from './useFileUpload';
import { removeProviderReferences } from './aiProviderCleanup';
import { publishAISessionsSnapshot } from './aiSessionsStore';
import {
AI_STATE_CHANGED_DRAFTS_BY_SCOPE,
AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE,
@@ -1004,6 +1005,17 @@ export function useAIState() {
// ── Computed ──
const activeProvider = providers.find(p => p.id === activeProviderId) ?? null;
// Stream/message updates publish here so AIConfig Context consumers do not
// re-render on every token. AIChatPanelsHost reads aiSessionsStore instead.
useLayoutEffect(() => {
publishAISessionsSnapshot({
sessions,
activeSessionIdMap,
draftsByScope,
panelViewByScope,
});
}, [sessions, activeSessionIdMap, draftsByScope, panelViewByScope]);
return useMemo(() => ({
providers,
setProviders,
@@ -1039,10 +1051,8 @@ export function useAIState() {
setWebSearchConfig,
quickMessages,
setQuickMessages,
sessions,
activeSessionIdMap,
draftsByScope,
panelViewByScope,
// sessions / activeSessionIdMap / draftsByScope / panelViewByScope live in
// aiSessionsStore so streaming updates do not rebuild this config object.
setActiveSessionId,
ensureDraftForScope,
updateDraft,
@@ -1096,10 +1106,6 @@ export function useAIState() {
setWebSearchConfig,
quickMessages,
setQuickMessages,
sessions,
activeSessionIdMap,
draftsByScope,
panelViewByScope,
setActiveSessionId,
ensureDraftForScope,
updateDraft,
+11 -3
View File
@@ -7,7 +7,7 @@
* - Debounced sync to avoid too frequent API calls
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';
import { useCloudSync } from './useCloudSync';
import { useI18n } from '../i18n/I18nProvider';
import { getCloudSyncManager } from '../../infrastructure/services/CloudSyncManager';
@@ -49,6 +49,7 @@ import {
shouldRunRuntimeRemoteCheck,
} from './autoSyncRemoteSchedule';
import { resolveAutoSyncHashDecision } from './autoSyncHashDecision';
import { getNotesSnapshot, subscribeNotes } from './notesStore';
interface AutoSyncConfig {
enabled?: boolean;
@@ -163,6 +164,9 @@ interface RemoteVersionCheckOptions {
export const useAutoSync = (config: AutoSyncConfig) => {
const enabled = config.enabled !== false;
// Subscribe so note edits still trigger getSyncSnapshot rebuilds even when
// App omits notes props (notes live in notesStore, not App render).
const notesSnapshot = useSyncExternalStore(subscribeNotes, getNotesSnapshot, getNotesSnapshot);
const { t } = useI18n();
const tRef = useRef(t);
useEffect(() => {
@@ -254,8 +258,10 @@ export const useAutoSync = (config: AutoSyncConfig) => {
snippets: config.snippets,
customGroups: config.customGroups,
snippetPackages: config.snippetPackages,
notes: config.notes,
noteGroups: config.noteGroups,
// Prefer explicit overrides (tests); otherwise read the live notes store so
// App does not have to re-render on every note edit to keep sync current.
notes: config.notes ?? (notesSnapshot.notes as SyncPayload['notes']),
noteGroups: config.noteGroups ?? (notesSnapshot.noteGroups as SyncPayload['noteGroups']),
portForwardingRules: getEffectivePortForwardingRulesForSync(config.portForwardingRules),
groupConfigs: config.groupConfigs,
};
@@ -271,6 +277,8 @@ export const useAutoSync = (config: AutoSyncConfig) => {
config.noteGroups,
config.portForwardingRules,
config.groupConfigs,
notesSnapshot.notes,
notesSnapshot.noteGroups,
]);
// Build sync payload (includes plugin sidecars when host is available)
@@ -15,9 +15,11 @@ import {
readExternalMcpSilentSessions,
readExternalMcpStoredEnabled,
resetExternalMcpStartupReadyForTests,
resetExternalMcpStartupSyncOnceForTests,
shouldStartExternalMcpOnStartup,
shouldWaitForExternalMcpStartupReady,
syncExternalMcpStartupState,
syncExternalMcpStartupStateOnce,
waitForExternalMcpStartupReady,
writeExternalMcpFocusOnHostOpen,
writeExternalMcpSilentSessions,
@@ -178,17 +180,17 @@ describe('useExternalMcpToggleState startup ready gate', () => {
it('wires App startup reconcile to release the gate after enable settles', async () => {
const appSource = await import('node:fs').then((fs) =>
fs.readFileSync(new URL('../../App.tsx', import.meta.url), 'utf8'),
fs.readFileSync(new URL('../app/AppSideEffects.tsx', import.meta.url), 'utf8'),
);
const hookSource = await import('node:fs').then((fs) =>
fs.readFileSync(new URL('./useExternalMcpToggleState.ts', import.meta.url), 'utf8'),
);
assert.match(appSource, /await syncExternalMcpStartupState\(netcattyBridge\.get\(\)\)/);
assert.match(appSource, /await syncExternalMcpStartupStateOnce\(netcattyBridge\.get\(\)\)/);
assert.match(appSource, /markExternalMcpStartupReady\(\)/);
assert.ok(
appSource.indexOf('await syncExternalMcpStartupState(netcattyBridge.get())')
appSource.indexOf('await syncExternalMcpStartupStateOnce(netcattyBridge.get())')
< appSource.indexOf('markExternalMcpStartupReady()'),
'startup ready must be marked only after await syncExternalMcpStartupState',
'startup ready must be marked only after await syncExternalMcpStartupStateOnce',
);
assert.match(hookSource, /export async function syncExternalMcpStartupState/);
assert.match(hookSource, /await Promise\.resolve\(bridge\?\.externalMcpSetEnabled\?\.\(plan\.runtimeEnabled\)\)/);
@@ -247,6 +249,51 @@ describe('useExternalMcpToggleState startup ready gate', () => {
});
});
describe('syncExternalMcpStartupStateOnce single-flight', () => {
it('reaches the bridge once when the startup effect is invoked twice', async () => {
const restore = installMemoryLocalStorage();
try {
resetExternalMcpStartupReadyForTests();
resetExternalMcpStartupSyncOnceForTests();
const storageKeys = await import('../../infrastructure/config/storageKeys.ts');
globalThis.localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, 'true');
globalThis.localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
const configCalls: unknown[] = [];
const enabledCalls: boolean[] = [];
const bridge = {
externalMcpSetConfig: async (config: unknown) => {
configCalls.push(config);
return { ok: true };
},
externalMcpSetEnabled: async (enabled: boolean) => {
enabledCalls.push(enabled);
return { ok: true, enabled };
},
};
// Mount, cleanup, mount again: both effect runs share one reconcile.
const first = syncExternalMcpStartupStateOnce(bridge);
const second = syncExternalMcpStartupStateOnce(bridge);
assert.equal(first, second);
const [firstPlan, secondPlan] = await Promise.all([first, second]);
assert.equal(firstPlan, secondPlan);
assert.equal(firstPlan.runtimeEnabled, true);
assert.equal(configCalls.length, 1);
assert.deepEqual(enabledCalls, [true]);
// A later remount awaits the settled promise instead of re-sending IPC.
await syncExternalMcpStartupStateOnce(bridge);
assert.equal(configCalls.length, 1);
assert.deepEqual(enabledCalls, [true]);
} finally {
resetExternalMcpStartupSyncOnceForTests();
restore();
}
});
});
describe('syncExternalMcpStartupState generation guard', () => {
it('skips stale startup enable after concurrent top-bar toggle generation bump', async () => {
const restore = installMemoryLocalStorage();
@@ -64,6 +64,7 @@ export function resetExternalMcpStartupReadyForTests(): void {
externalMcpStartupReadyWaitPromise = null;
externalMcpStartupReadyWaitResolve = null;
externalMcpEnableGeneration = 0;
externalMcpStartupSyncPromise = null;
}
export function getExternalMcpEnableGenerationForTests(): number {
@@ -310,6 +311,26 @@ export async function syncExternalMcpStartupState(
return plan;
}
let externalMcpStartupSyncPromise: Promise<ExternalMcpStartupSyncPlan> | null = null;
/**
* Startup reconcile runs exactly once per renderer load. A remounted App must
* await the same settled promise instead of pushing a second enable/disable to
* the main process, which would fight the user's live toggle.
*/
export function syncExternalMcpStartupStateOnce(
bridge: ExternalMcpBridge | undefined = netcattyBridge.get(),
): Promise<ExternalMcpStartupSyncPlan> {
if (!externalMcpStartupSyncPromise) {
externalMcpStartupSyncPromise = syncExternalMcpStartupState(bridge);
}
return externalMcpStartupSyncPromise;
}
export function resetExternalMcpStartupSyncOnceForTests(): void {
externalMcpStartupSyncPromise = null;
}
export function useExternalMcpToggleState() {
// UI mirrors the stored switch. Startup reconcile (App mount, main window only)
// decides whether temporary mode should clear/persist and start the runtime.
@@ -2,7 +2,11 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const appSource = readFileSync(new URL('../../App.tsx', import.meta.url), 'utf8');
const appSource = [
readFileSync(new URL('../app/AppSideEffects.tsx', import.meta.url), 'utf8'),
readFileSync(new URL('../app/hosts/TerminalHost.tsx', import.meta.url), 'utf8'),
readFileSync(new URL('../app/hosts/ChromeHost.tsx', import.meta.url), 'utf8'),
].join('\n');
const hookSource = readFileSync(new URL('./useManualTerminalChromeSurfaceInjection.ts', import.meta.url), 'utf8');
const bridgeSource = readFileSync(new URL('../../components/terminalLayer/TerminalLayerTabBridge.tsx', import.meta.url), 'utf8');
const varsSource = readFileSync(new URL('../../infrastructure/theme/terminalAppearanceVars.ts', import.meta.url), 'utf8');
@@ -12,7 +16,9 @@ test('manual mode injects chrome surfaces from focused session theme', () => {
assert.match(bridgeSource, /useManualTerminalChromeSurfaceInjection/);
assert.match(bridgeSource, /!s\.followAppTerminalTheme && isTerminalLayerVisible/);
assert.match(readFileSync(new URL('../app/AppHostTreeLayer.tsx', import.meta.url), 'utf8'), /useManualTerminalChromeSurfaceInjection/);
assert.match(appSource, /resolveSessionAppearance=\{resolveFocusedAppearance\}/);
// App feeds the focused-session resolver to AppActiveTabChrome through the
// memoized shell chrome bag (AppShell spreads it onto the chrome component).
assert.match(appSource, /resolveSessionAppearance: resolveFocusedAppearance/);
assert.match(hookSource, /applyTopTabsChromeThemeVars\(theme\)/);
assert.match(hookSource, /injectTerminalLayerChromeSurfaceVars\(theme\)/);
assert.match(hookSource, /if \(wasEnabled\) \{[\s\S]*clearTerminalLayerChromeSurfaceVars\(\)/);
+145 -5
View File
@@ -2,6 +2,14 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, typ
import { runThemeTransition, type ThemeTransitionMode } from './themeTransition';
import { publishAppearanceChromeSnapshot } from './appearanceChromeStore';
import {
publishSettingsChromeSnapshot,
registerSettingsChromeActions,
} from './settingsChromeStore';
import {
publishTerminalSettingsSnapshot,
registerTerminalSettingsActions,
} from './terminalSettingsStore';
import { SyncConfig, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
import {
DEFAULT_HTTP_NETWORK_PROXY,
@@ -1709,6 +1717,142 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
const setEditorWordWrap = useCallback((enabled: boolean) => {
setEditorWordWrapState(enabled);
localStorageAdapter.writeString(STORAGE_KEY_EDITOR_WORD_WRAP, String(enabled));
notifySettingsChanged(STORAGE_KEY_EDITOR_WORD_WRAP, enabled);
}, [notifySettingsChanged]);
// Main-window chrome subscribes to this slice instead of receiving the whole
// settings object through the App domain bags. The store dedupes field by
// field, so unrelated settings churn never notifies chrome consumers.
useLayoutEffect(() => {
publishSettingsChromeSnapshot({
theme,
resolvedTheme,
lightUiThemeId,
darkUiThemeId,
uiLanguage,
windowOpacity,
showSftpTab,
showHostTreeSidebar,
showRecentHosts,
hostClickBehavior,
showOnlyUngroupedHostsInRoot,
dynamicTabTitleMode: terminalSettings.dynamicTabTitleMode,
disableTerminalFontZoom,
restoreTerminalCwd,
terminalSidePanelAutoOpen,
terminalSidePanelAutoOpenTab,
});
}, [
darkUiThemeId,
disableTerminalFontZoom,
hostClickBehavior,
lightUiThemeId,
resolvedTheme,
restoreTerminalCwd,
showHostTreeSidebar,
showOnlyUngroupedHostsInRoot,
showRecentHosts,
showSftpTab,
terminalSettings.dynamicTabTitleMode,
terminalSidePanelAutoOpen,
terminalSidePanelAutoOpenTab,
theme,
uiLanguage,
windowOpacity,
]);
useLayoutEffect(() => {
registerSettingsChromeActions({ setTheme, setWindowOpacity });
return () => {
registerSettingsChromeActions(null);
};
}, [setTheme, setWindowOpacity]);
// TerminalHost / terminal domain bags subscribe here instead of receiving
// settings through the App mega-subscriber.
useLayoutEffect(() => {
publishTerminalSettingsSnapshot({
terminalThemeId,
terminalThemeDarkId,
terminalThemeLightId,
followAppTerminalTheme,
terminalFontFamilyId,
terminalFontSize,
terminalSettings,
hotkeyScheme,
keyBindings,
isHotkeyRecording,
sftpDoubleClickBehavior,
sftpAutoSync,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
sftpAutoOpenSidebar,
sftpFollowTerminalCwd,
sftpDefaultViewMode,
editorWordWrap,
sessionLogsEnabled,
sessionLogsDir,
sessionLogsFormat,
sessionLogsTimestampsEnabled,
sshDebugLogsEnabled,
});
}, [
editorWordWrap,
followAppTerminalTheme,
hotkeyScheme,
isHotkeyRecording,
keyBindings,
sessionLogsDir,
sessionLogsEnabled,
sessionLogsFormat,
sessionLogsTimestampsEnabled,
sftpAutoOpenSidebar,
sftpAutoSync,
sftpDefaultViewMode,
sftpDoubleClickBehavior,
sftpFollowTerminalCwd,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
sshDebugLogsEnabled,
terminalFontFamilyId,
terminalFontSize,
terminalSettings,
terminalThemeDarkId,
terminalThemeId,
terminalThemeLightId,
]);
useLayoutEffect(() => {
registerTerminalSettingsActions({
setTerminalThemeId,
setTerminalThemeDarkId,
setTerminalThemeLightId,
setFollowAppTerminalTheme: setFollowAppTerminalThemeState,
setTerminalFontFamilyId,
setTerminalFontSize,
updateTerminalSetting,
setSftpFollowTerminalCwd,
setEditorWordWrap,
applyAppTheme,
});
return () => {
registerTerminalSettingsActions(null);
};
}, [
applyAppTheme,
setEditorWordWrap,
setSftpFollowTerminalCwd,
setTerminalFontFamilyId,
setTerminalFontSize,
setTerminalThemeDarkId,
setTerminalThemeId,
setTerminalThemeLightId,
updateTerminalSetting,
]);
return {
theme,
setTheme,
@@ -1798,11 +1942,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setSshTransportIdleTtlMs,
// Editor Settings
editorWordWrap,
setEditorWordWrap: useCallback((enabled: boolean) => {
setEditorWordWrapState(enabled);
localStorageAdapter.writeString(STORAGE_KEY_EDITOR_WORD_WRAP, String(enabled));
notifySettingsChanged(STORAGE_KEY_EDITOR_WORD_WRAP, enabled);
}, [notifySettingsChanged]),
setEditorWordWrap,
// Session Logs
sessionLogsEnabled,
setSessionLogsEnabled,
+8
View File
@@ -667,7 +667,9 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean; enabled?
hasCheckedOnStartupRef.current = true;
debugLog('Starting delayed update check for version:', updateState.currentVersion);
let checkArmed = true;
startupCheckTimeoutRef.current = setTimeout(async () => {
checkArmed = false;
// Re-check the toggle at fire time — the user may have toggled it
// after the timer was scheduled.
const stillEnabled = localStorageAdapter.readString(STORAGE_KEY_AUTO_UPDATE_ENABLED);
@@ -711,6 +713,12 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean; enabled?
return () => {
if (startupCheckTimeoutRef.current) {
clearTimeout(startupCheckTimeoutRef.current);
startupCheckTimeoutRef.current = null;
}
// A remount clears the timer before it fires — allow the next effect to
// schedule again instead of permanently skipping the check.
if (checkArmed) {
hasCheckedOnStartupRef.current = false;
}
};
}, [enabled, updateState.currentVersion, autoUpdateEnabled, performCheck]);
+16 -1
View File
@@ -1,6 +1,11 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { haveSameVaultAgentSnapshot, resolveVaultAgentEffectiveHost } from './useVaultAgentBridge';
import {
haveSameVaultAgentSnapshot,
resolveVaultAgentEffectiveHost,
resolveVaultAgentNotes,
} from './useVaultAgentBridge';
import { publishNotesSnapshot } from './notesStore';
type Snapshot = Parameters<typeof haveSameVaultAgentSnapshot>[0];
@@ -34,3 +39,13 @@ describe('resolveVaultAgentEffectiveHost', () => {
);
});
});
describe('resolveVaultAgentNotes', () => {
it('prefers the live notes store when App omitted the notes prop', () => {
const stale: Snapshot['notes'] = [];
const live = [{ id: 'n1', title: 'Live', content: 'x', updatedAt: 1 }] as Snapshot['notes'];
publishNotesSnapshot({ notes: live, noteGroups: [] });
assert.equal(resolveVaultAgentNotes(undefined, stale as never), live);
assert.equal(resolveVaultAgentNotes(stale as never, stale as never), stale);
});
});
+40 -5
View File
@@ -15,6 +15,7 @@ import {
rememberKeyPassphrase,
resolveDefaultKeyPassphraseAliases,
} from '../defaultKeyPassphrases';
import { getNotesActions, getNotesSnapshot, subscribeNotes } from './notesStore';
export interface UseVaultAgentBridgeInput {
hosts: Host[];
@@ -35,8 +36,10 @@ export interface UseVaultAgentBridgeInput {
updateGroupConfigs: (configs: GroupConfig[]) => void;
updateManagedSources: (sources: ManagedSource[]) => void;
updatePortForwardingRules: (rules: PortForwardingRule[]) => void;
notes: VaultNote[];
updateNotes: (notes: VaultNote[]) => void;
/** Optional override; defaults to notesStore so App need not subscribe to notes. */
notes?: VaultNote[];
/** Optional override; defaults to notesStore actions. */
updateNotes?: (notes: VaultNote[]) => void;
startTunnel: VaultAgentApiDeps['startTunnel'];
stopTunnel: VaultAgentApiDeps['stopTunnel'];
stopRuleTunnels: VaultAgentApiDeps['stopRuleTunnels'];
@@ -59,7 +62,7 @@ type VaultAgentSnapshot = {
const selectVaultAgentSnapshot = (input: UseVaultAgentBridgeInput): VaultAgentSnapshot => ({
hosts: input.hosts,
keys: input.keys,
notes: input.notes,
notes: input.notes ?? (getNotesSnapshot().notes as VaultNote[]),
snippets: input.snippets,
customGroups: input.customGroups,
groupConfigs: input.groupConfigs,
@@ -67,6 +70,16 @@ const selectVaultAgentSnapshot = (input: UseVaultAgentBridgeInput): VaultAgentSn
managedSources: input.managedSources,
});
/** Resolve notes for agent ops: live store wins when App omitted the prop. */
export function resolveVaultAgentNotes(
notesOverride: VaultNote[] | undefined,
snapshotNotes: VaultNote[],
): VaultNote[] {
return notesOverride !== undefined
? snapshotNotes
: (getNotesSnapshot().notes as VaultNote[]);
}
export const haveSameVaultAgentSnapshot = (
left: VaultAgentSnapshot,
right: VaultAgentSnapshot,
@@ -102,12 +115,34 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
lastSyncedVaultInputRef.current = selectedSnapshot;
}
// Keep notes fresh without forcing App to re-render on every note edit.
// Write in place: this object is also `lastSyncedVaultInputRef.current`, and
// the agent op handlers below rely on that aliasing so their writes survive
// the render-time comparison above. Replacing the object would break it.
useEffect(() => {
if (input.notes !== undefined) return;
const syncNotes = () => {
vaultSnapshotRef.current.notes = getNotesSnapshot().notes as VaultNote[];
};
syncNotes();
return subscribeNotes(syncNotes);
}, [input.notes]);
useEffect(() => {
registerVaultAgentHandler(async (op, params) => {
const current = inputRef.current;
const applyUpdateNotes = current.updateNotes
?? getNotesActions()?.updateNotes
?? ((notes: VaultNote[]) => {
console.warn('[useVaultAgentBridge] updateNotes unavailable');
void notes;
});
return handleVaultAgentOp(op, params, {
getHosts: () => vaultSnapshotRef.current.hosts,
getNotes: () => vaultSnapshotRef.current.notes,
getNotes: () => resolveVaultAgentNotes(
current.notes,
vaultSnapshotRef.current.notes,
),
getCustomGroups: () => vaultSnapshotRef.current.customGroups,
getGroupConfigs: () => vaultSnapshotRef.current.groupConfigs,
getPortForwardingRules: () => vaultSnapshotRef.current.portForwardingRules,
@@ -185,7 +220,7 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
}),
updateNotes: (notes) => {
vaultSnapshotRef.current.notes = notes;
current.updateNotes(notes);
applyUpdateNotes(notes);
},
updateSnippets: (nextSnippets) => {
vaultSnapshotRef.current.snippets = nextSnippets;
+26
View File
@@ -65,6 +65,10 @@ import {
} from "../../domain/connectionLogTerminalData";
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
import { loadSanitizedShellHistory } from "./shellHistoryPersistence";
import {
publishConnectionLogsSnapshot,
registerConnectionLogsActions,
} from "./connectionLogsStore";
import {
publishNotesSnapshot,
registerNotesActions,
@@ -968,6 +972,28 @@ export const useVaultState = () => {
});
}, [persistConnectionLogState]);
// Connection logs for Vault logs section — keep App domain bags off session churn.
useLayoutEffect(() => {
publishConnectionLogsSnapshot({ connectionLogs });
}, [connectionLogs]);
useLayoutEffect(() => {
registerConnectionLogsActions({
updateConnectionLog,
toggleConnectionLogSaved,
deleteConnectionLog,
clearUnsavedConnectionLogs,
});
return () => {
registerConnectionLogsActions(null);
};
}, [
updateConnectionLog,
toggleConnectionLogSaved,
deleteConnectionLog,
clearUnsavedConnectionLogs,
]);
// Convert a known host to a managed host
const convertKnownHostToHost = useCallback((knownHost: KnownHost): Host => {
const newHost: Host = {
@@ -0,0 +1,94 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { Host } from '../../domain/models.ts';
import {
EMPTY_VAULT_SNAPSHOT,
getVaultSnapshot,
getVaultSnapshotActions,
publishVaultSnapshot,
registerVaultSnapshotActions,
subscribeVaultSnapshot,
vaultSnapshotsEqual,
type VaultSnapshotActions,
} from './vaultSnapshotStore.ts';
function makeHost(id: string): Host {
return { id, label: id, hostname: `${id}.example`, username: 'root' } as Host;
}
test('vaultSnapshotStore notifies only when a field identity changes', () => {
const seen: number[] = [];
const unsubscribe = subscribeVaultSnapshot(() => {
seen.push(getVaultSnapshot().hosts.length);
});
const hosts = [makeHost('h1')];
const snapshot = { ...EMPTY_VAULT_SNAPSHOT, isVaultInitialized: true, hosts };
publishVaultSnapshot(snapshot);
assert.deepEqual(seen, [1]);
assert.equal(getVaultSnapshot().hosts, hosts);
assert.equal(getVaultSnapshot().isVaultInitialized, true);
// Same field identities in a fresh object must not wake subscribers.
publishVaultSnapshot({ ...snapshot });
assert.deepEqual(seen, [1]);
const moreHosts = [...hosts, makeHost('h2')];
publishVaultSnapshot({ ...snapshot, hosts: moreHosts });
assert.deepEqual(seen, [1, 2]);
unsubscribe();
publishVaultSnapshot(EMPTY_VAULT_SNAPSHOT);
assert.deepEqual(seen, [1, 2]);
});
test('vaultSnapshotsEqual compares every published field', () => {
assert.equal(vaultSnapshotsEqual(EMPTY_VAULT_SNAPSHOT, { ...EMPTY_VAULT_SNAPSHOT }), true);
assert.equal(
vaultSnapshotsEqual(EMPTY_VAULT_SNAPSHOT, { ...EMPTY_VAULT_SNAPSHOT, groupConfigs: [] }),
false,
);
assert.equal(
vaultSnapshotsEqual(EMPTY_VAULT_SNAPSHOT, {
...EMPTY_VAULT_SNAPSHOT,
isVaultInitialized: true,
}),
false,
);
});
test('vault actions register and unregister', () => {
assert.equal(getVaultSnapshotActions(), null);
const calls: string[] = [];
const noop = (() => {}) as never;
registerVaultSnapshotActions({
updateHosts: ((() => {
calls.push('hosts');
}) as unknown) as VaultSnapshotActions['updateHosts'],
updateKeys: noop,
importOrReuseKey: noop,
updateIdentities: noop,
updateProxyProfiles: noop,
updateSnippets: noop,
updateSnippetPackages: noop,
updateCustomGroups: noop,
updateKnownHosts: noop,
updateManagedSources: noop,
updateGroupConfigs: noop,
convertKnownHostToHost: noop,
readPersistedHosts: noop,
readPersistedManagedSources: noop,
commitPluginImporterData: noop,
commitVaultImportTransaction: noop,
updateHostDistro: noop,
updateHostLastConnected: noop,
addShellHistoryEntry: noop,
removeShellHistoryEntry: noop,
});
getVaultSnapshotActions()?.updateHosts([]);
assert.deepEqual(calls, ['hosts']);
registerVaultSnapshotActions(null);
assert.equal(getVaultSnapshotActions(), null);
});
+193
View File
@@ -0,0 +1,193 @@
import { useSyncExternalStore } from 'react';
import type {
GroupConfig,
Host,
Identity,
KnownHost,
ManagedSource,
ProxyProfile,
Snippet,
SSHKey,
} from '../../domain/models';
import type { useVaultState } from './useVaultState';
type VaultState = ReturnType<typeof useVaultState>;
type Listener = () => void;
/**
* The vault catalog every shell surface reads. Notes, note groups, connection
* logs and shell history are deliberately absent: they have their own stores
* (`notesStore`, `connectionLogsStore`, `shellHistoryStore`) because they churn
* on a different cadence than the vault catalog.
*/
export type VaultSnapshot = {
isVaultInitialized: boolean;
hosts: readonly Host[];
keys: readonly SSHKey[];
identities: readonly Identity[];
proxyProfiles: readonly ProxyProfile[];
snippets: readonly Snippet[];
snippetPackages: readonly string[];
customGroups: readonly string[];
knownHosts: readonly KnownHost[];
managedSources: readonly ManagedSource[];
groupConfigs: readonly GroupConfig[];
};
/**
* Vault mutators the shell needs. Derived from the hook return so the store
* contract cannot drift from `useVaultState`.
*/
export type VaultSnapshotActions = Pick<
VaultState,
| 'updateHosts'
| 'updateKeys'
| 'importOrReuseKey'
| 'updateIdentities'
| 'updateProxyProfiles'
| 'updateSnippets'
| 'updateSnippetPackages'
| 'updateCustomGroups'
| 'updateKnownHosts'
| 'updateManagedSources'
| 'updateGroupConfigs'
| 'convertKnownHostToHost'
| 'readPersistedHosts'
| 'readPersistedManagedSources'
| 'commitPluginImporterData'
| 'commitVaultImportTransaction'
| 'updateHostDistro'
| 'updateHostLastConnected'
| 'addShellHistoryEntry'
| 'removeShellHistoryEntry'
>;
const EMPTY_HOSTS: readonly Host[] = Object.freeze([]);
const EMPTY_KEYS: readonly SSHKey[] = Object.freeze([]);
const EMPTY_IDENTITIES: readonly Identity[] = Object.freeze([]);
const EMPTY_PROXY_PROFILES: readonly ProxyProfile[] = Object.freeze([]);
const EMPTY_SNIPPETS: readonly Snippet[] = Object.freeze([]);
const EMPTY_STRINGS: readonly string[] = Object.freeze([]);
const EMPTY_KNOWN_HOSTS: readonly KnownHost[] = Object.freeze([]);
const EMPTY_MANAGED_SOURCES: readonly ManagedSource[] = Object.freeze([]);
const EMPTY_GROUP_CONFIGS: readonly GroupConfig[] = Object.freeze([]);
export const EMPTY_VAULT_SNAPSHOT: VaultSnapshot = Object.freeze({
isVaultInitialized: false,
hosts: EMPTY_HOSTS,
keys: EMPTY_KEYS,
identities: EMPTY_IDENTITIES,
proxyProfiles: EMPTY_PROXY_PROFILES,
snippets: EMPTY_SNIPPETS,
snippetPackages: EMPTY_STRINGS,
customGroups: EMPTY_STRINGS,
knownHosts: EMPTY_KNOWN_HOSTS,
managedSources: EMPTY_MANAGED_SOURCES,
groupConfigs: EMPTY_GROUP_CONFIGS,
});
const SNAPSHOT_KEYS = Object.keys(EMPTY_VAULT_SNAPSHOT) as (keyof VaultSnapshot)[];
export function vaultSnapshotsEqual(prev: VaultSnapshot, next: VaultSnapshot): boolean {
return SNAPSHOT_KEYS.every((key) => prev[key] === next[key]);
}
/**
* External store for the vault catalog so hosts, vault surfaces and the
* terminal layer can subscribe to exactly the slice they render instead of
* receiving it through the App domain bags. Values and actions live in
* separate slots (same shape as `notesStore`) so an unstable setter identity
* cannot invalidate the value snapshot.
*/
class VaultSnapshotStore {
private snapshot: VaultSnapshot = EMPTY_VAULT_SNAPSHOT;
private actions: VaultSnapshotActions | null = null;
private listeners = new Set<Listener>();
private actionListeners = new Set<Listener>();
getSnapshot = (): VaultSnapshot => this.snapshot;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
setSnapshot(next: VaultSnapshot): void {
if (vaultSnapshotsEqual(this.snapshot, next)) return;
this.snapshot = next;
for (const listener of this.listeners) {
listener();
}
}
getActions = (): VaultSnapshotActions | null => this.actions;
subscribeActions = (listener: Listener): (() => void) => {
this.actionListeners.add(listener);
return () => {
this.actionListeners.delete(listener);
};
};
setActions(next: VaultSnapshotActions | null): void {
if (this.actions === next) return;
this.actions = next;
for (const listener of this.actionListeners) {
listener();
}
}
}
export const vaultSnapshotStore = new VaultSnapshotStore();
export function publishVaultSnapshot(snapshot: VaultSnapshot): void {
vaultSnapshotStore.setSnapshot(snapshot);
}
export function getVaultSnapshot(): VaultSnapshot {
return vaultSnapshotStore.getSnapshot();
}
export function subscribeVaultSnapshot(listener: Listener): () => void {
return vaultSnapshotStore.subscribe(listener);
}
export function registerVaultSnapshotActions(actions: VaultSnapshotActions | null): void {
vaultSnapshotStore.setActions(actions);
}
export function getVaultSnapshotActions(): VaultSnapshotActions | null {
return vaultSnapshotStore.getActions();
}
export function subscribeVaultSnapshotActions(listener: Listener): () => void {
return vaultSnapshotStore.subscribeActions(listener);
}
/** Subscribe to the whole vault catalog. Prefer a narrower selector hook. */
export function useVaultSnapshot(): VaultSnapshot {
return useSyncExternalStore(subscribeVaultSnapshot, getVaultSnapshot, getVaultSnapshot);
}
/**
* Subscribe to a single vault field. Consumers that only render hosts must not
* re-render when unrelated vault slices settle.
*/
export function useVaultSnapshotField<K extends keyof VaultSnapshot>(
field: K,
): VaultSnapshot[K] {
const read = () => getVaultSnapshot()[field];
return useSyncExternalStore(subscribeVaultSnapshot, read, read);
}
export function useVaultSnapshotActions(): VaultSnapshotActions | null {
return useSyncExternalStore(
subscribeVaultSnapshotActions,
getVaultSnapshotActions,
getVaultSnapshotActions,
);
}
+7
View File
@@ -3167,6 +3167,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
retryTokenRef.current = null;
reconnectPreparationTokenRef.current = null;
restoreCwdIntentRef.current = null;
// Cancel must invalidate the boot the same way Disconnect does. Unmount is
// no longer the only path that flips boot-active: under StrictMode the pane
// can survive this click, so a late startSSH/startMosh attach could revive
// the aborted attempt. Bump the epoch here and let closeSession target the
// pre-bump epoch.
invalidateBootEpochForClose();
isBootActiveRef.current = false;
setIsCancelling(true);
auth.setNeedsAuth(false);
auth.setAuthRetryMessage(null);
+9 -4
View File
@@ -45,7 +45,12 @@ const topTabsQuickControlsSource = readFileSync(new URL("./TopTabsQuickControls.
const syncStatusButtonSource = readFileSync(new URL("./SyncStatusButton.tsx", import.meta.url), "utf8");
const switchSource = readFileSync(new URL("./ui/switch.tsx", import.meta.url), "utf8");
const appViewSource = readFileSync(new URL("../application/app/AppView.tsx", import.meta.url), "utf8");
const appSource = readFileSync(new URL("../App.tsx", import.meta.url), "utf8");
const appSource = [
readFileSync(new URL("../App.tsx", import.meta.url), "utf8"),
readFileSync(new URL("../application/app/AppSideEffects.tsx", import.meta.url), "utf8"),
readFileSync(new URL("../application/app/hosts/TerminalHost.tsx", import.meta.url), "utf8"),
readFileSync(new URL("../application/app/hosts/ChromeHost.tsx", import.meta.url), "utf8"),
].join("\n");
const externalMcpToggleSource = readFileSync(new URL("../application/state/useExternalMcpToggleState.ts", import.meta.url), "utf8");
const zhTwAiSource = readFileSync(new URL("../application/i18n/locales/zh-TW/ai.ts", import.meta.url), "utf8");
const topTabItemsSource = readFileSync(new URL("./top-tabs/TopTabItems.tsx", import.meta.url), "utf8");
@@ -185,12 +190,12 @@ test("AppView hides External MCP toggle in peer session windows", () => {
});
test("External MCP top-bar status sync waits for App startup reconcile", () => {
assert.match(appSource, /await syncExternalMcpStartupState\(netcattyBridge\.get\(\)\)/);
assert.match(appSource, /await syncExternalMcpStartupStateOnce\(netcattyBridge\.get\(\)\)/);
assert.match(appSource, /markExternalMcpStartupReady\(\)/);
assert.ok(
appSource.indexOf("await syncExternalMcpStartupState(netcattyBridge.get())")
appSource.indexOf("await syncExternalMcpStartupStateOnce(netcattyBridge.get())")
< appSource.indexOf("markExternalMcpStartupReady()"),
"startup ready must be marked only after await syncExternalMcpStartupState",
"startup ready must be marked only after await syncExternalMcpStartupStateOnce",
);
assert.match(externalMcpToggleSource, /shouldWaitForExternalMcpStartupReady/);
assert.match(externalMcpToggleSource, /waitForExternalMcpStartupReady\(/);
+57 -27
View File
@@ -1,7 +1,17 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { syncVaultViewMemoSection, vaultViewAreEqual } from "./VaultView.tsx";
import { vaultViewAreEqual } from "./VaultView.tsx";
const vaultViewSource = readFileSync(
new URL("./VaultView.tsx", import.meta.url),
"utf8",
);
const vaultViewLayoutSource = readFileSync(
new URL("./vault/VaultViewLayout.tsx", import.meta.url),
"utf8",
);
const baseProps = {
hosts: [],
@@ -10,11 +20,8 @@ const baseProps = {
proxyProfiles: [],
snippets: [],
snippetPackages: [],
notes: [],
noteGroups: [],
customGroups: [],
knownHosts: [],
connectionLogs: [],
sessions: [],
managedSources: [],
groupConfigs: {},
@@ -79,34 +86,57 @@ test("VaultView re-renders when host-key verification setting changes", () => {
);
});
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("notes are not VaultView props — the notes section reads notesStore", () => {
// Note edits must not churn the App vault domain bag, so VaultView neither
// receives notes nor compares them.
for (const removed of [
"notes:",
"noteGroups:",
"onUpdateNotes:",
"onUpdateNoteGroups:",
]) {
assert.equal(
vaultViewSource.includes(removed),
false,
`VaultView must not declare ${removed}`,
);
}
assert.doesNotMatch(vaultViewSource, /prev\.notes === next\.notes/);
assert.match(vaultViewLayoutSource, /useNotesStore\(\)/);
assert.match(vaultViewLayoutSource, /function VaultNotesSection/);
});
test("VaultView compares connectionLogs identity when on logs section", () => {
syncVaultViewMemoSection("logs");
const nextLogs = [{ id: "log-2" }];
test("connectionLogs are not VaultView props — the logs section reads connectionLogsStore", () => {
for (const removed of [
"connectionLogs:",
"onToggleConnectionLogSaved:",
"onDeleteConnectionLog:",
"onClearUnsavedConnectionLogs:",
]) {
assert.equal(
vaultViewSource.includes(removed),
false,
`VaultView must not declare ${removed}`,
);
}
// The section-gated connectionLogs memo escape hatch is gone with the prop.
assert.equal(vaultViewSource.includes("syncVaultViewMemoSection"), false);
assert.equal(vaultViewSource.includes("vaultViewLatestConnectionLogs"), false);
assert.match(vaultViewLayoutSource, /useConnectionLogsStore\(\)/);
assert.match(vaultViewLayoutSource, /function VaultConnectionLogsSection/);
});
test("notes and connectionLogs churn cannot re-render VaultView", () => {
// Even if a caller smuggles them in, areEqual ignores both.
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,
{ ...baseProps, notes: [], connectionLogs: [] } as never,
{
...baseProps,
notes: [{ id: "n1" }],
connectionLogs: [{ id: "log-1" }],
} as never,
),
true,
);
syncVaultViewMemoSection("hosts");
});
@@ -71,12 +71,8 @@ const renderVault = (sortMode: string | null, hosts: Host[]) => {
proxyProfiles: [],
snippets: [],
snippetPackages: [],
notes: [],
noteGroups: [],
customGroups: [],
knownHosts: [],
shellHistory: [],
connectionLogs: [],
managedSources: [],
sessionCount: 0,
hotkeyScheme: "mac",
@@ -95,15 +91,10 @@ const renderVault = (sortMode: string | null, hosts: Host[]) => {
onUpdateProxyProfiles: noop,
onUpdateSnippets: noop,
onUpdateSnippetPackages: noop,
onUpdateNotes: noop,
onUpdateNoteGroups: noop,
onUpdateCustomGroups: noop,
onUpdateKnownHosts: noop,
onUpdateManagedSources: noop,
onConvertKnownHost: noop,
onToggleConnectionLogSaved: noop,
onDeleteConnectionLog: noop,
onClearUnsavedConnectionLogs: noop,
onOpenLogView: noop,
groupConfigs: [],
onUpdateGroupConfigs: noop,
+2 -46
View File
@@ -93,7 +93,6 @@ import {
SerialConfig,
SSHKey,
Snippet,
VaultNote,
} from "../types";
import { AppLogo } from "./AppLogo";
import { connectHostsStaggered } from "./connectHostsStaggered";
@@ -167,16 +166,6 @@ 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) => {
@@ -220,11 +209,8 @@ interface VaultViewProps {
proxyProfiles: ProxyProfile[];
snippets: Snippet[];
snippetPackages: string[];
notes: VaultNote[];
noteGroups: string[];
customGroups: string[];
knownHosts: KnownHost[];
connectionLogs: ConnectionLog[];
managedSources: ManagedSource[];
sessionCount: number;
hotkeyScheme: HotkeyScheme;
@@ -249,8 +235,6 @@ interface VaultViewProps {
onUpdateProxyProfiles: (profiles: ProxyProfile[]) => void;
onUpdateSnippets: (snippets: Snippet[]) => void;
onUpdateSnippetPackages: (pkgs: string[]) => void;
onUpdateNotes: (notes: VaultNote[]) => void;
onUpdateNoteGroups: (groups: string[]) => void;
onUpdateCustomGroups: (
groups: string[] | ((current: string[]) => string[]),
) => void;
@@ -280,9 +264,6 @@ interface VaultViewProps {
onClearAndRemoveManagedSources?: (sources: ManagedSource[]) => Promise<() => Promise<void>>;
onUnmanageSource?: (sourceId: string) => void;
onConvertKnownHost: (knownHost: KnownHost) => void;
onToggleConnectionLogSaved: (id: string) => void;
onDeleteConnectionLog: (id: string) => void;
onClearUnsavedConnectionLogs: () => void;
onOpenLogView: (log: ConnectionLog) => void;
onRunSnippet?: (snippet: Snippet, targetHosts: Host[]) => void;
groupConfigs: GroupConfig[];
@@ -314,11 +295,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
proxyProfiles,
snippets,
snippetPackages,
notes,
noteGroups,
customGroups,
knownHosts,
connectionLogs,
managedSources,
sessionCount,
hotkeyScheme,
@@ -340,8 +318,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onUpdateProxyProfiles,
onUpdateSnippets,
onUpdateSnippetPackages,
onUpdateNotes,
onUpdateNoteGroups,
onUpdateCustomGroups,
onCommitPluginImporterData,
onUpdateKnownHosts,
@@ -352,9 +328,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onClearAndRemoveManagedSources,
onUnmanageSource,
onConvertKnownHost,
onToggleConnectionLogSaved,
onDeleteConnectionLog,
onClearUnsavedConnectionLogs,
onOpenLogView,
onRunSnippet,
groupConfigs,
@@ -375,8 +348,6 @@ 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,
@@ -1427,7 +1398,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
Clock,
cn,
commitInlineGroupRename,
connectionLogs: resolvedConnectionLogs,
connectSelectedHosts,
ContextMenu,
ContextMenuContent,
@@ -1525,29 +1495,22 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
Network,
newFolderName,
newHostGroupPath,
noteGroups,
NotebookText,
notes,
NotesManager,
onCommitPluginImporterData,
onClearUnsavedConnectionLogs,
onConnectSerial,
onCreateLocalTerminal,
onDeleteConnectionLog,
onDeleteHost,
onImportOrReuseKey,
onOpenHostFromNote,
onOpenLogView,
onOpenSettings,
onRunSnippet,
onToggleConnectionLogSaved,
onUpdateCustomGroups,
onUpdateGroupConfigs,
onUpdateHosts,
onUpdateIdentities,
onUpdateKeys,
onUpdateNoteGroups,
onUpdateNotes,
onUpdateProxyProfiles,
onUpdateSnippetPackages,
onUpdateSnippets,
@@ -1680,11 +1643,6 @@ 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 &&
@@ -1692,12 +1650,10 @@ export const vaultViewAreEqual = (
prev.proxyProfiles === next.proxyProfiles &&
prev.snippets === next.snippets &&
prev.snippetPackages === next.snippetPackages &&
prev.notes === next.notes &&
prev.noteGroups === next.noteGroups &&
prev.customGroups === next.customGroups &&
prev.knownHosts === next.knownHosts &&
// shellHistory is not a VaultView prop; SnippetsManager reads shellHistoryStore.
connectionLogsEqual &&
// shellHistory / notes / connectionLogs are not VaultView props; those
// sections subscribe to shellHistoryStore / notesStore / connectionLogsStore.
prev.sessionCount === next.sessionCount &&
prev.managedSources === next.managedSources &&
prev.groupConfigs === next.groupConfigs &&
+5 -3
View File
@@ -22,11 +22,13 @@ function functionBody(source: string, functionName: string): string {
}
test("host and AI provider deletion use in-app confirmation dialogs", () => {
const appSource = readProjectFile("App.tsx");
const appSource = readProjectFile("application/app/AppSideEffects.tsx");
// App owns the confirm state; AppShell renders the dialog.
const appShellSource = readProjectFile("application/app/AppShell.tsx");
const aiSettingsSource = readProjectFile("components/settings/tabs/SettingsAITab.tsx");
assert.match(appSource, /import \{ ConfirmDialog \} from '\.\/components\/ui\/confirm-dialog';/);
assert.match(appSource, /<ConfirmDialog[\s\S]*confirm\.deleteHost/);
assert.match(appShellSource, /import \{ ConfirmDialog \} from '\.\.\/\.\.\/components\/ui\/confirm-dialog';/);
assert.match(appShellSource, /<ConfirmDialog[\s\S]*confirm\.deleteHost/);
assert.doesNotMatch(functionBody(appSource, "handleDeleteHost"), /window\.confirm|globalThis\.confirm|\bconfirm\(/);
assert.match(aiSettingsSource, /import \{ ConfirmDialog \} from "\.\.\/\.\.\/ui\/confirm-dialog";/);
+52 -18
View File
@@ -110,7 +110,7 @@ export const useActiveTabId = (side: "left" | "right"): string | null => {
);
};
export interface SftpContextValue {
export interface SftpHostsContextValue {
// Hosts list for connection picker
hosts: Host[];
// Live terminal sessions that can be reused for SFTP (shown in picker).
@@ -119,31 +119,44 @@ export interface SftpContextValue {
writableHosts: Host[];
// Host updater for bookmark persistence
updateHosts: (hosts: Host[]) => void;
}
// Callbacks for each side
export interface SftpPaneCallbacksContextValue {
leftCallbacks: SftpPaneCallbacks;
rightCallbacks: SftpPaneCallbacks;
}
/** @deprecated Prefer useSftpHosts / useSftpPaneCallbacks to avoid cross-churn. */
export type SftpContextValue = SftpHostsContextValue & SftpPaneCallbacksContextValue;
export interface SftpDragContextValue {
draggedFiles: (SftpTransferSource & { side: "left" | "right" })[] | null;
dragCallbacks: SftpDragCallbacks;
}
const SftpContext = createContext<SftpContextValue | null>(null);
const SftpHostsContext = createContext<SftpHostsContextValue | null>(null);
const SftpPaneCallbacksContext = createContext<SftpPaneCallbacksContextValue | null>(null);
const SftpDragContext = createContext<SftpDragContextValue | null>(null);
export const useSftpContext = () => {
const context = useContext(SftpContext);
if (!context) {
/** @deprecated Prefer selective hooks; this re-renders on hosts OR callbacks churn. */
export const useSftpContext = (): SftpContextValue => {
const hosts = useContext(SftpHostsContext);
const callbacks = useContext(SftpPaneCallbacksContext);
if (!hosts || !callbacks) {
throw new Error("useSftpContext must be used within SftpContextProvider");
}
return context;
return useMemo(
() => ({ ...hosts, ...callbacks }),
[hosts, callbacks],
);
};
// Hook to get callbacks for a specific side
export const useSftpPaneCallbacks = (side: "left" | "right"): SftpPaneCallbacks => {
const context = useSftpContext();
const context = useContext(SftpPaneCallbacksContext);
if (!context) {
throw new Error("useSftpPaneCallbacks must be used within SftpContextProvider");
}
return side === "left" ? context.leftCallbacks : context.rightCallbacks;
};
@@ -164,25 +177,37 @@ export const useSftpDrag = () => {
// Hook to get hosts
export const useSftpHosts = () => {
const context = useSftpContext();
const context = useContext(SftpHostsContext);
if (!context) {
throw new Error("useSftpHosts must be used within SftpContextProvider");
}
return context.hosts;
};
// Hook to get currently connected terminal hosts for the picker
export const useSftpConnectedHosts = () => {
const context = useSftpContext();
const context = useContext(SftpHostsContext);
if (!context) {
throw new Error("useSftpConnectedHosts must be used within SftpContextProvider");
}
return context.connectedHosts;
};
// Hook to get raw hosts for writeback
export const useSftpWritableHosts = () => {
const context = useSftpContext();
const context = useContext(SftpHostsContext);
if (!context) {
throw new Error("useSftpWritableHosts must be used within SftpContextProvider");
}
return context.writableHosts;
};
// Hook to get host updater
export const useSftpUpdateHosts = () => {
const context = useSftpContext();
const context = useContext(SftpHostsContext);
if (!context) {
throw new Error("useSftpUpdateHosts must be used within SftpContextProvider");
}
return context.updateHosts;
};
@@ -209,17 +234,24 @@ export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
rightCallbacks,
children,
}) => {
// Memoize the main context value (no drag state, so drag changes won't cause re-renders here)
const value = useMemo<SftpContextValue>(
// Hosts and pane callbacks are separate so hosts churn does not invalidate
// callback consumers (and callback identity churn does not invalidate hosts).
const hostsValue = useMemo<SftpHostsContextValue>(
() => ({
hosts,
connectedHosts,
writableHosts: writableHosts ?? hosts,
updateHosts,
}),
[hosts, connectedHosts, writableHosts, updateHosts],
);
const callbacksValue = useMemo<SftpPaneCallbacksContextValue>(
() => ({
leftCallbacks,
rightCallbacks,
}),
[hosts, connectedHosts, writableHosts, updateHosts, leftCallbacks, rightCallbacks],
[leftCallbacks, rightCallbacks],
);
// Memoize drag context separately so only drag consumers re-render on drag state changes
@@ -232,8 +264,10 @@ export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
);
return (
<SftpContext.Provider value={value}>
<SftpDragContext.Provider value={dragValue}>{children}</SftpDragContext.Provider>
</SftpContext.Provider>
<SftpHostsContext.Provider value={hostsValue}>
<SftpPaneCallbacksContext.Provider value={callbacksValue}>
<SftpDragContext.Provider value={dragValue}>{children}</SftpDragContext.Provider>
</SftpPaneCallbacksContext.Provider>
</SftpHostsContext.Provider>
);
};
+89
View File
@@ -425,6 +425,95 @@ test("manual disconnect keeps the session pane for reconnect", () => {
assert.match(startersSource, /bootEpoch,/);
});
test("cancel connect invalidates the boot epoch like disconnect", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const cancelStart = source.indexOf("const handleCancelConnect = () => {");
const cancelEnd = source.indexOf("const handleDisconnect = () => {", cancelStart);
assert.notEqual(cancelStart, -1);
assert.notEqual(cancelEnd, -1);
const body = source.slice(cancelStart, cancelEnd);
assert.match(body, /invalidateBootEpochForClose\(\)/);
assert.match(body, /isBootActiveRef\.current = false/);
// Both must land before cleanupSession so the close targets the pre-bump epoch.
assert.ok(
body.indexOf("invalidateBootEpochForClose()") < body.indexOf("void cleanupSession()"),
"cancel must invalidate the boot epoch before cleanupSession",
);
assert.ok(
body.indexOf("isBootActiveRef.current = false") < body.indexOf("void cleanupSession()"),
"cancel must clear boot-active before cleanupSession",
);
});
test("terminal boot is cancelable and closes eagerly on cleanup", () => {
const effectsSource = readFileSync(new URL("./useTerminalEffects.ts", import.meta.url), "utf8");
const startersSource = readFileSync(
new URL("./runtime/createTerminalSessionStarters.ts", import.meta.url),
"utf8",
);
assert.match(effectsSource, /const bootAbort = new AbortController\(\)/);
assert.match(effectsSource, /const bootStartOptions = \{ signal: bootAbort\.signal \}/);
for (const starter of [
"startPluginConnection",
"startSerial",
"startLocal",
"startTelnet",
"startMosh",
"startEt",
"startSSH",
]) {
assert.match(
effectsSource,
new RegExp(`sessionStarters\\.${starter}\\(term, bootStartOptions\\)`),
`${starter} must receive the boot abort signal`,
);
}
// Cleanup order: abort, then eager close, then the async capture/teardown.
const cleanupStart = effectsSource.indexOf(" disposed = true;");
assert.notEqual(cleanupStart, -1);
const cleanup = effectsSource.slice(cleanupStart);
const abortAt = cleanup.indexOf("bootAbort.abort()");
const closeAt = cleanup.indexOf("terminalBackend.closeSession(");
const captureAt = cleanup.indexOf("void completeClose()");
assert.ok(abortAt !== -1 && closeAt !== -1 && captureAt !== -1);
assert.ok(abortAt < closeAt, "cleanup must abort before closing the session");
assert.ok(closeAt < captureAt, "eager close must run before the async capture");
// Attached popups never own the backend session, and a connected pane keeps
// the existing capture-then-teardown path.
assert.match(cleanup, /!attachExistingSession && !hasConnectedRef\.current/);
// An aborted boot must stop counting as the current attempt so the existing
// orphan-close / attach-refusal guards cover cancellation too.
assert.match(
startersSource,
/options\?\.signal\?\.aborted !== true && isBootEpochCurrent\(\)/,
);
for (const starter of [
"startSSH",
"startTelnet",
"startMosh",
"startEt",
"startPluginConnection",
"startLocal",
"startSerial",
]) {
assert.match(
startersSource,
new RegExp(
`const ${starter} = async \\(term: XTerm, options\\?: TerminalSessionStartOptions\\)`,
),
`${starter} must accept an abort signal`,
);
}
// The plugin path holds its own in-flight request controller; the boot abort
// has to reach it or the extension request outlives the pane.
assert.match(startersSource, /options\?\.signal\?\.addEventListener\("abort", onBootAborted/);
assert.match(startersSource, /options\?\.signal\?\.removeEventListener\("abort", onBootAborted\)/);
});
test("hidden host information reveals actions without permanently covering terminal content", () => {
const source = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
@@ -104,6 +104,15 @@ export const getMissingChainHostIds = (
return requestedIds.filter((hostId) => !resolvedIds.has(hostId));
};
/**
* Cancellation handle for one boot attempt. The mount effect owns the
* controller and aborts it in cleanup, so a start that is still awaiting the
* bridge stops counting as the current attempt and closes any session it wins.
*/
export type TerminalSessionStartOptions = {
signal?: AbortSignal;
};
export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContext) => {
const globalTerminalSettings = {
verifyHostKeys: true,
@@ -125,9 +134,12 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
ctx.setChainProgress(null);
};
const createAttemptGuards = () => {
const createAttemptGuards = (options?: TerminalSessionStartOptions) => {
const bootEpoch = ctx.bootEpochRef?.current ?? 0;
const isCurrentAttempt = createBootAttemptGuard(ctx);
const isBootEpochCurrent = createBootAttemptGuard(ctx);
// An aborted boot is never the current attempt, so every existing guard
// (orphan close, attach refusal, UI suppression) also covers cancellation.
const isCurrentAttempt = () => options?.signal?.aborted !== true && isBootEpochCurrent();
return {
bootEpoch,
isCurrentAttempt,
@@ -196,8 +208,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}
};
const startSSH = async (term: XTerm) => {
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards();
const startSSH = async (term: XTerm, options?: TerminalSessionStartOptions) => {
if (options?.signal?.aborted) return;
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards(options);
// Correlate host-key prompts with this boot so a superseded start cannot
// reopen approval UI after disconnect → reconnect.
setTerminalBootEpoch(ctx.sessionId, bootEpoch);
@@ -807,8 +820,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}
};
const startTelnet = async (term: XTerm) => {
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards();
const startTelnet = async (term: XTerm, options?: TerminalSessionStartOptions) => {
if (options?.signal?.aborted) return;
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards(options);
if (!ctx.terminalBackend.telnetAvailable()) {
ctx.setError("Telnet bridge unavailable. Please run the desktop build.");
writeTerminalLine(ctx, term, "\r\n[Telnet bridge unavailable. Please run the desktop build.]");
@@ -1001,8 +1015,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}
};
const startMosh = async (term: XTerm) => {
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards();
const startMosh = async (term: XTerm, options?: TerminalSessionStartOptions) => {
if (options?.signal?.aborted) return;
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards(options);
if (!ctx.terminalBackend.moshAvailable()) {
ctx.setError("Mosh bridge unavailable. Please run the desktop build.");
writeTerminalLine(ctx, term, "\r\n[Mosh bridge unavailable. Please run the desktop build.]");
@@ -1238,8 +1253,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}
};
const startEt = async (term: XTerm) => {
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards();
const startEt = async (term: XTerm, options?: TerminalSessionStartOptions) => {
if (options?.signal?.aborted) return;
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards(options);
if (!ctx.terminalBackend.etAvailable()) {
ctx.setError("EternalTerminal bridge unavailable. Please run the desktop build.");
writeTerminalLine(ctx, term, "\r\n[EternalTerminal bridge unavailable. Please run the desktop build.]");
@@ -1546,8 +1562,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}
};
const startPluginConnection = async (term: XTerm) => {
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards();
const startPluginConnection = async (term: XTerm, options?: TerminalSessionStartOptions) => {
if (options?.signal?.aborted) return;
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards(options);
if (!ctx.terminalBackend.pluginConnectionAvailable()) {
ctx.setError("Plugin connection bridge unavailable. Please run the desktop build with Plugin Development enabled.");
writeTerminalLine(ctx, term, "\r\n[Plugin connection bridge unavailable.]");
@@ -1608,7 +1625,11 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
scheduleBootMonitor();
}, 50);
};
// The mount effect aborts its boot controller before the async teardown
// runs, so forward that straight to the in-flight extension request.
const onBootAborted = () => cancelPendingStart();
const releasePendingStartCancellation = () => {
options?.signal?.removeEventListener("abort", onBootAborted);
if (ctx.disposeExitRef.current === cancelPendingStart) {
ctx.disposeExitRef.current = previousDisposeExit;
}
@@ -1616,6 +1637,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
clearBootMonitor();
};
ctx.disposeExitRef.current = cancelPendingStart;
options?.signal?.addEventListener("abort", onBootAborted, { once: true });
scheduleBootMonitor();
try {
const startPromise = ctx.terminalBackend.startPluginConnection({
@@ -1692,8 +1714,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}
};
const startLocal = async (term: XTerm) => {
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards();
const startLocal = async (term: XTerm, options?: TerminalSessionStartOptions) => {
if (options?.signal?.aborted) return;
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards(options);
if (!ctx.terminalBackend.localAvailable()) {
ctx.setError("Local shell bridge unavailable. Please run the desktop build.");
writeTerminalLine(
@@ -1814,8 +1837,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
};
// Start Serial session
const startSerial = async (term: XTerm) => {
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards();
const startSerial = async (term: XTerm, options?: TerminalSessionStartOptions) => {
if (options?.signal?.aborted) return;
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards(options);
if (!ctx.serialConfig) {
ctx.setError("No serial configuration provided");
writeTerminalLine(ctx, term, "\r\n[Error: No serial configuration provided]");
@@ -126,7 +126,7 @@ test('session launch paths use the same effective protocol as Provider snapshots
);
assert.match(
effectsSource,
/else if \(effectiveTerminalProtocol === "mosh"\)[\s\S]*?sessionStarters\.startMosh\(term\)[\s\S]*?else if \(effectiveTerminalProtocol === "et"\)/,
/else if \(effectiveTerminalProtocol === "mosh"\)[\s\S]*?sessionStarters\.startMosh\(term, bootStartOptions\)[\s\S]*?else if \(effectiveTerminalProtocol === "et"\)/,
);
assert.match(
terminalSource,
+34 -7
View File
@@ -403,6 +403,10 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
useEffect(() => {
let disposed = false;
let initialFitTimer: ReturnType<typeof setTimeout> | undefined;
// Every boot owns a controller so cleanup can cancel an in-flight start
// instead of waiting for the async capture/teardown below to finish.
const bootAbort = new AbortController();
const bootStartOptions = { signal: bootAbort.signal };
const closeGeneration = ++terminalBootCloseGenerationRef.current;
if (bootEpochRef) {
bootEpochRef.current += 1;
@@ -713,32 +717,32 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
if (effectiveTerminalProtocol.startsWith("plugin:")) {
setBackendConnectingStatus();
setProgressLogs(["Initializing plugin connection..."]);
await sessionStarters.startPluginConnection(term);
await sessionStarters.startPluginConnection(term, bootStartOptions);
if (!bootStillActive()) return;
} else if (effectiveTerminalProtocol === "serial") {
setBackendConnectingStatus();
setProgressLogs(["Initializing serial connection..."]);
await sessionStarters.startSerial(term);
await sessionStarters.startSerial(term, bootStartOptions);
if (!bootStillActive()) return;
} else if (effectiveTerminalProtocol === "local") {
setBackendConnectingStatus();
setProgressLogs(["Initializing local shell..."]);
await sessionStarters.startLocal(term);
await sessionStarters.startLocal(term, bootStartOptions);
if (!bootStillActive()) return;
} else if (effectiveTerminalProtocol === "telnet") {
setBackendConnectingStatus();
setProgressLogs(["Initializing Telnet connection..."]);
await sessionStarters.startTelnet(term);
await sessionStarters.startTelnet(term, bootStartOptions);
if (!bootStillActive()) return;
} else if (effectiveTerminalProtocol === "mosh") {
setBackendConnectingStatus();
setProgressLogs(["Initializing Mosh connection..."]);
await sessionStarters.startMosh(term);
await sessionStarters.startMosh(term, bootStartOptions);
if (!bootStillActive()) return;
} else if (effectiveTerminalProtocol === "et") {
setBackendConnectingStatus();
setProgressLogs(["Initializing EternalTerminal connection..."]);
await sessionStarters.startEt(term);
await sessionStarters.startEt(term, bootStartOptions);
if (!bootStillActive()) return;
} else {
const resolvedAuth = resolveHostAuth({ host, keys, identities });
@@ -759,7 +763,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
setBackendConnectingStatus();
setProgressLogs(["Initializing secure channel..."]);
await sessionStarters.startSSH(term);
await sessionStarters.startSSH(term, bootStartOptions);
if (!bootStillActive()) return;
}
} catch (err) {
@@ -775,12 +779,35 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
return () => {
disposed = true;
if (initialFitTimer !== undefined) clearTimeout(initialFitTimer);
// Abort first: a start still awaiting the bridge must stop counting as
// the current attempt before anything below yields to a microtask.
bootAbort.abort();
isBootActiveRef.current = false;
if (hibernatedRef?.current) {
forceCloseHibernatedSession?.();
return;
}
// A boot that never reached "connected" has nothing worth capturing, and
// the teardown below awaits terminal drain plus serialization before it
// closes anything. Tell the main process now so it can abort the pending
// boot (passphrase prompt, TCP dial) for this epoch instead of letting it
// finish against a pane that is already gone. Attached popups never own
// the backend session, so they keep the display-route handoff path.
if (!attachExistingSession && !hasConnectedRef.current) {
try {
const closeResult = terminalBackend.closeSession(
sessionRef.current ?? sessionId,
{ bootEpoch: bootEpochRef ? bootEpochRef.current : undefined },
);
void Promise.resolve(closeResult).catch((err: unknown) => {
logger.warn("Failed to cancel pending terminal boot on unmount", err);
});
} catch (err) {
logger.warn("Failed to cancel pending terminal boot on unmount", err);
}
}
const persistCloseCapture = (data: string, source: string, dataLength: number) => {
logger.info("[Terminal] Capturing data on unmount", {
sessionId,
@@ -10,6 +10,7 @@ import type { TerminalSessionExitEvent } from '../../application/state/resolveTe
import { createTerminalSelectionAttachment } from '../../application/state/terminalSelectionAttachment';
import { getTopTabInsertionTarget, isPointInsideRect, WORKSPACE_SESSION_DRAG_TYPE } from '../../application/state/terminalDragData';
import { useAIState } from '../../application/state/useAIState';
import { useAISessionsStore } from '../../application/state/aiSessionsStore';
import { useStoredBoolean } from '../../application/state/useStoredBoolean';
import { isSavedVaultHost } from '../../domain/ephemeralHosts';
import {
@@ -33,6 +34,7 @@ import { LazyLoadBoundary } from '../ui/lazy-load-boundary';
import type { DropEntry } from '../../lib/sftpFileUtils';
import type { GroupConfig, Host, Identity, KnownHost, ProxyProfile, SSHKey, Snippet, TerminalSession, VaultNote, Workspace } from '../../types';
import type { ExecutorContext } from '../../infrastructure/ai/cattyAgent/executor';
import type { AISession } from '../../infrastructure/ai/types';
import Terminal from '../Terminal';
import { removePaneVisible, setPaneVisible } from '../terminal/paneVisibilityStore';
import type { TerminalBroadcastInputOptions } from '../terminal/terminalHelpers';
@@ -242,9 +244,17 @@ export const filterTabsMap = <T,>(source: Map<string, T>, validIds: Set<string>)
export { ChunkedEscapeFilter, hasNotifiableTerminalOutput } from './activityEscapeFilter';
type AIStateValue = ReturnType<typeof useAIState>;
/**
* Providers, permissions, agent config and the session mutators — everything
* except `sessions` / `activeSessionIdMap` / `draftsByScope` /
* `panelViewByScope`, which `useAIState` deliberately keeps out of its return
* and publishes to `aiSessionsStore` instead. Because the hot slices are absent,
* a landing token cannot change this Context's identity, so provider and
* permission consumers stay put while a turn streams.
*/
export type AIConfigValue = ReturnType<typeof useAIState>;
const AIStateContext = createContext<AIStateValue | null>(null);
const AIConfigContext = createContext<AIConfigValue | null>(null);
interface AIChatPanelsHostProps {
mountedTabIds: string[];
@@ -272,11 +282,11 @@ interface AIStateMaintenanceHostProps {
}
const AIStateProviderInner: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const aiState = useAIState();
const aiConfig = useAIState();
return (
<AIStateContext.Provider value={aiState}>
<AIConfigContext.Provider value={aiConfig}>
{children}
</AIStateContext.Provider>
</AIConfigContext.Provider>
);
};
@@ -286,13 +296,13 @@ AIStateProvider.displayName = 'AIStateProvider';
const AIStateMaintenanceHostInner: React.FC<AIStateMaintenanceHostProps> = ({
validAIScopeTargetIds,
}) => {
const aiState = useContext(AIStateContext);
const aiConfig = useContext(AIConfigContext);
if (!aiState) {
if (!aiConfig) {
throw new Error('AIStateMaintenanceHost must be rendered inside AIStateProvider');
}
const { cleanupOrphanedSessions } = aiState;
const { cleanupOrphanedSessions } = aiConfig;
useEffect(() => {
cleanupOrphanedSessions(validAIScopeTargetIds);
@@ -366,18 +376,22 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
onOpenVaultSectionFromChat,
onOpenVaultSnippetFromChat,
}) => {
const aiState = useContext(AIStateContext);
const aiConfig = useContext(AIConfigContext);
if (!aiState) {
if (!aiConfig) {
throw new Error('AIChatPanelsHost must be rendered inside AIStateProvider');
}
const {
sessions,
activeSessionIdMap,
defaultAgentId,
draftsByScope,
panelViewByScope,
} = useAISessionsStore();
const {
defaultAgentId,
showDraftView,
updateDraft,
} = aiState;
} = aiConfig;
useEffect(() => {
if (!pendingTerminalSelection) return;
@@ -432,44 +446,44 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
<LazyAIChatSidePanel
// Full list keeps fuzzy history ranking; panel areEqual only
// compares exact-scope session object refs for stream isolation.
sessions={aiState.sessions}
activeSessionIdMap={aiState.activeSessionIdMap}
draftsByScope={aiState.draftsByScope}
panelViewByScope={aiState.panelViewByScope}
setActiveSessionId={aiState.setActiveSessionId}
ensureDraftForScope={aiState.ensureDraftForScope}
updateDraft={aiState.updateDraft}
showDraftView={aiState.showDraftView}
showSessionView={aiState.showSessionView}
clearDraftForScope={aiState.clearDraftForScope}
addDraftFiles={aiState.addDraftFiles}
removeDraftFile={aiState.removeDraftFile}
createSession={aiState.createSession}
deleteSession={aiState.deleteSession}
updateSessionTitle={aiState.updateSessionTitle}
updateSessionExternalSessionId={aiState.updateSessionExternalSessionId}
addMessageToSession={aiState.addMessageToSession}
updateLastMessage={aiState.updateLastMessage}
updateMessageById={aiState.updateMessageById}
persistContextCompaction={aiState.persistContextCompaction}
providers={aiState.providers}
activeProviderId={aiState.activeProviderId}
activeModelId={aiState.activeModelId}
defaultAgentId={aiState.defaultAgentId}
toolIntegrationMode={aiState.toolIntegrationMode}
externalAgents={aiState.externalAgents}
setExternalAgents={aiState.setExternalAgents}
agentModelMap={aiState.agentModelMap}
setAgentModel={aiState.setAgentModel}
agentProviderMap={aiState.agentProviderMap}
setAgentProvider={aiState.setAgentProvider}
globalPermissionMode={aiState.globalPermissionMode}
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
commandBlocklist={aiState.commandBlocklist}
commandTimeout={aiState.commandTimeout}
maxIterations={aiState.maxIterations}
webSearchConfig={aiState.webSearchConfig}
quickMessages={aiState.quickMessages}
sessions={sessions as AISession[]}
activeSessionIdMap={activeSessionIdMap as Record<string, string | null>}
draftsByScope={draftsByScope}
panelViewByScope={panelViewByScope}
setActiveSessionId={aiConfig.setActiveSessionId}
ensureDraftForScope={aiConfig.ensureDraftForScope}
updateDraft={aiConfig.updateDraft}
showDraftView={aiConfig.showDraftView}
showSessionView={aiConfig.showSessionView}
clearDraftForScope={aiConfig.clearDraftForScope}
addDraftFiles={aiConfig.addDraftFiles}
removeDraftFile={aiConfig.removeDraftFile}
createSession={aiConfig.createSession}
deleteSession={aiConfig.deleteSession}
updateSessionTitle={aiConfig.updateSessionTitle}
updateSessionExternalSessionId={aiConfig.updateSessionExternalSessionId}
addMessageToSession={aiConfig.addMessageToSession}
updateLastMessage={aiConfig.updateLastMessage}
updateMessageById={aiConfig.updateMessageById}
persistContextCompaction={aiConfig.persistContextCompaction}
providers={aiConfig.providers}
activeProviderId={aiConfig.activeProviderId}
activeModelId={aiConfig.activeModelId}
defaultAgentId={aiConfig.defaultAgentId}
toolIntegrationMode={aiConfig.toolIntegrationMode}
externalAgents={aiConfig.externalAgents}
setExternalAgents={aiConfig.setExternalAgents}
agentModelMap={aiConfig.agentModelMap}
setAgentModel={aiConfig.setAgentModel}
agentProviderMap={aiConfig.agentProviderMap}
setAgentProvider={aiConfig.setAgentProvider}
globalPermissionMode={aiConfig.globalPermissionMode}
setGlobalPermissionMode={aiConfig.setGlobalPermissionMode}
commandBlocklist={aiConfig.commandBlocklist}
commandTimeout={aiConfig.commandTimeout}
maxIterations={aiConfig.maxIterations}
webSearchConfig={aiConfig.webSearchConfig}
quickMessages={aiConfig.quickMessages}
scopeType={context.scopeType}
scopeTargetId={context.scopeTargetId}
scopeHostIds={context.scopeHostIds}
+49 -18
View File
@@ -1,5 +1,5 @@
import { AlertCircle, AlertTriangle, CheckCircle, Info, X } from 'lucide-react';
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { setNotify } from '../../application/notification';
import { cn } from '../../lib/utils';
@@ -15,22 +15,37 @@ export interface Toast {
actionLabel?: string;
}
interface ToastContextValue {
toasts: Toast[];
interface ToastActionsValue {
showToast: (toast: Omit<Toast, 'id'>) => void;
dismissToast: (id: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
interface ToastStateValue {
toasts: Toast[];
}
export const useToast = () => {
const context = useContext(ToastContext);
const ToastActionsContext = createContext<ToastActionsValue | null>(null);
const ToastStateContext = createContext<ToastStateValue | null>(null);
/** Actions-only subscription — does not re-render when the toast list changes. */
export const useToastActions = () => {
const context = useContext(ToastActionsContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
throw new Error('useToastActions must be used within a ToastProvider');
}
return context;
};
/** Full toast API. Prefer useToastActions when you do not need the list. */
export const useToast = () => {
const actions = useContext(ToastActionsContext);
const state = useContext(ToastStateContext);
if (!actions || !state) {
throw new Error('useToast must be used within a ToastProvider');
}
return { ...state, ...actions };
};
// Simple hook for components that may not be inside ToastProvider
let globalShowToast: ((toast: Omit<Toast, 'id'>) => void) | null = null;
@@ -77,16 +92,16 @@ const TOAST_STYLES: Record<ToastType, string> = {
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [toasts, setToasts] = useState<Toast[]>([]);
const showToast = useCallback((toast: Omit<Toast, 'id'>) => {
const showToast = useCallback((nextToast: Omit<Toast, 'id'>) => {
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const newToast: Toast = { ...toast, id };
setToasts(prev => [...prev, newToast]);
const created: Toast = { ...nextToast, id };
setToasts(prev => [...prev, created]);
// Auto dismiss
if (toast.duration !== 0) {
if (nextToast.duration !== 0) {
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id));
}, toast.duration || 4000);
}, nextToast.duration || 4000);
}
}, []);
@@ -94,6 +109,16 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ childre
setToasts(prev => prev.filter(t => t.id !== id));
}, []);
const actionsValue = useMemo<ToastActionsValue>(
() => ({ showToast, dismissToast }),
[showToast, dismissToast],
);
const stateValue = useMemo<ToastStateValue>(
() => ({ toasts }),
[toasts],
);
// Register global toast function
useEffect(() => {
globalShowToast = showToast;
@@ -104,15 +129,21 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ childre
}, [showToast]);
return (
<ToastContext.Provider value={{ toasts, showToast, dismissToast }}>
{children}
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
</ToastContext.Provider>
<ToastActionsContext.Provider value={actionsValue}>
<ToastStateContext.Provider value={stateValue}>
{children}
<ToastContainer />
</ToastStateContext.Provider>
</ToastActionsContext.Provider>
);
};
const ToastContainer: React.FC<{ toasts: Toast[]; onDismiss: (id: string) => void }> = ({ toasts, onDismiss }) => {
if (toasts.length === 0) return null;
const ToastContainer: React.FC = () => {
const { toasts } = useContext(ToastStateContext) ?? { toasts: [] as Toast[] };
const actions = useContext(ToastActionsContext);
const onDismiss = actions?.dismissToast;
if (toasts.length === 0 || !onDismiss) return null;
const handleToastClick = (t: Toast) => {
if (t.onClick) {
+72 -18
View File
@@ -18,6 +18,8 @@ import {
vaultHeaderIconButtonClass,
vaultHeaderSecondaryButtonClass,
} from "./VaultPageHeader";
import { useConnectionLogsStore } from "../../application/state/connectionLogsStore";
import { useNotesStore } from "../../application/state/notesStore";
import { LazyLoadBoundary } from "../ui/lazy-load-boundary";
import { toast } from "../ui/toast";
import { AppWordmark } from "../AppWordmark";
@@ -31,6 +33,72 @@ const VaultSectionLoading = () => (
/>
);
/**
* Notes section subscribes to notesStore instead of taking notes as VaultView
* props, so note edits never invalidate the App vault domain bag.
*/
function VaultNotesSection({
NotesManager,
hosts,
isActive,
openNoteId,
onOpenNoteIdHandled,
onOpenHost,
}: {
NotesManager: React.ComponentType<any>;
hosts: any[];
isActive: boolean;
openNoteId: string | null;
onOpenNoteIdHandled: () => void;
onOpenHost: (host: any, source?: { noteId?: string }) => void;
}) {
const { notes, noteGroups, updateNotes, updateNoteGroups } = useNotesStore();
return (
<NotesManager
notes={notes}
noteGroups={noteGroups}
hosts={hosts}
onUpdateNotes={updateNotes}
onUpdateNoteGroups={updateNoteGroups}
isActive={isActive}
openNoteId={openNoteId}
onOpenNoteIdHandled={onOpenNoteIdHandled}
onOpenHost={onOpenHost}
/>
);
}
/**
* Logs section subscribes to connectionLogsStore so every session start/exit
* append stays out of the App vault/chrome domain bags.
*/
function VaultConnectionLogsSection({
ConnectionLogsManager,
hosts,
onOpenLogView,
}: {
ConnectionLogsManager: React.ComponentType<any>;
hosts: any[];
onOpenLogView: (log: any) => void;
}) {
const {
connectionLogs,
toggleConnectionLogSaved,
deleteConnectionLog,
clearUnsavedConnectionLogs,
} = useConnectionLogsStore();
return (
<ConnectionLogsManager
logs={connectionLogs}
hosts={hosts}
onToggleSaved={toggleConnectionLogSaved}
onDelete={deleteConnectionLog}
onClearUnsaved={clearUnsavedConnectionLogs}
onOpenLogView={onOpenLogView}
/>
);
}
export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
const {
Activity,
@@ -52,7 +120,6 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
Clock,
cn,
commitInlineGroupRename,
connectionLogs,
connectSelectedHosts,
ContextMenu,
ContextMenuContent,
@@ -148,16 +215,13 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
Network,
newFolderName,
newHostGroupPath,
onClearUnsavedConnectionLogs,
onConnectSerial,
onCreateLocalTerminal,
onDeleteConnectionLog,
onDeleteHost,
onImportOrReuseKey,
onOpenLogView,
onOpenSettings,
onRunSnippet,
onToggleConnectionLogSaved,
onUpdateCustomGroups,
onUpdateGroupConfigs,
onUpdateHosts,
@@ -280,15 +344,11 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
} = ctx;
const {
knownHosts,
noteGroups,
NotebookText,
notes,
NotesManager,
onOpenHostFromNote,
onOpenNoteIdHandled,
onOpenSnippetIdHandled,
onUpdateNoteGroups,
onUpdateNotes,
openNoteId,
openSnippetId,
} = ctx;
@@ -1181,12 +1241,9 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
)}
data-section="vault-notes-retained"
>
<NotesManager
notes={notes}
noteGroups={noteGroups}
<VaultNotesSection
NotesManager={NotesManager}
hosts={hosts}
onUpdateNotes={onUpdateNotes}
onUpdateNoteGroups={onUpdateNoteGroups}
isActive={currentSection === "notes"}
openNoteId={openNoteId ?? null}
onOpenNoteIdHandled={onOpenNoteIdHandled}
@@ -1311,12 +1368,9 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
resetKey="connection-logs"
>
<Suspense fallback={<VaultSectionLoading />}>
<LazyConnectionLogsManager
logs={connectionLogs}
<VaultConnectionLogsSection
ConnectionLogsManager={LazyConnectionLogsManager}
hosts={hosts}
onToggleSaved={onToggleConnectionLogSaved}
onDelete={onDeleteConnectionLog}
onClearUnsaved={onClearUnsavedConnectionLogs}
onOpenLogView={onOpenLogView}
/>
</Suspense>
+33 -23
View File
@@ -1,4 +1,4 @@
import { Suspense, lazy } from 'react';
import { StrictMode, Suspense, lazy } from 'react';
import ReactDOM from 'react-dom/client';
import '@fontsource/mona-sans/400.css';
import '@fontsource/mona-sans/500.css';
@@ -155,36 +155,46 @@ const renderApp = () => {
syncTrayWindowClass(route);
if (route === 'settings') {
root.render(
<ToastProvider>
<TooltipProvider delayDuration={300}>
<Suspense fallback={<SettingsWindowFallback />}>
<LazySettingsPage />
</Suspense>
</TooltipProvider>
</ToastProvider>
<StrictMode>
<ToastProvider>
<TooltipProvider delayDuration={300}>
<Suspense fallback={<SettingsWindowFallback />}>
<LazySettingsPage />
</Suspense>
</TooltipProvider>
</ToastProvider>
</StrictMode>
);
} else if (route === 'tray') {
root.render(
<ToastProvider>
<TooltipProvider delayDuration={300}>
<Suspense fallback={<div style={{ padding: 12, color: '#fff' }}>Loading tray panel…</div>}>
<LazyTrayPanel />
</Suspense>
</TooltipProvider>
</ToastProvider>
<StrictMode>
<ToastProvider>
<TooltipProvider delayDuration={300}>
<Suspense fallback={<div style={{ padding: 12, color: '#fff' }}>Loading tray panel…</div>}>
<LazyTrayPanel />
</Suspense>
</TooltipProvider>
</ToastProvider>
</StrictMode>
);
} else if (route === 'terminal-popup') {
root.render(
<ToastProvider>
<TooltipProvider delayDuration={300}>
<Suspense fallback={<TerminalPopupWindowFallback />}>
<LazyTerminalPopupPage />
</Suspense>
</TooltipProvider>
</ToastProvider>
<StrictMode>
<ToastProvider>
<TooltipProvider delayDuration={300}>
<Suspense fallback={<TerminalPopupWindowFallback />}>
<LazyTerminalPopupPage />
</Suspense>
</TooltipProvider>
</ToastProvider>
</StrictMode>
);
} else {
root.render(<App />);
root.render(
<StrictMode>
<App />
</StrictMode>
);
}
};