mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bcb98b9d6 | ||
|
|
de17e1baf2 | ||
|
|
d8011adfbd | ||
|
|
53df32e686 | ||
|
|
9c120ed2fa | ||
|
|
a287d303aa | ||
|
|
7185b38a46 | ||
|
|
f06c9aace0 | ||
|
|
3490b0673d | ||
|
|
70607cb26d | ||
|
|
a56b868d10 |
No files matched your search
@@ -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') {
|
||||
|
||||
@@ -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,32 @@ 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, /useTerminalAppearanceInjection\(accentedGlobalAppearance/);
|
||||
assert.match(terminalHostSource, /clearThemeIntent\(\)/);
|
||||
assert.match(runtimeSource, /injectTerminalAppearanceVars\(appearance\.theme, \{ includeChromeSurfaces \}\)/);
|
||||
assert.doesNotMatch(settingsSource, /pendingFollowAppTerminalThemeId/);
|
||||
assert.doesNotMatch(settingsSource, /applyFollowAppTerminalThemePick/);
|
||||
@@ -43,10 +48,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\}/);
|
||||
});
|
||||
@@ -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]);
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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,224 @@
|
||||
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('published Host bags omit notes, accent, and connectionLogs churn fields', () => {
|
||||
const vaultHost = hostSources['VaultHost.tsx'];
|
||||
const terminalHost = hostSources['TerminalHost.tsx'];
|
||||
assert.ok(vaultHost && terminalHost);
|
||||
|
||||
// Notes / connection logs live in dedicated stores — VaultHost must not
|
||||
// publish them into the vault domain bag that AppView consumes.
|
||||
assert.doesNotMatch(vaultHost, /\bnotes\s*,/);
|
||||
assert.doesNotMatch(vaultHost, /\bconnectionLogs\s*,/);
|
||||
assert.doesNotMatch(vaultHost, /notesStore|connectionLogsStore|useNotesStore|useConnectionLogs/);
|
||||
|
||||
// Accent feeds useThemeRuntime for local injection only; the published
|
||||
// terminal domain bag must not list accentMode/customAccent fields.
|
||||
const domainStart = terminalHost.indexOf('const terminalDomain = useMemo');
|
||||
assert.notEqual(domainStart, -1);
|
||||
const domain = terminalHost.slice(domainStart, terminalHost.indexOf('useLayoutEffect(() => {\n if (terminalDomain)', domainStart));
|
||||
assert.doesNotMatch(domain, /accentMode/);
|
||||
assert.doesNotMatch(domain, /customAccent/);
|
||||
assert.match(domain, /currentTerminalTheme,/);
|
||||
assert.match(
|
||||
terminalHost,
|
||||
/useTerminalAppearanceInjection\(accentedGlobalAppearance/,
|
||||
);
|
||||
});
|
||||
|
||||
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*\)/);
|
||||
});
|
||||
@@ -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';
|
||||
Executable
+1768
File diff suppressed because it is too large.
Load diff
+143
-76
@@ -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) => {
|
||||
|
||||
@@ -78,6 +78,30 @@ test("editor tabs use the followed terminal theme when follow-app terminal theme
|
||||
assert.equal(resolved?.id, currentTheme.id);
|
||||
});
|
||||
|
||||
test("follow-app chrome applies custom accent onto the published base theme", () => {
|
||||
const editorTab = {
|
||||
id: "editor-1",
|
||||
hostId: "host-1",
|
||||
sessionId: "sftp-1",
|
||||
};
|
||||
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
accentMode: "custom",
|
||||
customAccent: "0 100% 50%",
|
||||
activeTabId: toEditorTabId(editorTab.id),
|
||||
editorTabs: [editorTab as unknown as EditorTab],
|
||||
followAppTerminalTheme: true,
|
||||
hostById: new Map([
|
||||
["host-1", { id: "host-1", theme: hostTheme.id } as unknown as Host],
|
||||
]),
|
||||
});
|
||||
|
||||
assert.equal(resolved?.id, currentTheme.id);
|
||||
assert.notEqual(resolved?.colors.cursor, currentTheme.colors.cursor);
|
||||
assert.notEqual(resolved, currentTheme);
|
||||
});
|
||||
|
||||
test("log tabs use the saved log theme when available", () => {
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
|
||||
@@ -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({
|
||||
@@ -68,7 +68,9 @@ export function resolveActiveChromeTheme({
|
||||
};
|
||||
|
||||
const resolveHostTheme = (hostId: string): TerminalTheme => {
|
||||
if (followAppTerminalTheme) return currentTerminalTheme;
|
||||
if (followAppTerminalTheme) {
|
||||
return applyCustomAccentToTerminalTheme(currentTerminalTheme, accentMode, customAccent);
|
||||
}
|
||||
if (resolveSessionAppearance) {
|
||||
return resolveSessionAppearance(resolveHostScope(hostId)).theme;
|
||||
}
|
||||
@@ -90,12 +92,15 @@ export function resolveActiveChromeTheme({
|
||||
const logView = logViews.find((item) => item.id === activeTabId);
|
||||
if (logView) {
|
||||
const explicitThemeId = logView.log.themeId;
|
||||
return explicitThemeId ? themeById.get(explicitThemeId) ?? currentTerminalTheme : currentTerminalTheme;
|
||||
const base = explicitThemeId ? themeById.get(explicitThemeId) ?? currentTerminalTheme : currentTerminalTheme;
|
||||
return applyCustomAccentToTerminalTheme(base, accentMode, customAccent);
|
||||
}
|
||||
|
||||
const workspace = workspaceById.get(activeTabId);
|
||||
if (workspace) {
|
||||
if (followAppTerminalTheme) return currentTerminalTheme;
|
||||
if (followAppTerminalTheme) {
|
||||
return applyCustomAccentToTerminalTheme(currentTerminalTheme, accentMode, customAccent);
|
||||
}
|
||||
|
||||
if (workspace.viewMode === "focus") {
|
||||
const focusedSession = resolveWorkspaceTargetSessionFromMap(workspace, sessionById);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
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 { 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 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
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,
|
||||
accentedGlobalAppearance,
|
||||
clearIntent: clearThemeIntent,
|
||||
settleManualIntent: settleManualThemeIntent,
|
||||
pickTheme: pickTerminalTheme,
|
||||
resolveFocusedAppearance,
|
||||
currentTerminalTheme,
|
||||
} = themeRuntime;
|
||||
|
||||
// Inject live accent into CSS vars without publishing accented theme identity
|
||||
// into the terminal domain bag (accent drag must not rebuild AppShell).
|
||||
useTerminalAppearanceInjection(accentedGlobalAppearance, {
|
||||
includeChromeSurfaces: followAppTerminalTheme,
|
||||
});
|
||||
|
||||
const prevFollowAppTerminalThemeRef = useRef(followAppTerminalTheme);
|
||||
useLayoutEffect(() => {
|
||||
if (prevFollowAppTerminalThemeRef.current === followAppTerminalTheme) return;
|
||||
prevFollowAppTerminalThemeRef.current = followAppTerminalTheme;
|
||||
clearThemeIntent();
|
||||
}, [followAppTerminalTheme, clearThemeIntent]);
|
||||
|
||||
// Bridge exposes the stable base theme only — ChromeHost must not republish
|
||||
// when accentedGlobalAppearance identity churns during color-picker drag.
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,271 @@
|
||||
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*\}/);
|
||||
});
|
||||
|
||||
test('terminal popup config survives StrictMode unsubscribe/resubscribe', () => {
|
||||
const preloadSource = readFileSync(new URL('../../electron/preload.cjs', import.meta.url), 'utf8');
|
||||
const apiSource = readFileSync(new URL('../../electron/preload/api.cjs', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(preloadSource, /lastPayload:\s*null/);
|
||||
assert.match(
|
||||
preloadSource,
|
||||
/terminalPopupConfigState\.lastPayload = payload/,
|
||||
'incoming popup config must be retained beyond the one-shot pending slot',
|
||||
);
|
||||
|
||||
const subscribeAt = apiSource.indexOf('onTerminalPopupConfig:');
|
||||
assert.notEqual(subscribeAt, -1);
|
||||
const subscribe = apiSource.slice(subscribeAt, subscribeAt + 700);
|
||||
assert.match(
|
||||
subscribe,
|
||||
/terminalPopupConfigState\.pending \?\? terminalPopupConfigState\.lastPayload/,
|
||||
'resubscribe must replay lastPayload after pending was drained',
|
||||
);
|
||||
assert.match(subscribe, /terminalPopupConfigState\.pending = null/);
|
||||
assert.doesNotMatch(
|
||||
subscribe,
|
||||
/terminalPopupConfigState\.lastPayload = null/,
|
||||
'StrictMode remount must not clear lastPayload on subscribe',
|
||||
);
|
||||
});
|
||||
|
||||
test('vault init cancels the superseded StrictMode effect before publishing ready', () => {
|
||||
const vaultSource = readFileSync(
|
||||
new URL('../state/useVaultState.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const initAt = vaultSource.indexOf('let cancelled = false;');
|
||||
assert.notEqual(initAt, -1, 'vault init must track cancellation');
|
||||
const initSlice = vaultSource.slice(initAt, initAt + 12000);
|
||||
assert.match(initSlice, /return \(\) => \{\s*\n\s*cancelled = true;\s*\n\s*\};/);
|
||||
assert.match(
|
||||
initSlice,
|
||||
/if \(!cancelled\) \{\s*\n\s*setIsInitialized\(true\);\s*\n\s*setVaultInitialized\(true\);\s*\n\s*\}/,
|
||||
'only the surviving init may mark the vault ready',
|
||||
);
|
||||
assert.match(initSlice, /if \(cancelled\) return;/);
|
||||
});
|
||||
|
||||
test('global hotkey registration cleans up across StrictMode remount', () => {
|
||||
const systemEffectsSource = readFileSync(
|
||||
new URL('../state/systemSettingsEffects.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const hotkeyAt = systemEffectsSource.indexOf('Persist and sync toggle window hotkey setting');
|
||||
assert.notEqual(hotkeyAt, -1);
|
||||
const hotkeyEffect = systemEffectsSource.slice(hotkeyAt, hotkeyAt + 2200);
|
||||
assert.match(hotkeyEffect, /let cancelled = false;/);
|
||||
assert.match(hotkeyEffect, /if \(cancelled\) return;/);
|
||||
assert.match(
|
||||
hotkeyEffect,
|
||||
/if \(didRegister\) \{\s*\n\s*bridge\?\.unregisterGlobalHotkey/,
|
||||
'cleanup must unregister a registration started by this effect',
|
||||
);
|
||||
// Early return before notify must not skip returning the cleanup function.
|
||||
assert.doesNotMatch(
|
||||
hotkeyEffect,
|
||||
/if \(!persistMountedRef\.current\) return;\s*\n\s*notifySettingsChanged/,
|
||||
);
|
||||
});
|
||||
|
||||
test('settings persistMountedRef resets on StrictMode cleanup', () => {
|
||||
const settingsSource = readFileSync(
|
||||
new URL('../state/useSettingsState.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const markAt = settingsSource.indexOf('Mark persist effects mounted AFTER all persist useEffects');
|
||||
assert.notEqual(markAt, -1);
|
||||
const markEffect = settingsSource.slice(markAt, markAt + 500);
|
||||
assert.match(markEffect, /persistMountedRef\.current = true;/);
|
||||
assert.match(
|
||||
markEffect,
|
||||
/return \(\) => \{\s*\n\s*persistMountedRef\.current = false;\s*\n\s*\};/,
|
||||
'remount must treat boot as a fresh mount, not a settings change',
|
||||
);
|
||||
});
|
||||
|
||||
test('tray panel connect flush latches against StrictMode double invoke', () => {
|
||||
const sideEffectsSource = readFileSync(
|
||||
new URL('./AppSideEffects.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(sideEffectsSource, /pendingTrayConnectFlushKeyRef/);
|
||||
assert.match(
|
||||
sideEffectsSource,
|
||||
/if \(pendingTrayConnectFlushKeyRef\.current === flushKey\) return;/,
|
||||
);
|
||||
});
|
||||
|
||||
test('ssh transport idle TTL notify latches against StrictMode double invoke', () => {
|
||||
const settingsSource = readFileSync(
|
||||
new URL('../state/useSettingsState.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(settingsSource, /lastPushedSshTransportIdleTtlRef/);
|
||||
assert.match(
|
||||
settingsSource,
|
||||
/if \(lastPushedSshTransportIdleTtlRef\.current === sshTransportIdleTtlMs\) return;/,
|
||||
);
|
||||
assert.match(
|
||||
settingsSource,
|
||||
/lastPushedSshTransportIdleTtlRef\.current = sshTransportIdleTtlMs;/,
|
||||
);
|
||||
});
|
||||
|
||||
test('terminal selection Ask-AI payload is consumed once under StrictMode', () => {
|
||||
const hostSource = readFileSync(
|
||||
new URL('../../components/terminalLayer/TerminalLayerSupport.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(hostSource, /consumedTerminalSelectionRequestIds/);
|
||||
assert.doesNotMatch(
|
||||
hostSource,
|
||||
/consumedTerminalSelectionRequestIdRef/,
|
||||
'component refs reset on StrictMode remount; use a module Set',
|
||||
);
|
||||
assert.match(
|
||||
hostSource,
|
||||
/if \(consumedTerminalSelectionRequestIds\.has\(pendingTerminalSelection\.requestId\)\)/,
|
||||
);
|
||||
const latchAt = hostSource.indexOf('markTerminalSelectionRequestConsumed(pendingTerminalSelection.requestId)');
|
||||
const draftAt = hostSource.indexOf('updateDraft(scopeKey, defaultAgentId');
|
||||
assert.ok(latchAt > 0 && draftAt > latchAt, 'must latch before mutating the draft');
|
||||
});
|
||||
|
||||
test('Codex App Server interaction bridge is app-singleton like MCP approvals', () => {
|
||||
assert.match(appSource, /setupCodexAppServerInteractionBridge/);
|
||||
const panelSource = readFileSync(
|
||||
new URL('../../components/AIChatSidePanel.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
panelSource,
|
||||
/setupCodexAppServerInteractionBridge/,
|
||||
'per-panel Codex IPC listeners fan out approvals under retained multi-tab mounts',
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 }),
|
||||
{
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -267,6 +267,32 @@ test('shared host tree uses the followed terminal theme when follow-app terminal
|
||||
assert.equal(resolved.id, currentTheme.id);
|
||||
});
|
||||
|
||||
test('follow-app host tree applies custom accent onto the published base theme', () => {
|
||||
const currentTheme = makeTheme('app-light', 'light', '#ffffff');
|
||||
const host = {
|
||||
id: 'host-1',
|
||||
label: 'Host',
|
||||
hostname: 'host.local',
|
||||
username: 'root',
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
} as Host;
|
||||
|
||||
const resolved = resolveWorkTabHostTreeTheme({
|
||||
activeHostId: host.id,
|
||||
accentMode: 'custom',
|
||||
currentTerminalTheme: currentTheme,
|
||||
customAccent: '0 100% 50%',
|
||||
followAppTerminalTheme: true,
|
||||
hostById: new Map([[host.id, host]]),
|
||||
themeById: new Map([[currentTheme.id, currentTheme]]),
|
||||
});
|
||||
|
||||
assert.equal(resolved.id, currentTheme.id);
|
||||
assert.notEqual(resolved.colors.cursor, currentTheme.colors.cursor);
|
||||
assert.notEqual(resolved, currentTheme);
|
||||
});
|
||||
|
||||
test('shared host tree falls back to the current terminal theme without an active host', () => {
|
||||
const currentTheme = makeTheme('app-dark', 'dark', '#111111');
|
||||
|
||||
|
||||
@@ -179,7 +179,9 @@ export function resolveWorkTabHostTreeTheme({
|
||||
hostById: ReadonlyMap<string, Host>;
|
||||
themeById: ReadonlyMap<string, TerminalTheme>;
|
||||
}): TerminalTheme {
|
||||
if (!activeHostId || followAppTerminalTheme) return currentTerminalTheme;
|
||||
if (!activeHostId || followAppTerminalTheme) {
|
||||
return applyCustomAccentToTerminalTheme(currentTerminalTheme, accentMode, customAccent);
|
||||
}
|
||||
|
||||
const host = hostById.get(activeHostId) ?? null;
|
||||
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -81,6 +81,19 @@ test('notesStore gated helpers stay empty and never notify', () => {
|
||||
unsub();
|
||||
});
|
||||
|
||||
test('useNotesStore source gates subscribe when enabled is false', async () => {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const source = readFileSync(new URL('./notesStore.ts', import.meta.url), 'utf8');
|
||||
assert.match(source, /enabled \? subscribeNotes : subscribeNotesNoop/);
|
||||
assert.match(source, /enabled \? subscribeNotesActions : subscribeNotesNoop/);
|
||||
assert.match(source, /getNotesSnapshot/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/enabled \? getNotesSnapshot : getEmptyNotesSnapshot/,
|
||||
'hidden notes mounts should keep the last live snapshot, not flash empty',
|
||||
);
|
||||
});
|
||||
|
||||
test('registerNotesActions exposes update handlers', () => {
|
||||
const calls: string[] = [];
|
||||
registerNotesActions({
|
||||
|
||||
@@ -112,20 +112,25 @@ const noopUpdateNoteGroups: NotesActions['updateNoteGroups'] = () => {};
|
||||
|
||||
/**
|
||||
* Subscribe to notes catalog + vault mutation actions for Notes / AI panels.
|
||||
*
|
||||
* Pass `{ enabled: false }` for retained-but-hidden mounts (e.g. terminal
|
||||
* notes side panel) so vault note publishes do not re-render them. Snapshot
|
||||
* reads still use the live store so reopen does not flash empty data.
|
||||
*/
|
||||
export function useNotesStore(): {
|
||||
export function useNotesStore(options?: { enabled?: boolean }): {
|
||||
notes: VaultNote[];
|
||||
noteGroups: string[];
|
||||
updateNotes: NotesActions['updateNotes'];
|
||||
updateNoteGroups: NotesActions['updateNoteGroups'];
|
||||
} {
|
||||
const enabled = options?.enabled !== false;
|
||||
const snapshot = useSyncExternalStore(
|
||||
subscribeNotes,
|
||||
enabled ? subscribeNotes : subscribeNotesNoop,
|
||||
getNotesSnapshot,
|
||||
getNotesSnapshot,
|
||||
);
|
||||
const actions = useSyncExternalStore(
|
||||
subscribeNotesActions,
|
||||
enabled ? subscribeNotesActions : subscribeNotesNoop,
|
||||
getNotesActions,
|
||||
getNotesActions,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -62,20 +62,25 @@ export function useSystemSettingsEffects({
|
||||
// Persist and sync toggle window hotkey setting
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let cancelled = false;
|
||||
let didRegister = false;
|
||||
// Register/unregister the global hotkey in main process (needed on mount)
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.registerGlobalHotkey) {
|
||||
if (toggleWindowHotkey && globalHotkeyEnabled) {
|
||||
setHotkeyRegistrationError(null);
|
||||
didRegister = true;
|
||||
bridge
|
||||
.registerGlobalHotkey(toggleWindowHotkey)
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
if (result?.success === false) {
|
||||
console.warn('[GlobalHotkey] Hotkey registration failed:', result.error);
|
||||
setHotkeyRegistrationError(result.error || 'Failed to register hotkey');
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
console.warn('[GlobalHotkey] Failed to register hotkey:', err);
|
||||
setHotkeyRegistrationError(err?.message || 'Failed to register hotkey');
|
||||
});
|
||||
@@ -87,9 +92,20 @@ export function useSystemSettingsEffects({
|
||||
}
|
||||
}
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
|
||||
// Skip IPC on initial mount
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
|
||||
// Skip settings-sync IPC on initial mount; still return cleanup below.
|
||||
if (persistMountedRef.current) {
|
||||
notifySettingsChanged(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
// Drop Mount1's registration before Mount2 re-registers (StrictMode), and
|
||||
// release the accelerator on real unmount / disable transitions.
|
||||
if (didRegister) {
|
||||
bridge?.unregisterGlobalHotkey?.().catch((err) => {
|
||||
console.warn('[GlobalHotkey] Failed to unregister hotkey on cleanup', err);
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [
|
||||
toggleWindowHotkey,
|
||||
enabled,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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\(\)/);
|
||||
|
||||
@@ -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,
|
||||
@@ -748,7 +756,10 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
|
||||
// Push idle TTL to the main-process transport registry on load and whenever
|
||||
// it changes (including cross-window IPC/storage updates to local state).
|
||||
const lastPushedSshTransportIdleTtlRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (lastPushedSshTransportIdleTtlRef.current === sshTransportIdleTtlMs) return;
|
||||
lastPushedSshTransportIdleTtlRef.current = sshTransportIdleTtlMs;
|
||||
notifySettingsChanged(STORAGE_KEY_SSH_TRANSPORT_IDLE_TTL_MS, sshTransportIdleTtlMs);
|
||||
}, [sshTransportIdleTtlMs, notifySettingsChanged]);
|
||||
|
||||
@@ -1634,11 +1645,13 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
notifySettingsChanged,
|
||||
});
|
||||
|
||||
// Fix 1: Mark all persist effects as mounted.
|
||||
// This MUST be declared AFTER all persist useEffects so that React runs it last
|
||||
// during the initial mount cycle (effects fire in declaration order).
|
||||
// Mark persist effects mounted AFTER all persist useEffects so React runs
|
||||
// this last (declaration order). Cleanup clears the latch for remount.
|
||||
useEffect(() => {
|
||||
persistMountedRef.current = true;
|
||||
return () => {
|
||||
persistMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Get merged key bindings (defaults + custom overrides)
|
||||
@@ -1709,6 +1722,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 +1947,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,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const runtimeSource = readFileSync(new URL('./useThemeRuntime.ts', import.meta.url), 'utf8');
|
||||
const terminalHostSource = readFileSync(
|
||||
new URL('../app/hosts/TerminalHost.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
test('published currentTerminalTheme resolves without live custom accent', () => {
|
||||
// Base global appearance must use accentMode: 'theme' so HSL drag does not
|
||||
// mint a new theme object into TerminalHost / AppShell domain bags.
|
||||
assert.match(
|
||||
runtimeSource,
|
||||
/accentMode: 'theme',\s*\n\s*customAccent: '',/,
|
||||
'globalAppearance must ignore live custom accent',
|
||||
);
|
||||
assert.match(runtimeSource, /accentedGlobalAppearance/);
|
||||
assert.match(
|
||||
runtimeSource,
|
||||
/currentTerminalTheme: globalAppearance\.theme/,
|
||||
'published theme must come from the stable base appearance',
|
||||
);
|
||||
});
|
||||
|
||||
test('TerminalHost injects accented appearance without bridging it', () => {
|
||||
assert.match(
|
||||
terminalHostSource,
|
||||
/useTerminalAppearanceInjection\(accentedGlobalAppearance/,
|
||||
'CSS vars still track live accent during drag',
|
||||
);
|
||||
const bridgeStart = terminalHostSource.indexOf('const themeBridgeActions = useMemo');
|
||||
assert.notEqual(bridgeStart, -1);
|
||||
const bridge = terminalHostSource.slice(bridgeStart, bridgeStart + 900);
|
||||
assert.match(bridge, /globalAppearance,/);
|
||||
assert.doesNotMatch(
|
||||
bridge,
|
||||
/accentedGlobalAppearance/,
|
||||
'theme bridge must not republish on accented appearance identity churn',
|
||||
);
|
||||
});
|
||||
@@ -65,8 +65,10 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
|
||||
const accentRef = useRef({ accentMode, customAccent });
|
||||
accentRef.current = { accentMode, customAccent };
|
||||
|
||||
// Chrome / injection consumers need live accent on every settings change.
|
||||
const appearanceSettings = useMemo((): TerminalAppearanceSettings => ({
|
||||
// Live accent settings are only for CSS-var injection. The published bag must
|
||||
// resolve against a stable base theme so accent drag does not rebuild
|
||||
// TerminalHost / AppShell domains every HSL tick.
|
||||
const appearanceSettingsForInject = useMemo((): TerminalAppearanceSettings => ({
|
||||
...appearanceSettingsBase,
|
||||
accentMode,
|
||||
customAccent,
|
||||
@@ -74,9 +76,19 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
|
||||
|
||||
const globalAppearance = useMemo(() => resolveGlobalTerminalAppearance({
|
||||
userIntent,
|
||||
settings: appearanceSettings,
|
||||
settings: {
|
||||
...appearanceSettingsBase,
|
||||
accentMode: 'theme',
|
||||
customAccent: '',
|
||||
},
|
||||
customThemes,
|
||||
}), [userIntent, appearanceSettings, customThemes]);
|
||||
}), [userIntent, appearanceSettingsBase, customThemes]);
|
||||
|
||||
const accentedGlobalAppearance = useMemo(() => resolveGlobalTerminalAppearance({
|
||||
userIntent,
|
||||
settings: appearanceSettingsForInject,
|
||||
customThemes,
|
||||
}), [userIntent, appearanceSettingsForInject, customThemes]);
|
||||
|
||||
// Read accent from a ref so this callback identity does not churn on drag.
|
||||
// Callers that invoke it after an accent-only update still get the latest
|
||||
@@ -125,10 +137,12 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
|
||||
}, []);
|
||||
|
||||
// Stable bag identity so App domain memos can depend on members (or the
|
||||
// bag) without thrashing on every parent render.
|
||||
// bag) without thrashing on every parent render. Accented appearance is
|
||||
// intentionally excluded — injection reads it separately.
|
||||
return useMemo(() => ({
|
||||
userIntent,
|
||||
globalAppearance,
|
||||
accentedGlobalAppearance,
|
||||
resolveFocusedAppearance,
|
||||
pickTheme,
|
||||
clearIntent,
|
||||
@@ -137,6 +151,7 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
|
||||
}), [
|
||||
userIntent,
|
||||
globalAppearance,
|
||||
accentedGlobalAppearance,
|
||||
resolveFocusedAppearance,
|
||||
pickTheme,
|
||||
clearIntent,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
@@ -1013,11 +1039,13 @@ export const useVaultState = () => {
|
||||
}, [hosts]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const init = async () => {
|
||||
try {
|
||||
await withVaultImportLock("vault", async () => {
|
||||
recoverPluginImporterTransaction(localStorageAdapter, PLUGIN_IMPORT_TRANSACTION_KEYS);
|
||||
});
|
||||
if (cancelled) return;
|
||||
const savedHosts = localStorageAdapter.read<Host[]>(STORAGE_KEY_HOSTS);
|
||||
|
||||
if (savedHosts) {
|
||||
@@ -1026,6 +1054,7 @@ export const useVaultState = () => {
|
||||
// and causes this stale result to be discarded.
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
const decrypted = await decryptHosts(savedHosts);
|
||||
if (cancelled) return;
|
||||
if (ver === hostsWriteVersion.current) {
|
||||
const sanitized = normalizeVaultOrder(
|
||||
migrateHostsFromLegacyLineTimestamps(
|
||||
@@ -1072,6 +1101,7 @@ export const useVaultState = () => {
|
||||
// Decrypt sensitive fields (passphrase, privateKey)
|
||||
const keyVer = ++keysWriteVersion.current;
|
||||
const decryptedKeys = await decryptKeys(migratedKeys);
|
||||
if (cancelled) return;
|
||||
if (keyVer === keysWriteVersion.current) {
|
||||
const orderedKeys = normalizeVaultOrder(decryptedKeys);
|
||||
setKeys(orderedKeys);
|
||||
@@ -1092,6 +1122,7 @@ export const useVaultState = () => {
|
||||
if (savedIdentities) {
|
||||
const idVer = ++identitiesWriteVersion.current;
|
||||
const decryptedIds = await decryptIdentities(savedIdentities);
|
||||
if (cancelled) return;
|
||||
if (idVer === identitiesWriteVersion.current) {
|
||||
const orderedIdentities = normalizeVaultOrder(decryptedIds);
|
||||
setIdentities(orderedIdentities);
|
||||
@@ -1107,6 +1138,7 @@ export const useVaultState = () => {
|
||||
if (savedProxyProfiles) {
|
||||
const proxyVer = ++proxyProfilesWriteVersion.current;
|
||||
const decryptedProfiles = await decryptProxyProfiles(savedProxyProfiles);
|
||||
if (cancelled) return;
|
||||
if (proxyVer === proxyProfilesWriteVersion.current) {
|
||||
const orderedProfiles = normalizeVaultOrder(decryptedProfiles);
|
||||
setProxyProfiles(orderedProfiles);
|
||||
@@ -1117,6 +1149,8 @@ export const useVaultState = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
// Read remaining non-encrypted data fresh after all async gaps above
|
||||
const savedGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_GROUPS);
|
||||
const savedSnippets =
|
||||
@@ -1193,6 +1227,7 @@ export const useVaultState = () => {
|
||||
if (savedGroupConfigs) {
|
||||
const gcVer = ++groupConfigsWriteVersion.current;
|
||||
const decryptedGC = await decryptGroupConfigs(savedGroupConfigs);
|
||||
if (cancelled) return;
|
||||
if (gcVer === groupConfigsWriteVersion.current) {
|
||||
const sanitizedGC = normalizeVaultOrder(decryptedGC.map(sanitizeGroupConfig));
|
||||
setGroupConfigs(sanitizedGC);
|
||||
@@ -1203,12 +1238,19 @@ export const useVaultState = () => {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsInitialized(true);
|
||||
setVaultInitialized(true);
|
||||
// StrictMode remount cancels the first init; only the surviving effect
|
||||
// may publish "vault ready" or terminals can boot against empty keys.
|
||||
if (!cancelled) {
|
||||
setIsInitialized(true);
|
||||
setVaultInitialized(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
init();
|
||||
void init();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [updateHosts, updateSnippets]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -35,8 +35,8 @@ import {
|
||||
resolveDisplayedSession,
|
||||
} from './ai/aiPanelViewState';
|
||||
import {
|
||||
endDraftSend,
|
||||
tryBeginDraftSend,
|
||||
endSendForKey,
|
||||
tryBeginSendForKey,
|
||||
} from './ai/draftSendGate';
|
||||
import { selectDraftForAgentSwitch } from '../application/state/aiDraftState';
|
||||
import {
|
||||
@@ -55,7 +55,6 @@ import { aiSessionIdSetEqual, exactScopeAISessionsEqual } from '../domain/aiSess
|
||||
import { buildExternalAgentHistoryMessagesForBridge } from './ai/externalAgentHistory';
|
||||
import { canSendWithAgent, findEnabledExternalAgent } from './ai/agentSendEligibility';
|
||||
import { registerGrantPersister } from '../infrastructure/ai/shared/approvalGate';
|
||||
import { setupCodexAppServerInteractionBridge } from '../infrastructure/ai/shared/codexAppServerInteractions';
|
||||
import { stopAgentTurn } from '../infrastructure/ai/harness/agentStop';
|
||||
import { getAgentRuntime } from '../infrastructure/ai/harness/globalAgentRuntime';
|
||||
import { useAIPermissionGrantsState } from '../application/state/useAIPermissionGrantsState';
|
||||
@@ -385,7 +384,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
currentDraftRef.current = currentDraft;
|
||||
const activeSessionRef = useRef(activeSession);
|
||||
activeSessionRef.current = activeSession;
|
||||
const draftSendInFlightRef = useRef(false);
|
||||
|
||||
const defaultTargetSession = useMemo<DefaultTargetSessionHint | undefined>(() => {
|
||||
const connectedSessions = terminalSessions.filter((session) => session.connected !== false);
|
||||
@@ -1005,9 +1003,11 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
const modelAttachments = attachments.filter((attachment) => !isTerminalSelectionAttachment(attachment));
|
||||
const isDraftMode = currentPanelView.mode === 'draft';
|
||||
|
||||
if (isDraftMode && !tryBeginDraftSend(draftSendInFlightRef)) {
|
||||
const sendGateKey = currentSessionView?.id ?? `draft:${scopeKey}`;
|
||||
if (!tryBeginSendForKey(sendGateKey)) {
|
||||
return;
|
||||
}
|
||||
let sessionSendGateKey: string | null = null;
|
||||
|
||||
try {
|
||||
let sessionId = currentSessionView?.id ?? null;
|
||||
@@ -1020,12 +1020,20 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
clearScopeDraft();
|
||||
showScopeSessionView(createdSession.id);
|
||||
setActiveSessionId(createdSession.id);
|
||||
sessionSendGateKey = sessionId;
|
||||
if (!tryBeginSendForKey(sessionSendGateKey)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAIChatSessionStreaming(sessionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isExternalAgent = sendAgentId !== 'catty';
|
||||
|
||||
const sendActiveProvider = isExternalAgent ? activeProvider : effectiveActiveProvider;
|
||||
@@ -1133,8 +1141,9 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
}, modelAttachments.length > 0 ? modelAttachments : undefined);
|
||||
}
|
||||
} finally {
|
||||
if (isDraftMode) {
|
||||
endDraftSend(draftSendInFlightRef);
|
||||
endSendForKey(sendGateKey);
|
||||
if (sessionSendGateKey && sessionSendGateKey !== sendGateKey) {
|
||||
endSendForKey(sessionSendGateKey);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
@@ -1155,12 +1164,15 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
currentAgentId !== 'catty'
|
||||
|| !session
|
||||
|| isStreaming
|
||||
|| isAIChatSessionStreaming(session.id)
|
||||
|| !effectiveActiveProvider
|
||||
|| !effectiveActiveModelId.trim()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tryBeginSendForKey(session.id)) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
abortControllersRef.current.set(session.id, controller);
|
||||
setStreamingForScope(session.id, true);
|
||||
@@ -1198,6 +1210,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
if (abortControllersRef.current.get(session.id) === controller) {
|
||||
abortControllersRef.current.delete(session.id);
|
||||
}
|
||||
endSendForKey(session.id);
|
||||
}
|
||||
}, [
|
||||
abortControllersRef,
|
||||
@@ -1296,8 +1309,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
return registerGrantPersister((rule) => { addGrant(rule); });
|
||||
}, [addGrant]);
|
||||
|
||||
useEffect(() => setupCodexAppServerInteractionBridge(), []);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
if (!activeSessionId) return;
|
||||
setSteeringSessionId(current => current === activeSessionId ? null : current);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const source = readFileSync(new URL('./LogView.tsx', import.meta.url), 'utf8');
|
||||
|
||||
test('LogView applies live custom accent at the leaf from appearanceChromeStore', () => {
|
||||
assert.match(source, /useAppearanceChromeStore/);
|
||||
assert.match(source, /applyCustomAccentToTerminalTheme\(baseTheme, accentMode, customAccent\)/);
|
||||
// Must not rely on an accent-baked defaultTerminalTheme from AppShell bags.
|
||||
assert.match(
|
||||
source,
|
||||
/published defaultTerminalTheme is the stable base catalog/,
|
||||
);
|
||||
});
|
||||
+12
-6
@@ -9,6 +9,8 @@ import { cn } from "../lib/utils";
|
||||
import { ConnectionLog, TerminalTheme } from "../types";
|
||||
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
|
||||
import { useCustomThemes } from "../application/state/customThemeStore";
|
||||
import { useAppearanceChromeStore } from "../application/state/appearanceChromeStore";
|
||||
import { applyCustomAccentToTerminalTheme } from "../domain/terminalAppearance";
|
||||
import { Button } from "./ui/button";
|
||||
import ThemeCustomizeModal from "./terminal/ThemeCustomizeModal";
|
||||
|
||||
@@ -40,6 +42,10 @@ const LogViewComponent: React.FC<LogViewProps> = ({
|
||||
|
||||
// Subscribe to custom theme changes so editing triggers re-render
|
||||
const customThemes = useCustomThemes();
|
||||
// Leaf accent: published defaultTerminalTheme is the stable base catalog
|
||||
// theme; apply live custom accent here so log replay matches terminal chrome
|
||||
// without thrashing AppShell on every HSL tick.
|
||||
const { accentMode, customAccent } = useAppearanceChromeStore();
|
||||
const explicitThemeId = useMemo(() => {
|
||||
if (!log.themeId) return undefined;
|
||||
const exists = TERMINAL_THEMES.some((theme) => theme.id === log.themeId)
|
||||
@@ -58,13 +64,13 @@ const LogViewComponent: React.FC<LogViewProps> = ({
|
||||
if (previewTheme) {
|
||||
return previewTheme;
|
||||
}
|
||||
if (explicitThemeId) {
|
||||
return TERMINAL_THEMES.find(t => t.id === explicitThemeId)
|
||||
const baseTheme = explicitThemeId
|
||||
? (TERMINAL_THEMES.find(t => t.id === explicitThemeId)
|
||||
|| customThemes.find(t => t.id === explicitThemeId)
|
||||
|| defaultTerminalTheme;
|
||||
}
|
||||
return defaultTerminalTheme;
|
||||
}, [customThemes, defaultTerminalTheme, explicitThemeId, previewTheme]);
|
||||
|| defaultTerminalTheme)
|
||||
: defaultTerminalTheme;
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
}, [accentMode, customAccent, customThemes, defaultTerminalTheme, explicitThemeId, previewTheme]);
|
||||
|
||||
const currentFontSize = log.fontSize ?? defaultFontSize;
|
||||
|
||||
|
||||
+12
-1
@@ -117,6 +117,7 @@ import {
|
||||
shouldUseFreshSshConnectionForAutomation,
|
||||
} from "@/domain/hostConnectScripts.ts";
|
||||
import { isVaultInitialized } from "@/application/state/vaultInitStore.ts";
|
||||
import { useVaultSnapshotField } from "@/application/state/vaultSnapshotStore.ts";
|
||||
import { netcattyBridge } from "@/infrastructure/services/netcattyBridge.ts";
|
||||
import { ScriptExecutionOverlay } from "./terminal/ScriptExecutionOverlay";
|
||||
import { isScriptSnippet } from "@/domain/snippetScript.ts";
|
||||
@@ -352,6 +353,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const message = openExternalErrorTextRef.current;
|
||||
toast.error(message.body, message.title);
|
||||
}, []);
|
||||
// Reactive vault-ready flag so restored panes re-arm boot after hydration
|
||||
// (module isVaultInitialized() alone is not a React dependency).
|
||||
const vaultInitialized = useVaultSnapshotField("isVaultInitialized");
|
||||
const connectScriptsConsumedRef = useRef(false);
|
||||
const connectScriptsCompletedIdsRef = useRef(new Set<string>());
|
||||
const connectScriptsInFlightRef = useRef(false);
|
||||
@@ -3167,6 +3171,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);
|
||||
@@ -4007,7 +4018,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onWake: wakeFromHibernateRuntime,
|
||||
});
|
||||
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, vaultInitialized, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -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\(/);
|
||||
|
||||
@@ -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,60 @@ 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\(\{\s*enabled:\s*isActive,?\s*\}\)/,
|
||||
);
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
@@ -474,6 +445,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
const [pendingOpenNoteId, setPendingOpenNoteId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const handleOpenNoteIdHandled = useCallback(() => {
|
||||
setPendingOpenNoteId(null);
|
||||
}, []);
|
||||
const [pendingOpenSnippetId, setPendingOpenSnippetId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
@@ -1427,7 +1401,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
Clock,
|
||||
cn,
|
||||
commitInlineGroupRename,
|
||||
connectionLogs: resolvedConnectionLogs,
|
||||
connectSelectedHosts,
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@@ -1525,34 +1498,27 @@ 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,
|
||||
openNoteId: pendingOpenNoteId,
|
||||
onOpenNoteIdHandled: () => setPendingOpenNoteId(null),
|
||||
onOpenNoteIdHandled: handleOpenNoteIdHandled,
|
||||
openSnippetId: pendingOpenSnippetId,
|
||||
onOpenSnippetIdHandled: () => setPendingOpenSnippetId(null),
|
||||
Pin,
|
||||
@@ -1680,11 +1646,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 +1653,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 &&
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import {
|
||||
endDraftSend,
|
||||
endSend,
|
||||
endSendForKey,
|
||||
tryBeginDraftSend,
|
||||
tryBeginSend,
|
||||
tryBeginSendForKey,
|
||||
} from "./draftSendGate.ts";
|
||||
|
||||
test("draft send gate allows only one in-flight draft send at a time", () => {
|
||||
@@ -16,3 +21,36 @@ test("draft send gate allows only one in-flight draft send at a time", () => {
|
||||
|
||||
assert.equal(tryBeginDraftSend(gate), true);
|
||||
});
|
||||
|
||||
test("send gate aliases cover session-mode re-entry the same way", () => {
|
||||
const gate = { current: false };
|
||||
assert.equal(tryBeginSend(gate), true);
|
||||
assert.equal(tryBeginSend(gate), false);
|
||||
endSend(gate);
|
||||
assert.equal(tryBeginSend(gate), true);
|
||||
});
|
||||
|
||||
test("module send latch survives across independent gate objects (remount-safe)", () => {
|
||||
const key = `test-send-${Date.now()}-${Math.random()}`;
|
||||
assert.equal(tryBeginSendForKey(key), true);
|
||||
assert.equal(tryBeginSendForKey(key), false);
|
||||
endSendForKey(key);
|
||||
assert.equal(tryBeginSendForKey(key), true);
|
||||
endSendForKey(key);
|
||||
});
|
||||
|
||||
test("AIChatSidePanel gates every send including session mode", () => {
|
||||
const source = readFileSync(new URL("../AIChatSidePanel.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /tryBeginSendForKey\(sendGateKey\)/);
|
||||
assert.match(source, /isAIChatSessionStreaming\(sessionId\)/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/isDraftMode && !tryBeginDraftSend/,
|
||||
"session-mode sends must share the sync re-entry gate",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/sendInFlightRef/,
|
||||
"component refs reset on StrictMode remount; use module key latch",
|
||||
);
|
||||
});
|
||||
@@ -10,3 +10,19 @@ export function tryBeginDraftSend(gate: { current: boolean }): boolean {
|
||||
export function endDraftSend(gate: { current: boolean }): void {
|
||||
gate.current = false;
|
||||
}
|
||||
|
||||
export const tryBeginSend = tryBeginDraftSend;
|
||||
export const endSend = endDraftSend;
|
||||
|
||||
const sendInFlightByKey = new Set<string>();
|
||||
|
||||
export function tryBeginSendForKey(key: string): boolean {
|
||||
if (!key || sendInFlightByKey.has(key)) return false;
|
||||
sendInFlightByKey.add(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function endSendForKey(key: string): void {
|
||||
if (!key) return;
|
||||
sendInFlightByKey.delete(key);
|
||||
}
|
||||
@@ -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";/);
|
||||
|
||||
@@ -473,7 +473,7 @@ const deleteLexicalTextRange = (range: Range, onUpdate: () => void): boolean =>
|
||||
return didDelete;
|
||||
};
|
||||
|
||||
export function InlineMarkdownEditor({
|
||||
export const InlineMarkdownEditor = React.memo(function InlineMarkdownEditor({
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
@@ -486,6 +486,8 @@ export function InlineMarkdownEditor({
|
||||
const { t } = useI18n();
|
||||
const editorRef = useRef<MDXEditorMethods>(null);
|
||||
const latestMarkdownRef = useRef(value);
|
||||
const syncedPropValueRef = useRef(value);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const lastLinkActivationRef = useRef<{ href: string; at: number } | null>(null);
|
||||
const [hostPicker, setHostPicker] = useState<HostPickerState>({
|
||||
@@ -530,7 +532,14 @@ export function InlineMarkdownEditor({
|
||||
}, [hostCandidates, hostPicker.query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (latestMarkdownRef.current === value) return;
|
||||
if (latestMarkdownRef.current === value) {
|
||||
syncedPropValueRef.current = value;
|
||||
return;
|
||||
}
|
||||
if (latestMarkdownRef.current !== syncedPropValueRef.current) {
|
||||
return;
|
||||
}
|
||||
syncedPropValueRef.current = value;
|
||||
latestMarkdownRef.current = value;
|
||||
editorRef.current?.setMarkdown(value);
|
||||
}, [value]);
|
||||
@@ -650,9 +659,32 @@ export function InlineMarkdownEditor({
|
||||
}, [hosts]);
|
||||
|
||||
useEffect(() => {
|
||||
const frame = window.requestAnimationFrame(annotateHostLinks);
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [annotateHostLinks, value]);
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
let frame = 0;
|
||||
const schedule = () => {
|
||||
if (frame) return;
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
frame = 0;
|
||||
annotateHostLinks();
|
||||
});
|
||||
};
|
||||
|
||||
schedule();
|
||||
if (editorMode !== "preview") {
|
||||
return () => {
|
||||
if (frame) window.cancelAnimationFrame(frame);
|
||||
};
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(schedule);
|
||||
observer.observe(container, { childList: true, subtree: true });
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (frame) window.cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [annotateHostLinks, editorMode]);
|
||||
|
||||
const annotateCodeBlockCopyButtons = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -1001,4 +1033,4 @@ export function InlineMarkdownEditor({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const managerSource = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
|
||||
const layoutSource = readFileSync(
|
||||
new URL("../vault/VaultViewLayout.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
test("note content drafts stay in refs so MDX keystrokes do not rebuild the shell", () => {
|
||||
assert.doesNotMatch(
|
||||
managerSource,
|
||||
/const \[draftContent, setDraftContent\]/,
|
||||
"draftContent React state causes a full NotesManager render per keystroke",
|
||||
);
|
||||
assert.match(
|
||||
managerSource,
|
||||
/draftContentRef\.current = fields\.content;/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
managerSource,
|
||||
/setDraftContent\(fields\.content\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("NotesManager teardown flush uses a stable ref under StrictMode", () => {
|
||||
assert.match(managerSource, /flushNoteDraftRef\.current = flushNoteDraft/);
|
||||
assert.match(
|
||||
managerSource,
|
||||
/useEffect\(\(\) => \(\) => \{\s*\n\s*flushNoteDraftRef\.current\(\);\s*\n\s*\}, \[\]\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Vault notes section is memoized against unrelated VaultView churn", () => {
|
||||
assert.match(layoutSource, /const MemoVaultNotesSection = React\.memo/);
|
||||
assert.match(layoutSource, /<MemoVaultNotesSection\b/);
|
||||
assert.match(layoutSource, /const handleNotesOpenHost = useCallback/);
|
||||
assert.match(layoutSource, /onOpenHost=\{handleNotesOpenHost\}/);
|
||||
assert.match(
|
||||
layoutSource,
|
||||
/useNotesStore\(\{\s*\n\s*enabled:\s*isActive,\s*\n\s*\}\)/,
|
||||
);
|
||||
assert.match(
|
||||
layoutSource,
|
||||
/if \(next\.isActive && prev\.hosts !== next\.hosts\) return false;/,
|
||||
"hidden retained notes must ignore hosts identity churn",
|
||||
);
|
||||
});
|
||||
|
||||
test("hidden terminal notes side panel does not subscribe to notes publishes", () => {
|
||||
const slotsSource = readFileSync(
|
||||
new URL("../terminalLayer/terminalLayerSidePanelSlots.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(slotsSource, /useNotesStore\(\{\s*enabled:\s*isVisible\s*\}\)/);
|
||||
});
|
||||
|
||||
test("notes manager prefetches the MDXEditor chunk when becoming active", () => {
|
||||
assert.match(managerSource, /prefetchInlineMarkdownEditor/);
|
||||
assert.match(
|
||||
managerSource,
|
||||
/if \(!isActive\) return;\s*\n\s*prefetchInlineMarkdownEditor\(\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test("mode toggle flushes ref-only content drafts before remounting the editor", () => {
|
||||
assert.match(
|
||||
managerSource,
|
||||
/flushNoteDraft\(\);\s*\n\s*setNoteEditorMode/,
|
||||
"preview/edit remount must see the in-progress body",
|
||||
);
|
||||
});
|
||||
|
||||
test("host-link annotation does not re-run on every markdown value keystroke", () => {
|
||||
const editorSource = readFileSync(
|
||||
new URL("./InlineMarkdownEditor.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
editorSource,
|
||||
/annotateHostLinks,\s*value\s*\]/,
|
||||
"value in annotateHostLinks effect deps walks the DOM on every keystroke",
|
||||
);
|
||||
assert.match(editorSource, /\[annotateHostLinks, editorMode\]/);
|
||||
assert.match(
|
||||
editorSource,
|
||||
/syncedPropValueRef/,
|
||||
"external note publishes must not clobber an in-progress local draft",
|
||||
);
|
||||
assert.match(
|
||||
editorSource,
|
||||
/latestMarkdownRef\.current !== syncedPropValueRef\.current/,
|
||||
);
|
||||
});
|
||||
@@ -83,6 +83,11 @@ const InlineMarkdownEditor = lazy(() =>
|
||||
import("./InlineMarkdownEditor").then((module) => ({ default: module.InlineMarkdownEditor })),
|
||||
);
|
||||
|
||||
/** Warm the MDXEditor chunk before Suspense. */
|
||||
export function prefetchInlineMarkdownEditor(): void {
|
||||
void import("./InlineMarkdownEditor");
|
||||
}
|
||||
|
||||
interface NoteFolderNode {
|
||||
name: string;
|
||||
path: string;
|
||||
@@ -412,7 +417,6 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
const draftTimerRef = useRef<number | null>(null);
|
||||
const [draftNoteId, setDraftNoteId] = useState<string | null>(null);
|
||||
const [draftTitle, setDraftTitle] = useState<string | null>(null);
|
||||
const [draftContent, setDraftContent] = useState<string | null>(null);
|
||||
|
||||
const clearDraftTimer = useCallback(() => {
|
||||
if (draftTimerRef.current !== null) {
|
||||
@@ -432,7 +436,6 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
draftContentRef.current = null;
|
||||
setDraftNoteId(null);
|
||||
setDraftTitle(null);
|
||||
setDraftContent(null);
|
||||
if (title === null && content === null) return;
|
||||
commitNotes(sortedNotesRef.current.map((note) => {
|
||||
if (note.id !== noteId) return note;
|
||||
@@ -458,27 +461,34 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
flushNoteDraft();
|
||||
}
|
||||
draftNoteIdRef.current = noteId;
|
||||
setDraftNoteId(noteId);
|
||||
if (fields.title !== undefined) {
|
||||
draftTitleRef.current = fields.title;
|
||||
setDraftNoteId(noteId);
|
||||
setDraftTitle(fields.title);
|
||||
}
|
||||
if (fields.content !== undefined) {
|
||||
draftContentRef.current = fields.content;
|
||||
setDraftContent(fields.content);
|
||||
}
|
||||
scheduleNoteDraftFlush();
|
||||
}, [flushNoteDraft, scheduleNoteDraftFlush]);
|
||||
|
||||
const flushNoteDraftRef = useRef(flushNoteDraft);
|
||||
flushNoteDraftRef.current = flushNoteDraft;
|
||||
|
||||
useEffect(() => () => {
|
||||
flushNoteDraft();
|
||||
}, [flushNoteDraft]);
|
||||
flushNoteDraftRef.current();
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isActive) return;
|
||||
flushNoteDraft();
|
||||
}, [flushNoteDraft, isActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
prefetchInlineMarkdownEditor();
|
||||
}, [isActive]);
|
||||
|
||||
useEffect(() => {
|
||||
const flushOnTeardown = () => {
|
||||
flushNoteDraft();
|
||||
@@ -509,14 +519,12 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
? {
|
||||
...selectedNote,
|
||||
...(draftTitle !== null ? { title: draftTitle } : {}),
|
||||
...(draftContent !== null ? { content: draftContent } : {}),
|
||||
}
|
||||
: selectedNote;
|
||||
const overlayNoteView = overlayNote && draftNoteId === overlayNote.id
|
||||
? {
|
||||
...overlayNote,
|
||||
...(draftTitle !== null ? { title: draftTitle } : {}),
|
||||
...(draftContent !== null ? { content: draftContent } : {}),
|
||||
}
|
||||
: overlayNote;
|
||||
|
||||
@@ -643,9 +651,9 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
draftTitleRef.current = title;
|
||||
};
|
||||
|
||||
const saveNoteContentDraft = (note: VaultNote, content: string) => {
|
||||
updateNoteDraft(note.id, { content });
|
||||
};
|
||||
const saveNoteContentDraft = useCallback((noteId: string, content: string) => {
|
||||
updateNoteDraft(noteId, { content });
|
||||
}, [updateNoteDraft]);
|
||||
|
||||
const handleOpenHostFromNote = useCallback((host: Host, noteId: string) => {
|
||||
onOpenHost?.(host, { noteId });
|
||||
@@ -665,9 +673,12 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
data-note-mode-switch
|
||||
aria-label={label}
|
||||
className="app-no-drag h-8 w-8 shrink-0 rounded-md p-0 text-muted-foreground transition-colors hover:bg-secondary/70 hover:text-foreground"
|
||||
onClick={() => setNoteEditorMode((currentMode) => (
|
||||
currentMode === "edit" ? "preview" : "edit"
|
||||
))}
|
||||
onClick={() => {
|
||||
flushNoteDraft();
|
||||
setNoteEditorMode((currentMode) => (
|
||||
currentMode === "edit" ? "preview" : "edit"
|
||||
));
|
||||
}}
|
||||
>
|
||||
<Icon size={16} />
|
||||
</Button>
|
||||
@@ -1709,7 +1720,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
placeholder={t("notes.editor.placeholder")}
|
||||
editorMode={noteEditorMode}
|
||||
previewEmptyLabel={t("notes.preview.empty")}
|
||||
onChange={(content) => saveNoteContentDraft(selectedNoteView, content)}
|
||||
onChange={(content) => saveNoteContentDraft(selectedNoteView.id, content)}
|
||||
hosts={hosts}
|
||||
onOpenHost={(host) => handleOpenHostFromNote(host, selectedNoteView.id)}
|
||||
onOpenExternalLink={openExternal}
|
||||
@@ -1799,7 +1810,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
value={overlayNoteView.content}
|
||||
placeholder={t("notes.editor.placeholder")}
|
||||
editorMode={noteEditorMode}
|
||||
onChange={(content) => saveNoteContentDraft(overlayNoteView, content)}
|
||||
onChange={(content) => saveNoteContentDraft(overlayNoteView.id, content)}
|
||||
previewEmptyLabel={t("notes.preview.empty")}
|
||||
hosts={hosts}
|
||||
onOpenHost={(host) => handleOpenHostFromNote(host, overlayNoteView.id)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -27,3 +27,18 @@ test("gpu tab passes null utilization to ResourceBar instead of zero", () => {
|
||||
assert.doesNotMatch(source, /value=\{util \?\? 0\}/);
|
||||
assert.doesNotMatch(source, /value=\{memPct \?\? 0\}/);
|
||||
});
|
||||
|
||||
test("gpu tab keeps a compact sparkline history like overview/nvtop charts", () => {
|
||||
const source = readFileSync(new URL("./GpuManagerTab.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /HISTORY_LIMIT/);
|
||||
assert.match(source, /GpuSparkline/);
|
||||
assert.match(source, /historyByDevice/);
|
||||
assert.match(source, /setHistoryByDevice/);
|
||||
});
|
||||
|
||||
test("resource bar animates width and uses load-aware tones", () => {
|
||||
const source = readFileSync(new URL("./ResourceBar.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /transition-\[width,background-color\]/);
|
||||
assert.match(source, /bg-amber-500/);
|
||||
assert.match(source, /bg-destructive/);
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
AcceleratorProcessInfo,
|
||||
AcceleratorSnapshot,
|
||||
} from '../../domain/systemManager/types';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { ResourceBar } from './ResourceBar';
|
||||
import {
|
||||
SystemPanelEmpty,
|
||||
@@ -31,6 +32,13 @@ interface GpuManagerTabProps {
|
||||
refreshIntervalSec: number;
|
||||
}
|
||||
|
||||
const HISTORY_LIMIT = 24;
|
||||
|
||||
interface DeviceHistorySample {
|
||||
util: number;
|
||||
memory: number;
|
||||
}
|
||||
|
||||
function formatMb(mb: number | null | undefined): string {
|
||||
if (!Number.isFinite(mb)) return '--';
|
||||
const value = Number(mb);
|
||||
@@ -53,14 +61,63 @@ function vendorLabel(
|
||||
: t('systemManager.gpu.vendor.ascend');
|
||||
}
|
||||
|
||||
function deviceKey(device: AcceleratorDeviceInfo): string {
|
||||
return `${device.vendor}-${device.index}-${device.uuid || device.name}`;
|
||||
}
|
||||
|
||||
/** Compact nvtop-style sparkline (area + stroke), reusing the overview chart feel. */
|
||||
function GpuSparkline({
|
||||
values,
|
||||
className,
|
||||
}: {
|
||||
values: number[];
|
||||
className?: string;
|
||||
}) {
|
||||
const width = 120;
|
||||
const height = 28;
|
||||
const safeValues = values.length > 1 ? values : [values[0] ?? 0, values[0] ?? 0];
|
||||
const points = safeValues.map((value, index) => {
|
||||
const x = safeValues.length === 1 ? width : (index / (safeValues.length - 1)) * width;
|
||||
const clamped = Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0));
|
||||
const y = height - (clamped / 100) * (height - 4) - 2;
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
});
|
||||
const area = `M0,${height} L${points.join(' L')} L${width},${height} Z`;
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={cn('h-7 w-full overflow-visible', className)}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
role="img"
|
||||
aria-hidden
|
||||
>
|
||||
<path d={area} fill="currentColor" opacity="0.12" />
|
||||
<polyline
|
||||
points={points.join(' ')}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="transition-opacity duration-300"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const DeviceCard = memo(function DeviceCard({
|
||||
device,
|
||||
utilHistory,
|
||||
memHistory,
|
||||
}: {
|
||||
device: AcceleratorDeviceInfo;
|
||||
utilHistory: number[];
|
||||
memHistory: number[];
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const memPct = memoryPercent(device);
|
||||
const util = Number.isFinite(device.utilizationPercent) ? Number(device.utilizationPercent) : null;
|
||||
const tone = device.vendor === 'ascend' ? 'text-orange-500' : 'text-emerald-500';
|
||||
|
||||
return (
|
||||
<div className="border-b border-border/30 px-3 py-2.5 space-y-2">
|
||||
@@ -102,20 +159,35 @@ const DeviceCard = memo(function DeviceCard({
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<ResourceBar
|
||||
label={t('systemManager.gpu.util')}
|
||||
value={util}
|
||||
/>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<ResourceBar
|
||||
label={device.vendor === 'ascend' ? t('systemManager.gpu.hbm') : t('systemManager.gpu.memory')}
|
||||
value={memPct}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-[10px] tabular-nums text-muted-foreground shrink-0">
|
||||
{formatMb(device.memoryUsedMb)} / {formatMb(device.memoryTotalMb)}
|
||||
</span>
|
||||
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_72px] gap-2 items-end">
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<ResourceBar
|
||||
label={t('systemManager.gpu.util')}
|
||||
value={util}
|
||||
size="md"
|
||||
/>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<ResourceBar
|
||||
label={device.vendor === 'ascend' ? t('systemManager.gpu.hbm') : t('systemManager.gpu.memory')}
|
||||
value={memPct}
|
||||
className="flex-1"
|
||||
size="md"
|
||||
/>
|
||||
<span className="text-[10px] tabular-nums text-muted-foreground shrink-0">
|
||||
{formatMb(device.memoryUsedMb)} / {formatMb(device.memoryTotalMb)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn('min-w-0', tone)} title={t('systemManager.gpu.util')}>
|
||||
<GpuSparkline values={utilHistory.length ? utilHistory : [util ?? 0]} />
|
||||
<GpuSparkline
|
||||
values={memHistory.length ? memHistory : [memPct ?? 0]}
|
||||
className="opacity-80"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{Number.isFinite(device.fanPercent) ? (
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{t('systemManager.gpu.fan', { value: Math.round(Number(device.fanPercent)) })}
|
||||
@@ -155,9 +227,11 @@ export const GpuManagerTab = memo(function GpuManagerTab({
|
||||
const stableT = useStableTranslate();
|
||||
const intervalMs = Math.max(2, refreshIntervalSec) * 1000;
|
||||
const [gpuListPending, setGpuListPending] = useState(false);
|
||||
const [historyByDevice, setHistoryByDevice] = useState<Record<string, DeviceHistorySample[]>>({});
|
||||
|
||||
useEffect(() => {
|
||||
setGpuListPending(false);
|
||||
setHistoryByDevice({});
|
||||
}, [sessionId]);
|
||||
|
||||
const fetcher = useCallback(async (): Promise<AcceleratorSnapshot | null> => {
|
||||
@@ -190,6 +264,23 @@ export const GpuManagerTab = memo(function GpuManagerTab({
|
||||
const processes = useMemo(() => data?.processes ?? [], [data?.processes]);
|
||||
const isRefreshActive = loading || gpuListPending;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || !devices.length) return;
|
||||
setHistoryByDevice((prev) => {
|
||||
const next: Record<string, DeviceHistorySample[]> = { ...prev };
|
||||
for (const device of devices) {
|
||||
const key = deviceKey(device);
|
||||
const sample: DeviceHistorySample = {
|
||||
util: Number.isFinite(device.utilizationPercent) ? Number(device.utilizationPercent) : 0,
|
||||
memory: memoryPercent(device) ?? 0,
|
||||
};
|
||||
const series = [...(next[key] || []), sample].slice(-HISTORY_LIMIT);
|
||||
next[key] = series;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [devices, isVisible, data?.probedAt]);
|
||||
|
||||
const meta = useMemo(() => {
|
||||
const nvidiaCount = devices.filter((d) => d.vendor === 'nvidia').length;
|
||||
const ascendCount = devices.filter((d) => d.vendor === 'ascend').length;
|
||||
@@ -269,9 +360,18 @@ export const GpuManagerTab = memo(function GpuManagerTab({
|
||||
{t('systemManager.gpu.empty')}
|
||||
</div>
|
||||
) : (
|
||||
devices.map((device) => (
|
||||
<DeviceCard key={`${device.vendor}-${device.index}-${device.uuid || device.name}`} device={device} />
|
||||
))
|
||||
devices.map((device) => {
|
||||
const key = deviceKey(device);
|
||||
const series = historyByDevice[key] || [];
|
||||
return (
|
||||
<DeviceCard
|
||||
key={key}
|
||||
device={device}
|
||||
utilHistory={series.map((sample) => sample.util)}
|
||||
memHistory={series.map((sample) => sample.memory)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<div className="px-3 pt-3 pb-1.5 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{t('systemManager.gpu.processes')}
|
||||
|
||||
@@ -5,20 +5,39 @@ interface ResourceBarProps {
|
||||
label: string;
|
||||
value: number | null | undefined;
|
||||
className?: string;
|
||||
/** Slightly taller track for GPU cards. */
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
export const ResourceBar = memo(function ResourceBar({ label, value, className }: ResourceBarProps) {
|
||||
function barTone(clamped: number): string {
|
||||
if (clamped > 85) return 'bg-destructive/80';
|
||||
if (clamped > 60) return 'bg-amber-500/80';
|
||||
return 'bg-primary/75';
|
||||
}
|
||||
|
||||
export const ResourceBar = memo(function ResourceBar({
|
||||
label,
|
||||
value,
|
||||
className,
|
||||
size = 'sm',
|
||||
}: ResourceBarProps) {
|
||||
const finite = typeof value === 'number' && Number.isFinite(value);
|
||||
const clamped = finite ? Math.max(0, Math.min(100, value)) : 0;
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2 min-w-0', className)}>
|
||||
<span className="text-[10px] text-muted-foreground w-7 shrink-0">{label}</span>
|
||||
<div className="flex-1 h-1 rounded-full bg-muted/50 overflow-hidden min-w-[48px]">
|
||||
{label ? (
|
||||
<span className="text-[10px] text-muted-foreground w-7 shrink-0">{label}</span>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 rounded-full bg-muted/50 overflow-hidden min-w-[48px]',
|
||||
size === 'md' ? 'h-1.5' : 'h-1',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full',
|
||||
finite && clamped > 85 ? 'bg-destructive/70' : 'bg-primary/70',
|
||||
!finite && 'opacity-0',
|
||||
'h-full rounded-full transition-[width,background-color] duration-500 ease-out motion-reduce:transition-none',
|
||||
finite ? barTone(clamped) : 'opacity-0',
|
||||
)}
|
||||
style={{ width: `${clamped}%` }}
|
||||
/>
|
||||
|
||||
@@ -425,6 +425,117 @@ 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 \}/);
|
||||
assert.match(
|
||||
effectsSource,
|
||||
/queueMicrotask\(\(\) => \{\s*\n\s*if \(disposed\) return;\s*\n\s*void boot\(\);/,
|
||||
"backend boot must defer past StrictMode's synchronous re-invoke",
|
||||
);
|
||||
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 + sync dispose for never-connected
|
||||
// boots (StrictMode remount), else the async capture/teardown path.
|
||||
const cleanupStart = effectsSource.indexOf(" disposed = true;");
|
||||
assert.notEqual(cleanupStart, -1);
|
||||
const cleanup = effectsSource.slice(cleanupStart);
|
||||
const abortAt = cleanup.indexOf("bootAbort.abort()");
|
||||
const neverConnectedAt = cleanup.indexOf("if (!hasConnectedRef.current)");
|
||||
assert.ok(abortAt !== -1 && neverConnectedAt !== -1);
|
||||
assert.ok(abortAt < neverConnectedAt, "cleanup must abort before the never-connected close branch");
|
||||
const neverConnectedBranch = cleanup.slice(
|
||||
neverConnectedAt,
|
||||
cleanup.indexOf("const persistCloseCapture", neverConnectedAt),
|
||||
);
|
||||
const closeAt = neverConnectedBranch.indexOf("terminalBackend.closeSession(");
|
||||
const syncDisposeAt = neverConnectedBranch.indexOf("disposeOwnedRuntime();");
|
||||
const earlyReturnAt = neverConnectedBranch.indexOf("return;");
|
||||
assert.ok(closeAt !== -1 && syncDisposeAt !== -1 && earlyReturnAt !== -1);
|
||||
assert.ok(closeAt < syncDisposeAt, "eager close must run before sync runtime dispose");
|
||||
assert.ok(
|
||||
syncDisposeAt < earlyReturnAt,
|
||||
"never-connected boots must sync-dispose before leaving cleanup",
|
||||
);
|
||||
// Owner panes close the pending backend; attach popups only dispose xterm.
|
||||
assert.match(neverConnectedBranch, /if \(!attachExistingSession\)/);
|
||||
assert.match(effectsSource, /let ownedRuntime:/);
|
||||
assert.match(
|
||||
cleanup,
|
||||
/void completeClose\(\)/,
|
||||
"connected boots still use the async capture path",
|
||||
);
|
||||
|
||||
// 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");
|
||||
|
||||
|
||||
@@ -14,6 +14,12 @@ interface TerminalAutocompleteInputContext {
|
||||
typedInputBufferRef: MutableRefObject<string>;
|
||||
typedBufferReliableRef: MutableRefObject<boolean>;
|
||||
previewActiveRef: MutableRefObject<boolean>;
|
||||
/**
|
||||
* Drop the popup highlight (and cascade panels) without hiding suggestions.
|
||||
* Used when the user keeps typing so Enter cannot accept a stale default
|
||||
* selection during the debounce window.
|
||||
*/
|
||||
clearPopupSelection: () => void;
|
||||
termRef: RefObject<XTerm | null>;
|
||||
hostIdRef: MutableRefObject<string>;
|
||||
hostOsRef: MutableRefObject<"linux" | "windows" | "macos">;
|
||||
@@ -35,6 +41,7 @@ export function handleTerminalAutocompleteInput(
|
||||
typedInputBufferRef,
|
||||
typedBufferReliableRef,
|
||||
previewActiveRef,
|
||||
clearPopupSelection,
|
||||
termRef,
|
||||
hostIdRef,
|
||||
hostOsRef,
|
||||
@@ -205,6 +212,9 @@ export function handleTerminalAutocompleteInput(
|
||||
// text. Drop preview-active so Escape dismisses the popup without
|
||||
// reverting these edits back to the stale baseline (#1005).
|
||||
previewActiveRef.current = false;
|
||||
// Clear the default/nav highlight immediately so Enter during the
|
||||
// suggestion debounce cannot accept a stale never-previewed row.
|
||||
clearPopupSelection();
|
||||
|
||||
// Re-align any visible ghost text to the freshly-updated buffer
|
||||
// immediately. Without this the ghost keeps the tail it captured at
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
||||
import type { GhostTextAddon } from "./GhostTextAddon";
|
||||
import type { AutocompleteSettings, AutocompleteState, SubDirEntry } from "./useTerminalAutocomplete";
|
||||
import type { Snippet } from "../../../domain/models";
|
||||
import { isPreviewlessSelectionCurrentForLine } from "./terminalAutocompleteLayout";
|
||||
|
||||
interface TerminalAutocompleteKeyEventContext {
|
||||
settingsRef: MutableRefObject<AutocompleteSettings>;
|
||||
@@ -10,14 +11,27 @@ interface TerminalAutocompleteKeyEventContext {
|
||||
typedInputBufferRef: MutableRefObject<string>;
|
||||
typedBufferReliableRef: MutableRefObject<boolean>;
|
||||
previewActiveRef: MutableRefObject<boolean>;
|
||||
/**
|
||||
* True once a live preview has rewritten the line for the current popup
|
||||
* cycle. Survives edits that clear `previewActiveRef` so Enter does not
|
||||
* treat an edited preview as a never-previewed default selection.
|
||||
*/
|
||||
previewCommittedRef: MutableRefObject<boolean>;
|
||||
/** Typed input that produced the current popup suggestions. */
|
||||
previewBaselineRef: MutableRefObject<string>;
|
||||
lastAcceptedCommandRef: MutableRefObject<string | null>;
|
||||
setState: Dispatch<SetStateAction<AutocompleteState>>;
|
||||
expandSubDir: (level: number, entry: SubDirEntry, moveFocus?: boolean) => void;
|
||||
writeToTerminal: (text: string) => void;
|
||||
clearState: () => void;
|
||||
/**
|
||||
* Clear cascade panels (and optionally retreat focus) while invalidating
|
||||
* in-flight directory fetches so a late response cannot reinstall them.
|
||||
* Omit `toFocusLevel` to dismiss the whole cascade back to the main list.
|
||||
*/
|
||||
dismissSubDirCascade: (toFocusLevel?: number) => void;
|
||||
renderSubDirPath: (level: number, entry: SubDirEntry) => void;
|
||||
handleSubDirSelect: (level: number, entry: SubDirEntry) => void;
|
||||
fetchSubDirForIndex: (index: number) => void;
|
||||
renderPreviewSelection: (index: number) => void;
|
||||
acceptPreviewlessSelection: (index: number) => boolean;
|
||||
acceptSnippet: (snippet: Snippet) => boolean;
|
||||
@@ -45,14 +59,16 @@ export function handleTerminalAutocompleteKeyEvent(
|
||||
typedInputBufferRef,
|
||||
typedBufferReliableRef,
|
||||
previewActiveRef,
|
||||
previewCommittedRef,
|
||||
previewBaselineRef,
|
||||
lastAcceptedCommandRef,
|
||||
setState,
|
||||
expandSubDir,
|
||||
writeToTerminal,
|
||||
clearState,
|
||||
dismissSubDirCascade,
|
||||
renderSubDirPath,
|
||||
handleSubDirSelect,
|
||||
fetchSubDirForIndex,
|
||||
renderPreviewSelection,
|
||||
acceptPreviewlessSelection,
|
||||
acceptSnippet,
|
||||
@@ -185,6 +201,7 @@ export function handleTerminalAutocompleteKeyEvent(
|
||||
// the line and let Tab reach the shell for native completion.
|
||||
clearState();
|
||||
previewActiveRef.current = false;
|
||||
previewCommittedRef.current = false;
|
||||
return true;
|
||||
}
|
||||
// Hide stale ghost text before Tab reaches the shell — the shell's
|
||||
@@ -250,14 +267,13 @@ export function handleTerminalAutocompleteKeyEvent(
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
// Invalidate outstanding listings: Escape keeps the main-row
|
||||
// selection, so an index-only fetch guard would otherwise reinstall
|
||||
// the panels the user just dismissed.
|
||||
if (focusLevel > 0) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
subDirPanels: prev.subDirPanels.slice(0, focusLevel),
|
||||
subDirFocusLevel: focusLevel - 1,
|
||||
}));
|
||||
dismissSubDirCascade(focusLevel - 1);
|
||||
} else {
|
||||
setState((prev) => ({ ...prev, subDirPanels: [], subDirFocusLevel: -1 }));
|
||||
dismissSubDirCascade();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -290,40 +306,79 @@ export function handleTerminalAutocompleteKeyEvent(
|
||||
subDirPanels: [], subDirFocusLevel: -1,
|
||||
}));
|
||||
if (settingsRef.current.livePreview) renderPreviewSelection(next);
|
||||
if (next >= 0) fetchSubDirForIndex(next);
|
||||
// Sub-dir cascade prefetch runs from useTerminalAutocomplete after
|
||||
// selectedIndex commits (also covers default-select without ArrowDown).
|
||||
return false;
|
||||
}
|
||||
|
||||
// Enter on popup. The selected candidate is already rendered into the
|
||||
// line by live-preview, so let Enter reach the shell. Don't record here:
|
||||
// handleInput's Enter path records the *actual* line — it uses
|
||||
// lastAcceptedCommandRef (set on select) but falls back to the live
|
||||
// buffer when the user edited the previewed command (typing nulls that
|
||||
// ref), so recording stays accurate in both cases.
|
||||
// Enter on popup. When live-preview has already rewritten the line —
|
||||
// or the user edited that preview (previewActive cleared, committed
|
||||
// still set) — let Enter reach the shell. When a row is selected but
|
||||
// never previewed (default first-row highlight, #2821), insert+execute
|
||||
// like the previewless path. Don't record here: handleInput's Enter
|
||||
// path records the *actual* line — it uses lastAcceptedCommandRef
|
||||
// (set on select) but falls back to the live buffer when the user
|
||||
// edited the previewed command (typing nulls that ref).
|
||||
if (isAutocompleteConfirmEnter(e, settingsRef.current)) {
|
||||
const selected = s.selectedIndex >= 0 ? s.suggestions[s.selectedIndex] : null;
|
||||
// Never-previewed default/nav selection: typing during the debounce
|
||||
// window leaves selectedIndex on a stale row while both preview refs
|
||||
// stay false. Refuse accept unless the row still matches the fetch
|
||||
// baseline or prefix-completes the edited line.
|
||||
if (
|
||||
selected &&
|
||||
!previewActiveRef.current &&
|
||||
!previewCommittedRef.current &&
|
||||
typedBufferReliableRef.current &&
|
||||
!isPreviewlessSelectionCurrentForLine({
|
||||
currentLine: typedInputBufferRef.current,
|
||||
suggestionBaseline: previewBaselineRef.current,
|
||||
suggestionText: selected.text,
|
||||
suggestionSource: selected.source,
|
||||
})
|
||||
) {
|
||||
clearState();
|
||||
previewActiveRef.current = false;
|
||||
previewCommittedRef.current = false;
|
||||
return true;
|
||||
}
|
||||
if (selected?.source === "snippet" && selected.snippet) {
|
||||
if (!acceptSnippet(selected.snippet)) {
|
||||
clearState();
|
||||
previewActiveRef.current = false;
|
||||
previewCommittedRef.current = false;
|
||||
return true;
|
||||
}
|
||||
e.preventDefault();
|
||||
previewActiveRef.current = false;
|
||||
previewCommittedRef.current = false;
|
||||
return false; // consume — run the snippet, not the typed text
|
||||
}
|
||||
if (!settingsRef.current.livePreview && selected) {
|
||||
if (
|
||||
selected &&
|
||||
settingsRef.current.livePreview &&
|
||||
(previewActiveRef.current || previewCommittedRef.current)
|
||||
) {
|
||||
clearState();
|
||||
previewActiveRef.current = false;
|
||||
previewCommittedRef.current = false;
|
||||
return true;
|
||||
}
|
||||
if (selected) {
|
||||
if (acceptPreviewlessSelection(s.selectedIndex)) {
|
||||
e.preventDefault();
|
||||
previewActiveRef.current = false;
|
||||
previewCommittedRef.current = false;
|
||||
return false;
|
||||
}
|
||||
clearState();
|
||||
previewActiveRef.current = false;
|
||||
previewCommittedRef.current = false;
|
||||
return true;
|
||||
}
|
||||
clearState();
|
||||
previewActiveRef.current = false;
|
||||
previewCommittedRef.current = false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -339,6 +394,7 @@ export function handleTerminalAutocompleteKeyEvent(
|
||||
ghost?.hide();
|
||||
clearState();
|
||||
previewActiveRef.current = false;
|
||||
previewCommittedRef.current = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -112,6 +112,89 @@ export function areSuggestionsEqual(
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a suggestion refresh should preserve the current popup highlight.
|
||||
*
|
||||
* Equal suggestion rows alone are not enough: typing clears the highlight to
|
||||
* -1 for the debounce window, and a narrowed prefix often yields the same
|
||||
* rows. Preserve only when the input baseline is unchanged (spurious refetch /
|
||||
* ↑↓ baseline slot); otherwise restore the first-row default.
|
||||
*/
|
||||
export function shouldPreserveAutocompletePopupSelection(options: {
|
||||
suggestionsUnchanged: boolean;
|
||||
previousInputBaseline: string;
|
||||
nextInputBaseline: string;
|
||||
}): boolean {
|
||||
return (
|
||||
options.suggestionsUnchanged &&
|
||||
options.previousInputBaseline === options.nextInputBaseline
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial / refreshed popup selection (#2821).
|
||||
* New suggestion lists default to the first row so Enter can confirm without
|
||||
* an ArrowDown. Unchanged lists keep the user's current index (including the
|
||||
* -1 "typed baseline" slot from ↑/↓ wrapping) when
|
||||
* `shouldPreserveAutocompletePopupSelection` is true.
|
||||
*/
|
||||
export function resolveAutocompletePopupSelectedIndex(options: {
|
||||
suggestionCount: number;
|
||||
previousSelectedIndex: number;
|
||||
keepPreviousSelection: boolean;
|
||||
}): number {
|
||||
if (options.suggestionCount <= 0) return -1;
|
||||
if (
|
||||
options.keepPreviousSelection &&
|
||||
options.previousSelectedIndex >= -1 &&
|
||||
options.previousSelectedIndex < options.suggestionCount
|
||||
) {
|
||||
return options.previousSelectedIndex;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a never-previewed popup selection is still safe to accept on Enter.
|
||||
*
|
||||
* Default first-row highlight (#2821) stays selected during the suggestion
|
||||
* debounce window. If the user typed further, accepting would replace the
|
||||
* edited line with a stale row when allowLineReplacement is on. Keep accept
|
||||
* when the line still matches the fetch baseline (incl. fuzzy), or when the
|
||||
* candidate still prefix-completes the edited line.
|
||||
*/
|
||||
export function isPreviewlessSelectionCurrentForLine(options: {
|
||||
currentLine: string;
|
||||
suggestionBaseline: string;
|
||||
suggestionText: string;
|
||||
suggestionSource: string;
|
||||
}): boolean {
|
||||
if (options.currentLine === options.suggestionBaseline) return true;
|
||||
if (options.suggestionSource === "snippet") return false;
|
||||
return options.suggestionText.startsWith(options.currentLine);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a level-0 directory prefetch may install its panel.
|
||||
*
|
||||
* Index-only checks are not enough after typing replaces the first row:
|
||||
* an older in-flight listing can share index 0 with a newer suggestion.
|
||||
* Require matching fetch generation and suggestion identity too.
|
||||
*/
|
||||
export function shouldApplySubDirPrefetchResult(options: {
|
||||
requestVersion: number;
|
||||
currentVersion: number;
|
||||
requestIndex: number;
|
||||
selectedIndex: number;
|
||||
requestSuggestionText: string;
|
||||
selectedSuggestionText: string | undefined;
|
||||
}): boolean {
|
||||
if (options.requestVersion !== options.currentVersion) return false;
|
||||
if (options.selectedIndex !== options.requestIndex) return false;
|
||||
if (options.selectedSuggestionText === undefined) return false;
|
||||
return options.selectedSuggestionText === options.requestSuggestionText;
|
||||
}
|
||||
|
||||
export function areSubDirPanelsEqual(left: SubDirPanel[], right: SubDirPanel[]): boolean {
|
||||
if (left.length !== right.length) return false;
|
||||
for (let i = 0; i < left.length; i++) {
|
||||
|
||||
@@ -34,6 +34,9 @@ import {
|
||||
resolveAutocompleteAnchorInViewport,
|
||||
resolveAutocompleteCursorColumn,
|
||||
resolveAutocompleteCwdWithSource,
|
||||
resolveAutocompletePopupSelectedIndex,
|
||||
shouldApplySubDirPrefetchResult,
|
||||
shouldPreserveAutocompletePopupSelection,
|
||||
} from "./terminalAutocompleteLayout";
|
||||
import { handleTerminalAutocompleteInput } from "./terminalAutocompleteInput";
|
||||
import { handleTerminalAutocompleteKeyEvent } from "./terminalAutocompleteKeyEvent";
|
||||
@@ -229,6 +232,12 @@ export function useTerminalAutocomplete(
|
||||
const previewBaselineRef = useRef<string>("");
|
||||
/** Whether a popup candidate is currently rendered into the command line (#1005). */
|
||||
const previewActiveRef = useRef(false);
|
||||
/**
|
||||
* Whether a live preview has rewritten the line for the current popup cycle.
|
||||
* Stays true after the user edits the previewed text (which clears
|
||||
* previewActiveRef) so Enter does not accept the stale selected row.
|
||||
*/
|
||||
const previewCommittedRef = useRef(false);
|
||||
/** Monotonic counter to invalidate stale async sub-dir fetches */
|
||||
const subDirFetchVersionRef = useRef(0);
|
||||
/**
|
||||
@@ -352,11 +361,64 @@ export function useTerminalAutocomplete(
|
||||
// Bump version to invalidate any in-flight async completions
|
||||
fetchVersionRef.current++;
|
||||
subDirFetchVersionRef.current++;
|
||||
previewActiveRef.current = false;
|
||||
previewCommittedRef.current = false;
|
||||
setState((prev) =>
|
||||
prev.popupVisible || prev.suggestions.length > 0 ? { ...EMPTY_STATE } : prev,
|
||||
);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Clear the popup highlight (and cascade) while keeping suggestions visible.
|
||||
* Syncs stateRef immediately so a same-tick Enter cannot accept a stale row.
|
||||
*/
|
||||
const clearPopupSelection = useCallback(() => {
|
||||
const prev = stateRef.current;
|
||||
if (
|
||||
!prev.popupVisible ||
|
||||
(prev.selectedIndex < 0 && prev.subDirPanels.length === 0 && prev.subDirFocusLevel < 0)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Drop in-flight level-0 fetches so a stale directory cannot install after
|
||||
// the next refresh defaults back to index 0 for a different first row.
|
||||
subDirFetchVersionRef.current++;
|
||||
const next = {
|
||||
...prev,
|
||||
selectedIndex: -1,
|
||||
subDirPanels: [] as typeof prev.subDirPanels,
|
||||
subDirFocusLevel: -1,
|
||||
};
|
||||
stateRef.current = next;
|
||||
setState(next);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Dismiss cascade panels (Escape) while keeping the main popup selection.
|
||||
* Always bumps the fetch generation so a late listing cannot reopen panels.
|
||||
*/
|
||||
const dismissSubDirCascade = useCallback((toFocusLevel?: number) => {
|
||||
subDirFetchVersionRef.current++;
|
||||
setState((prev) => {
|
||||
if (toFocusLevel !== undefined && toFocusLevel >= 0) {
|
||||
const nextPanels = prev.subDirPanels.slice(0, toFocusLevel + 1);
|
||||
if (
|
||||
prev.subDirFocusLevel === toFocusLevel &&
|
||||
areSubDirPanelsEqual(prev.subDirPanels, nextPanels)
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
subDirPanels: nextPanels,
|
||||
subDirFocusLevel: toFocusLevel,
|
||||
};
|
||||
}
|
||||
if (prev.subDirPanels.length === 0 && prev.subDirFocusLevel < 0) return prev;
|
||||
return { ...prev, subDirPanels: [], subDirFocusLevel: -1 };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const repositionPopup = useCallback(() => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
@@ -439,11 +501,23 @@ export function useTerminalAutocomplete(
|
||||
if (!dirPath) return;
|
||||
|
||||
const requestVersion = ++subDirFetchVersionRef.current;
|
||||
const requestSuggestionText = item.text;
|
||||
fetchDirEntries(dirPath).then((entries) => {
|
||||
if (requestVersion !== subDirFetchVersionRef.current) return;
|
||||
startTransition(() => {
|
||||
setState((prev) => {
|
||||
if (prev.selectedIndex !== index) return prev;
|
||||
if (
|
||||
!shouldApplySubDirPrefetchResult({
|
||||
requestVersion,
|
||||
currentVersion: subDirFetchVersionRef.current,
|
||||
requestIndex: index,
|
||||
selectedIndex: prev.selectedIndex,
|
||||
requestSuggestionText,
|
||||
selectedSuggestionText: prev.suggestions[index]?.text,
|
||||
})
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
const nextPanels = entries.length > 0 ? [{ entries, selectedIndex: -1, dirPath }] : [];
|
||||
if (
|
||||
prev.subDirFocusLevel === -1 &&
|
||||
@@ -580,6 +654,7 @@ export function useTerminalAutocomplete(
|
||||
typedInputBufferRef.current = newCommand;
|
||||
typedBufferReliableRef.current = true;
|
||||
previewActiveRef.current = true;
|
||||
previewCommittedRef.current = true;
|
||||
lastAcceptedCommandRef.current = newCommand;
|
||||
}, [termRef, writeToTerminal]);
|
||||
|
||||
@@ -764,8 +839,12 @@ export function useTerminalAutocomplete(
|
||||
// Popup
|
||||
if (settingsRef.current.showPopupMenu && completions.length > 0) {
|
||||
// Live-preview baseline: the typed input these suggestions completed.
|
||||
// Capture the prior baseline first — equal suggestion rows after typing
|
||||
// must not preserve the debounce clear's programmatic -1.
|
||||
const previousInputBaseline = previewBaselineRef.current;
|
||||
previewBaselineRef.current = input;
|
||||
previewActiveRef.current = false;
|
||||
previewCommittedRef.current = false;
|
||||
const cursorColumn = resolveAutocompleteCursorColumn(term, currentPrompt);
|
||||
const anchor = resolveAutocompleteAnchorInViewport(
|
||||
term,
|
||||
@@ -777,9 +856,23 @@ export function useTerminalAutocomplete(
|
||||
setState((prev) => {
|
||||
if (version !== fetchVersionRef.current) return prev;
|
||||
|
||||
const suggestionsUnchanged =
|
||||
prev.popupVisible && areSuggestionsEqual(prev.suggestions, completions);
|
||||
const keepPreviousSelection = shouldPreserveAutocompletePopupSelection({
|
||||
suggestionsUnchanged,
|
||||
previousInputBaseline,
|
||||
nextInputBaseline: input,
|
||||
});
|
||||
const selectedIndex = resolveAutocompletePopupSelectedIndex({
|
||||
suggestionCount: completions.length,
|
||||
previousSelectedIndex: prev.selectedIndex,
|
||||
keepPreviousSelection,
|
||||
});
|
||||
const keepSubDirPanels =
|
||||
suggestionsUnchanged && selectedIndex === prev.selectedIndex;
|
||||
const nextState: AutocompleteState = {
|
||||
suggestions: completions,
|
||||
selectedIndex: -1,
|
||||
selectedIndex,
|
||||
popupVisible: true,
|
||||
popupAnchorViewport: {
|
||||
left: anchor.anchorLeft,
|
||||
@@ -787,8 +880,11 @@ export function useTerminalAutocomplete(
|
||||
bottom: anchor.anchorBottom,
|
||||
},
|
||||
expandUpward: anchor.expandUpward,
|
||||
subDirPanels: [],
|
||||
subDirFocusLevel: -1,
|
||||
// Fresh lists (or a selection change) drop cascade panels.
|
||||
// Unchanged selection keeps them so a spurious refetch does not
|
||||
// collapse an open directory tree.
|
||||
subDirPanels: keepSubDirPanels ? prev.subDirPanels : [],
|
||||
subDirFocusLevel: keepSubDirPanels ? prev.subDirFocusLevel : -1,
|
||||
};
|
||||
|
||||
if (
|
||||
@@ -798,9 +894,9 @@ export function useTerminalAutocomplete(
|
||||
prev.popupAnchorViewport.left === nextState.popupAnchorViewport.left &&
|
||||
prev.popupAnchorViewport.top === nextState.popupAnchorViewport.top &&
|
||||
prev.popupAnchorViewport.bottom === nextState.popupAnchorViewport.bottom &&
|
||||
prev.subDirFocusLevel === -1 &&
|
||||
prev.subDirPanels.length === 0 &&
|
||||
areSuggestionsEqual(prev.suggestions, nextState.suggestions)
|
||||
prev.subDirFocusLevel === nextState.subDirFocusLevel &&
|
||||
areSubDirPanelsEqual(prev.subDirPanels, nextState.subDirPanels) &&
|
||||
suggestionsUnchanged
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
@@ -835,6 +931,7 @@ export function useTerminalAutocomplete(
|
||||
typedInputBufferRef,
|
||||
typedBufferReliableRef,
|
||||
previewActiveRef,
|
||||
clearPopupSelection,
|
||||
termRef,
|
||||
hostIdRef,
|
||||
hostOsRef,
|
||||
@@ -844,7 +941,7 @@ export function useTerminalAutocomplete(
|
||||
fetchSuggestions,
|
||||
});
|
||||
},
|
||||
[fetchSuggestions, termRef, clearState],
|
||||
[fetchSuggestions, termRef, clearState, clearPopupSelection],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -859,14 +956,16 @@ export function useTerminalAutocomplete(
|
||||
typedInputBufferRef,
|
||||
typedBufferReliableRef,
|
||||
previewActiveRef,
|
||||
previewCommittedRef,
|
||||
previewBaselineRef,
|
||||
lastAcceptedCommandRef,
|
||||
setState,
|
||||
expandSubDir,
|
||||
writeToTerminal,
|
||||
clearState,
|
||||
dismissSubDirCascade,
|
||||
renderSubDirPath,
|
||||
handleSubDirSelect,
|
||||
fetchSubDirForIndex,
|
||||
renderPreviewSelection,
|
||||
acceptPreviewlessSelection,
|
||||
acceptSnippet,
|
||||
@@ -875,6 +974,28 @@ export function useTerminalAutocomplete(
|
||||
[writeToTerminal],
|
||||
);
|
||||
|
||||
// Prefetch directory cascade panels after selectedIndex commits. Covers both
|
||||
// default-select (#2821) and ↑/↓ navigation. Must run post-commit: calling
|
||||
// from the startTransition refresh path races when fetchDirEntries resolves
|
||||
// before selectedIndex has flushed, and the result handler drops the panels.
|
||||
// Depend only on selection/list identity — not cascade emptiness — so Escape
|
||||
// dismissing level-0 panels does not immediately re-prefetch and reopen them.
|
||||
useEffect(() => {
|
||||
if (!state.popupVisible || state.selectedIndex < 0) return;
|
||||
if (state.selectedIndex >= state.suggestions.length) return;
|
||||
// Skip when cascade state already exists so an unchanged suggestion
|
||||
// refresh (new array identity, same rows) does not wipe nested panels.
|
||||
if (state.subDirPanels.length > 0 || state.subDirFocusLevel >= 0) return;
|
||||
fetchSubDirForIndex(state.selectedIndex);
|
||||
// Cascade emptiness is a skip guard only — omit from deps so Escape dismissal sticks.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- selection/list only
|
||||
}, [
|
||||
state.popupVisible,
|
||||
state.selectedIndex,
|
||||
state.suggestions,
|
||||
fetchSubDirForIndex,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Render the suggestion at `index` straight into the command line (Termius
|
||||
* live-preview, #1005). `index < 0` restores the user's typed baseline.
|
||||
@@ -906,6 +1027,7 @@ export function useTerminalAutocomplete(
|
||||
typedBufferReliableRef.current = true;
|
||||
const isPreview = index >= 0 && candidate !== baseline;
|
||||
previewActiveRef.current = isPreview;
|
||||
if (isPreview) previewCommittedRef.current = true;
|
||||
lastAcceptedCommandRef.current = isPreview ? candidate : null;
|
||||
// Live-preview can move/wrap the cursor. Recompute the anchor after xterm
|
||||
// has processed the write so the popup doesn't drift or flip into a stale
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
shouldSuppressHostStartupCommandOnReconnect,
|
||||
shouldStartTerminalBackend,
|
||||
} from "./restoredSessionGate.ts";
|
||||
import { setVaultInitialized } from "../../application/state/vaultInitStore.ts";
|
||||
|
||||
test("restored disconnected sessions initialize as connecting", () => {
|
||||
assert.equal(
|
||||
@@ -19,10 +20,27 @@ test("normal sessions initialize as connecting", () => {
|
||||
assert.equal(getInitialTerminalStatus(), "connecting");
|
||||
});
|
||||
|
||||
test("restored disconnected sessions start terminal backend", () => {
|
||||
test("restored disconnected sessions start terminal backend only after vault hydration", () => {
|
||||
setVaultInitialized(false);
|
||||
assert.equal(shouldStartTerminalBackend(), false);
|
||||
setVaultInitialized(true);
|
||||
assert.equal(shouldStartTerminalBackend(), true);
|
||||
});
|
||||
|
||||
test("terminal boot waits for vaultInitialized before creating a backend session", () => {
|
||||
const source = readFileSync(new URL("./useTerminalEffects.ts", import.meta.url), "utf8");
|
||||
assert.match(
|
||||
source,
|
||||
/if \(!attachExistingSession && !vaultInitialized\) \{\s*\n\s*return;/,
|
||||
"boot effect must wait for vault hydration before creating xterm/backend",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/vaultInitialized,/,
|
||||
"vaultInitialized must be in the boot effect dependency list",
|
||||
);
|
||||
});
|
||||
|
||||
test("host startup command policy distinguishes restored and automatic reconnects", () => {
|
||||
assert.equal(shouldSuppressHostStartupCommandOnReconnect("restored"), false);
|
||||
assert.equal(shouldSuppressHostStartupCommandOnReconnect("manual"), false);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TerminalSession } from "../../domain/models";
|
||||
import { isVaultInitialized } from "../../application/state/vaultInitStore";
|
||||
|
||||
export type TerminalReconnectMode = "restored" | "manual" | "automatic";
|
||||
|
||||
@@ -6,7 +7,11 @@ export const getInitialTerminalStatus = (): TerminalSession["status"] => (
|
||||
"connecting"
|
||||
);
|
||||
|
||||
export const shouldStartTerminalBackend = (): boolean => true;
|
||||
/**
|
||||
* Backend start waits for vault hydration so restored sessions cannot dial SSH
|
||||
* with empty keys while hosts have already been published mid-init.
|
||||
*/
|
||||
export const shouldStartTerminalBackend = (): boolean => isVaultInitialized();
|
||||
|
||||
export const shouldSuppressHostStartupCommandOnReconnect = (
|
||||
mode: TerminalReconnectMode,
|
||||
|
||||
@@ -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]");
|
||||
|
||||
@@ -602,6 +602,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
const searchAddon = new SearchAddon();
|
||||
term.loadAddon(searchAddon);
|
||||
|
||||
for (const orphan of Array.from(ctx.container.querySelectorAll(":scope > .xterm"))) {
|
||||
orphan.remove();
|
||||
}
|
||||
|
||||
term.open(ctx.container);
|
||||
|
||||
// Inline raster images (Kitty graphics / SIXEL / iTerm IIP). Loaded right after
|
||||
|
||||
@@ -67,7 +67,7 @@ test("terminal close fully drains pending output before finalizing capture", ()
|
||||
new URL("../useTerminalEffects.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const cleanupStart = source.indexOf("return () => {", source.indexOf("boot();"));
|
||||
const cleanupStart = source.indexOf("return () => {", source.indexOf("void boot();"));
|
||||
const flushIndex = source.indexOf("await flushPendingTerminalWritesBeforeHibernate(term)", cleanupStart);
|
||||
const incompleteIndex = source.indexOf("if (!flushed)", flushIndex);
|
||||
const finalizeIndex = source.indexOf("resolveConnectionLogCapturePayload(finalizeTerminalLogData)", cleanupStart);
|
||||
@@ -77,3 +77,40 @@ test("terminal close fully drains pending output before finalizing capture", ()
|
||||
assert.ok(incompleteIndex > flushIndex);
|
||||
assert.ok(finalizeIndex > flushIndex);
|
||||
});
|
||||
|
||||
test("never-connected StrictMode cleanup sync-disposes its owned xterm runtime", () => {
|
||||
const source = readFileSync(
|
||||
new URL("../useTerminalEffects.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const cleanupStart = source.indexOf("return () => {", source.indexOf("void boot();"));
|
||||
assert.ok(cleanupStart >= 0);
|
||||
const neverConnectedClose = source.indexOf(
|
||||
"if (!hasConnectedRef.current)",
|
||||
cleanupStart,
|
||||
);
|
||||
assert.ok(neverConnectedClose > cleanupStart);
|
||||
const branch = source.slice(
|
||||
neverConnectedClose,
|
||||
source.indexOf("const persistCloseCapture", neverConnectedClose),
|
||||
);
|
||||
assert.match(branch, /if \(!attachExistingSession\)/);
|
||||
assert.match(branch, /disposeOwnedRuntime\(\);/);
|
||||
assert.match(branch, /return;/);
|
||||
assert.ok(
|
||||
branch.indexOf("disposeOwnedRuntime();") < branch.indexOf("return;"),
|
||||
"orphaned StrictMode xterm must be disposed before leaving cleanup",
|
||||
);
|
||||
});
|
||||
|
||||
test("createXTermRuntime clears orphaned .xterm children before open", () => {
|
||||
const source = readFileSync(
|
||||
new URL("./createXTermRuntime.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const openAt = source.indexOf("term.open(ctx.container);");
|
||||
assert.ok(openAt > 0);
|
||||
const beforeOpen = source.slice(Math.max(0, openAt - 350), openAt);
|
||||
assert.match(beforeOpen, /querySelectorAll\(":scope > \.xterm"\)/);
|
||||
assert.match(beforeOpen, /orphan\.remove\(\)/);
|
||||
});
|
||||
@@ -843,7 +843,7 @@ export const attachSessionToTerminal = (
|
||||
},
|
||||
) => {
|
||||
if (!isTerminalBootActive(ctx)) {
|
||||
closeOrphanBackendSession(ctx, id);
|
||||
closeOrphanBackendSession(ctx, id, { bootEpoch: ctx.bootEpochRef?.current });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ function createContext(overrides: Record<string, unknown> = {}) {
|
||||
const previews: number[] = [];
|
||||
const accepted: number[] = [];
|
||||
const clears: number[] = [];
|
||||
const dismissals: Array<number | undefined> = [];
|
||||
const stateRef = { current: state };
|
||||
|
||||
return {
|
||||
@@ -53,6 +54,8 @@ function createContext(overrides: Record<string, unknown> = {}) {
|
||||
previews,
|
||||
accepted,
|
||||
clears,
|
||||
dismissals,
|
||||
getState: () => state,
|
||||
context: {
|
||||
settingsRef: {
|
||||
current: {
|
||||
@@ -72,6 +75,8 @@ function createContext(overrides: Record<string, unknown> = {}) {
|
||||
typedInputBufferRef: { current: "sh" },
|
||||
typedBufferReliableRef: { current: true },
|
||||
previewActiveRef: { current: false },
|
||||
previewCommittedRef: { current: false },
|
||||
previewBaselineRef: { current: "sh" },
|
||||
lastAcceptedCommandRef: { current: null },
|
||||
setState(update: typeof state | ((prev: typeof state) => typeof state)) {
|
||||
state = typeof update === "function" ? update(state) : update;
|
||||
@@ -80,9 +85,9 @@ function createContext(overrides: Record<string, unknown> = {}) {
|
||||
expandSubDir() {},
|
||||
writeToTerminal(text: string) { writes.push(text); },
|
||||
clearState() { clears.push(1); },
|
||||
dismissSubDirCascade(toFocusLevel?: number) { dismissals.push(toFocusLevel); },
|
||||
renderSubDirPath() {},
|
||||
handleSubDirSelect() {},
|
||||
fetchSubDirForIndex() {},
|
||||
renderPreviewSelection(index: number) { previews.push(index); },
|
||||
acceptSnippet() { return true; },
|
||||
acceptPreviewlessSelection(index: number) { accepted.push(index); return true; },
|
||||
@@ -202,3 +207,225 @@ test("serial-style popup Shift+Enter confirms candidate when terminal shortcut i
|
||||
assert.deepEqual(accepted, [0]);
|
||||
assert.deepEqual(clears, []);
|
||||
});
|
||||
|
||||
test("live-preview Enter accepts the default-selected candidate when it is not previewed yet", () => {
|
||||
const { context, accepted, clears } = createContext({
|
||||
stateRef: {
|
||||
current: {
|
||||
suggestions: [suggestion("show version")],
|
||||
selectedIndex: 0,
|
||||
popupVisible: true,
|
||||
popupAnchorViewport: { left: 0, top: 0, bottom: 0 },
|
||||
expandUpward: false,
|
||||
subDirPanels: [],
|
||||
subDirFocusLevel: -1,
|
||||
},
|
||||
},
|
||||
});
|
||||
context.settingsRef.current.livePreview = true;
|
||||
context.previewActiveRef.current = false;
|
||||
const event = keyEvent("Enter");
|
||||
|
||||
const result = handleTerminalAutocompleteKeyEvent(event, context);
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.equal(event.defaultPrevented, true);
|
||||
assert.deepEqual(accepted, [0]);
|
||||
assert.deepEqual(clears, []);
|
||||
});
|
||||
|
||||
test("live-preview Enter passes through a stale default selection after further typing", () => {
|
||||
// Default first-row highlight is never previewed. Typing during the
|
||||
// debounce window leaves selectedIndex on the old row while both preview
|
||||
// refs stay false — Enter must not wipe the edited line via line replacement.
|
||||
const { context, accepted, clears } = createContext({
|
||||
stateRef: {
|
||||
current: {
|
||||
suggestions: [suggestion("git status")],
|
||||
selectedIndex: 0,
|
||||
popupVisible: true,
|
||||
popupAnchorViewport: { left: 0, top: 0, bottom: 0 },
|
||||
expandUpward: false,
|
||||
subDirPanels: [],
|
||||
subDirFocusLevel: -1,
|
||||
},
|
||||
},
|
||||
});
|
||||
context.settingsRef.current.livePreview = true;
|
||||
context.settingsRef.current.allowLineReplacement = true;
|
||||
context.previewActiveRef.current = false;
|
||||
context.previewCommittedRef.current = false;
|
||||
context.previewBaselineRef.current = "gst";
|
||||
context.typedInputBufferRef.current = "gstx";
|
||||
const event = keyEvent("Enter");
|
||||
|
||||
const result = handleTerminalAutocompleteKeyEvent(event, context);
|
||||
|
||||
assert.equal(result, true);
|
||||
assert.equal(event.defaultPrevented, false);
|
||||
assert.deepEqual(accepted, []);
|
||||
assert.deepEqual(clears, [1]);
|
||||
});
|
||||
|
||||
test("live-preview Enter still accepts default selection that completes edited prefix", () => {
|
||||
const { context, accepted, clears } = createContext({
|
||||
stateRef: {
|
||||
current: {
|
||||
suggestions: [suggestion("show version")],
|
||||
selectedIndex: 0,
|
||||
popupVisible: true,
|
||||
popupAnchorViewport: { left: 0, top: 0, bottom: 0 },
|
||||
expandUpward: false,
|
||||
subDirPanels: [],
|
||||
subDirFocusLevel: -1,
|
||||
},
|
||||
},
|
||||
});
|
||||
context.settingsRef.current.livePreview = true;
|
||||
context.previewActiveRef.current = false;
|
||||
context.previewCommittedRef.current = false;
|
||||
context.previewBaselineRef.current = "sh";
|
||||
context.typedInputBufferRef.current = "sho";
|
||||
const event = keyEvent("Enter");
|
||||
|
||||
const result = handleTerminalAutocompleteKeyEvent(event, context);
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.equal(event.defaultPrevented, true);
|
||||
assert.deepEqual(accepted, [0]);
|
||||
assert.deepEqual(clears, []);
|
||||
});
|
||||
|
||||
test("live-preview Enter still passes through when the candidate is already on the line", () => {
|
||||
const { context, accepted, clears } = createContext({
|
||||
stateRef: {
|
||||
current: {
|
||||
suggestions: [suggestion("show version")],
|
||||
selectedIndex: 0,
|
||||
popupVisible: true,
|
||||
popupAnchorViewport: { left: 0, top: 0, bottom: 0 },
|
||||
expandUpward: false,
|
||||
subDirPanels: [],
|
||||
subDirFocusLevel: -1,
|
||||
},
|
||||
},
|
||||
});
|
||||
context.settingsRef.current.livePreview = true;
|
||||
context.previewActiveRef.current = true;
|
||||
context.previewCommittedRef.current = true;
|
||||
const event = keyEvent("Enter");
|
||||
|
||||
const result = handleTerminalAutocompleteKeyEvent(event, context);
|
||||
|
||||
assert.equal(result, true);
|
||||
assert.equal(event.defaultPrevented, false);
|
||||
assert.deepEqual(accepted, []);
|
||||
assert.deepEqual(clears, [1]);
|
||||
});
|
||||
|
||||
test("live-preview Enter passes through an edited preview instead of accepting the old row", () => {
|
||||
// Arrow navigation live-previewed a row, then the user typed (clears
|
||||
// previewActive) before debounce refresh. Enter must keep the edited
|
||||
// line — not wipe it via acceptPreviewlessSelection.
|
||||
const { context, accepted, clears } = createContext({
|
||||
stateRef: {
|
||||
current: {
|
||||
suggestions: [suggestion("show version")],
|
||||
selectedIndex: 0,
|
||||
popupVisible: true,
|
||||
popupAnchorViewport: { left: 0, top: 0, bottom: 0 },
|
||||
expandUpward: false,
|
||||
subDirPanels: [],
|
||||
subDirFocusLevel: -1,
|
||||
},
|
||||
},
|
||||
});
|
||||
context.settingsRef.current.livePreview = true;
|
||||
context.previewActiveRef.current = false;
|
||||
context.previewCommittedRef.current = true;
|
||||
context.typedInputBufferRef.current = "show version --short";
|
||||
const event = keyEvent("Enter");
|
||||
|
||||
const result = handleTerminalAutocompleteKeyEvent(event, context);
|
||||
|
||||
assert.equal(result, true);
|
||||
assert.equal(event.defaultPrevented, false);
|
||||
assert.deepEqual(accepted, []);
|
||||
assert.deepEqual(clears, [1]);
|
||||
});
|
||||
|
||||
test("Escape at level 0 dismisses cascade via dismissSubDirCascade so in-flight fetches cannot reinstall", () => {
|
||||
// Escape clears panels while keeping the main-row selection. Without
|
||||
// invalidating outstanding directory fetches, a late response would
|
||||
// reinstall the panel and undo the dismissal.
|
||||
const { context, dismissals } = createContext({
|
||||
stateRef: {
|
||||
current: {
|
||||
suggestions: [{
|
||||
text: "cd docs/",
|
||||
displayText: "docs/",
|
||||
source: "path" as const,
|
||||
score: 1,
|
||||
fileType: "directory" as const,
|
||||
}],
|
||||
selectedIndex: 0,
|
||||
popupVisible: true,
|
||||
popupAnchorViewport: { left: 0, top: 0, bottom: 0 },
|
||||
expandUpward: false,
|
||||
subDirPanels: [{
|
||||
entries: [{ name: "src", type: "directory" as const }],
|
||||
selectedIndex: -1,
|
||||
dirPath: "/home/docs",
|
||||
}],
|
||||
subDirFocusLevel: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
const event = keyEvent("Escape");
|
||||
|
||||
const result = handleTerminalAutocompleteKeyEvent(event, context);
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.equal(event.defaultPrevented, true);
|
||||
assert.deepEqual(dismissals, [undefined]);
|
||||
});
|
||||
|
||||
test("Escape at nested level dismisses via dismissSubDirCascade with prior focus level", () => {
|
||||
const { context, dismissals } = createContext({
|
||||
stateRef: {
|
||||
current: {
|
||||
suggestions: [{
|
||||
text: "cd docs/",
|
||||
displayText: "docs/",
|
||||
source: "path" as const,
|
||||
score: 1,
|
||||
fileType: "directory" as const,
|
||||
}],
|
||||
selectedIndex: 0,
|
||||
popupVisible: true,
|
||||
popupAnchorViewport: { left: 0, top: 0, bottom: 0 },
|
||||
expandUpward: false,
|
||||
subDirPanels: [
|
||||
{
|
||||
entries: [{ name: "src", type: "directory" as const }],
|
||||
selectedIndex: 0,
|
||||
dirPath: "/home/docs",
|
||||
},
|
||||
{
|
||||
entries: [{ name: "main.ts", type: "file" as const }],
|
||||
selectedIndex: 0,
|
||||
dirPath: "/home/docs/src",
|
||||
},
|
||||
],
|
||||
subDirFocusLevel: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
const event = keyEvent("Escape");
|
||||
|
||||
const result = handleTerminalAutocompleteKeyEvent(event, context);
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.equal(event.defaultPrevented, true);
|
||||
assert.deepEqual(dismissals, [0]);
|
||||
});
|
||||
@@ -1,12 +1,17 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import {
|
||||
clampAutocompletePopupGeometry,
|
||||
computeAutocompletePopupPlacement,
|
||||
isPreviewlessSelectionCurrentForLine,
|
||||
resolveAutocompleteAnchorInViewport,
|
||||
resolveAutocompleteClampViewport,
|
||||
resolveAutocompleteCursorColumn,
|
||||
resolveAutocompletePopupSelectedIndex,
|
||||
shouldApplySubDirPrefetchResult,
|
||||
shouldPreserveAutocompletePopupSelection,
|
||||
type PopupPlacementInput,
|
||||
} from "./autocomplete/terminalAutocompleteLayout.ts";
|
||||
|
||||
@@ -501,3 +506,207 @@ test("resolveAutocompleteAnchorInViewport ignores the helper textarea horizontal
|
||||
assert.equal(anchor.anchorLeft, 640 + cellWidth * cursorColumn);
|
||||
assert.notEqual(anchor.anchorLeft, textarea.getBoundingClientRect().left);
|
||||
});
|
||||
|
||||
test("resolveAutocompletePopupSelectedIndex defaults to the first suggestion", () => {
|
||||
assert.equal(
|
||||
resolveAutocompletePopupSelectedIndex({
|
||||
suggestionCount: 3,
|
||||
previousSelectedIndex: -1,
|
||||
keepPreviousSelection: false,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldPreserveAutocompletePopupSelection requires an unchanged input baseline", () => {
|
||||
assert.equal(
|
||||
shouldPreserveAutocompletePopupSelection({
|
||||
suggestionsUnchanged: true,
|
||||
previousInputBaseline: "cd do",
|
||||
nextInputBaseline: "cd do",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
// Typing clears selection to -1; equal rows after a narrower prefix must
|
||||
// restore the first-row default instead of preserving that programmatic -1.
|
||||
assert.equal(
|
||||
shouldPreserveAutocompletePopupSelection({
|
||||
suggestionsUnchanged: true,
|
||||
previousInputBaseline: "cd do",
|
||||
nextInputBaseline: "cd doc",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldPreserveAutocompletePopupSelection({
|
||||
suggestionsUnchanged: false,
|
||||
previousInputBaseline: "cd do",
|
||||
nextInputBaseline: "cd do",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveAutocompletePopupSelectedIndex keeps navigation when suggestions are unchanged", () => {
|
||||
assert.equal(
|
||||
resolveAutocompletePopupSelectedIndex({
|
||||
suggestionCount: 3,
|
||||
previousSelectedIndex: 2,
|
||||
keepPreviousSelection: true,
|
||||
}),
|
||||
2,
|
||||
);
|
||||
assert.equal(
|
||||
resolveAutocompletePopupSelectedIndex({
|
||||
suggestionCount: 3,
|
||||
previousSelectedIndex: -1,
|
||||
keepPreviousSelection: true,
|
||||
}),
|
||||
-1,
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveAutocompletePopupSelectedIndex clamps a stale selection when the list shrinks", () => {
|
||||
assert.equal(
|
||||
resolveAutocompletePopupSelectedIndex({
|
||||
suggestionCount: 1,
|
||||
previousSelectedIndex: 4,
|
||||
keepPreviousSelection: true,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("isPreviewlessSelectionCurrentForLine keeps fetch-baseline fuzzy rows", () => {
|
||||
assert.equal(
|
||||
isPreviewlessSelectionCurrentForLine({
|
||||
currentLine: "gst",
|
||||
suggestionBaseline: "gst",
|
||||
suggestionText: "git status",
|
||||
suggestionSource: "history",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("isPreviewlessSelectionCurrentForLine rejects edits that no longer complete", () => {
|
||||
assert.equal(
|
||||
isPreviewlessSelectionCurrentForLine({
|
||||
currentLine: "gstx",
|
||||
suggestionBaseline: "gst",
|
||||
suggestionText: "git status",
|
||||
suggestionSource: "history",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("isPreviewlessSelectionCurrentForLine keeps prefix progress during debounce", () => {
|
||||
assert.equal(
|
||||
isPreviewlessSelectionCurrentForLine({
|
||||
currentLine: "sho",
|
||||
suggestionBaseline: "sh",
|
||||
suggestionText: "show version",
|
||||
suggestionSource: "history",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("selected popup row prefetches sub-dir panels after selectedIndex commits", () => {
|
||||
// Default-select (#2821) and ↑/↓ must prefetch via a post-commit effect.
|
||||
// Prefetching inside the startTransition refresh path races: fetchDirEntries
|
||||
// can resolve before selectedIndex flushes, and the result handler drops the
|
||||
// panels when prev.selectedIndex still mismatches.
|
||||
const source = readFileSync(
|
||||
new URL("./autocomplete/useTerminalAutocomplete.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/useEffect\(\(\) => \{\s*if \(!state\.popupVisible \|\| state\.selectedIndex < 0\) return;[\s\S]*if \(state\.subDirPanels\.length > 0 \|\| state\.subDirFocusLevel >= 0\) return;[\s\S]*fetchSubDirForIndex\(state\.selectedIndex\);/,
|
||||
);
|
||||
// Escape clears subDirPanels without changing selection; if cascade emptiness
|
||||
// is a dependency, the effect re-prefetches and the dismissal never sticks.
|
||||
const effectMatch = source.match(
|
||||
/useEffect\(\(\) => \{\s*if \(!state\.popupVisible \|\| state\.selectedIndex < 0\) return;[\s\S]*?\},\s*\[([\s\S]*?)\]\);/,
|
||||
);
|
||||
assert.ok(effectMatch, "expected sub-dir prefetch useEffect deps");
|
||||
const deps = effectMatch[1] ?? "";
|
||||
assert.match(deps, /state\.popupVisible/);
|
||||
assert.match(deps, /state\.selectedIndex/);
|
||||
assert.match(deps, /state\.suggestions/);
|
||||
assert.match(deps, /fetchSubDirForIndex/);
|
||||
assert.doesNotMatch(deps, /state\.subDirPanels/);
|
||||
assert.doesNotMatch(deps, /state\.subDirFocusLevel/);
|
||||
});
|
||||
|
||||
test("typing clear invalidates in-flight sub-dir fetches and restores default on baseline change", () => {
|
||||
const source = readFileSync(
|
||||
new URL("./autocomplete/useTerminalAutocomplete.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const clearPopupSelection = useCallback\(\(\) => \{[\s\S]*?subDirFetchVersionRef\.current\+\+;[\s\S]*?selectedIndex: -1,/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/shouldPreserveAutocompletePopupSelection\(\{\s*suggestionsUnchanged,\s*previousInputBaseline,\s*nextInputBaseline: input,/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const dismissSubDirCascade = useCallback\(\([\s\S]*?subDirFetchVersionRef\.current\+\+;/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/shouldApplySubDirPrefetchResult\(\{/,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldApplySubDirPrefetchResult rejects stale version, index, or suggestion identity", () => {
|
||||
assert.equal(
|
||||
shouldApplySubDirPrefetchResult({
|
||||
requestVersion: 3,
|
||||
currentVersion: 3,
|
||||
requestIndex: 0,
|
||||
selectedIndex: 0,
|
||||
requestSuggestionText: "cd docs/",
|
||||
selectedSuggestionText: "cd docs/",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldApplySubDirPrefetchResult({
|
||||
requestVersion: 3,
|
||||
currentVersion: 4,
|
||||
requestIndex: 0,
|
||||
selectedIndex: 0,
|
||||
requestSuggestionText: "cd docs/",
|
||||
selectedSuggestionText: "cd docs/",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldApplySubDirPrefetchResult({
|
||||
requestVersion: 3,
|
||||
currentVersion: 3,
|
||||
requestIndex: 0,
|
||||
selectedIndex: 1,
|
||||
requestSuggestionText: "cd docs/",
|
||||
selectedSuggestionText: "cd docs/",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldApplySubDirPrefetchResult({
|
||||
requestVersion: 3,
|
||||
currentVersion: 3,
|
||||
requestIndex: 0,
|
||||
selectedIndex: 0,
|
||||
requestSuggestionText: "cd docs/",
|
||||
selectedSuggestionText: "cd src/",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -165,7 +165,7 @@ export function resolveSelectionOverlayPosition(term: any, container: HTMLElemen
|
||||
}
|
||||
|
||||
export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, chainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, isComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef, onPluginRuntimeCwdChange, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRules, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, onSnippetShortkeyRef, snippetsRef, splitResizeActive, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState } = ctx;
|
||||
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, chainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, isComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef, onPluginRuntimeCwdChange, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRules, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, onSnippetShortkeyRef, snippetsRef, splitResizeActive, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState, vaultInitialized } = ctx;
|
||||
const effectiveTerminalProtocol = resolveEffectiveTerminalProtocol(host);
|
||||
const hibernateHiddenTabs = resolveTerminalHibernateEnabledForProtocol(
|
||||
terminalSettings,
|
||||
@@ -401,8 +401,20 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
// Restored sessions can mount while vault init has published hosts but not
|
||||
// yet decrypted keys. Wait for hydration (and re-run when it flips) so the
|
||||
// first SSH dial does not go out with an empty keychain.
|
||||
if (!attachExistingSession && !vaultInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let initialFitTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let ownedRuntime: { dispose: () => void } | null = null;
|
||||
// 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;
|
||||
@@ -421,6 +433,25 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
setIsDisconnectedDialogDismissed(false);
|
||||
promptLineBreakStateRef.current = createPromptLineBreakState();
|
||||
|
||||
const disposeOwnedRuntime = () => {
|
||||
const runtime = ownedRuntime;
|
||||
ownedRuntime = null;
|
||||
if (!runtime) return;
|
||||
if (xtermRuntimeRef.current === runtime) {
|
||||
xtermRuntimeRef.current = null;
|
||||
termRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
serializeAddonRef.current = null;
|
||||
searchAddonRef.current = null;
|
||||
hasRuntimeRef.current = false;
|
||||
}
|
||||
try {
|
||||
runtime.dispose();
|
||||
} catch (err) {
|
||||
logger.warn("Failed to dispose terminal runtime on unmount", err);
|
||||
}
|
||||
};
|
||||
|
||||
const boot = async () => {
|
||||
try {
|
||||
if (disposed || !containerRef.current) return;
|
||||
@@ -510,6 +541,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
ownedRuntime = runtime;
|
||||
xtermRuntimeRef.current = runtime;
|
||||
termRef.current = runtime.term;
|
||||
fitAddonRef.current = runtime.fitAddon;
|
||||
@@ -713,32 +745,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 +791,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) {
|
||||
@@ -770,14 +802,45 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
}
|
||||
};
|
||||
|
||||
boot();
|
||||
queueMicrotask(() => {
|
||||
if (disposed) return;
|
||||
void boot();
|
||||
});
|
||||
|
||||
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?.();
|
||||
disposeOwnedRuntime();
|
||||
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 (skip closeSession) but still sync-dispose xterm.
|
||||
if (!hasConnectedRef.current) {
|
||||
if (!attachExistingSession) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
disposeOwnedRuntime();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -801,15 +864,27 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
closeGeneration,
|
||||
terminalBootCloseGenerationRef.current,
|
||||
)) {
|
||||
// Remount already replaced the live refs; still drop this boot's
|
||||
// orphaned DOM node if dispose was deferred past the generation bump.
|
||||
disposeOwnedRuntime();
|
||||
return;
|
||||
}
|
||||
if (!flushed) {
|
||||
logger.warn("Terminal output did not drain before close capture; skipping stale capture");
|
||||
teardown();
|
||||
ownedRuntime = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isTerminalCloseGenerationCurrent(
|
||||
closeGeneration,
|
||||
terminalBootCloseGenerationRef.current,
|
||||
)) {
|
||||
disposeOwnedRuntime();
|
||||
return;
|
||||
}
|
||||
|
||||
const connectionLogPayload = !terminalDataCapturedRef.current
|
||||
? resolveConnectionLogCapturePayload(finalizeTerminalLogData)
|
||||
: null;
|
||||
@@ -819,6 +894,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
connectionLogPayload.source,
|
||||
connectionLogPayload.data.length,
|
||||
);
|
||||
ownedRuntime = null;
|
||||
scheduleTerminalCloseTeardown(teardown);
|
||||
return;
|
||||
}
|
||||
@@ -832,32 +908,44 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
closeGeneration,
|
||||
terminalBootCloseGenerationRef.current,
|
||||
)) {
|
||||
disposeOwnedRuntime();
|
||||
return;
|
||||
}
|
||||
if (payload) {
|
||||
persistCloseCapture(payload.data, payload.source, payload.data.length);
|
||||
}
|
||||
ownedRuntime = null;
|
||||
scheduleTerminalCloseTeardown(teardown);
|
||||
} catch (err) {
|
||||
if (!isTerminalCloseGenerationCurrent(
|
||||
closeGeneration,
|
||||
terminalBootCloseGenerationRef.current,
|
||||
)) {
|
||||
disposeOwnedRuntime();
|
||||
return;
|
||||
}
|
||||
logger.warn("Failed to serialize terminal data on unmount:", err);
|
||||
ownedRuntime = null;
|
||||
scheduleTerminalCloseTeardown(teardown);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ownedRuntime = null;
|
||||
teardown();
|
||||
};
|
||||
|
||||
void completeClose();
|
||||
};
|
||||
|
||||
}, [forceCloseHibernatedSession, handleTerminalDataCaptureOnce, host.id, sessionId]);
|
||||
}, [
|
||||
attachExistingSession,
|
||||
forceCloseHibernatedSession,
|
||||
handleTerminalDataCaptureOnce,
|
||||
host.id,
|
||||
sessionId,
|
||||
vaultInitialized,
|
||||
]);
|
||||
|
||||
|
||||
// Connection timeline and timeout visuals
|
||||
|
||||
@@ -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);
|
||||
@@ -350,6 +360,18 @@ function aiChatPanelsHostAreEqual(
|
||||
return true;
|
||||
}
|
||||
|
||||
const consumedTerminalSelectionRequestIds = new Set<string>();
|
||||
const CONSUMED_TERMINAL_SELECTION_REQUEST_ID_LIMIT = 64;
|
||||
|
||||
function markTerminalSelectionRequestConsumed(requestId: string): void {
|
||||
consumedTerminalSelectionRequestIds.add(requestId);
|
||||
if (consumedTerminalSelectionRequestIds.size <= CONSUMED_TERMINAL_SELECTION_REQUEST_ID_LIMIT) {
|
||||
return;
|
||||
}
|
||||
const oldest = consumedTerminalSelectionRequestIds.values().next().value;
|
||||
if (oldest !== undefined) consumedTerminalSelectionRequestIds.delete(oldest);
|
||||
}
|
||||
|
||||
const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
|
||||
mountedTabIds,
|
||||
activeTabId,
|
||||
@@ -366,30 +388,36 @@ 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;
|
||||
if (consumedTerminalSelectionRequestIds.has(pendingTerminalSelection.requestId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const context = contextsByTabId.get(pendingTerminalSelection.tabId);
|
||||
if (!context) return;
|
||||
|
||||
const attachment = createTerminalSelectionAttachment(pendingTerminalSelection.text);
|
||||
if (!attachment) {
|
||||
onPendingTerminalSelectionConsumed?.(pendingTerminalSelection.requestId);
|
||||
return;
|
||||
}
|
||||
markTerminalSelectionRequestConsumed(pendingTerminalSelection.requestId);
|
||||
onPendingTerminalSelectionConsumed?.(pendingTerminalSelection.requestId);
|
||||
if (!attachment) return;
|
||||
|
||||
const scopeKey = `${context.scopeType}:${context.scopeTargetId ?? ''}`;
|
||||
const isSessionView =
|
||||
@@ -402,7 +430,6 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
|
||||
...draft,
|
||||
attachments: [...draft.attachments, attachment],
|
||||
}));
|
||||
onPendingTerminalSelectionConsumed?.(pendingTerminalSelection.requestId);
|
||||
}, [
|
||||
activeSessionIdMap,
|
||||
contextsByTabId,
|
||||
@@ -432,44 +459,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}
|
||||
|
||||
@@ -406,12 +406,14 @@ function SidePanelNotesSlotInner({
|
||||
isVisible: boolean;
|
||||
}) {
|
||||
const openNoteRequest = (ctx.notesOpenNoteByTab as Map<string, { noteId: string; requestId: number }>).get(tabId) ?? null;
|
||||
// Gate subscription while Notes is hidden so vault edits (and the full-page
|
||||
// notebook) do not re-render this retained side-panel mount.
|
||||
const {
|
||||
notes,
|
||||
noteGroups,
|
||||
updateNotes,
|
||||
updateNoteGroups,
|
||||
} = useNotesStore();
|
||||
} = useNotesStore({ enabled: isVisible });
|
||||
|
||||
const {
|
||||
NotesManager,
|
||||
|
||||
+49
-18
@@ -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) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React from "react";
|
||||
import React, { useCallback } from "react";
|
||||
import { deleteVaultKey } from "../../application/defaultKeyPassphrases";
|
||||
import { usePluginImporterCommit } from "../../application/state/usePluginImporterCommit";
|
||||
import { preserveConcurrentHostLineTimestampUpdate } from "../../domain/host";
|
||||
@@ -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,95 @@ const VaultSectionLoading = () => (
|
||||
/>
|
||||
);
|
||||
|
||||
/**
|
||||
* Notes section subscribes to notesStore instead of taking notes as VaultView
|
||||
* props, so note edits never invalidate the App vault domain bag.
|
||||
*
|
||||
* While inactive (retained but hidden), freeze hosts and drop the store
|
||||
* subscription so Hosts/Keys churn and note publishes do not reconcile the
|
||||
* heavy NotesManager + MDXEditor tree.
|
||||
*/
|
||||
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({
|
||||
enabled: isActive,
|
||||
});
|
||||
return (
|
||||
<NotesManager
|
||||
notes={notes}
|
||||
noteGroups={noteGroups}
|
||||
hosts={hosts}
|
||||
onUpdateNotes={updateNotes}
|
||||
onUpdateNoteGroups={updateNoteGroups}
|
||||
isActive={isActive}
|
||||
openNoteId={openNoteId}
|
||||
onOpenNoteIdHandled={onOpenNoteIdHandled}
|
||||
onOpenHost={onOpenHost}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const MemoVaultNotesSection = React.memo(
|
||||
VaultNotesSection,
|
||||
(prev, next) => {
|
||||
if (
|
||||
prev.isActive !== next.isActive
|
||||
|| prev.openNoteId !== next.openNoteId
|
||||
|| prev.NotesManager !== next.NotesManager
|
||||
|| prev.onOpenNoteIdHandled !== next.onOpenNoteIdHandled
|
||||
|| prev.onOpenHost !== next.onOpenHost
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (next.isActive && prev.hosts !== next.hosts) return false;
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 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 +143,6 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
Clock,
|
||||
cn,
|
||||
commitInlineGroupRename,
|
||||
connectionLogs,
|
||||
connectSelectedHosts,
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@@ -148,16 +238,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 +367,11 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
} = ctx;
|
||||
const {
|
||||
knownHosts,
|
||||
noteGroups,
|
||||
NotebookText,
|
||||
notes,
|
||||
NotesManager,
|
||||
onOpenHostFromNote,
|
||||
onOpenNoteIdHandled,
|
||||
onOpenSnippetIdHandled,
|
||||
onUpdateNoteGroups,
|
||||
onUpdateNotes,
|
||||
openNoteId,
|
||||
openSnippetId,
|
||||
} = ctx;
|
||||
@@ -306,6 +389,13 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
source.groupName === path || source.groupName.startsWith(path + "/"),
|
||||
),
|
||||
);
|
||||
const handleNotesOpenHost = useCallback((host: any, source?: { noteId?: string }) => {
|
||||
if (source?.noteId && onOpenHostFromNote) {
|
||||
onOpenHostFromNote(host, source);
|
||||
return;
|
||||
}
|
||||
handleHostConnect(host);
|
||||
}, [handleHostConnect, onOpenHostFromNote]);
|
||||
const visibleTreeGroupPaths = React.useMemo(
|
||||
() => collectVisibleVaultGroupPaths(treeViewGroupTree),
|
||||
[treeViewGroupTree],
|
||||
@@ -1181,22 +1271,13 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
)}
|
||||
data-section="vault-notes-retained"
|
||||
>
|
||||
<NotesManager
|
||||
notes={notes}
|
||||
noteGroups={noteGroups}
|
||||
<MemoVaultNotesSection
|
||||
NotesManager={NotesManager}
|
||||
hosts={hosts}
|
||||
onUpdateNotes={onUpdateNotes}
|
||||
onUpdateNoteGroups={onUpdateNoteGroups}
|
||||
isActive={currentSection === "notes"}
|
||||
openNoteId={openNoteId ?? null}
|
||||
onOpenNoteIdHandled={onOpenNoteIdHandled}
|
||||
onOpenHost={(host: any, source: any) => {
|
||||
if (source?.noteId && onOpenHostFromNote) {
|
||||
onOpenHostFromNote(host, source);
|
||||
return;
|
||||
}
|
||||
handleHostConnect(host);
|
||||
}}
|
||||
onOpenHost={handleNotesOpenHost}
|
||||
/>
|
||||
</div>
|
||||
{currentSection === "keys" && (
|
||||
@@ -1311,12 +1392,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>
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
# StrictMode + 消灭巨型订阅者
|
||||
|
||||
## 已拍板的目标
|
||||
|
||||
- 整棵 React 树开 `StrictMode`(主窗 + settings/tray/terminal-popup 所有 `root.render`)。
|
||||
- Terminal boot:cleanup **立即 abort/close**,Cancel 对齐 Disconnect 的 epoch 失效;非 plugin 协议也吃 `AbortSignal`。
|
||||
- 你列的 8 条重渲命门 **全部修完**,且验收标准是:**没有任何高频状态再让 App 根组件 / AppView 五域整壳无谓重渲**。
|
||||
- 做完后循环 Bugbot → fix → 再 review,直到 clean;再跑遗漏清单,有漏继续清。
|
||||
|
||||
## 核心架构(延续 #2790,上推到根)
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph publishers [PublisherIslands_no_children_props]
|
||||
SettingsPub[SettingsPublisher]
|
||||
VaultPub[VaultPublisher]
|
||||
SessionPub[SessionPublisher]
|
||||
DialogsPub[DialogsPublisher]
|
||||
end
|
||||
|
||||
subgraph stores [ExternalStores_useSyncExternalStore]
|
||||
Notes[notesStore]
|
||||
Accent[appearanceChromeStore]
|
||||
ConnLogs[connectionLogsStore]
|
||||
SettingsChrome[settingsChromeStore]
|
||||
VaultSnap[vaultSnapshotStore]
|
||||
SessionSnap[sessionSnapshotStore]
|
||||
AISessions[aiSessionsStore]
|
||||
AIConfig[AIConfigContext_slow]
|
||||
end
|
||||
|
||||
subgraph shell [AppShell_memo_no_root_hooks]
|
||||
ChromeHost[ChromeHost]
|
||||
VaultHost[VaultHost]
|
||||
TerminalHost[TerminalHost]
|
||||
DialogsHost[DialogsHost]
|
||||
end
|
||||
|
||||
SettingsPub --> Accent
|
||||
SettingsPub --> SettingsChrome
|
||||
VaultPub --> Notes
|
||||
VaultPub --> ConnLogs
|
||||
VaultPub --> VaultSnap
|
||||
SessionPub --> SessionSnap
|
||||
SessionPub --> AISessions
|
||||
|
||||
ChromeHost --> Accent
|
||||
ChromeHost --> SettingsChrome
|
||||
VaultHost --> VaultSnap
|
||||
VaultHost --> Notes
|
||||
VaultHost --> ConnLogs
|
||||
TerminalHost --> SessionSnap
|
||||
TerminalHost --> AISessions
|
||||
TerminalHost --> AIConfig
|
||||
```
|
||||
|
||||
**关键不变量**
|
||||
|
||||
- Publisher 组件只跑现有 hooks(`useVaultState` / `useSessionState` / `useSettingsState` 等),通过 `useLayoutEffect`/`publish*` 写入 store,**不向子树传业务 props**。
|
||||
- 壳与岛屿只 `useSyncExternalStore`(或慢变 Context)按需订阅;**禁止**再在同一组件里 `useVaultState` + 渲染整棵 `AppView`。
|
||||
- Domain bag([`appViewDomains.ts`](application/app/appViewDomains.ts))要么删掉,要么缩成「岛屿内部的局部 props 包」——**不得**再由 App 根因 notes/accent/logs/settings/sessions 而重建。
|
||||
|
||||
复用现有模式:[`notesStore.ts`](application/state/notesStore.ts)、[`appearanceChromeStore.ts`](application/state/appearanceChromeStore.ts)。
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — 分支与设计落盘
|
||||
|
||||
- 从最新 `main` 开分支:`perf/strictmode-mega-subscriber-elimination`。
|
||||
- 写 spec:`docs/superpowers/specs/2026-08-07-strictmode-mega-subscriber-design.md`(验收清单 = 下文 Definition of Done)。
|
||||
- 写 plan 副本到 `docs/superpowers/plans/`(便于多 agent 对齐)。
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — StrictMode 硬化(可与 Phase 2 并行)
|
||||
|
||||
| 位点 | 改法 |
|
||||
|------|------|
|
||||
| [`index.tsx`](index.tsx) | 所有 `root.render` 外包 `<React.StrictMode>`(主窗 / settings / tray / terminal-popup) |
|
||||
| [`Terminal.tsx`](components/Terminal.tsx) `handleCancelConnect` | 对齐 `handleDisconnect`:`invalidateBootEpochForClose()` + `isBootActiveRef.current = false`,再 `cleanupSession` |
|
||||
| [`useTerminalEffects.ts`](components/terminal/useTerminalEffects.ts) | cleanup:**先** `AbortController.abort()` + `closeSession({ bootEpoch })`,再异步 capture/teardown;`boot()` 全协议传 `signal`(SSH/local/mosh/telnet/et/serial,plugin 已有) |
|
||||
| starters / bridge | 扩展 `AbortSignal` 到 dial 全程;与现有 `registerPendingBootAbort` / `abortPendingBoot` 对齐 |
|
||||
| [`App.tsx`](App.tsx) `pendingNewWindowSession` | payload identity latch:先 clear / consume,再 `createSessionFromCloneSource` |
|
||||
| External MCP startup | module single-flight `syncExternalMcpStartupStateOnce`;cleanup 不双飞 IPC |
|
||||
| [`useUpdateCheck`](application/app/) / [`useAppStartupEffects`](application/app/useAppStartupEffects.ts) | cleanup 重置错误 latch;update toast 按 version latch |
|
||||
| splash `rendererReady` | module once |
|
||||
| PF auto-start | 保留 ref latch,补 StrictMode 双 effect 测试;失败可清 latch 重试 |
|
||||
|
||||
**测试**:Cancel+epoch 源码/行为断言;boot cleanup 在 capture 前 close;MCP single-flight;pendingNewWindow 不双建;update toast 不双弹。
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — 高频状态迁出 domain(清 2/3/4/5)
|
||||
|
||||
并行可拆 3 个 agent:
|
||||
|
||||
1. **notes**:从 [`appVaultDomain`](App.tsx) / VaultView props 移除;Vault/NotesManager 只 `useNotesStore()`。更新 [`VaultView.memo.test.tsx`](components/VaultView.memo.test.tsx):notes churn 不再打 VaultView/AppView。
|
||||
2. **accent**:从 `appChromeDomain` / `AppHostTreeLayer` props 移除;[`AppView.tsx`](application/app/AppView.tsx) 的 `appThemeStyle` 改为叶子/局部订阅 `appearanceChromeStore`(Vault 容器、HostTree chrome 各自订)。accent drag → **零 AppView 五域重建**。
|
||||
3. **connectionLogs**:新建 `connectionLogsStore`(同 notesStore 形状);`useVaultState` publish;logs section 才订阅;从 vault domain deps 删除。日志追加 → **不打 AppView**。
|
||||
4. **settings 整袋**:新建 `settingsChromeStore`(或扩展 appearance store),只发布 TopTabs/HostTree/chrome 真正需要的字段;`appChromeDomain` **禁止**再塞整袋 `settings`。任意无关 settings 字段变化不重建 chrome 壳。
|
||||
|
||||
同步改:[`AppFollowTerminalTheme.test.ts`](application/app/AppFollowTerminalTheme.test.ts)、[`AppMounts.test.ts`](application/app/AppMounts.test.ts)、[`appViewDomains.test.ts`](application/app/appViewDomains.test.ts)。
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — 物理拆掉 App 巨型订阅者(清 1,彻底)
|
||||
|
||||
目标:[`App.tsx`](App.tsx) / 当前内层 `App` **不再**同时持有 vault+session+settings 的 React state 并渲染壳。
|
||||
|
||||
落点(新文件,建议):
|
||||
|
||||
- `application/app/publishers/SettingsPublisher.tsx`
|
||||
- `application/app/publishers/VaultPublisher.tsx`
|
||||
- `application/app/publishers/SessionPublisher.tsx`
|
||||
- `application/app/publishers/DialogsPublisher.tsx`(或并入壳内低频岛)
|
||||
- `application/state/vaultSnapshotStore.ts`(hosts/keys/snippets/… 慢变快变按字段 notify;至少支持 selector)
|
||||
- `application/state/sessionSnapshotStore.ts`(sessions/workspaces;presentation 已有 store 的继续复用)
|
||||
- `application/app/AppShell.tsx` — memo,无巨型 hook
|
||||
- `ChromeHost` / `VaultHost` / `TerminalHost` / `DialogsHost` — 各自订阅
|
||||
|
||||
迁移策略:先 Publisher 旁路 publish + 岛屿读 store,再删 App 根上的 domain `useMemo` 与 props 漏斗;保持 IPC/handler 注册不丢。
|
||||
|
||||
验收:**在 Profiler 或契约测试中**,改 notes / accent / connectionLogs / 无关 settings / 单 session 流式更新时,`AppShell` 与无关 Host **不** re-render。
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Context 拆分(清 6/7/8)
|
||||
|
||||
1. **AI**:`aiSessionsStore`(`useSyncExternalStore` + per-scope selector)+ 慢变 `AIConfigContext`(providers/permissions)。[`TerminalLayerSupport.tsx`](components/terminalLayer/TerminalLayerSupport.tsx) 的 `AIStateProvider` 不再整包 `sessions` 塞 Context。`AIChatPanelsHost` 只订本 scope。
|
||||
2. **SFTP**:拆 `SftpHostsContext` 与 `SftpPaneCallbacksContext`(left/right 可再拆);保留已有 `SftpDragContext` / `activeTabStore`。
|
||||
3. **Toast**:`ToastActionsContext`(稳定 `showToast`/`dismissToast`,`useMemo`)与 `ToastStateContext`(仅 Toaster 订 `toasts`)。全局 `toast.*` 不变。
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — 开 StrictMode 压测与契约测试门禁
|
||||
|
||||
- Phase 1 修完后正式合入 StrictMode(若分 PR,最后一 PR 打开;本任务按用户要求一次做完则同分支打开)。
|
||||
- 新增/加强:
|
||||
- domain/store memo 契约测试(notes/accent/logs/settings/AI scope)
|
||||
- architecture 测试:AppShell/App 源码禁止 `useVaultState`+`useSessionState`+`useSettingsState` 同组件渲染
|
||||
- StrictMode 幂等:pendingNewWindow、MCP startup、update check/toast、Cancel boot
|
||||
- 跑:`npm test` 相关子集 + 关键文件全量;`npm run lint`。
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Bugbot 死循环到 clean
|
||||
|
||||
按 [`review-bugbot`](~/.cursor/skills-cursor/review-bugbot/SKILL.md):
|
||||
|
||||
1. `Diff: branch changes` 跑 Bugbot
|
||||
2. 修全部有效 finding
|
||||
3. 再跑,直到 **无有效问题**
|
||||
4. 若 diff 算不出,改 `natural language` 重试一次
|
||||
|
||||
穿插:security-review 仅在用户另要时;本任务默认 Bugbot 循环。
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — 遗漏审计(你要求的「不可以有遗漏」)
|
||||
|
||||
对照清单逐项打勾,漏则继续改:
|
||||
|
||||
- [ ] App 根无巨型三 hook 同渲染
|
||||
- [ ] notes / accent / connectionLogs / 整袋 settings 不进跨壳 domain
|
||||
- [ ] AI 流式不导致 sibling panel / 无关 Host 重渲
|
||||
- [ ] Sftp hosts 变不拖 callbacks 消费者(反之亦然)
|
||||
- [ ] Toast 更新不重渲整 App 树订阅者
|
||||
- [ ] StrictMode 已开;Cancel/boot abort/双挂载幂等测过
|
||||
- [ ] 旧 memo 测试与 #2790 契约仍绿,并补「父壳也不炸」断言
|
||||
- [ ] 无「父已炸、子假装没事」残留(以壳是否订阅为准,不以子 areEqual 为准)
|
||||
|
||||
---
|
||||
|
||||
## 多 agent 并行切分
|
||||
|
||||
| Agent | 范围 |
|
||||
|-------|------|
|
||||
| A | StrictMode + Terminal abort/Cancel + App 幂等 effects |
|
||||
| B | notes + connectionLogs store 迁出 + Vault 订阅改造 |
|
||||
| C | accent + settingsChromeStore + AppView/HostTree 叶子订 |
|
||||
| D | App Publisher/Shell 物理拆分 + vault/session snapshot stores |
|
||||
| E | AI sessions store + Sftp Context 拆 + Toast 拆 |
|
||||
| 主控 | 合入冲突、契约测试、遗漏审计、Bugbot 循环 |
|
||||
|
||||
---
|
||||
|
||||
## 风险与约束
|
||||
|
||||
- Electron 长连接:StrictMode 双挂载依赖 Phase 1;未完成前不要只开 StrictMode。
|
||||
- 不引入新状态库依赖;沿用自研 `useSyncExternalStore` store。
|
||||
- 不借机大改产品行为;handler/IPC 语义保持。
|
||||
- ASCII 源码;中文仅注释/文案已有处。
|
||||
- 提交仅在你明确要求时再做。
|
||||
@@ -0,0 +1,80 @@
|
||||
# Design: StrictMode + eliminate App mega-subscribers
|
||||
|
||||
Date: 2026-08-07
|
||||
Status: Implemented on `cursor/cloud-agent-1786101120448-jga1a` (stacked on `perf/strictmode-mega-subscriber-elimination`)
|
||||
|
||||
## Summary
|
||||
|
||||
Open React `StrictMode` on every renderer root and remove the App-level
|
||||
“mega subscriber” that co-hosted vault + session + settings state while
|
||||
rendering the whole shell. High-churn fields (notes, accent, connection logs,
|
||||
AI sessions, toast, SFTP hosts) must update only the islands that read them.
|
||||
|
||||
## Goals
|
||||
|
||||
1. All `root.render` call sites wrap `<StrictMode>` (main, settings, tray,
|
||||
terminal-popup).
|
||||
2. Terminal boot/Cancel are StrictMode-safe: abort + epoch-scoped close before
|
||||
async capture; no first-connect race from double mount.
|
||||
3. `App.tsx` / `AppShell` never call `useVaultState` + `useSessionState` +
|
||||
`useSettingsState` in the same component that renders the shell.
|
||||
4. Dragging custom accent, appending connection logs, editing notes, or
|
||||
streaming AI tokens must not rebuild unrelated Host / AppView domains.
|
||||
|
||||
## Architecture
|
||||
|
||||
Publisher islands own the mega hooks and publish into external stores via
|
||||
`useSyncExternalStore`. Host islands subscribe to the slices they need and
|
||||
assemble domain bags field-by-field into `appShellPropsStore`. `AppShell` is
|
||||
memoized and only re-renders when published bags change by value equality.
|
||||
|
||||
| Publisher | Store(s) | Host |
|
||||
|-----------|----------|------|
|
||||
| SettingsPublisher | appearanceChromeStore, settingsChromeStore, terminalSettingsStore | ChromeHost (+ theme leaves) |
|
||||
| VaultPublisher | vaultSnapshotStore, notesStore, connectionLogsStore, shellHistoryStore | VaultHost |
|
||||
| SessionPublisher | sessionSnapshotStore, aiSessionsStore | TerminalHost |
|
||||
| (dialogs local UI) | appLocalUiStore | DialogsHost |
|
||||
|
||||
## StrictMode invariants
|
||||
|
||||
- Terminal boot defers past the synchronous remount (`queueMicrotask`) and
|
||||
scopes worker close by `bootEpoch`.
|
||||
- Never-connected cleanup sync-disposes the owned xterm runtime.
|
||||
- Vault async init cancels the superseded effect and only the survivor may call
|
||||
`setVaultInitialized(true)`.
|
||||
- Backend start waits for vault hydration so restored panes do not dial with an
|
||||
empty keychain.
|
||||
- One-shot preload subscriptions (terminal popup config) retain `lastPayload`
|
||||
across unsubscribe/resubscribe.
|
||||
- Startup latches (clone-session, PF auto-start, update toast, rendererReady,
|
||||
MCP sync) are idempotent under double invoke.
|
||||
|
||||
## Accent isolation
|
||||
|
||||
Published `currentTerminalTheme` is the **base** catalog theme. Live custom
|
||||
accent is applied at leaves (`appearanceChromeStore`) and in CSS-var injection,
|
||||
never baked into the terminal/chrome domain bag on every HSL tick.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- [x] App root has no three mega hooks co-rendering the shell
|
||||
- [x] notes / accent / connectionLogs / whole settings bag stay out of
|
||||
cross-shell domain deps
|
||||
- [x] AI streaming does not re-render sibling panels / unrelated Hosts
|
||||
- [x] SFTP hosts context is split from pane callbacks
|
||||
- [x] Toast state updates do not re-render action-only subscribers
|
||||
- [x] StrictMode enabled; Cancel/boot/double-mount contracts green
|
||||
- [x] Child memo tests plus parent-shell / Host bag assertions
|
||||
- [x] No “parent exploded, child pretends OK” (shell subscription is the bar)
|
||||
- [x] Bugbot loop clean on the delivery PR
|
||||
|
||||
## Non-goals
|
||||
|
||||
- New third-party state libraries
|
||||
- Product behavior changes beyond StrictMode-correctness and re-render isolation
|
||||
- Public API changes to Electron CLI / MCP surfaces
|
||||
|
||||
## Plan pointer
|
||||
|
||||
Implementation steps live in
|
||||
`docs/superpowers/plans/2026-08-07-strictmode-mega-subscriber.md`.
|
||||
@@ -51,7 +51,11 @@ const ACCELERATOR_COLLECT_INNER = [
|
||||
"fi; ",
|
||||
'if command -v npu-smi >/dev/null 2>&1; then ',
|
||||
'printf "%s\\n" "__NC_NPU_BEGIN__"; ',
|
||||
// Prefer info -l; fall back to info -m (SwanLab / Ascend mapping table).
|
||||
"ids=$(npu-smi info -l 2>/dev/null | sed -n 's/^[[:space:]]*NPU ID[[:space:]]*:[[:space:]]*\\([0-9][0-9]*\\).*/\\1/p'); ",
|
||||
'if [ -z "$ids" ]; then ',
|
||||
"ids=$(npu-smi info -m 2>/dev/null | awk 'NR>1 && $1 ~ /^[0-9]+$/ {print $1}' | sort -nu); ",
|
||||
"fi; ",
|
||||
'if [ -n "$ids" ]; then ',
|
||||
'for id in $ids; do ',
|
||||
'printf "%s\\n" "__NC_NPU_DEVICE__=$id"; ',
|
||||
@@ -60,10 +64,11 @@ const ACCELERATOR_COLLECT_INNER = [
|
||||
'npu-smi info -t usages -i "$id" 2>/dev/null || true; ',
|
||||
'npu-smi info -t memory -i "$id" 2>/dev/null || true; ',
|
||||
"done; ",
|
||||
"else ",
|
||||
"fi; ",
|
||||
// Always keep the summary table; typed queries can be empty on ModelArts /
|
||||
// containers, and Windows collectors only emit this dump.
|
||||
'printf "%s\\n" "__NC_NPU_INFO__"; ',
|
||||
"npu-smi info 2>/dev/null || true; ",
|
||||
"fi; ",
|
||||
'printf "%s\\n" "__NC_NPU_PROCS__"; ',
|
||||
"npu-smi info -t proc-mem 2>/dev/null || true; ",
|
||||
'printf "%s\\n" "__NC_NPU_END__"; ',
|
||||
@@ -75,11 +80,51 @@ const ACCELERATOR_COLLECT_SCRIPT = `exec sh -c ${JSON.stringify(ACCELERATOR_COLL
|
||||
|
||||
function parseCsvNumber(raw) {
|
||||
const text = String(raw ?? "").trim();
|
||||
if (!text || /^\[?n\/?a\]?$/i.test(text) || /^not\s+supported$/i.test(text)) return null;
|
||||
if (
|
||||
!text
|
||||
|| text === "-"
|
||||
|| /^\[?n\/?a\]?$/i.test(text)
|
||||
|| /^not\s+supported$/i.test(text)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const n = Number.parseFloat(text.replace(/[,%]/g, ""));
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function extractAscendDriverVersion(text) {
|
||||
const match = String(text || "").match(
|
||||
/\|\s*npu-smi\s+(\S+)\s+.*?Version:\s*(\S+)/i,
|
||||
);
|
||||
if (!match) return null;
|
||||
const version = (match[2] || match[1] || "").replace(/\|/g, "").trim();
|
||||
return version || null;
|
||||
}
|
||||
|
||||
function applyMemPair(device, used, total, { aggregate = false } = {}) {
|
||||
const usedN = parseCsvNumber(used);
|
||||
const totalN = parseCsvNumber(total);
|
||||
if (!Number.isFinite(usedN) && !Number.isFinite(totalN)) return;
|
||||
if (
|
||||
aggregate
|
||||
&& Number.isFinite(device.memoryUsedMb)
|
||||
&& Number.isFinite(usedN)
|
||||
) {
|
||||
device.memoryUsedMb = Number(device.memoryUsedMb) + usedN;
|
||||
device.memoryTotalMb = Number(device.memoryTotalMb || 0)
|
||||
+ (Number.isFinite(totalN) ? totalN : 0);
|
||||
return;
|
||||
}
|
||||
if (Number.isFinite(usedN)) device.memoryUsedMb = usedN;
|
||||
if (Number.isFinite(totalN)) device.memoryTotalMb = totalN;
|
||||
}
|
||||
|
||||
function maxFinite(current, next) {
|
||||
if (!Number.isFinite(next)) return current;
|
||||
if (!Number.isFinite(current)) return next;
|
||||
return Math.max(current, next);
|
||||
}
|
||||
|
||||
function parseCsvFields(line) {
|
||||
// nvidia-smi csv is simple (no embedded commas in queried fields with nounits)
|
||||
return String(line || "")
|
||||
@@ -243,48 +288,137 @@ function parseAscendDeviceBlock(index, block) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `npu-smi info` summary tables.
|
||||
*
|
||||
* Modern CANN (24.x+) rows look like nputop's fixture:
|
||||
* | 6 910B1 | OK | 100.8 33 0 / 0 |
|
||||
* | 0 | 0000:01:00.0 | 0 0 / 0 3384 / 65536 |
|
||||
* Chip-ID on the second row is NOT the NPU ID; attach metrics to the
|
||||
* preceding NPU summary row (same approach as youyve/nputop libascend).
|
||||
*
|
||||
* Multi-chip cards (Atlas A3 / 310P) repeat the NPU summary once per chip.
|
||||
* Sidebar shows one row per NPU ID and aggregates chip memory / util.
|
||||
*/
|
||||
function parseAscendInfoTable(sectionText) {
|
||||
const devices = [];
|
||||
const lines = String(sectionText || "").split("\n");
|
||||
for (const line of lines) {
|
||||
// | 0 910B3 | OK | 71.8 42 |
|
||||
const match = line.match(
|
||||
/^\|\s*(\d+)\s+(\S+(?:\s+\S+)*?)\s+\|\s*(\S+)\s+\|\s*([0-9.]+)\s+([0-9.]+)\s*\|/,
|
||||
);
|
||||
if (!match) continue;
|
||||
const index = Number.parseInt(match[1], 10);
|
||||
if (!Number.isFinite(index)) continue;
|
||||
if (devices.some((d) => d.index === index)) continue;
|
||||
devices.push({
|
||||
vendor: "ascend",
|
||||
index,
|
||||
uuid: "",
|
||||
name: match[2].trim(),
|
||||
utilizationPercent: null,
|
||||
memoryUsedMb: null,
|
||||
memoryTotalMb: null,
|
||||
temperatureC: parseCsvNumber(match[5]),
|
||||
powerDrawW: parseCsvNumber(match[4]),
|
||||
powerLimitW: null,
|
||||
fanPercent: null,
|
||||
driverVersion: null,
|
||||
health: match[3].trim(),
|
||||
});
|
||||
const driverVersion = extractAscendDriverVersion(sectionText);
|
||||
|
||||
// Summary: NPU ID, Name, Health, Power, Temp; tolerate Hugepages column after Temp.
|
||||
// Power may be "NA" / "-"; Name is a single token like 910B1 / Ascend910.
|
||||
const summaryRe =
|
||||
/^\|\s*(\d+)\s+(\S+)\s+\|\s*(\S+)\s+\|\s*(\S+)\s+(\d+(?:\.\d+)?)\b/;
|
||||
// Bus-Id chip row (CANN 24.x): | ChipID [PhyID] | Bus-Id | AICore(%) ... mem pairs ... |
|
||||
const busChipRe =
|
||||
/^\|\s*(\d+)\s*(\d*)\s*\|\s*([0-9A-Fa-f:.]+|NA)\s*\|\s*(\d+(?:\.\d+)?)\b/;
|
||||
// Legacy whitespace chip row: | NPU Chip Logic AICore Mem/Tot HBM/Tot |
|
||||
const legacyChipRe =
|
||||
/^\|\s*(\d+)\s+\d+\s+\d+\s+(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)/;
|
||||
|
||||
let lastDevice = null;
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i].trim();
|
||||
if (!line.startsWith("|")) continue;
|
||||
|
||||
const summary = line.match(summaryRe);
|
||||
if (summary) {
|
||||
const index = Number.parseInt(summary[1], 10);
|
||||
if (!Number.isFinite(index)) continue;
|
||||
const name = summary[2].trim();
|
||||
if (!name || /^(?:NPU|Chip|Name)$/i.test(name)) continue;
|
||||
let device = devices.find((d) => d.index === index);
|
||||
const isNew = !device;
|
||||
if (!device) {
|
||||
device = {
|
||||
vendor: "ascend",
|
||||
index,
|
||||
uuid: "",
|
||||
name,
|
||||
utilizationPercent: null,
|
||||
memoryUsedMb: null,
|
||||
memoryTotalMb: null,
|
||||
temperatureC: parseCsvNumber(summary[5]),
|
||||
powerDrawW: parseCsvNumber(summary[4]),
|
||||
powerLimitW: null,
|
||||
fanPercent: null,
|
||||
driverVersion,
|
||||
health: summary[3].trim(),
|
||||
_chipCount: 0,
|
||||
};
|
||||
devices.push(device);
|
||||
} else {
|
||||
if (!device.name) device.name = name;
|
||||
device.temperatureC = maxFinite(device.temperatureC, parseCsvNumber(summary[5]));
|
||||
if (device.powerDrawW == null) device.powerDrawW = parseCsvNumber(summary[4]);
|
||||
if (!device.health || /^ok$/i.test(device.health)) {
|
||||
const health = summary[3].trim();
|
||||
if (health) device.health = health;
|
||||
}
|
||||
if (!device.driverVersion && driverVersion) device.driverVersion = driverVersion;
|
||||
}
|
||||
lastDevice = device;
|
||||
|
||||
const next = (lines[i + 1] || "").trim();
|
||||
const busChip = next.match(busChipRe);
|
||||
if (busChip) {
|
||||
i += 1;
|
||||
const util = parseCsvNumber(busChip[4]);
|
||||
device.utilizationPercent = maxFinite(device.utilizationPercent, util);
|
||||
const pairs = [...next.matchAll(/(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)/g)];
|
||||
if (pairs.length > 0) {
|
||||
const hbm = pairs[pairs.length - 1];
|
||||
applyMemPair(device, hbm[1], hbm[2], { aggregate: !isNew && device._chipCount > 0 });
|
||||
}
|
||||
device._chipCount = (device._chipCount || 0) + 1;
|
||||
continue;
|
||||
}
|
||||
const legacyNext = next.match(legacyChipRe);
|
||||
if (legacyNext) {
|
||||
i += 1;
|
||||
device.utilizationPercent = maxFinite(
|
||||
device.utilizationPercent,
|
||||
parseCsvNumber(legacyNext[2]),
|
||||
);
|
||||
applyMemPair(device, legacyNext[5], legacyNext[6], {
|
||||
aggregate: !isNew && device._chipCount > 0,
|
||||
});
|
||||
device._chipCount = (device._chipCount || 0) + 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const legacy = line.match(legacyChipRe);
|
||||
if (legacy) {
|
||||
const index = Number.parseInt(legacy[1], 10);
|
||||
const device = devices.find((d) => d.index === index) || lastDevice;
|
||||
if (!device) continue;
|
||||
device.utilizationPercent = maxFinite(
|
||||
device.utilizationPercent,
|
||||
parseCsvNumber(legacy[2]),
|
||||
);
|
||||
applyMemPair(device, legacy[5], legacy[6], { aggregate: (device._chipCount || 0) > 0 });
|
||||
device._chipCount = (device._chipCount || 0) + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const busChip = line.match(busChipRe);
|
||||
if (busChip && lastDevice) {
|
||||
const util = parseCsvNumber(busChip[4]);
|
||||
lastDevice.utilizationPercent = maxFinite(lastDevice.utilizationPercent, util);
|
||||
const pairs = [...line.matchAll(/(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)/g)];
|
||||
if (pairs.length > 0) {
|
||||
const hbm = pairs[pairs.length - 1];
|
||||
applyMemPair(lastDevice, hbm[1], hbm[2], {
|
||||
aggregate: (lastDevice._chipCount || 0) > 0,
|
||||
});
|
||||
}
|
||||
lastDevice._chipCount = (lastDevice._chipCount || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Chip rows: | 0 0 0 12 1234 / 32768 0 / 32768 |
|
||||
for (const line of lines) {
|
||||
const chip = line.match(
|
||||
/^\|\s*(\d+)\s+\d+\s+\d+\s+(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)/,
|
||||
);
|
||||
if (!chip) continue;
|
||||
const index = Number.parseInt(chip[1], 10);
|
||||
const device = devices.find((d) => d.index === index);
|
||||
if (!device) continue;
|
||||
device.utilizationPercent = parseCsvNumber(chip[2]);
|
||||
// Prefer HBM pair (last) when present
|
||||
device.memoryUsedMb = parseCsvNumber(chip[5]);
|
||||
device.memoryTotalMb = parseCsvNumber(chip[6]);
|
||||
for (const device of devices) {
|
||||
delete device._chipCount;
|
||||
}
|
||||
return devices;
|
||||
}
|
||||
@@ -308,8 +442,11 @@ function parseAscendTypedSections(sectionText) {
|
||||
function parseAscendProcesses(sectionText) {
|
||||
const processes = [];
|
||||
for (const line of String(sectionText || "").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || /no running processes/i.test(trimmed)) continue;
|
||||
|
||||
// Common proc-mem lines include NPU/Chip/Pid/Name/Memory
|
||||
const match = line.match(
|
||||
const match = trimmed.match(
|
||||
/(?:NPU|Device)?\s*I?D?\s*[:=]?\s*(\d+).*?\b(?:PID|Pid)\s*[:=]?\s*(\d+).*?\b(?:Name|Process)\s*[:=]?\s*(\S+).*?(?:Memory|Mem)\s*[:=]?\s*(\d+(?:\.\d+)?)/i,
|
||||
);
|
||||
if (match) {
|
||||
@@ -323,8 +460,24 @@ function parseAscendProcesses(sectionText) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// nputop / npu-smi info process table:
|
||||
// | 0 0 | 124528 | python3.8 | 17400 |
|
||||
const infoProc = trimmed.match(
|
||||
/^\|\s*(\d+)\s+(\d+)\s+\|\s+(\d+)\s+\|\s*([^|]+?)\s*\|\s*(\d+(?:\.\d+)?)/,
|
||||
);
|
||||
if (infoProc) {
|
||||
processes.push({
|
||||
vendor: "ascend",
|
||||
gpuIndex: Number.parseInt(infoProc[1], 10) || 0,
|
||||
pid: Number.parseInt(infoProc[3], 10),
|
||||
processName: infoProc[4].trim(),
|
||||
memoryUsedMb: parseCsvNumber(infoProc[5]),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Pipe-separated: | 0 | 0 | 12345 | python | 1024 |
|
||||
const pipeTable = line.match(
|
||||
const pipeTable = trimmed.match(
|
||||
/^\|\s*(\d+)\s*\|\s*\d+\s*\|\s*(\d+)\s*\|\s*([^|]+?)\s*\|\s*(\d+(?:\.\d+)?)/,
|
||||
);
|
||||
if (pipeTable) {
|
||||
@@ -339,7 +492,7 @@ function parseAscendProcesses(sectionText) {
|
||||
}
|
||||
|
||||
// Whitespace-delimited inside one outer |: | 0 0 12345 python 1024 |
|
||||
const wsTable = line.match(
|
||||
const wsTable = trimmed.match(
|
||||
/^\|\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\d+(?:\.\d+)?)\s*\|?\s*$/,
|
||||
);
|
||||
if (!wsTable) continue;
|
||||
@@ -354,6 +507,18 @@ function parseAscendProcesses(sectionText) {
|
||||
return processes.filter((p) => Number.isFinite(p.pid) && p.pid > 0);
|
||||
}
|
||||
|
||||
function dedupeAcceleratorProcesses(processes) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const processInfo of processes) {
|
||||
const key = `${processInfo.vendor}:${processInfo.gpuIndex}:${processInfo.pid}:${processInfo.processName}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(processInfo);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function sliceMarkedSection(text, beginMarker, endMarkers) {
|
||||
const start = text.indexOf(beginMarker);
|
||||
if (start === -1) return "";
|
||||
@@ -383,17 +548,55 @@ function parseAcceleratorSnapshot(stdout) {
|
||||
const nvidiaProcesses = parseNvidiaProcesses(nvidiaProcessesText, nvidiaDevices);
|
||||
|
||||
let ascendDevices = parseAscendTypedSections(npuSection);
|
||||
const infoDump = sliceMarkedSection(npuSection, "__NC_NPU_INFO__", [
|
||||
"__NC_NPU_PROCS__",
|
||||
"__NC_NPU_END__",
|
||||
]);
|
||||
const tableDevices = parseAscendInfoTable(infoDump || npuSection);
|
||||
if (ascendDevices.length === 0) {
|
||||
const infoDump = sliceMarkedSection(npuSection, "__NC_NPU_INFO__", [
|
||||
"__NC_NPU_PROCS__",
|
||||
"__NC_NPU_END__",
|
||||
]);
|
||||
ascendDevices = parseAscendInfoTable(infoDump || npuSection);
|
||||
ascendDevices = tableDevices;
|
||||
} else if (tableDevices.length > 0) {
|
||||
// Fill gaps when typed -t queries returned stubs but the summary table is rich.
|
||||
const byIndex = new Map(ascendDevices.map((d) => [d.index, d]));
|
||||
for (const tableDevice of tableDevices) {
|
||||
const existing = byIndex.get(tableDevice.index);
|
||||
if (!existing) {
|
||||
ascendDevices.push(tableDevice);
|
||||
byIndex.set(tableDevice.index, tableDevice);
|
||||
continue;
|
||||
}
|
||||
if (!existing.name || /^Ascend NPU\b/i.test(existing.name)) {
|
||||
existing.name = tableDevice.name;
|
||||
}
|
||||
for (const key of [
|
||||
"utilizationPercent",
|
||||
"memoryUsedMb",
|
||||
"memoryTotalMb",
|
||||
"temperatureC",
|
||||
"powerDrawW",
|
||||
"health",
|
||||
]) {
|
||||
if (existing[key] == null && tableDevice[key] != null) {
|
||||
existing[key] = tableDevice[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const ascendProcText = sliceMarkedSection(npuSection, "__NC_NPU_PROCS__", [
|
||||
"__NC_NPU_END__",
|
||||
]);
|
||||
const ascendProcesses = parseAscendProcesses(ascendProcText);
|
||||
// Processes often live in the `npu-smi info` dump (nputop fixtures), not only -t proc-mem.
|
||||
const ascendProcesses = dedupeAcceleratorProcesses([
|
||||
...parseAscendProcesses(ascendProcText),
|
||||
...parseAscendProcesses(infoDump || npuSection),
|
||||
]);
|
||||
|
||||
const ascendDriverVersion = extractAscendDriverVersion(infoDump || npuSection);
|
||||
if (ascendDriverVersion) {
|
||||
for (const device of ascendDevices) {
|
||||
if (!device.driverVersion) device.driverVersion = ascendDriverVersion;
|
||||
}
|
||||
}
|
||||
|
||||
const devices = [...nvidiaDevices, ...ascendDevices].sort((a, b) => {
|
||||
if (a.vendor !== b.vendor) return a.vendor.localeCompare(b.vendor);
|
||||
|
||||
@@ -97,6 +97,10 @@ test("POSIX accelerator collector keeps sed quotes inside JSON-wrapped sh -c", (
|
||||
assert.match(ACCELERATOR_COLLECT_SCRIPT, /sed -n '/);
|
||||
// Outer wrapper must not use raw single quotes around the whole script body.
|
||||
assert.doesNotMatch(ACCELERATOR_COLLECT_SCRIPT, /^exec sh -c '/);
|
||||
// Always dump npu-smi info; discover IDs via info -l then info -m.
|
||||
assert.match(ACCELERATOR_COLLECT_SCRIPT, /__NC_NPU_INFO__/);
|
||||
assert.match(ACCELERATOR_COLLECT_SCRIPT, /npu-smi info -m/);
|
||||
assert.match(ACCELERATOR_COLLECT_SCRIPT, /npu-smi info 2>\/dev\/null/);
|
||||
});
|
||||
|
||||
test("POSIX accelerator collector is syntactically valid for sh -c", () => {
|
||||
@@ -152,6 +156,52 @@ test("parseAscendInfoTable reads summary and chip rows", () => {
|
||||
assert.equal(devices[0].temperatureC, 42);
|
||||
});
|
||||
|
||||
test("parseAscendInfoTable reads CANN 24.x Bus-Id table with Hugepages column", () => {
|
||||
// Fixture from GitHub issue #2811 (ModelArts / Ascend 910B1, npu-smi 24.1.rc2).
|
||||
// Chip row Chip-ID is 0 while NPU ID is 6; metrics must attach to the NPU row.
|
||||
const devices = parseAscendInfoTable(`
|
||||
+------------------------------------------------------------------------------------------------+
|
||||
| npu-smi 24.1.rc2 Version: 24.1.rc2 |
|
||||
+---------------------------+---------------+----------------------------------------------------+
|
||||
| NPU Name | Health | Power(W) Temp(C) Hugepages-Usage(page)|
|
||||
| Chip | Bus-Id | AICore(%) Memory-Usage(MB) HBM-Usage(MB) |
|
||||
+===========================+===============+====================================================+
|
||||
| 6 910B1 | OK | 100.8 33 0 / 0 |
|
||||
| 0 | 0000:01:00.0 | 0 0 / 0 3384 / 65536 |
|
||||
+===========================+===============+====================================================+
|
||||
| No running processes found in NPU 6 |
|
||||
+===========================+===============+====================================================+
|
||||
`);
|
||||
assert.equal(devices.length, 1);
|
||||
assert.equal(devices[0].index, 6);
|
||||
assert.equal(devices[0].name, "910B1");
|
||||
assert.equal(devices[0].health, "OK");
|
||||
assert.equal(devices[0].powerDrawW, 100.8);
|
||||
assert.equal(devices[0].temperatureC, 33);
|
||||
assert.equal(devices[0].utilizationPercent, 0);
|
||||
assert.equal(devices[0].memoryUsedMb, 3384);
|
||||
assert.equal(devices[0].memoryTotalMb, 65536);
|
||||
});
|
||||
|
||||
test("parseAcceleratorSnapshot falls back to modern npu-smi info table", () => {
|
||||
const snapshot = parseAcceleratorSnapshot(`
|
||||
__NC_ACCEL_BEGIN__
|
||||
__NC_NPU_BEGIN__
|
||||
__NC_NPU_INFO__
|
||||
| NPU Name | Health | Power(W) Temp(C) Hugepages-Usage(page)|
|
||||
| Chip | Bus-Id | AICore(%) Memory-Usage(MB) HBM-Usage(MB) |
|
||||
| 6 910B1 | OK | 100.8 33 0 / 0 |
|
||||
| 0 | 0000:01:00.0 | 0 0 / 0 3384 / 65536 |
|
||||
__NC_NPU_PROCS__
|
||||
__NC_NPU_END__
|
||||
__NC_ACCEL_END__
|
||||
`);
|
||||
assert.equal(snapshot.devices.length, 1);
|
||||
assert.equal(snapshot.devices[0].vendor, "ascend");
|
||||
assert.equal(snapshot.devices[0].index, 6);
|
||||
assert.equal(snapshot.devices[0].memoryTotalMb, 65536);
|
||||
});
|
||||
|
||||
test("parseAcceleratorSnapshot merges nvidia and ascend marked sections", () => {
|
||||
const snapshot = parseAcceleratorSnapshot(`
|
||||
__NC_ACCEL_BEGIN__
|
||||
@@ -177,3 +227,130 @@ __NC_ACCEL_END__
|
||||
assert.equal(snapshot.processes.length, 1);
|
||||
assert.equal(snapshot.nvidiaDriverVersion, "550.54");
|
||||
});
|
||||
|
||||
test("parseAcceleratorSnapshot enriches typed Ascend stubs from info table", () => {
|
||||
const snapshot = parseAcceleratorSnapshot(`
|
||||
__NC_ACCEL_BEGIN__
|
||||
__NC_NPU_BEGIN__
|
||||
__NC_NPU_DEVICE__=6
|
||||
__NC_NPU_INFO__
|
||||
| NPU Name | Health | Power(W) Temp(C) Hugepages-Usage(page)|
|
||||
| Chip | Bus-Id | AICore(%) Memory-Usage(MB) HBM-Usage(MB) |
|
||||
| 6 910B1 | OK | 100.8 33 0 / 0 |
|
||||
| 0 | 0000:01:00.0 | 0 0 / 0 3384 / 65536 |
|
||||
__NC_NPU_PROCS__
|
||||
__NC_NPU_END__
|
||||
__NC_ACCEL_END__
|
||||
`);
|
||||
assert.equal(snapshot.devices.length, 1);
|
||||
assert.equal(snapshot.devices[0].index, 6);
|
||||
assert.equal(snapshot.devices[0].name, "910B1");
|
||||
assert.equal(snapshot.devices[0].memoryUsedMb, 3384);
|
||||
assert.equal(snapshot.devices[0].memoryTotalMb, 65536);
|
||||
assert.equal(snapshot.devices[0].temperatureC, 33);
|
||||
assert.equal(snapshot.devices[0].powerDrawW, 100.8);
|
||||
});
|
||||
|
||||
// Fixtures adapted from youyve/nputop tests/test_libascend.py (Apache-2.0).
|
||||
// Valuable because we do not have Ascend hardware in CI.
|
||||
|
||||
test("parseAscendInfoTable reads nputop 910B2C dual-NPU fixture", () => {
|
||||
const devices = parseAscendInfoTable(`
|
||||
+------------------------------------------------------------------------------------------------+
|
||||
| npu-smi 23.0.2.1 Version: 23.0.2.1 |
|
||||
+---------------------------+---------------+----------------------------------------------------+
|
||||
| NPU Name | Health | Power(W) Temp(C) Hugepages-Usage(page)|
|
||||
| Chip | Bus-Id | AICore(%) Memory-Usage(MB) HBM-Usage(MB) |
|
||||
+===========================+===============+====================================================+
|
||||
| 0 910B2C | OK | 88.6 51 0 / 0 |
|
||||
| 0 | 0000:5A:00.0 | 0 0 / 0 20701/ 65536 |
|
||||
+===========================+===============+====================================================+
|
||||
| 1 910B2C | OK | 99.6 50 0 / 0 |
|
||||
| 0 | 0000:19:00.0 | 0 0 / 0 20687/ 65536 |
|
||||
+===========================+===============+====================================================+
|
||||
`);
|
||||
assert.equal(devices.length, 2);
|
||||
assert.equal(devices[0].name, "910B2C");
|
||||
assert.equal(devices[0].memoryUsedMb, 20701);
|
||||
assert.equal(devices[0].memoryTotalMb, 65536);
|
||||
assert.equal(devices[0].powerDrawW, 88.6);
|
||||
assert.equal(devices[0].driverVersion, "23.0.2.1");
|
||||
assert.equal(devices[1].memoryUsedMb, 20687);
|
||||
});
|
||||
|
||||
test("parseAscendInfoTable reads nputop 310B4 no-HBM column fixture", () => {
|
||||
const devices = parseAscendInfoTable(`
|
||||
| npu-smi 23.0.0 Version: 23.0.0 |
|
||||
| NPU Name | Health | Power(W) Temp(C) Hugepages-Usage(page) |
|
||||
| Chip Device | Bus-Id | AICore(%) Memory-Usage(MB) |
|
||||
| 0 310B4 | Alarm | 0.0 65 15 / 15 |
|
||||
| 0 0 | NA | 0 3628 / 15609 |
|
||||
`);
|
||||
assert.equal(devices.length, 1);
|
||||
assert.equal(devices[0].name, "310B4");
|
||||
assert.equal(devices[0].health, "Alarm");
|
||||
assert.equal(devices[0].memoryUsedMb, 3628);
|
||||
assert.equal(devices[0].memoryTotalMb, 15609);
|
||||
assert.equal(devices[0].temperatureC, 65);
|
||||
});
|
||||
|
||||
test("parseAscendInfoTable aggregates Atlas A3 multi-chip NPU rows", () => {
|
||||
const devices = parseAscendInfoTable(`
|
||||
| npu-smi 25.2.0 Version: 25.2.0 |
|
||||
| NPU Name | Health | Power(W) Temp(C) Hugepages-Usage(page)|
|
||||
| Chip Phy-ID | Bus-Id | AICore(%) Memory-Usage(MB) HBM-Usage(MB) |
|
||||
| 0 Ascend910 | OK | 162.8 37 0 / 0 |
|
||||
| 0 0 | 0000:9C:00.0 | 0 0 / 0 3133 / 65536 |
|
||||
| 0 Ascend910 | OK | - 37 0 / 0 |
|
||||
| 1 1 | 0000:9E:00.0 | 0 0 / 0 2876 / 65536 |
|
||||
| 1 Ascend910 | OK | 167.1 38 0 / 0 |
|
||||
| 0 2 | 0000:37:00.0 | 0 0 / 0 3116 / 65536 |
|
||||
| 1 Ascend910 | OK | - 38 0 / 0 |
|
||||
| 1 3 | 0000:39:00.0 | 0 0 / 0 10568/ 65536 |
|
||||
`);
|
||||
assert.equal(devices.length, 2);
|
||||
assert.equal(devices[0].name, "Ascend910");
|
||||
assert.equal(devices[0].powerDrawW, 162.8);
|
||||
assert.equal(devices[0].memoryUsedMb, 3133 + 2876);
|
||||
assert.equal(devices[0].memoryTotalMb, 65536 + 65536);
|
||||
assert.equal(devices[1].memoryUsedMb, 3116 + 10568);
|
||||
assert.equal(devices[1].powerDrawW, 167.1);
|
||||
});
|
||||
|
||||
test("parseAcceleratorSnapshot reads nputop 310P3 processes from info dump", () => {
|
||||
const snapshot = parseAcceleratorSnapshot(`
|
||||
__NC_ACCEL_BEGIN__
|
||||
__NC_NPU_BEGIN__
|
||||
__NC_NPU_INFO__
|
||||
| npu-smi 24.1.0.1 Version: 24.1.0.1 |
|
||||
| NPU Name | Health | Power(W) Temp(C) Hugepages-Usage(page) |
|
||||
| Chip Device | Bus-Id | AICore(%) Memory-Usage(MB) |
|
||||
| 1 310P3 | OK | NA 62 7210 / 7210 |
|
||||
| 0 0 | 0000:01:00.0 | 0 16302/ 44280 |
|
||||
| 1 310P3 | OK | NA 62 7210 / 7210 |
|
||||
| 1 1 | 0000:01:00.0 | 0 15543/ 43693 |
|
||||
| 2 310P3 | OK | NA 61 17057 / 17057 |
|
||||
| 0 2 | 0000:02:00.0 | 0 35563/ 44280 |
|
||||
| 2 310P3 | OK | NA 61 16823 / 16823 |
|
||||
| 1 3 | 0000:02:00.0 | 0 35204/ 43693 |
|
||||
| NPU Chip | Process id | Process name | Process memory(MB) |
|
||||
| 1 0 | 3277562 | mindie_llm_back | 14513 |
|
||||
| 1 1 | 3277565 | mindie_llm_back | 14513 |
|
||||
| 2 0 | 3034986 | mindie_llm_back | 34207 |
|
||||
| 2 1 | 3034989 | mindie_llm_back | 33740 |
|
||||
__NC_NPU_PROCS__
|
||||
__NC_NPU_END__
|
||||
__NC_ACCEL_END__
|
||||
`);
|
||||
assert.equal(snapshot.devices.length, 2);
|
||||
assert.equal(snapshot.devices[0].index, 1);
|
||||
assert.equal(snapshot.devices[0].name, "310P3");
|
||||
assert.equal(snapshot.devices[0].memoryUsedMb, 16302 + 15543);
|
||||
assert.equal(snapshot.devices[0].powerDrawW, null);
|
||||
assert.equal(snapshot.devices[0].driverVersion, "24.1.0.1");
|
||||
assert.equal(snapshot.processes.length, 4);
|
||||
assert.equal(snapshot.processes[0].pid, 3277562);
|
||||
assert.equal(snapshot.processes[0].processName, "mindie_llm_back");
|
||||
assert.equal(snapshot.processes[0].memoryUsedMb, 14513);
|
||||
assert.equal(snapshot.processes[0].gpuIndex, 1);
|
||||
});
|
||||
@@ -400,6 +400,10 @@ function createTerminalWorkerManager(options = {}) {
|
||||
const outputRoutePending = new Map();
|
||||
const pendingSessionStartSequences = new Map();
|
||||
const latestSessionStartSequences = new Map();
|
||||
// bootEpoch for the latest pending / opened same-id start. Stale StrictMode
|
||||
// orphan closes carry an older epoch and must not cancel a newer remount.
|
||||
const pendingSessionStartBootEpochs = new Map();
|
||||
const sessionBootEpochs = new Map();
|
||||
const suppressedPendingOutputSessions = new Set();
|
||||
const workerSessionIds = new Set();
|
||||
const sessionWebContentsIds = new Map();
|
||||
@@ -900,6 +904,50 @@ function createTerminalWorkerManager(options = {}) {
|
||||
closedSessionGenerations.delete(sessionId);
|
||||
}
|
||||
|
||||
function normalizeBootEpoch(bootEpoch) {
|
||||
if (!Number.isFinite(bootEpoch)) return undefined;
|
||||
return Number(bootEpoch);
|
||||
}
|
||||
|
||||
function rememberPendingStartBootEpoch(sessionId, bootEpoch) {
|
||||
const normalized = normalizeBootEpoch(bootEpoch);
|
||||
if (!sessionId || normalized === undefined) return;
|
||||
pendingSessionStartBootEpochs.set(sessionId, normalized);
|
||||
}
|
||||
|
||||
function promotePendingStartBootEpoch(sessionId, bootEpoch) {
|
||||
const normalized = normalizeBootEpoch(bootEpoch) ?? pendingSessionStartBootEpochs.get(sessionId);
|
||||
if (!sessionId || normalized === undefined) return;
|
||||
sessionBootEpochs.set(sessionId, normalized);
|
||||
}
|
||||
|
||||
function clearSessionBootEpochTracking(sessionId) {
|
||||
if (!sessionId) return;
|
||||
pendingSessionStartBootEpochs.delete(sessionId);
|
||||
sessionBootEpochs.delete(sessionId);
|
||||
}
|
||||
|
||||
function resolveOwnerBootEpoch(sessionId) {
|
||||
if (!sessionId) return undefined;
|
||||
if (pendingSessionStartBootEpochs.has(sessionId)) {
|
||||
return pendingSessionStartBootEpochs.get(sessionId);
|
||||
}
|
||||
return sessionBootEpochs.get(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Epoch-scoped closes dispose one specific boot. When a newer remount already
|
||||
* owns the same sessionId, the stale close must be a no-op for lifecycle
|
||||
* bookkeeping — otherwise StrictMode abort → orphan close kills the remount
|
||||
* with "closed before its output route opened".
|
||||
*/
|
||||
function shouldSkipStaleEpochClose(sessionId, bootEpoch) {
|
||||
const closeEpoch = normalizeBootEpoch(bootEpoch);
|
||||
if (closeEpoch === undefined || !sessionId) return false;
|
||||
const ownerEpoch = resolveOwnerBootEpoch(sessionId);
|
||||
return ownerEpoch !== undefined && ownerEpoch > closeEpoch;
|
||||
}
|
||||
|
||||
function canPruneClosedSessionTombstone(sessionId) {
|
||||
return !pendingSessionStartSequences.has(sessionId)
|
||||
&& !sessionGenerations.has(sessionId)
|
||||
@@ -951,6 +999,7 @@ function createTerminalWorkerManager(options = {}) {
|
||||
if (entry?.requestedSessionId
|
||||
&& pendingSessionStartSequences.get(entry.requestedSessionId) === entry.requestSequence) {
|
||||
pendingSessionStartSequences.delete(entry.requestedSessionId);
|
||||
pendingSessionStartBootEpochs.delete(entry.requestedSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1030,6 +1079,7 @@ function createTerminalWorkerManager(options = {}) {
|
||||
if (!sessionId) return;
|
||||
markSessionClosed(sessionId);
|
||||
latestSessionStartSequences.delete(sessionId);
|
||||
clearSessionBootEpochTracking(sessionId);
|
||||
suppressedPendingOutputSessions.delete(sessionId);
|
||||
workerSessionIds.delete(sessionId);
|
||||
clearBufferedOutput(sessionId);
|
||||
@@ -1297,6 +1347,7 @@ function createTerminalWorkerManager(options = {}) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
promotePendingStartBootEpoch(sessionId, entry.bootEpoch);
|
||||
entry.resolve(message.result);
|
||||
}, (error) => {
|
||||
if (pending.get(message.requestId) !== entry) return;
|
||||
@@ -1715,6 +1766,7 @@ function createTerminalWorkerManager(options = {}) {
|
||||
reject,
|
||||
requestSequence,
|
||||
requestedSessionId,
|
||||
bootEpoch: normalizeBootEpoch(payload?.bootEpoch),
|
||||
webContentsId: optionsForRequest.webContentsId,
|
||||
opensOutputSession: channel === "netcatty:start"
|
||||
|| channel === "netcatty:local:reconnect"
|
||||
@@ -1724,6 +1776,10 @@ function createTerminalWorkerManager(options = {}) {
|
||||
});
|
||||
let notifyClosedAfterPost = false;
|
||||
if (channel === "netcatty:close:await" && payload?.sessionId) {
|
||||
if (shouldSkipStaleEpochClose(payload.sessionId, payload?.bootEpoch)) {
|
||||
pending.delete(requestId);
|
||||
return Promise.resolve({ skipped: true, reason: "boot-epoch-mismatch" });
|
||||
}
|
||||
const closesPendingLifecycle = hasPendingSessionLifecycle(payload.sessionId);
|
||||
notifyClosedAfterPost = !closedSessions.has(payload.sessionId) || closesPendingLifecycle;
|
||||
cancelPendingSessionStart(payload.sessionId);
|
||||
@@ -1736,6 +1792,7 @@ function createTerminalWorkerManager(options = {}) {
|
||||
}
|
||||
pendingSessionStartSequences.set(requestedSessionId, requestSequence);
|
||||
latestSessionStartSequences.set(requestedSessionId, requestSequence);
|
||||
rememberPendingStartBootEpoch(requestedSessionId, payload?.bootEpoch);
|
||||
// Track host id for SFTP transfer session leases (global transfer center).
|
||||
if (typeof payload?.hostId === "string" && payload.hostId) {
|
||||
sessionHostIds.set(requestedSessionId, payload.hostId);
|
||||
@@ -1833,6 +1890,11 @@ function createTerminalWorkerManager(options = {}) {
|
||||
|
||||
function send(channel, payload, optionsForSend = {}) {
|
||||
pruneClosedSessionTombstones();
|
||||
if (channel === "netcatty:close"
|
||||
&& payload?.sessionId
|
||||
&& shouldSkipStaleEpochClose(payload.sessionId, payload?.bootEpoch)) {
|
||||
return;
|
||||
}
|
||||
if (channel === "netcatty:close"
|
||||
&& payload?.sessionId
|
||||
&& closedSessions.has(payload.sessionId)
|
||||
@@ -1956,6 +2018,8 @@ function createTerminalWorkerManager(options = {}) {
|
||||
outputRoutePending.clear();
|
||||
pendingSessionStartSequences.clear();
|
||||
latestSessionStartSequences.clear();
|
||||
pendingSessionStartBootEpochs.clear();
|
||||
sessionBootEpochs.clear();
|
||||
suppressedPendingOutputSessions.clear();
|
||||
workerSessionIds.clear();
|
||||
sessionWebContentsIds.clear();
|
||||
|
||||
@@ -3170,6 +3170,117 @@ test("an old start response cannot reopen a session claimed by a later same-id s
|
||||
assert.deepEqual(routed, [{ sessionId: "session-1", data: "new-banner" }]);
|
||||
});
|
||||
|
||||
|
||||
test("start-close-start StrictMode order keeps the remount start alive", async () => {
|
||||
const child = new FakeChild();
|
||||
const manager = createTerminalWorkerManager({
|
||||
utilityProcess: { fork: () => child },
|
||||
terminalOutputChannel: { openSession: () => true, closeSession() {} },
|
||||
electronModule: {
|
||||
webContents: {
|
||||
fromId(id) {
|
||||
return { id, send() {} };
|
||||
},
|
||||
},
|
||||
},
|
||||
workerScriptPath: "/worker.cjs",
|
||||
});
|
||||
|
||||
// Mount1 start
|
||||
const first = manager.request(
|
||||
"netcatty:local:start",
|
||||
{ sessionId: "session-1", bootEpoch: 1 },
|
||||
{ webContentsId: 7 },
|
||||
);
|
||||
const firstRequest = child.messages.at(-1);
|
||||
|
||||
// Mount1 cleanup close (before remount start)
|
||||
manager.send("netcatty:close", { sessionId: "session-1", bootEpoch: 1 }, { webContentsId: 7 });
|
||||
|
||||
// Mount2 remount start
|
||||
const second = manager.request(
|
||||
"netcatty:local:start",
|
||||
{ sessionId: "session-1", bootEpoch: 2 },
|
||||
{ webContentsId: 7 },
|
||||
);
|
||||
const secondRequest = child.messages.at(-1);
|
||||
|
||||
child.emit("message", {
|
||||
kind: "response",
|
||||
requestId: firstRequest.requestId,
|
||||
result: { sessionId: "session-1" },
|
||||
sessionGeneration: 0,
|
||||
});
|
||||
await assert.rejects(first, /superseded by a newer start request|closed before its output route opened/u);
|
||||
|
||||
child.emit("message", {
|
||||
kind: "response",
|
||||
requestId: secondRequest.requestId,
|
||||
result: { sessionId: "session-1" },
|
||||
sessionGeneration: 1,
|
||||
});
|
||||
await second;
|
||||
assert.equal(manager.hasOpenSession("session-1"), true);
|
||||
});
|
||||
|
||||
test("stale bootEpoch close must not reject a newer same-id start (StrictMode remount)", async () => {
|
||||
const child = new FakeChild();
|
||||
const manager = createTerminalWorkerManager({
|
||||
utilityProcess: { fork: () => child },
|
||||
terminalOutputChannel: { openSession: () => true, closeSession() {} },
|
||||
electronModule: {
|
||||
webContents: {
|
||||
fromId(id) {
|
||||
return { id, send() {} };
|
||||
},
|
||||
},
|
||||
},
|
||||
workerScriptPath: "/worker.cjs",
|
||||
});
|
||||
|
||||
const first = manager.request(
|
||||
"netcatty:local:start",
|
||||
{ sessionId: "session-1", bootEpoch: 1 },
|
||||
{ webContentsId: 7 },
|
||||
);
|
||||
const firstRequest = child.messages.at(-1);
|
||||
const second = manager.request(
|
||||
"netcatty:local:start",
|
||||
{ sessionId: "session-1", bootEpoch: 2 },
|
||||
{ webContentsId: 7 },
|
||||
);
|
||||
const secondRequest = child.messages.at(-1);
|
||||
|
||||
// Orphan close from the aborted StrictMode boot after the remount start is pending.
|
||||
// This is the exact IPC order that produces:
|
||||
// superseded → "closed before its output route opened"
|
||||
const closeResult = await manager.request(
|
||||
"netcatty:close:await",
|
||||
{ sessionId: "session-1", bootEpoch: 1 },
|
||||
{ webContentsId: 7 },
|
||||
);
|
||||
assert.deepEqual(closeResult, { skipped: true, reason: "boot-epoch-mismatch" });
|
||||
manager.send("netcatty:close", { sessionId: "session-1", bootEpoch: 1 }, { webContentsId: 7 });
|
||||
|
||||
child.emit("message", {
|
||||
kind: "response",
|
||||
requestId: firstRequest.requestId,
|
||||
result: { sessionId: "session-1" },
|
||||
sessionGeneration: 0,
|
||||
});
|
||||
await assert.rejects(first, /superseded by a newer start request|closed before its output route opened/u);
|
||||
|
||||
child.emit("message", {
|
||||
kind: "response",
|
||||
requestId: secondRequest.requestId,
|
||||
result: { sessionId: "session-1" },
|
||||
sessionGeneration: 1,
|
||||
});
|
||||
await second;
|
||||
assert.equal(manager.hasOpenSession("session-1"), true);
|
||||
});
|
||||
|
||||
|
||||
test("closed-session tombstones are bounded without admitting recent stale output", async () => {
|
||||
const child = new FakeChild();
|
||||
const tapped = [];
|
||||
|
||||
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user