mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 14:30:13 +00:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
daa1d50d82 | ||
|
|
159e82d677 | ||
|
|
ac220dbacc | ||
|
|
5f76503d46 | ||
|
|
cba9e02c63 | ||
|
|
b631ee0ab6 | ||
|
|
6d5d7f6b27 | ||
|
|
1400d1abc3 | ||
|
|
1f8659d057 | ||
|
|
34813639c9 | ||
|
|
9973309a15 | ||
|
|
3490b0673d | ||
|
|
70607cb26d | ||
|
|
a56b868d10 | ||
|
|
c4e2391dd8 | ||
|
|
cfa47270be | ||
|
|
b7979e02be | ||
|
|
2c6390bf6f | ||
|
|
0f535d0b80 | ||
|
|
68ad8a6118 | ||
|
|
0144c9c6d3 | ||
|
|
4bcc619de4 | ||
|
|
cc5ad3be16 |
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\}/);
|
||||
});
|
||||
@@ -53,3 +53,8 @@ test('shared host tree forwards work-surface host management callbacks', () => {
|
||||
assert.match(hostTreeLayerSource, /onNewHost=\{onNewHost\}/);
|
||||
assert.match(hostTreeLayerSource, /onEditHost=\{onEditHost\}/);
|
||||
});
|
||||
|
||||
test('shared host tree layer is memoized with a custom areEqual', () => {
|
||||
assert.match(hostTreeLayerSource, /memo\(AppHostTreeLayerInner,\s*appHostTreeLayerAreEqual\)/);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useMemo } from 'react';
|
||||
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>;
|
||||
@@ -47,7 +46,33 @@ export function getAppHostTreeLayerStyle(surfaceVisible: boolean): React.CSSProp
|
||||
};
|
||||
}
|
||||
|
||||
export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
|
||||
function appHostTreeLayerAreEqual(
|
||||
prev: AppHostTreeLayerProps,
|
||||
next: AppHostTreeLayerProps,
|
||||
): boolean {
|
||||
return prev.enabled === next.enabled
|
||||
&& prev.hosts === next.hosts
|
||||
&& prev.customGroups === next.customGroups
|
||||
&& prev.groupConfigs === next.groupConfigs
|
||||
&& prev.sessions === next.sessions
|
||||
&& prev.workspaces === next.workspaces
|
||||
&& prev.editorTabs === next.editorTabs
|
||||
&& prev.logViews === next.logViews
|
||||
&& prev.orderedTabs === next.orderedTabs
|
||||
// accentMode / customAccent intentionally omitted — read from
|
||||
// appearanceChromeStore so accent drag does not rebuild the App shell.
|
||||
&& prev.currentTerminalTheme === next.currentTerminalTheme
|
||||
&& prev.followAppTerminalTheme === next.followAppTerminalTheme
|
||||
&& prev.hostById === next.hostById
|
||||
&& prev.themeById === next.themeById
|
||||
&& prev.resolveSessionAppearance === next.resolveSessionAppearance
|
||||
&& prev.onConnect === next.onConnect
|
||||
&& prev.onNewHost === next.onNewHost
|
||||
&& prev.onEditHost === next.onEditHost
|
||||
&& prev.onCreateLocalTerminal === next.onCreateLocalTerminal;
|
||||
}
|
||||
|
||||
const AppHostTreeLayerInner: React.FC<AppHostTreeLayerProps> = ({
|
||||
enabled,
|
||||
hosts,
|
||||
customGroups,
|
||||
@@ -57,9 +82,7 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
|
||||
editorTabs,
|
||||
logViews,
|
||||
orderedTabs,
|
||||
accentMode,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
themeById,
|
||||
@@ -70,6 +93,7 @@ export const AppHostTreeLayer: 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]);
|
||||
@@ -154,3 +178,6 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AppHostTreeLayer = memo(AppHostTreeLayerInner, appHostTreeLayerAreEqual);
|
||||
AppHostTreeLayer.displayName = 'AppHostTreeLayer';
|
||||
@@ -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
+300
-186
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo } from 'react';
|
||||
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AlertTriangle, Download, Trash2 } from 'lucide-react';
|
||||
import { activeTabStore, toEditorTabId, useIsEditorTabActive } from '../state/activeTabStore';
|
||||
import { editorTabStore } from '../state/editorTabStore';
|
||||
@@ -12,7 +12,7 @@ import { QuickScriptEditorDialog } from '../../components/scripts/QuickScriptEdi
|
||||
import { AddToWorkspaceDialog } from '../../components/workspace/AddToWorkspaceDialog';
|
||||
import { KeyboardInteractiveModal } from '../../components/KeyboardInteractiveModal';
|
||||
import { PassphraseModal } from '../../components/PassphraseModal';
|
||||
import { UnsavedChangesProvider } from '../../components/editor/UnsavedChangesDialog';
|
||||
import { UnsavedChangesProvider, promptUnsavedChanges } from '../../components/editor/UnsavedChangesDialog';
|
||||
import { SnippetExecutionProvider } from '../../components/SnippetExecutionProvider';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../../components/ui/dialog';
|
||||
@@ -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';
|
||||
@@ -66,6 +70,144 @@ const TextEditorTabFallback = ({ tabId }: { tabId: string }) => {
|
||||
);
|
||||
};
|
||||
|
||||
/** Local draft so keystrokes do not rebuild App chrome domain every character. */
|
||||
function RenameDraftDialog({
|
||||
open,
|
||||
inputId,
|
||||
initialName,
|
||||
title,
|
||||
nameLabel,
|
||||
placeholder,
|
||||
cancelLabel,
|
||||
saveLabel,
|
||||
onCancel,
|
||||
onSave,
|
||||
}: {
|
||||
open: boolean;
|
||||
inputId: string;
|
||||
initialName: string;
|
||||
title: string;
|
||||
nameLabel: string;
|
||||
placeholder: string;
|
||||
cancelLabel: string;
|
||||
saveLabel: string;
|
||||
onCancel: () => void;
|
||||
onSave: (name: string) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(initialName);
|
||||
useEffect(() => {
|
||||
if (open) setDraft(initialName);
|
||||
}, [open, initialName]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) onCancel();
|
||||
}}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 py-2">
|
||||
<Label htmlFor={inputId}>{nameLabel}</Label>
|
||||
<Input
|
||||
id={inputId}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
if (!draft.trim()) return;
|
||||
onSave(draft);
|
||||
}}
|
||||
autoFocus
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onCancel}>{cancelLabel}</Button>
|
||||
<Button onClick={() => onSave(draft)} disabled={!draft.trim()}>{saveLabel}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
@@ -114,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,
|
||||
sessionRenameValue, sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
|
||||
setNavigateToSection, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
|
||||
setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
|
||||
resolveEmptyVaultConflict, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
||||
sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
|
||||
setNavigateToSection, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
|
||||
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, workspaceRenameValue, 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,
|
||||
@@ -155,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();
|
||||
@@ -211,6 +358,70 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
pluginViewTabStore.close(tabId);
|
||||
}, [orderedTabsWithEditors]);
|
||||
|
||||
const orderedTabsWithEditorsRef = useRef(orderedTabsWithEditors);
|
||||
orderedTabsWithEditorsRef.current = orderedTabsWithEditors;
|
||||
|
||||
// Stable for TopTabs memo: read ordered tabs via ref; prompt via module singleton.
|
||||
const handleRequestCloseEditorTab = useCallback(async (id: string): Promise<boolean> => {
|
||||
const tab = editorTabStore.getTab(id);
|
||||
if (!tab) return false;
|
||||
|
||||
const closeEditorAndActivateNeighbor = () => {
|
||||
const closingTabId = toEditorTabId(id);
|
||||
const list = orderedTabsWithEditorsRef.current;
|
||||
const idx = list.indexOf(closingTabId);
|
||||
releaseEditorTabSaveCoordinator(id);
|
||||
editorTabStore.close(id);
|
||||
if (activeTabStore.getActiveTabId() !== closingTabId) return;
|
||||
const next = list[idx - 1] ?? list[idx + 1] ?? 'vault';
|
||||
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
|
||||
};
|
||||
|
||||
const dirty = tab.content !== tab.baselineContent;
|
||||
if (!dirty) {
|
||||
closeEditorAndActivateNeighbor();
|
||||
return true;
|
||||
}
|
||||
const choice = await promptUnsavedChanges(tab.fileName);
|
||||
if (choice === 'cancel') return false;
|
||||
if (choice === 'discard') {
|
||||
closeEditorAndActivateNeighbor();
|
||||
return true;
|
||||
}
|
||||
if (choice === 'save') {
|
||||
const ok = await saveEditorTab(id);
|
||||
if (!ok) {
|
||||
const msg = editorTabStore.getTab(id)?.saveError ?? 'Save failed';
|
||||
toast.error(msg, 'SFTP');
|
||||
return false;
|
||||
}
|
||||
const latest = editorTabStore.getTab(id);
|
||||
if (!latest || latest.content !== latest.baselineContent) return false;
|
||||
closeEditorAndActivateNeighbor();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
// Keep the hotkey ref current during render so Cmd/Ctrl+W never sees the
|
||||
// App.tsx stub `() => false` between commit and useEffect.
|
||||
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
|
||||
|
||||
const handleSaveSessionRename = useCallback((name: string) => {
|
||||
if (!sessionRenameTarget) return;
|
||||
if (!name.trim()) return;
|
||||
submitSessionRename(sessionRenameTarget.id, name);
|
||||
resetSessionRename();
|
||||
}, [resetSessionRename, sessionRenameTarget, submitSessionRename]);
|
||||
|
||||
const handleSaveWorkspaceRename = useCallback((name: string) => {
|
||||
if (!workspaceRenameTarget) return;
|
||||
if (!name.trim()) return;
|
||||
submitWorkspaceRename(workspaceRenameTarget.id, name);
|
||||
resetWorkspaceRename();
|
||||
}, [resetWorkspaceRename, submitWorkspaceRename, workspaceRenameTarget]);
|
||||
|
||||
useEffect(() => {
|
||||
const catalog = buildPluginSettingScopeCatalog({
|
||||
hosts,
|
||||
@@ -224,58 +435,11 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
return (
|
||||
<SnippetExecutionProvider>
|
||||
<UnsavedChangesProvider>
|
||||
{({ prompt }) => {
|
||||
// Helper: close an editor tab and activate the neighbor (left-preference), or vault.
|
||||
const closeEditorAndActivateNeighbor = (id: string) => {
|
||||
const closingTabId = toEditorTabId(id);
|
||||
const list = orderedTabsWithEditors;
|
||||
const idx = list.indexOf(closingTabId);
|
||||
releaseEditorTabSaveCoordinator(id);
|
||||
editorTabStore.close(id);
|
||||
if (activeTabStore.getActiveTabId() !== closingTabId) return;
|
||||
const next = list[idx - 1] ?? list[idx + 1] ?? 'vault';
|
||||
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
|
||||
};
|
||||
|
||||
// Real dirty-confirm close handler.
|
||||
const handleRequestCloseEditorTab = async (id: string): Promise<boolean> => {
|
||||
const tab = editorTabStore.getTab(id);
|
||||
if (!tab) return false;
|
||||
const dirty = tab.content !== tab.baselineContent;
|
||||
if (!dirty) {
|
||||
closeEditorAndActivateNeighbor(id);
|
||||
return true;
|
||||
}
|
||||
const choice = await prompt(tab.fileName);
|
||||
if (choice === 'cancel') return false;
|
||||
if (choice === 'discard') {
|
||||
closeEditorAndActivateNeighbor(id);
|
||||
return true;
|
||||
}
|
||||
if (choice === 'save') {
|
||||
const ok = await saveEditorTab(id);
|
||||
if (!ok) {
|
||||
const msg = editorTabStore.getTab(id)?.saveError ?? 'Save failed';
|
||||
toast.error(msg, 'SFTP');
|
||||
return false;
|
||||
}
|
||||
const latest = editorTabStore.getTab(id);
|
||||
if (!latest || latest.content !== latest.baselineContent) return false;
|
||||
closeEditorAndActivateNeighbor(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Expose to the hotkey dispatcher (Cmd/Ctrl+W).
|
||||
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
|
||||
|
||||
return (
|
||||
{() => (
|
||||
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" data-terminal-appearance-root onContextMenu={handleRootContextMenu}>
|
||||
<TopTabs
|
||||
theme={resolvedTheme}
|
||||
themePreference={settings.theme}
|
||||
themePreference={themePreference}
|
||||
hosts={hosts}
|
||||
sessions={sessions}
|
||||
orphanSessions={orphanSessions}
|
||||
@@ -295,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}
|
||||
@@ -319,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}
|
||||
@@ -328,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}
|
||||
@@ -365,7 +527,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onUpdateHosts={updateHosts}
|
||||
/>
|
||||
|
||||
<VaultViewContainer appThemeStyle={appThemeStyle}>
|
||||
<AppVaultThemeSurface VaultViewContainer={VaultViewContainer}>
|
||||
<VaultView
|
||||
hosts={hosts}
|
||||
keys={keys}
|
||||
@@ -373,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}
|
||||
@@ -401,8 +560,6 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onUpdateProxyProfiles={updateProxyProfiles}
|
||||
onUpdateSnippets={updateSnippets}
|
||||
onUpdateSnippetPackages={updateSnippetPackages}
|
||||
onUpdateNotes={updateNotes}
|
||||
onUpdateNoteGroups={updateNoteGroups}
|
||||
onUpdateCustomGroups={updateCustomGroups}
|
||||
onCommitPluginImporterData={commitPluginImporterData}
|
||||
onUpdateKnownHosts={updateKnownHosts}
|
||||
@@ -413,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}
|
||||
@@ -429,7 +583,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onVaultFocusRequestHandled={() => setVaultFocusRequest(null)}
|
||||
terminalSettings={terminalSettings}
|
||||
/>
|
||||
</VaultViewContainer>
|
||||
</AppVaultThemeSurface>
|
||||
|
||||
<SftpViewMount
|
||||
hosts={terminalHosts}
|
||||
@@ -464,8 +618,6 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
identities={identities}
|
||||
snippets={snippets}
|
||||
snippetPackages={snippetPackages}
|
||||
notes={notes}
|
||||
noteGroups={noteGroups}
|
||||
sessions={sessions}
|
||||
workspaces={workspaces}
|
||||
knownHosts={effectiveKnownHosts}
|
||||
@@ -477,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}
|
||||
@@ -529,16 +679,14 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
updateHosts={updateTerminalHosts}
|
||||
updateSnippets={updateSnippets}
|
||||
updateSnippetPackages={updateSnippetPackages}
|
||||
updateNotes={updateNotes}
|
||||
updateNoteGroups={updateNoteGroups}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
||||
sftpAutoSync={sftpAutoSync}
|
||||
sftpShowHiddenFiles={sftpShowHiddenFiles}
|
||||
sftpUseCompressedUpload={sftpUseCompressedUpload}
|
||||
sftpAutoOpenSidebar={sftpAutoOpenSidebar}
|
||||
terminalSidePanelAutoOpen={settings.terminalSidePanelAutoOpen}
|
||||
terminalSidePanelAutoOpenTab={settings.terminalSidePanelAutoOpenTab}
|
||||
terminalSidePanelAutoOpen={terminalSidePanelAutoOpen}
|
||||
terminalSidePanelAutoOpenTab={terminalSidePanelAutoOpenTab}
|
||||
sftpFollowTerminalCwd={sftpFollowTerminalCwd}
|
||||
setSftpFollowTerminalCwd={setSftpFollowTerminalCwd}
|
||||
editorWordWrap={editorWordWrap}
|
||||
@@ -548,7 +696,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
sessionLogsFormat={sessionLogsFormat}
|
||||
sessionLogsTimestampsEnabled={sessionLogsTimestampsEnabled}
|
||||
sshDebugLogsEnabled={sshDebugLogsEnabled}
|
||||
showHostTreeSidebar={settings.showHostTreeSidebar}
|
||||
showHostTreeSidebar={showHostTreeSidebar}
|
||||
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
|
||||
toggleSidePanelRef={toggleSidePanelRef}
|
||||
onStartSessionRename={startSessionRename}
|
||||
@@ -556,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) => (
|
||||
@@ -587,10 +732,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
</LazyLoadBoundary>
|
||||
))}
|
||||
|
||||
<AppPluginKeybindingHost
|
||||
locale={settings.uiLanguage}
|
||||
theme={resolvedTheme}
|
||||
themeTokens={pluginThemeTokens}
|
||||
<AppPluginKeybindingThemeHost
|
||||
sessions={sessions}
|
||||
workspaces={workspaces}
|
||||
/>
|
||||
@@ -681,7 +823,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
results={quickResults}
|
||||
sessions={sessions}
|
||||
workspaces={workspaces}
|
||||
showSftpTab={settings.showSftpTab}
|
||||
showSftpTab={showSftpTab}
|
||||
onQueryChange={setQuickSearch}
|
||||
onSelect={handleHostConnectWithProtocolCheck}
|
||||
onEditHost={(host) => {
|
||||
@@ -715,59 +857,32 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
</LazyLoadBoundary>
|
||||
)}
|
||||
|
||||
<Dialog open={!!sessionRenameTarget} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
resetSessionRename();
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('dialog.renameSession.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 py-2">
|
||||
<Label htmlFor="session-name">{t('field.name')}</Label>
|
||||
<Input
|
||||
id="session-name"
|
||||
value={sessionRenameValue}
|
||||
onChange={(e) => setSessionRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') submitSessionRename(); }}
|
||||
autoFocus
|
||||
placeholder={t('placeholder.sessionName')}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={resetSessionRename}>{t('common.cancel')}</Button>
|
||||
<Button onClick={submitSessionRename} disabled={!sessionRenameValue.trim()}>{t('common.save')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<RenameDraftDialog
|
||||
open={!!sessionRenameTarget}
|
||||
inputId="session-name"
|
||||
initialName={sessionRenameTarget ? (sessionRenameTarget.customName || sessionRenameTarget.hostLabel) : ''}
|
||||
title={t('dialog.renameSession.title')}
|
||||
nameLabel={t('field.name')}
|
||||
placeholder={t('placeholder.sessionName')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
saveLabel={t('common.save')}
|
||||
onCancel={resetSessionRename}
|
||||
onSave={handleSaveSessionRename}
|
||||
/>
|
||||
|
||||
<RenameDraftDialog
|
||||
open={!!workspaceRenameTarget}
|
||||
inputId="workspace-name"
|
||||
initialName={workspaceRenameTarget ? workspaceRenameTarget.title : ''}
|
||||
title={t('dialog.renameWorkspace.title')}
|
||||
nameLabel={t('field.name')}
|
||||
placeholder={t('placeholder.workspaceName')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
saveLabel={t('common.save')}
|
||||
onCancel={resetWorkspaceRename}
|
||||
onSave={handleSaveWorkspaceRename}
|
||||
/>
|
||||
|
||||
<Dialog open={!!workspaceRenameTarget} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
resetWorkspaceRename();
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('dialog.renameWorkspace.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 py-2">
|
||||
<Label htmlFor="workspace-name">{t('field.name')}</Label>
|
||||
<Input
|
||||
id="workspace-name"
|
||||
value={workspaceRenameValue}
|
||||
onChange={(e) => setWorkspaceRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') submitWorkspaceRename(); }}
|
||||
autoFocus
|
||||
placeholder={t('placeholder.workspaceName')}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={resetWorkspaceRename}>{t('common.cancel')}</Button>
|
||||
<Button onClick={submitWorkspaceRename} disabled={!workspaceRenameValue.trim()}>{t('common.save')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{isCreateWorkspaceOpen && (
|
||||
<LazyLoadBoundary name="Create workspace" resetKey="create-workspace">
|
||||
@@ -871,8 +986,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
)}
|
||||
</UnsavedChangesProvider>
|
||||
</SnippetExecutionProvider>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -8,6 +8,8 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.empty': 'No data available.',
|
||||
'systemManager.tabs.overview': 'Overview',
|
||||
'systemManager.tabs.processes': 'Processes',
|
||||
'systemManager.tabs.ports': 'Ports',
|
||||
'systemManager.tabs.services': 'Services',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.tabs.gpu': 'GPU',
|
||||
@@ -25,6 +27,8 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDockerImages': 'Failed to load images',
|
||||
'systemManager.errors.loadOverview': 'Failed to load system overview',
|
||||
'systemManager.errors.loadGpu': 'Failed to load GPU / NPU stats',
|
||||
'systemManager.errors.loadPorts': 'Failed to load listening ports',
|
||||
'systemManager.errors.loadServices': 'Failed to load systemd services',
|
||||
'systemManager.errors.sshChannelUnavailable': 'The server refused to open a new execution channel. Try again later, or reconnect this host.',
|
||||
|
||||
'systemManager.overview.empty': 'No system overview data yet.',
|
||||
@@ -189,6 +193,34 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.start': 'Start',
|
||||
'systemManager.docker.stop': 'Stop',
|
||||
|
||||
'systemManager.ports.unavailable': 'No listening-port tools (ss / netstat) detected on this host.',
|
||||
'systemManager.ports.loading': 'Loading listening ports…',
|
||||
'systemManager.ports.empty': 'No listening ports reported.',
|
||||
'systemManager.ports.search': 'Search ports…',
|
||||
'systemManager.ports.meta': '{{count}} listener(s)',
|
||||
'systemManager.ports.filter.all': 'All',
|
||||
'systemManager.ports.unknownProcess': 'Unknown process',
|
||||
'systemManager.ports.terminate': 'Terminate',
|
||||
'systemManager.ports.confirmTerminate': 'Send SIGTERM to process {{pid}} that holds this port?',
|
||||
|
||||
'systemManager.services.unavailable': 'systemctl is not available on this host.',
|
||||
'systemManager.services.loading': 'Loading systemd services…',
|
||||
'systemManager.services.empty': 'No systemd services found.',
|
||||
'systemManager.services.search': 'Search services…',
|
||||
'systemManager.services.meta': '{{count}} service(s)',
|
||||
'systemManager.services.filter.all': 'All',
|
||||
'systemManager.services.filter.running': 'Running',
|
||||
'systemManager.services.filter.failed': 'Failed',
|
||||
'systemManager.services.filter.inactive': 'Inactive',
|
||||
'systemManager.services.start': 'Start',
|
||||
'systemManager.services.stop': 'Stop',
|
||||
'systemManager.services.restart': 'Restart',
|
||||
'systemManager.services.enable': 'Enable',
|
||||
'systemManager.services.disable': 'Disable',
|
||||
'systemManager.services.reload': 'Reload',
|
||||
'systemManager.services.scope.user': 'user',
|
||||
'systemManager.services.confirmAction': '{{action}} {{name}}?',
|
||||
|
||||
'systemManager.gpu.unavailable': 'No NVIDIA GPU or Ascend NPU tools detected on this host.',
|
||||
'systemManager.gpu.loading': 'Loading accelerator stats…',
|
||||
'systemManager.gpu.empty': 'Accelerator tools are present, but no devices were reported.',
|
||||
|
||||
@@ -8,6 +8,8 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.empty': 'Нет данных.',
|
||||
'systemManager.tabs.overview': 'Обзор',
|
||||
'systemManager.tabs.processes': 'Процессы',
|
||||
'systemManager.tabs.ports': 'Порты',
|
||||
'systemManager.tabs.services': 'Службы',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.tabs.gpu': 'GPU',
|
||||
@@ -25,6 +27,8 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDockerImages': 'Не удалось загрузить образы',
|
||||
'systemManager.errors.loadOverview': 'Не удалось загрузить обзор системы',
|
||||
'systemManager.errors.loadGpu': 'Не удалось загрузить статистику GPU / NPU',
|
||||
'systemManager.errors.loadPorts': 'Не удалось загрузить слушающие порты',
|
||||
'systemManager.errors.loadServices': 'Не удалось загрузить службы systemd',
|
||||
'systemManager.errors.sshChannelUnavailable': 'Сервер отказался открыть новый канал выполнения. Повторите попытку позже или переподключите этот хост.',
|
||||
|
||||
'systemManager.overview.empty': 'Данных обзора пока нет.',
|
||||
@@ -189,6 +193,34 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.start': 'Запустить',
|
||||
'systemManager.docker.stop': 'Остановить',
|
||||
|
||||
'systemManager.ports.unavailable': 'На этом хосте не обнаружены инструменты портов (ss / netstat).',
|
||||
'systemManager.ports.loading': 'Загрузка слушающих портов…',
|
||||
'systemManager.ports.empty': 'Слушающие порты не найдены.',
|
||||
'systemManager.ports.search': 'Поиск портов…',
|
||||
'systemManager.ports.meta': '{{count}} слушающих',
|
||||
'systemManager.ports.filter.all': 'Все',
|
||||
'systemManager.ports.unknownProcess': 'Неизвестный процесс',
|
||||
'systemManager.ports.terminate': 'Завершить',
|
||||
'systemManager.ports.confirmTerminate': 'Отправить SIGTERM процессу {{pid}}, занимающему этот порт?',
|
||||
|
||||
'systemManager.services.unavailable': 'systemctl недоступен на этом хосте.',
|
||||
'systemManager.services.loading': 'Загрузка служб systemd…',
|
||||
'systemManager.services.empty': 'Службы systemd не найдены.',
|
||||
'systemManager.services.search': 'Поиск служб…',
|
||||
'systemManager.services.meta': '{{count}} служб',
|
||||
'systemManager.services.filter.all': 'Все',
|
||||
'systemManager.services.filter.running': 'Запущены',
|
||||
'systemManager.services.filter.failed': 'С ошибкой',
|
||||
'systemManager.services.filter.inactive': 'Неактивны',
|
||||
'systemManager.services.start': 'Запустить',
|
||||
'systemManager.services.stop': 'Остановить',
|
||||
'systemManager.services.restart': 'Перезапустить',
|
||||
'systemManager.services.enable': 'Включить автозапуск',
|
||||
'systemManager.services.disable': 'Отключить автозапуск',
|
||||
'systemManager.services.reload': 'Перезагрузить',
|
||||
'systemManager.services.scope.user': 'user',
|
||||
'systemManager.services.confirmAction': '{{action}} {{name}}?',
|
||||
|
||||
'systemManager.gpu.unavailable': 'На этом хосте не обнаружены инструменты NVIDIA GPU или Ascend NPU.',
|
||||
'systemManager.gpu.loading': 'Загрузка статистики ускорителей…',
|
||||
'systemManager.gpu.empty': 'Инструменты ускорителей найдены, но устройства не сообщены.',
|
||||
|
||||
@@ -8,6 +8,8 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.empty': '暂无数据。',
|
||||
'systemManager.tabs.overview': '概览',
|
||||
'systemManager.tabs.processes': '进程',
|
||||
'systemManager.tabs.ports': '端口',
|
||||
'systemManager.tabs.services': '服务',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.tabs.gpu': 'GPU',
|
||||
@@ -25,6 +27,8 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDockerImages': '加载镜像列表失败',
|
||||
'systemManager.errors.loadOverview': '加载系统概览失败',
|
||||
'systemManager.errors.loadGpu': '加载 GPU / NPU 状态失败',
|
||||
'systemManager.errors.loadPorts': '加载监听端口失败',
|
||||
'systemManager.errors.loadServices': '加载 systemd 服务失败',
|
||||
'systemManager.errors.sshChannelUnavailable': '服务器拒绝打开新的执行通道。请稍后重试,或重新连接当前主机。',
|
||||
|
||||
'systemManager.overview.empty': '暂无系统概览数据。',
|
||||
@@ -189,6 +193,34 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.start': '启动',
|
||||
'systemManager.docker.stop': '停止',
|
||||
|
||||
'systemManager.ports.unavailable': '此主机未检测到端口工具(ss / netstat)。',
|
||||
'systemManager.ports.loading': '正在加载监听端口…',
|
||||
'systemManager.ports.empty': '暂无监听端口。',
|
||||
'systemManager.ports.search': '搜索端口…',
|
||||
'systemManager.ports.meta': '{{count}} 个监听',
|
||||
'systemManager.ports.filter.all': '全部',
|
||||
'systemManager.ports.unknownProcess': '未知进程',
|
||||
'systemManager.ports.terminate': '结束进程',
|
||||
'systemManager.ports.confirmTerminate': '向占用该端口的进程 {{pid}} 发送 SIGTERM?',
|
||||
|
||||
'systemManager.services.unavailable': '此主机未检测到 systemctl。',
|
||||
'systemManager.services.loading': '正在加载 systemd 服务…',
|
||||
'systemManager.services.empty': '暂无 systemd 服务。',
|
||||
'systemManager.services.search': '搜索服务…',
|
||||
'systemManager.services.meta': '{{count}} 个服务',
|
||||
'systemManager.services.filter.all': '全部',
|
||||
'systemManager.services.filter.running': '运行中',
|
||||
'systemManager.services.filter.failed': '失败',
|
||||
'systemManager.services.filter.inactive': '未运行',
|
||||
'systemManager.services.start': '启动',
|
||||
'systemManager.services.stop': '停止',
|
||||
'systemManager.services.restart': '重启',
|
||||
'systemManager.services.enable': '开机启用',
|
||||
'systemManager.services.disable': '禁用开机',
|
||||
'systemManager.services.reload': '重载',
|
||||
'systemManager.services.scope.user': '用户',
|
||||
'systemManager.services.confirmAction': '确认对 {{name}} 执行「{{action}}」?',
|
||||
|
||||
'systemManager.gpu.unavailable': '此主机未检测到 NVIDIA GPU 或昇腾 NPU 工具。',
|
||||
'systemManager.gpu.loading': '正在加载加速卡状态…',
|
||||
'systemManager.gpu.empty': '已检测到加速卡工具,但未返回设备信息。',
|
||||
|
||||
@@ -8,6 +8,8 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.empty': '暫無資料。',
|
||||
'systemManager.tabs.overview': '概覽',
|
||||
'systemManager.tabs.processes': '程序',
|
||||
'systemManager.tabs.ports': '連接埠',
|
||||
'systemManager.tabs.services': '服務',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.tabs.gpu': 'GPU',
|
||||
@@ -25,6 +27,8 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDockerImages': '載入映像列表失敗',
|
||||
'systemManager.errors.loadOverview': '載入系統概覽失敗',
|
||||
'systemManager.errors.loadGpu': '載入 GPU / NPU 狀態失敗',
|
||||
'systemManager.errors.loadPorts': '載入監聽連接埠失敗',
|
||||
'systemManager.errors.loadServices': '載入 systemd 服務失敗',
|
||||
'systemManager.errors.sshChannelUnavailable': '伺服器拒絕開啟新的執行通道。請稍後重試,或重新連線目前主機。',
|
||||
|
||||
'systemManager.overview.empty': '暫無系統概覽資料。',
|
||||
@@ -189,6 +193,34 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.start': '啟動',
|
||||
'systemManager.docker.stop': '停止',
|
||||
|
||||
'systemManager.ports.unavailable': '此主機未偵測到連接埠工具(ss / netstat)。',
|
||||
'systemManager.ports.loading': '正在載入監聽連接埠…',
|
||||
'systemManager.ports.empty': '暫無監聽連接埠。',
|
||||
'systemManager.ports.search': '搜尋連接埠…',
|
||||
'systemManager.ports.meta': '{{count}} 個監聽',
|
||||
'systemManager.ports.filter.all': '全部',
|
||||
'systemManager.ports.unknownProcess': '未知程序',
|
||||
'systemManager.ports.terminate': '結束程序',
|
||||
'systemManager.ports.confirmTerminate': '向佔用該連接埠的程序 {{pid}} 傳送 SIGTERM?',
|
||||
|
||||
'systemManager.services.unavailable': '此主機未偵測到 systemctl。',
|
||||
'systemManager.services.loading': '正在載入 systemd 服務…',
|
||||
'systemManager.services.empty': '暫無 systemd 服務。',
|
||||
'systemManager.services.search': '搜尋服務…',
|
||||
'systemManager.services.meta': '{{count}} 個服務',
|
||||
'systemManager.services.filter.all': '全部',
|
||||
'systemManager.services.filter.running': '執行中',
|
||||
'systemManager.services.filter.failed': '失敗',
|
||||
'systemManager.services.filter.inactive': '未執行',
|
||||
'systemManager.services.start': '啟動',
|
||||
'systemManager.services.stop': '停止',
|
||||
'systemManager.services.restart': '重新啟動',
|
||||
'systemManager.services.enable': '開機啟用',
|
||||
'systemManager.services.disable': '停用開機',
|
||||
'systemManager.services.reload': '重新載入',
|
||||
'systemManager.services.scope.user': '使用者',
|
||||
'systemManager.services.confirmAction': '確認對 {{name}} 執行「{{action}}」?',
|
||||
|
||||
'systemManager.gpu.unavailable': '此主機未偵測到 NVIDIA GPU 或昇騰 NPU 工具。',
|
||||
'systemManager.gpu.loading': '正在載入加速卡狀態…',
|
||||
'systemManager.gpu.empty': '已偵測到加速卡工具,但未回傳裝置資訊。',
|
||||
|
||||
@@ -7,7 +7,7 @@ const chromeThemeSource = readFileSync(new URL("./useActiveChromeTheme.ts", impo
|
||||
const storeSource = readFileSync(new URL("./activeTabStore.ts", import.meta.url), "utf8");
|
||||
|
||||
test("active tab changes notify chrome theme before react subscribers", () => {
|
||||
const setActiveTabIdBody = storeSource.match(/setActiveTabId = \(id: string\) => \{[\s\S]*?\n {2}\};/)?.[0] ?? "";
|
||||
const setActiveTabIdBody = storeSource.match(/setActiveTabId = \(id: string(?:, options\?: \w+)?\) => \{[\s\S]*?\n {2}\};/)?.[0] ?? "";
|
||||
assert.match(setActiveTabIdBody, /this\.syncListeners\.forEach\(\(listener\) => listener\(id\)\)/);
|
||||
assert.match(setActiveTabIdBody, /this\.scheduleNotify\(\)/);
|
||||
assert.ok(
|
||||
|
||||
@@ -4,6 +4,37 @@ import test from 'node:test';
|
||||
import { activeTabStore, fromEditorTabId, isEditorTabId, toEditorTabId } from './activeTabStore';
|
||||
import { terminalLayoutSuppressStore } from './terminalLayoutSuppressStore';
|
||||
|
||||
test('active tab store remembers the previous tab for restore-on-close', () => {
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
(globalThis as { window?: unknown }).window ??= globalThis;
|
||||
|
||||
const marker = `prev-tab-${Date.now()}`;
|
||||
const first = `${marker}-a`;
|
||||
const second = `${marker}-b`;
|
||||
const third = `${marker}-c`;
|
||||
const before = activeTabStore.getActiveTabId();
|
||||
|
||||
try {
|
||||
activeTabStore.setActiveTabId(first);
|
||||
activeTabStore.setActiveTabId(second);
|
||||
assert.equal(activeTabStore.getPreviousActiveTabId(), first);
|
||||
|
||||
activeTabStore.setActiveTabId(third);
|
||||
assert.equal(activeTabStore.getPreviousActiveTabId(), second);
|
||||
|
||||
activeTabStore.setActiveTabId(first, { recordPrevious: false });
|
||||
assert.equal(activeTabStore.getActiveTabId(), first);
|
||||
assert.equal(activeTabStore.getPreviousActiveTabId(), second);
|
||||
} finally {
|
||||
activeTabStore.setActiveTabId(before, { recordPrevious: false });
|
||||
if (previousWindow === undefined) {
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
} else {
|
||||
(globalThis as { window?: unknown }).window = previousWindow;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('editor tab helpers round trip ids', () => {
|
||||
assert.equal(toEditorTabId('file-1'), 'editor:file-1');
|
||||
assert.equal(fromEditorTabId('editor:file-1'), 'file-1');
|
||||
|
||||
@@ -16,14 +16,22 @@ export const toEditorTabId = (editorId: string): string => `${EDITOR_PREFIX}${ed
|
||||
/** Strip the "editor:" prefix to recover the internal editorTab id. */
|
||||
export const fromEditorTabId = (tabId: string): string => tabId.slice(EDITOR_PREFIX.length);
|
||||
|
||||
type SetActiveTabOptions = {
|
||||
/** When false, keep the previous-tab pointer unchanged (used for close restore). */
|
||||
recordPrevious?: boolean;
|
||||
};
|
||||
|
||||
class ActiveTabStore {
|
||||
private activeTabId: string = 'vault';
|
||||
private previousActiveTabId: string | null = null;
|
||||
private listeners = new Set<Listener>();
|
||||
private syncListeners = new Set<SyncListener>();
|
||||
private notifyRafId: number | null = null;
|
||||
|
||||
getActiveTabId = () => this.activeTabId;
|
||||
|
||||
getPreviousActiveTabId = () => this.previousActiveTabId;
|
||||
|
||||
private scheduleNotify = () => {
|
||||
if (this.notifyRafId !== null) return;
|
||||
const schedule = typeof requestAnimationFrame === 'function'
|
||||
@@ -35,8 +43,11 @@ class ActiveTabStore {
|
||||
});
|
||||
};
|
||||
|
||||
setActiveTabId = (id: string) => {
|
||||
setActiveTabId = (id: string, options?: SetActiveTabOptions) => {
|
||||
if (this.activeTabId !== id) {
|
||||
if (options?.recordPrevious !== false) {
|
||||
this.previousActiveTabId = this.activeTabId;
|
||||
}
|
||||
this.activeTabId = id;
|
||||
this.syncListeners.forEach((listener) => listener(id));
|
||||
// Coalesce rapid tab switches into one notification per frame and avoid
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import type React from 'react';
|
||||
import {
|
||||
STORAGE_KEY_AI_PANEL_DIAGNOSTIC_HIDE,
|
||||
STORAGE_KEY_AI_PANEL_DIAGNOSTIC_PROFILE,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
|
||||
export const AI_PANEL_DIAGNOSTIC_HIDE_KEY = STORAGE_KEY_AI_PANEL_DIAGNOSTIC_HIDE;
|
||||
export const AI_PANEL_DIAGNOSTIC_PROFILE_KEY = STORAGE_KEY_AI_PANEL_DIAGNOSTIC_PROFILE;
|
||||
export const AI_PANEL_FORCE_HIDE_ALL_CONTENT = false;
|
||||
export const AI_PANEL_FORCE_HIDE_SHELL = false;
|
||||
|
||||
export type AIPanelDiagnosticPart =
|
||||
| 'all'
|
||||
| 'attachments'
|
||||
| 'header'
|
||||
| 'history'
|
||||
| 'input'
|
||||
| 'markdown'
|
||||
| 'messages'
|
||||
| 'recent'
|
||||
| 'toolcalls';
|
||||
|
||||
function readLocalStorageValue(key: string): string {
|
||||
try {
|
||||
return localStorageAdapter.readString(key) ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function getAIPanelDiagnosticHiddenParts(): ReadonlySet<string> {
|
||||
if (AI_PANEL_FORCE_HIDE_ALL_CONTENT) {
|
||||
return new Set(['all']);
|
||||
}
|
||||
const raw = readLocalStorageValue(AI_PANEL_DIAGNOSTIC_HIDE_KEY);
|
||||
return new Set(
|
||||
raw
|
||||
.split(',')
|
||||
.map((part) => part.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
export function isAIPanelDiagnosticPartHidden(
|
||||
part: AIPanelDiagnosticPart,
|
||||
hiddenParts = getAIPanelDiagnosticHiddenParts(),
|
||||
): boolean {
|
||||
return hiddenParts.has('all') || hiddenParts.has(part);
|
||||
}
|
||||
|
||||
export function isAIPanelDiagnosticsProfilingEnabled(): boolean {
|
||||
const raw = readLocalStorageValue(AI_PANEL_DIAGNOSTIC_PROFILE_KEY).trim().toLowerCase();
|
||||
return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
|
||||
}
|
||||
|
||||
export function logAIPanelProfiler(
|
||||
id: string,
|
||||
phase: 'mount' | 'update' | 'nested-update',
|
||||
actualDuration: number,
|
||||
baseDuration: number,
|
||||
): void {
|
||||
if (!isAIPanelDiagnosticsProfilingEnabled()) return;
|
||||
console.info(
|
||||
`[AI panel profile] ${id} ${phase}: actual=${actualDuration.toFixed(1)}ms base=${baseDuration.toFixed(1)}ms`,
|
||||
);
|
||||
}
|
||||
|
||||
export function profileAIPanelCalculation<T>(label: string, calculate: () => T): T {
|
||||
if (!isAIPanelDiagnosticsProfilingEnabled()) return calculate();
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
return calculate();
|
||||
} finally {
|
||||
const elapsed = performance.now() - startedAt;
|
||||
console.info(`[AI panel profile] ${label}: ${elapsed.toFixed(1)}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
export function getAIPanelProfilerProps(id: string): Pick<React.ProfilerProps, 'id' | 'onRender'> {
|
||||
return {
|
||||
id,
|
||||
onRender: logAIPanelProfiler,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,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,31 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
getAppearanceChromeSnapshot,
|
||||
publishAppearanceChromeSnapshot,
|
||||
subscribeAppearanceChrome,
|
||||
} from './appearanceChromeStore.ts';
|
||||
|
||||
test('appearanceChromeStore notifies subscribers only when accent fields change', () => {
|
||||
const events: string[] = [];
|
||||
const unsubscribe = subscribeAppearanceChrome(() => {
|
||||
const snap = getAppearanceChromeSnapshot();
|
||||
events.push(`${snap.accentMode}:${snap.customAccent}`);
|
||||
});
|
||||
|
||||
publishAppearanceChromeSnapshot({ accentMode: 'custom', customAccent: '#ff0000' });
|
||||
assert.equal(events.at(-1), 'custom:#ff0000');
|
||||
assert.equal(getAppearanceChromeSnapshot().customAccent, '#ff0000');
|
||||
|
||||
publishAppearanceChromeSnapshot({ accentMode: 'custom', customAccent: '#ff0000' });
|
||||
assert.equal(events.length, 1);
|
||||
|
||||
publishAppearanceChromeSnapshot({ accentMode: 'custom', customAccent: '#00ff00' });
|
||||
assert.equal(events.at(-1), 'custom:#00ff00');
|
||||
|
||||
publishAppearanceChromeSnapshot({ accentMode: 'theme', customAccent: '#00ff00' });
|
||||
assert.equal(events.at(-1), 'theme:#00ff00');
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
export type AppearanceChromeSnapshot = {
|
||||
accentMode: 'theme' | 'custom';
|
||||
customAccent: string;
|
||||
};
|
||||
|
||||
const DEFAULT_SNAPSHOT: AppearanceChromeSnapshot = Object.freeze({
|
||||
accentMode: 'theme',
|
||||
customAccent: '',
|
||||
});
|
||||
|
||||
/**
|
||||
* External store for app accent chrome so Terminal leaves can apply custom
|
||||
* accent without TerminalLayer rebuilding on every color-picker drag tick.
|
||||
*/
|
||||
class AppearanceChromeStore {
|
||||
private snapshot: AppearanceChromeSnapshot = DEFAULT_SNAPSHOT;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
getSnapshot = (): AppearanceChromeSnapshot => this.snapshot;
|
||||
|
||||
subscribe = (listener: Listener): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
setSnapshot(next: AppearanceChromeSnapshot): void {
|
||||
if (
|
||||
this.snapshot.accentMode === next.accentMode
|
||||
&& this.snapshot.customAccent === next.customAccent
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.snapshot = next;
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const appearanceChromeStore = new AppearanceChromeStore();
|
||||
|
||||
export function publishAppearanceChromeSnapshot(
|
||||
snapshot: AppearanceChromeSnapshot,
|
||||
): void {
|
||||
appearanceChromeStore.setSnapshot(snapshot);
|
||||
}
|
||||
|
||||
export function getAppearanceChromeSnapshot(): AppearanceChromeSnapshot {
|
||||
return appearanceChromeStore.getSnapshot();
|
||||
}
|
||||
|
||||
export function subscribeAppearanceChrome(listener: Listener): () => void {
|
||||
return appearanceChromeStore.subscribe(listener);
|
||||
}
|
||||
|
||||
export function useAppearanceChromeStore(): AppearanceChromeSnapshot {
|
||||
return useSyncExternalStore(
|
||||
subscribeAppearanceChrome,
|
||||
getAppearanceChromeSnapshot,
|
||||
getAppearanceChromeSnapshot,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
EMPTY_NOTES_SNAPSHOT,
|
||||
getEmptyNotesSnapshot,
|
||||
getNotesActions,
|
||||
getNotesSnapshot,
|
||||
publishNotesSnapshot,
|
||||
registerNotesActions,
|
||||
subscribeNotes,
|
||||
subscribeNotesNoop,
|
||||
} from './notesStore.ts';
|
||||
|
||||
test('notesStore notifies subscribers only when snapshot identity changes', () => {
|
||||
const events: number[] = [];
|
||||
const unsubscribe = subscribeNotes(() => {
|
||||
events.push(getNotesSnapshot().notes.length);
|
||||
});
|
||||
|
||||
const firstNotes = [{
|
||||
id: 'n1',
|
||||
title: 'One',
|
||||
content: 'body',
|
||||
tags: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
order: 1000,
|
||||
}];
|
||||
const firstGroups = ['Ops'];
|
||||
publishNotesSnapshot({ notes: firstNotes, noteGroups: firstGroups });
|
||||
assert.equal(events.at(-1), 1);
|
||||
assert.equal(getNotesSnapshot().notes, firstNotes);
|
||||
assert.equal(getNotesSnapshot().noteGroups, firstGroups);
|
||||
|
||||
publishNotesSnapshot({ notes: firstNotes, noteGroups: firstGroups });
|
||||
assert.equal(events.length, 1);
|
||||
|
||||
const secondNotes = [
|
||||
...firstNotes,
|
||||
{
|
||||
id: 'n2',
|
||||
title: 'Two',
|
||||
content: 'more',
|
||||
tags: [],
|
||||
createdAt: 2,
|
||||
updatedAt: 2,
|
||||
order: 2000,
|
||||
},
|
||||
];
|
||||
publishNotesSnapshot({ notes: secondNotes, noteGroups: firstGroups });
|
||||
assert.equal(events.at(-1), 2);
|
||||
|
||||
publishNotesSnapshot({ notes: secondNotes, noteGroups: ['Ops', 'DB'] });
|
||||
assert.equal(events.length, 3);
|
||||
assert.deepEqual(getNotesSnapshot().noteGroups, ['Ops', 'DB']);
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test('notesStore gated helpers stay empty and never notify', () => {
|
||||
let called = 0;
|
||||
const unsub = subscribeNotesNoop(() => {
|
||||
called += 1;
|
||||
});
|
||||
publishNotesSnapshot({
|
||||
notes: [{
|
||||
id: 'n1',
|
||||
title: 'One',
|
||||
content: 'body',
|
||||
tags: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
order: 1000,
|
||||
}],
|
||||
noteGroups: ['Ops'],
|
||||
});
|
||||
assert.equal(called, 0);
|
||||
assert.equal(getEmptyNotesSnapshot(), EMPTY_NOTES_SNAPSHOT);
|
||||
assert.equal(getEmptyNotesSnapshot().notes.length, 0);
|
||||
unsub();
|
||||
});
|
||||
|
||||
test('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({
|
||||
updateNotes: () => {
|
||||
calls.push('notes');
|
||||
},
|
||||
updateNoteGroups: () => {
|
||||
calls.push('groups');
|
||||
},
|
||||
});
|
||||
const actions = getNotesActions();
|
||||
assert.ok(actions);
|
||||
actions!.updateNotes([]);
|
||||
actions!.updateNoteGroups([]);
|
||||
assert.deepEqual(calls, ['notes', 'groups']);
|
||||
registerNotesActions(null);
|
||||
assert.equal(getNotesActions(), null);
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
import type { VaultNote } from '../../domain/models';
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
export type NotesSnapshot = {
|
||||
notes: readonly VaultNote[];
|
||||
noteGroups: readonly string[];
|
||||
};
|
||||
|
||||
export type NotesActions = {
|
||||
updateNotes: (notes: VaultNote[]) => boolean | void;
|
||||
updateNoteGroups: (groups: string[]) => void;
|
||||
};
|
||||
|
||||
const EMPTY_NOTES: readonly VaultNote[] = Object.freeze([]);
|
||||
const EMPTY_NOTE_GROUPS: readonly string[] = Object.freeze([]);
|
||||
|
||||
export const EMPTY_NOTES_SNAPSHOT: NotesSnapshot = Object.freeze({
|
||||
notes: EMPTY_NOTES,
|
||||
noteGroups: EMPTY_NOTE_GROUPS,
|
||||
});
|
||||
|
||||
/**
|
||||
* External store for vault notes so Notes / AI side-panel consumers can
|
||||
* subscribe without forcing TerminalLayer re-renders on every note edit.
|
||||
*/
|
||||
class NotesStore {
|
||||
private snapshot: NotesSnapshot = EMPTY_NOTES_SNAPSHOT;
|
||||
private actions: NotesActions | null = null;
|
||||
private listeners = new Set<Listener>();
|
||||
private actionListeners = new Set<Listener>();
|
||||
|
||||
getSnapshot = (): NotesSnapshot => this.snapshot;
|
||||
|
||||
subscribe = (listener: Listener): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
setSnapshot(next: NotesSnapshot): void {
|
||||
if (
|
||||
this.snapshot.notes === next.notes
|
||||
&& this.snapshot.noteGroups === next.noteGroups
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.snapshot = next;
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
getActions = (): NotesActions | null => this.actions;
|
||||
|
||||
subscribeActions = (listener: Listener): (() => void) => {
|
||||
this.actionListeners.add(listener);
|
||||
return () => {
|
||||
this.actionListeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
setActions(next: NotesActions | null): void {
|
||||
if (this.actions === next) return;
|
||||
this.actions = next;
|
||||
for (const listener of this.actionListeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const notesStore = new NotesStore();
|
||||
|
||||
export function publishNotesSnapshot(snapshot: NotesSnapshot): void {
|
||||
notesStore.setSnapshot(snapshot);
|
||||
}
|
||||
|
||||
export function getNotesSnapshot(): NotesSnapshot {
|
||||
return notesStore.getSnapshot();
|
||||
}
|
||||
|
||||
export function subscribeNotes(listener: Listener): () => void {
|
||||
return notesStore.subscribe(listener);
|
||||
}
|
||||
|
||||
/** No-op subscribe for gated (hidden) panel mounts. */
|
||||
export function subscribeNotesNoop(_listener: Listener): () => void {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
export function getEmptyNotesSnapshot(): NotesSnapshot {
|
||||
return EMPTY_NOTES_SNAPSHOT;
|
||||
}
|
||||
|
||||
export function registerNotesActions(actions: NotesActions | null): void {
|
||||
notesStore.setActions(actions);
|
||||
}
|
||||
|
||||
export function getNotesActions(): NotesActions | null {
|
||||
return notesStore.getActions();
|
||||
}
|
||||
|
||||
export function subscribeNotesActions(listener: Listener): () => void {
|
||||
return notesStore.subscribeActions(listener);
|
||||
}
|
||||
|
||||
const noopUpdateNotes: NotesActions['updateNotes'] = () => {};
|
||||
const noopUpdateNoteGroups: NotesActions['updateNoteGroups'] = () => {};
|
||||
|
||||
/**
|
||||
* Subscribe to notes catalog + vault mutation actions for Notes / AI panels.
|
||||
*
|
||||
* 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(options?: { enabled?: boolean }): {
|
||||
notes: VaultNote[];
|
||||
noteGroups: string[];
|
||||
updateNotes: NotesActions['updateNotes'];
|
||||
updateNoteGroups: NotesActions['updateNoteGroups'];
|
||||
} {
|
||||
const enabled = options?.enabled !== false;
|
||||
const snapshot = useSyncExternalStore(
|
||||
enabled ? subscribeNotes : subscribeNotesNoop,
|
||||
getNotesSnapshot,
|
||||
getNotesSnapshot,
|
||||
);
|
||||
const actions = useSyncExternalStore(
|
||||
enabled ? subscribeNotesActions : subscribeNotesNoop,
|
||||
getNotesActions,
|
||||
getNotesActions,
|
||||
);
|
||||
return {
|
||||
notes: snapshot.notes as VaultNote[],
|
||||
noteGroups: snapshot.noteGroups as string[],
|
||||
updateNotes: actions?.updateNotes ?? noopUpdateNotes,
|
||||
updateNoteGroups: actions?.updateNoteGroups ?? noopUpdateNoteGroups,
|
||||
};
|
||||
}
|
||||
@@ -15,6 +15,16 @@ function isExpired(entry: StoreEntry): boolean {
|
||||
return Date.now() > entry.expiresAt;
|
||||
}
|
||||
|
||||
function normalizeCapabilities(capabilities: SessionCapabilities): SessionCapabilities {
|
||||
return {
|
||||
...capabilities,
|
||||
hasSs: capabilities.hasSs === true,
|
||||
hasNetstat: capabilities.hasNetstat === true,
|
||||
hasLsof: capabilities.hasLsof === true,
|
||||
hasSystemctl: capabilities.hasSystemctl === true,
|
||||
};
|
||||
}
|
||||
|
||||
function notifySession(sessionId: string) {
|
||||
listenersBySessionId.get(sessionId)?.forEach((listener) => listener());
|
||||
}
|
||||
@@ -28,13 +38,13 @@ export const sessionCapabilitiesStore = {
|
||||
notifySession(sessionId);
|
||||
return undefined;
|
||||
}
|
||||
return entry.capabilities;
|
||||
return normalizeCapabilities(entry.capabilities);
|
||||
},
|
||||
|
||||
set(sessionId: string, capabilities: SessionCapabilities, ttlMs: number) {
|
||||
const entry: StoreEntry = {
|
||||
capabilities: {
|
||||
...capabilities,
|
||||
...normalizeCapabilities(capabilities),
|
||||
probedAt: Date.now(),
|
||||
},
|
||||
expiresAt: Date.now() + ttlMs,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -147,6 +147,7 @@ test("closing one split pane keeps the other terminal as an orphan tab", () => {
|
||||
workspaceId: "ws-1",
|
||||
layoutResult,
|
||||
remainingSessions: nextSessions,
|
||||
preferredTabId: "s-outside",
|
||||
}),
|
||||
"s2",
|
||||
);
|
||||
@@ -197,6 +198,81 @@ test("closing the last visible solo tab does not fall back to a hidden MCP sessi
|
||||
);
|
||||
});
|
||||
|
||||
test("closing a temporary solo tab restores the previously focused tab", () => {
|
||||
const standalone = (id: string, status: TerminalSession["status"] = "connected"): TerminalSession => ({
|
||||
...session(id),
|
||||
workspaceId: undefined,
|
||||
status,
|
||||
});
|
||||
const remainingSessions = [
|
||||
standalone("s1", "disconnected"),
|
||||
standalone("s2"),
|
||||
standalone("s3"),
|
||||
standalone("s4"),
|
||||
standalone("s5", "disconnected"),
|
||||
standalone("s6"),
|
||||
];
|
||||
const layoutResult = closeSessionWorkspaceLayoutState([], undefined, "s7");
|
||||
|
||||
assert.equal(
|
||||
resolveActiveTabAfterCloseSession({
|
||||
currentActiveTabId: "s7",
|
||||
closedSessionId: "s7",
|
||||
workspaceId: undefined,
|
||||
layoutResult,
|
||||
remainingSessions,
|
||||
preferredTabId: "s5",
|
||||
}),
|
||||
"s5",
|
||||
);
|
||||
});
|
||||
|
||||
test("closing a temporary solo tab ignores a preferred tab that no longer exists", () => {
|
||||
const standalone = (id: string): TerminalSession => ({
|
||||
...session(id),
|
||||
workspaceId: undefined,
|
||||
});
|
||||
const remainingSessions = [standalone("s1"), standalone("s2"), standalone("s6")];
|
||||
const layoutResult = closeSessionWorkspaceLayoutState([], undefined, "s7");
|
||||
|
||||
assert.equal(
|
||||
resolveActiveTabAfterCloseSession({
|
||||
currentActiveTabId: "s7",
|
||||
closedSessionId: "s7",
|
||||
workspaceId: undefined,
|
||||
layoutResult,
|
||||
remainingSessions,
|
||||
preferredTabId: "s5",
|
||||
}),
|
||||
"s6",
|
||||
);
|
||||
});
|
||||
|
||||
test("closeSessionsState restores the previous tab instead of the newest remaining tab", () => {
|
||||
const standaloneSession = (id: string): TerminalSession => ({
|
||||
...session(id),
|
||||
workspaceId: undefined,
|
||||
});
|
||||
|
||||
const result = closeSessionsState({
|
||||
sessions: [
|
||||
standaloneSession("s1"),
|
||||
standaloneSession("s2"),
|
||||
standaloneSession("s5"),
|
||||
standaloneSession("s6"),
|
||||
standaloneSession("s7"),
|
||||
],
|
||||
workspaces: [],
|
||||
sessionIds: ["s7"],
|
||||
currentActiveTabId: "s7",
|
||||
preferredTabId: "s5",
|
||||
tabOrder: ["s1", "s2", "s5", "s6", "s7"],
|
||||
});
|
||||
|
||||
assert.equal(result.activeTabId, "s5");
|
||||
assert.deepEqual(result.sessions.map((candidate) => candidate.id), ["s1", "s2", "s5", "s6"]);
|
||||
});
|
||||
|
||||
test("closing several standalone sessions removes the whole batch", () => {
|
||||
const standaloneSession = (id: string): TerminalSession => ({
|
||||
...session(id),
|
||||
|
||||
@@ -129,22 +129,64 @@ export function applyCloseSessionToSessions(
|
||||
));
|
||||
}
|
||||
|
||||
export function isPreferredTabAvailableAfterClose(
|
||||
input: {
|
||||
preferredTabId: string | null | undefined;
|
||||
closedSessionId: string;
|
||||
layoutResult: CloseSessionWorkspaceLayoutResult;
|
||||
remainingSessions: readonly TerminalSession[];
|
||||
},
|
||||
): input is {
|
||||
preferredTabId: string;
|
||||
closedSessionId: string;
|
||||
layoutResult: CloseSessionWorkspaceLayoutResult;
|
||||
remainingSessions: readonly TerminalSession[];
|
||||
} {
|
||||
const { preferredTabId, closedSessionId, layoutResult, remainingSessions } = input;
|
||||
if (!preferredTabId || preferredTabId === closedSessionId) return false;
|
||||
if (
|
||||
preferredTabId === layoutResult.dissolvedWorkspaceId
|
||||
|| preferredTabId === layoutResult.removedWorkspaceId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (preferredTabId === "vault" || preferredTabId === "sftp") return true;
|
||||
if (layoutResult.workspaces.some((workspace) => workspace.id === preferredTabId)) return true;
|
||||
return remainingSessions.some((session) => (
|
||||
session.id === preferredTabId
|
||||
&& !session.workspaceId
|
||||
&& !session.hiddenFromTabs
|
||||
));
|
||||
}
|
||||
|
||||
export function resolveActiveTabAfterCloseSession({
|
||||
currentActiveTabId,
|
||||
closedSessionId,
|
||||
workspaceId,
|
||||
layoutResult,
|
||||
remainingSessions,
|
||||
preferredTabId,
|
||||
}: {
|
||||
currentActiveTabId: string | null;
|
||||
closedSessionId: string;
|
||||
workspaceId: string | undefined;
|
||||
layoutResult: CloseSessionWorkspaceLayoutResult;
|
||||
remainingSessions: readonly TerminalSession[];
|
||||
preferredTabId?: string | null;
|
||||
}): string | null {
|
||||
const fallbackWorkspace = layoutResult.workspaces[layoutResult.workspaces.length - 1];
|
||||
const fallbackSolo = remainingSessions.filter((session) => !session.workspaceId && !session.hiddenFromTabs).slice(-1)[0];
|
||||
const preferredInput = {
|
||||
preferredTabId,
|
||||
closedSessionId,
|
||||
layoutResult,
|
||||
remainingSessions,
|
||||
};
|
||||
const preferredFallback = isPreferredTabAvailableAfterClose(preferredInput)
|
||||
? preferredInput.preferredTabId
|
||||
: undefined;
|
||||
const fallback = layoutResult.lastRemainingSessionId
|
||||
?? preferredFallback
|
||||
?? fallbackWorkspace?.id
|
||||
?? fallbackSolo?.id
|
||||
?? "vault";
|
||||
@@ -166,12 +208,14 @@ export function closeSessionsState({
|
||||
workspaces,
|
||||
sessionIds,
|
||||
currentActiveTabId,
|
||||
preferredTabId,
|
||||
tabOrder,
|
||||
}: {
|
||||
sessions: readonly TerminalSession[];
|
||||
workspaces: readonly Workspace[];
|
||||
sessionIds: readonly string[];
|
||||
currentActiveTabId: string | null;
|
||||
preferredTabId?: string | null;
|
||||
tabOrder: readonly string[];
|
||||
}): CloseSessionsStateResult {
|
||||
let nextSessions = [...sessions];
|
||||
@@ -207,6 +251,7 @@ export function closeSessionsState({
|
||||
workspaceId,
|
||||
layoutResult,
|
||||
remainingSessions: nextSessions,
|
||||
preferredTabId,
|
||||
});
|
||||
if (activeTabAfterClose) {
|
||||
nextActiveTabId = activeTabAfterClose;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export type SortField = "name" | "size" | "modified" | "type";
|
||||
export type SortOrder = "asc" | "desc";
|
||||
|
||||
export interface ColumnWidths {
|
||||
name: number;
|
||||
modified: number;
|
||||
size: number;
|
||||
type: number;
|
||||
}
|
||||
|
||||
export type SftpColumnVisibility = Record<keyof ColumnWidths, boolean>;
|
||||
|
||||
export const DEFAULT_SFTP_COLUMN_VISIBILITY: SftpColumnVisibility = {
|
||||
name: true,
|
||||
modified: true,
|
||||
size: true,
|
||||
type: true,
|
||||
};
|
||||
|
||||
export const normalizeSftpColumnVisibility = (value: unknown): SftpColumnVisibility => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return DEFAULT_SFTP_COLUMN_VISIBILITY;
|
||||
}
|
||||
|
||||
const stored = value as Partial<Record<keyof ColumnWidths, unknown>>;
|
||||
return {
|
||||
name: true,
|
||||
modified: stored.modified !== false,
|
||||
size: stored.size !== false,
|
||||
type: stored.type !== false,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||
import type { SftpBookmark } from "../../../domain/models";
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { STORAGE_KEY_SFTP_LOCAL_BOOKMARKS } from "../../../infrastructure/config/storageKeys";
|
||||
import { createSftpBookmark } from "./bookmarkHelpers";
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
let snapshot: SftpBookmark[] =
|
||||
localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS) ?? [];
|
||||
|
||||
export function subscribeLocalSftpBookmarks(listener: Listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function getLocalSftpBookmarksSnapshot() {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function rehydrateLocalSftpBookmarks() {
|
||||
snapshot = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS) ?? [];
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
export function setLocalSftpBookmarks(
|
||||
next: SftpBookmark[] | ((prev: SftpBookmark[]) => SftpBookmark[]),
|
||||
) {
|
||||
snapshot = typeof next === "function" ? next(snapshot) : next;
|
||||
localStorageAdapter.write(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS, snapshot);
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("storage", (event) => {
|
||||
if (event.key === STORAGE_KEY_SFTP_LOCAL_BOOKMARKS) {
|
||||
rehydrateLocalSftpBookmarks();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
interface UseLocalSftpBookmarksParams {
|
||||
currentPath: string | undefined;
|
||||
}
|
||||
|
||||
export const useLocalSftpBookmarks = ({
|
||||
currentPath,
|
||||
}: UseLocalSftpBookmarksParams) => {
|
||||
const bookmarks = useSyncExternalStore(
|
||||
subscribeLocalSftpBookmarks,
|
||||
getLocalSftpBookmarksSnapshot,
|
||||
getLocalSftpBookmarksSnapshot,
|
||||
);
|
||||
|
||||
const isCurrentPathBookmarked = useMemo(
|
||||
() => !!currentPath && bookmarks.some((b) => b.path === currentPath),
|
||||
[currentPath, bookmarks],
|
||||
);
|
||||
|
||||
const toggleBookmark = useCallback(() => {
|
||||
if (!currentPath) return;
|
||||
if (isCurrentPathBookmarked) {
|
||||
setLocalSftpBookmarks((prev) => prev.filter((b) => b.path !== currentPath));
|
||||
} else {
|
||||
setLocalSftpBookmarks((prev) => [...prev, createSftpBookmark(currentPath)]);
|
||||
}
|
||||
}, [currentPath, isCurrentPathBookmarked]);
|
||||
|
||||
const deleteBookmark = useCallback((id: string) => {
|
||||
setLocalSftpBookmarks((prev) => prev.filter((b) => b.id !== id));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
bookmarks,
|
||||
isCurrentPathBookmarked,
|
||||
toggleBookmark,
|
||||
deleteBookmark,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* SFTP Clipboard Store
|
||||
*
|
||||
* Manages clipboard state for SFTP file operations (copy/cut/paste)
|
||||
* This is a simple store that holds the clipboard state and operation type.
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
type SftpClipboardOperation = "copy" | "cut";
|
||||
|
||||
export interface SftpClipboardFile {
|
||||
name: string;
|
||||
isDirectory: boolean;
|
||||
}
|
||||
|
||||
interface SftpClipboardState {
|
||||
files: SftpClipboardFile[];
|
||||
sourcePath: string;
|
||||
sourceConnectionId: string;
|
||||
sourceSide: "left" | "right";
|
||||
operation: SftpClipboardOperation;
|
||||
}
|
||||
|
||||
type ClipboardListener = () => void;
|
||||
|
||||
let clipboardState: SftpClipboardState | null = null;
|
||||
const clipboardListeners = new Set<ClipboardListener>();
|
||||
|
||||
const notifyListeners = () => {
|
||||
clipboardListeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
export const sftpClipboardStore = {
|
||||
getSnapshot: (): SftpClipboardState | null => clipboardState,
|
||||
|
||||
subscribe: (listener: ClipboardListener) => {
|
||||
clipboardListeners.add(listener);
|
||||
return () => clipboardListeners.delete(listener);
|
||||
},
|
||||
|
||||
/**
|
||||
* Copy files to clipboard
|
||||
*/
|
||||
copy: (
|
||||
files: SftpClipboardFile[],
|
||||
sourcePath: string,
|
||||
sourceConnectionId: string,
|
||||
sourceSide: "left" | "right"
|
||||
) => {
|
||||
clipboardState = {
|
||||
files,
|
||||
sourcePath,
|
||||
sourceConnectionId,
|
||||
sourceSide,
|
||||
operation: "copy",
|
||||
};
|
||||
notifyListeners();
|
||||
},
|
||||
|
||||
/**
|
||||
* Cut files to clipboard
|
||||
*/
|
||||
cut: (
|
||||
files: SftpClipboardFile[],
|
||||
sourcePath: string,
|
||||
sourceConnectionId: string,
|
||||
sourceSide: "left" | "right"
|
||||
) => {
|
||||
clipboardState = {
|
||||
files,
|
||||
sourcePath,
|
||||
sourceConnectionId,
|
||||
sourceSide,
|
||||
operation: "cut",
|
||||
};
|
||||
notifyListeners();
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear clipboard (called after paste for cut operation)
|
||||
*/
|
||||
clear: () => {
|
||||
clipboardState = null;
|
||||
notifyListeners();
|
||||
},
|
||||
|
||||
/**
|
||||
* Update clipboard file list (used for partial cut transfers)
|
||||
*/
|
||||
updateFiles: (files: SftpClipboardFile[]) => {
|
||||
if (!clipboardState) return;
|
||||
if (files.length === 0) {
|
||||
clipboardState = null;
|
||||
} else {
|
||||
clipboardState = {
|
||||
...clipboardState,
|
||||
files,
|
||||
};
|
||||
}
|
||||
notifyListeners();
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if there are files in the clipboard
|
||||
*/
|
||||
hasFiles: (): boolean => clipboardState !== null && clipboardState.files.length > 0,
|
||||
|
||||
/**
|
||||
* Get the clipboard state
|
||||
*/
|
||||
get: (): SftpClipboardState | null => clipboardState,
|
||||
};
|
||||
|
||||
/**
|
||||
* React hook to subscribe to clipboard state changes
|
||||
*/
|
||||
export const useSftpClipboard = (): SftpClipboardState | null => {
|
||||
return useSyncExternalStore(
|
||||
sftpClipboardStore.subscribe,
|
||||
sftpClipboardStore.getSnapshot,
|
||||
sftpClipboardStore.getSnapshot
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* SFTP Dialog Action Store
|
||||
*
|
||||
* Manages dialog action triggers for SFTP operations.
|
||||
* This store allows keyboard shortcuts to trigger dialogs in the appropriate pane.
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore, useEffect } from "react";
|
||||
import { sftpFocusStore, SftpFocusedSide } from "./sftpFocusStore";
|
||||
|
||||
type SftpDialogActionType = "rename" | "delete" | "newFolder" | "newFile" | null;
|
||||
|
||||
interface SftpDialogAction {
|
||||
type: SftpDialogActionType;
|
||||
targetSide: SftpFocusedSide;
|
||||
targetScopeId: string;
|
||||
targetFiles?: string[]; // For rename (single file) or delete (multiple files)
|
||||
timestamp: number; // To distinguish different triggers of the same action
|
||||
}
|
||||
|
||||
type ActionListener = () => void;
|
||||
|
||||
let dialogAction: SftpDialogAction | null = null;
|
||||
const actionListeners = new Set<ActionListener>();
|
||||
|
||||
const notifyListeners = () => {
|
||||
actionListeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
export const sftpDialogActionStore = {
|
||||
getSnapshot: (): SftpDialogAction | null => dialogAction,
|
||||
|
||||
subscribe: (listener: ActionListener) => {
|
||||
actionListeners.add(listener);
|
||||
return () => actionListeners.delete(listener);
|
||||
},
|
||||
|
||||
/**
|
||||
* Trigger a dialog action
|
||||
*/
|
||||
trigger: (type: SftpDialogActionType, targetScopeId: string, targetFiles?: string[]) => {
|
||||
if (!type) {
|
||||
dialogAction = null;
|
||||
} else {
|
||||
dialogAction = {
|
||||
type,
|
||||
targetSide: sftpFocusStore.getFocusedSide(),
|
||||
targetScopeId,
|
||||
targetFiles,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
notifyListeners();
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear the current action (called after a pane handles it)
|
||||
*/
|
||||
clear: () => {
|
||||
dialogAction = null;
|
||||
notifyListeners();
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the current action
|
||||
*/
|
||||
get: (): SftpDialogAction | null => dialogAction,
|
||||
};
|
||||
|
||||
/**
|
||||
* React hook to subscribe to dialog action changes
|
||||
*/
|
||||
export const useSftpDialogAction = (): SftpDialogAction | null => {
|
||||
return useSyncExternalStore(
|
||||
sftpDialogActionStore.subscribe,
|
||||
sftpDialogActionStore.getSnapshot,
|
||||
sftpDialogActionStore.getSnapshot
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* React hook for a pane to respond to dialog actions
|
||||
* Only the pane matching the targetSide will execute the callback
|
||||
*/
|
||||
export const useSftpDialogActionHandler = (
|
||||
side: SftpFocusedSide,
|
||||
scopeId: string,
|
||||
handlers: {
|
||||
onRename?: (fileName: string) => void;
|
||||
onDelete?: (fileNames: string[]) => void;
|
||||
onNewFolder?: () => void;
|
||||
onNewFile?: () => void;
|
||||
},
|
||||
isActive = true
|
||||
) => {
|
||||
const action = useSftpDialogAction();
|
||||
|
||||
useEffect(() => {
|
||||
if (!action || action.targetSide !== side || action.targetScopeId !== scopeId || !isActive) return;
|
||||
|
||||
// Handle the action and clear it
|
||||
switch (action.type) {
|
||||
case "rename":
|
||||
if (handlers.onRename && action.targetFiles?.[0]) {
|
||||
handlers.onRename(action.targetFiles[0]);
|
||||
}
|
||||
break;
|
||||
case "delete":
|
||||
if (handlers.onDelete && action.targetFiles) {
|
||||
handlers.onDelete(action.targetFiles);
|
||||
}
|
||||
break;
|
||||
case "newFolder":
|
||||
handlers.onNewFolder?.();
|
||||
break;
|
||||
case "newFile":
|
||||
handlers.onNewFile?.();
|
||||
break;
|
||||
}
|
||||
|
||||
// Clear the action after handling
|
||||
sftpDialogActionStore.clear();
|
||||
}, [action, side, scopeId, handlers, isActive]);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* SFTP Focused Pane Store
|
||||
*
|
||||
* Tracks which SFTP pane (left or right) is currently focused.
|
||||
* This is used to determine which pane should receive keyboard shortcut actions.
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export type SftpFocusedSide = "left" | "right";
|
||||
|
||||
type FocusListener = () => void;
|
||||
|
||||
let focusedSide: SftpFocusedSide = "left";
|
||||
const focusListeners = new Set<FocusListener>();
|
||||
|
||||
const notifyListeners = () => {
|
||||
focusListeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
export const sftpFocusStore = {
|
||||
getSnapshot: (): SftpFocusedSide => focusedSide,
|
||||
|
||||
subscribe: (listener: FocusListener) => {
|
||||
focusListeners.add(listener);
|
||||
return () => focusListeners.delete(listener);
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the focused side
|
||||
*/
|
||||
setFocusedSide: (side: SftpFocusedSide) => {
|
||||
if (focusedSide !== side) {
|
||||
focusedSide = side;
|
||||
notifyListeners();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the current focused side
|
||||
*/
|
||||
getFocusedSide: (): SftpFocusedSide => focusedSide,
|
||||
};
|
||||
|
||||
/**
|
||||
* React hook to subscribe to focused side changes
|
||||
*/
|
||||
export const useSftpFocusedSide = (): SftpFocusedSide => {
|
||||
return useSyncExternalStore(
|
||||
sftpFocusStore.subscribe,
|
||||
sftpFocusStore.getSnapshot,
|
||||
sftpFocusStore.getSnapshot
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { STORAGE_KEY_SFTP_HOST_VIEW_MODES } from "../../../infrastructure/config/storageKeys";
|
||||
|
||||
// ── Shared external store for per-host SFTP view mode preferences ──
|
||||
|
||||
type ViewMode = 'list' | 'tree';
|
||||
type Listener = () => void;
|
||||
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
let snapshot: Record<string, ViewMode> =
|
||||
localStorageAdapter.read<Record<string, ViewMode>>(STORAGE_KEY_SFTP_HOST_VIEW_MODES) ?? {};
|
||||
|
||||
function subscribe(listener: Listener) {
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function persist(next: Record<string, ViewMode>) {
|
||||
snapshot = next;
|
||||
localStorageAdapter.write(STORAGE_KEY_SFTP_HOST_VIEW_MODES, snapshot);
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
// Sync across windows/tabs via storage events
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('storage', (e) => {
|
||||
if (e.key !== STORAGE_KEY_SFTP_HOST_VIEW_MODES) return;
|
||||
try {
|
||||
snapshot = e.newValue
|
||||
? (JSON.parse(e.newValue) as Record<string, ViewMode>)
|
||||
: {};
|
||||
} catch {
|
||||
snapshot = {};
|
||||
}
|
||||
for (const l of listeners) l();
|
||||
});
|
||||
}
|
||||
|
||||
/** Get the saved view mode for a specific host, or null if none saved. */
|
||||
export function getHostViewMode(hostId: string): ViewMode | null {
|
||||
return snapshot[hostId] ?? null;
|
||||
}
|
||||
|
||||
/** Save the view mode preference for a specific host. */
|
||||
export function setHostViewMode(hostId: string, mode: ViewMode): void {
|
||||
if (snapshot[hostId] === mode) return;
|
||||
persist({ ...snapshot, [hostId]: mode });
|
||||
}
|
||||
|
||||
// ── Hook ──
|
||||
|
||||
export function useSftpHostViewMode(hostId: string | undefined) {
|
||||
const store = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
|
||||
const mode: ViewMode | null = hostId ? (store[hostId] ?? null) : null;
|
||||
|
||||
const setMode = useCallback((newMode: ViewMode) => {
|
||||
if (hostId) {
|
||||
setHostViewMode(hostId, newMode);
|
||||
}
|
||||
}, [hostId]);
|
||||
|
||||
return { hostViewMode: mode, setHostViewMode: setMode };
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
|
||||
export interface SftpTreeSelectionItem {
|
||||
path: string;
|
||||
name: string;
|
||||
isDirectory: boolean;
|
||||
sourcePath: string;
|
||||
}
|
||||
|
||||
interface SftpTreeSelectionState {
|
||||
visibleItems: SftpTreeSelectionItem[];
|
||||
visibleItemsByPath: Map<string, SftpTreeSelectionItem>;
|
||||
visibleIndexByPath: Map<string, number>;
|
||||
visiblePathsSet: Set<string>;
|
||||
selectedPaths: Set<string>;
|
||||
}
|
||||
|
||||
const EMPTY_PATHS = new Set<string>();
|
||||
|
||||
const EMPTY_STATE: SftpTreeSelectionState = {
|
||||
visibleItems: [],
|
||||
visibleItemsByPath: new Map(),
|
||||
visibleIndexByPath: new Map(),
|
||||
visiblePathsSet: new Set(),
|
||||
selectedPaths: EMPTY_PATHS,
|
||||
};
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
const paneStates = new Map<string, SftpTreeSelectionState>();
|
||||
const paneListeners = new Map<string, Set<Listener>>();
|
||||
|
||||
const notifyPaneListeners = (paneId: string) => {
|
||||
paneListeners.get(paneId)?.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
const getPaneState = (paneId: string): SftpTreeSelectionState =>
|
||||
paneStates.get(paneId) ?? EMPTY_STATE;
|
||||
|
||||
const setPaneState = (
|
||||
paneId: string,
|
||||
updater: (state: SftpTreeSelectionState) => SftpTreeSelectionState,
|
||||
) => {
|
||||
const prev = getPaneState(paneId);
|
||||
const next = updater(prev);
|
||||
if (next === prev) return;
|
||||
if (next.visibleItems.length === 0 && next.selectedPaths.size === 0) {
|
||||
paneStates.delete(paneId);
|
||||
} else {
|
||||
paneStates.set(paneId, next);
|
||||
}
|
||||
notifyPaneListeners(paneId);
|
||||
};
|
||||
|
||||
export const sftpTreeSelectionStore = {
|
||||
getPaneState,
|
||||
|
||||
getSelectedItems: (paneId: string): SftpTreeSelectionItem[] => {
|
||||
const state = getPaneState(paneId);
|
||||
const result: SftpTreeSelectionItem[] = [];
|
||||
for (const path of state.selectedPaths) {
|
||||
const item = state.visibleItemsByPath.get(path);
|
||||
if (item) result.push(item);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
setVisibleItems: (paneId: string, visibleItems: SftpTreeSelectionItem[]) => {
|
||||
const visibleItemsByPath = new Map<string, SftpTreeSelectionItem>();
|
||||
const visibleIndexByPath = new Map<string, number>();
|
||||
const visiblePathsSet = new Set(visibleItems.map((item) => item.path));
|
||||
visibleItems.forEach((item, index) => {
|
||||
visibleItemsByPath.set(item.path, item);
|
||||
visibleIndexByPath.set(item.path, index);
|
||||
});
|
||||
setPaneState(paneId, (state) => {
|
||||
const newSelected = new Set([...state.selectedPaths].filter((p) => visiblePathsSet.has(p)));
|
||||
const changed =
|
||||
newSelected.size !== state.selectedPaths.size ||
|
||||
[...newSelected].some((p) => !state.selectedPaths.has(p));
|
||||
return {
|
||||
visibleItems,
|
||||
visibleItemsByPath,
|
||||
visibleIndexByPath,
|
||||
visiblePathsSet,
|
||||
selectedPaths: changed ? newSelected : state.selectedPaths,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setSelection: (paneId: string, selectedPaths: Iterable<string>) => {
|
||||
setPaneState(paneId, (state) => ({
|
||||
...state,
|
||||
selectedPaths: new Set(Array.from(selectedPaths).filter((path) => state.visiblePathsSet.has(path))),
|
||||
}));
|
||||
},
|
||||
|
||||
clearSelection: (paneId: string) => {
|
||||
setPaneState(paneId, (state) => ({ ...state, selectedPaths: EMPTY_PATHS }));
|
||||
},
|
||||
|
||||
clearAllExcept: (paneIdsToKeep?: Iterable<string>) => {
|
||||
const keep = new Set(paneIdsToKeep ?? []);
|
||||
Array.from(paneStates.keys()).forEach((paneId) => {
|
||||
if (keep.has(paneId)) return;
|
||||
setPaneState(paneId, (state) => {
|
||||
if (state.selectedPaths.size === 0) return state;
|
||||
return { ...state, selectedPaths: EMPTY_PATHS };
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
selectAllVisible: (paneId: string) => {
|
||||
setPaneState(paneId, (state) => ({
|
||||
...state,
|
||||
selectedPaths: new Set(
|
||||
state.visibleItems.map((item) => item.path),
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
clearPane: (paneId: string) => {
|
||||
if (!paneStates.has(paneId)) return;
|
||||
paneStates.delete(paneId);
|
||||
notifyPaneListeners(paneId);
|
||||
},
|
||||
|
||||
subscribe: (paneId: string, listener: Listener) => {
|
||||
const listeners = paneListeners.get(paneId) ?? new Set<Listener>();
|
||||
listeners.add(listener);
|
||||
paneListeners.set(paneId, listeners);
|
||||
return () => {
|
||||
const current = paneListeners.get(paneId);
|
||||
if (!current) return;
|
||||
current.delete(listener);
|
||||
if (current.size === 0) {
|
||||
paneListeners.delete(paneId);
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const useSftpTreeSelectionState = (paneId: string): SftpTreeSelectionState => {
|
||||
const subscribe = useCallback(
|
||||
(listener: () => void) => sftpTreeSelectionStore.subscribe(paneId, listener),
|
||||
[paneId],
|
||||
);
|
||||
return useSyncExternalStore(
|
||||
subscribe,
|
||||
() => sftpTreeSelectionStore.getPaneState(paneId),
|
||||
() => sftpTreeSelectionStore.getPaneState(paneId),
|
||||
);
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
DEFAULT_SFTP_TRANSFER_POOL_IDLE_TTL_MS,
|
||||
isTransferPoolIdleReclaimDisabled,
|
||||
resolveSftpTransferPoolIdleTtlMs,
|
||||
} from "../../../infrastructure/config/sftpTransferPool.ts";
|
||||
|
||||
test("default pool idle TTL is five minutes", () => {
|
||||
assert.equal(DEFAULT_SFTP_TRANSFER_POOL_IDLE_TTL_MS, 5 * 60_000);
|
||||
assert.equal(resolveSftpTransferPoolIdleTtlMs(() => null), DEFAULT_SFTP_TRANSFER_POOL_IDLE_TTL_MS);
|
||||
});
|
||||
|
||||
test("accepts preset values including never-reclaim zero", () => {
|
||||
assert.equal(resolveSftpTransferPoolIdleTtlMs(() => 60_000), 60_000);
|
||||
assert.equal(resolveSftpTransferPoolIdleTtlMs(() => 0), 0);
|
||||
assert.equal(resolveSftpTransferPoolIdleTtlMs(() => 123), DEFAULT_SFTP_TRANSFER_POOL_IDLE_TTL_MS);
|
||||
});
|
||||
|
||||
test("transfer pool idle reclaim helper is deprecated and always reclaim-enabled", () => {
|
||||
// Channels close when idle; SSH park is separate. The helper always returns false.
|
||||
assert.equal(isTransferPoolIdleReclaimDisabled(0), false);
|
||||
assert.equal(isTransferPoolIdleReclaimDisabled(300_000), false);
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import type { Host, Identity, KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
|
||||
import { createKnownHostFromHostKeyInfo } from "../../../domain/knownHosts";
|
||||
import type { SftpHostKeyInfo, SftpHostKeyVerificationState, SftpPane } from "./types";
|
||||
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
|
||||
import { useSftpHostCredentials } from "./useSftpHostCredentials";
|
||||
@@ -206,15 +207,7 @@ const createKnownHostFromSftpHostKeyInfo = (
|
||||
hostKeyInfo: SftpHostKeyInfo,
|
||||
now = Date.now(),
|
||||
idSuffix = Math.random().toString(36).slice(2, 11),
|
||||
): KnownHost => ({
|
||||
id: hostKeyInfo.knownHostId || `kh-${now}-${idSuffix}`,
|
||||
hostname: hostKeyInfo.hostname,
|
||||
port: hostKeyInfo.port || 22,
|
||||
keyType: hostKeyInfo.keyType,
|
||||
publicKey: hostKeyInfo.publicKey || `SHA256:${hostKeyInfo.fingerprint}`,
|
||||
fingerprint: hostKeyInfo.fingerprint,
|
||||
discoveredAt: now,
|
||||
});
|
||||
): KnownHost => createKnownHostFromHostKeyInfo(hostKeyInfo, { now, idSuffix });
|
||||
|
||||
export const useSftpConnections = ({
|
||||
hosts,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("./useSftpPaneSorting.ts", import.meta.url), "utf8");
|
||||
|
||||
test("useSftpPaneSorting imports column layout from application state", () => {
|
||||
assert.doesNotMatch(source, /from ["'].*components\//);
|
||||
assert.match(source, /from ["']\.\/columnLayout["']/);
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
DEFAULT_SFTP_COLUMN_VISIBILITY,
|
||||
normalizeSftpColumnVisibility,
|
||||
type ColumnWidths,
|
||||
type SftpColumnVisibility,
|
||||
type SortField,
|
||||
type SortOrder,
|
||||
} from "./columnLayout";
|
||||
import { STORAGE_KEY_SFTP_VISIBLE_COLUMNS } from "../../../infrastructure/config/storageKeys";
|
||||
import {
|
||||
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
|
||||
localStorageAdapter,
|
||||
} from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { useSftpDirectoriesFirst } from "./useSftpDirectoriesFirst";
|
||||
|
||||
export interface UseSftpPaneSortingResult {
|
||||
sortField: SortField;
|
||||
sortOrder: SortOrder;
|
||||
directoriesFirst: boolean;
|
||||
columnWidths: ColumnWidths;
|
||||
visibleColumns: SftpColumnVisibility;
|
||||
handleSort: (field: SortField) => void;
|
||||
handleResizeStart: (field: keyof ColumnWidths, e: React.MouseEvent) => void;
|
||||
toggleColumnVisibility: (field: keyof ColumnWidths) => void;
|
||||
toggleDirectoriesFirst: () => void;
|
||||
}
|
||||
|
||||
export const useSftpPaneSorting = (): UseSftpPaneSortingResult => {
|
||||
const [sortField, setSortField] = useState<SortField>("name");
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>("asc");
|
||||
const { directoriesFirst, toggleDirectoriesFirst } = useSftpDirectoriesFirst();
|
||||
const [columnWidths, setColumnWidths] = useState<ColumnWidths>({
|
||||
name: 56,
|
||||
modified: 28,
|
||||
size: 7,
|
||||
type: 9,
|
||||
});
|
||||
const [visibleColumns, setVisibleColumns] = useState<SftpColumnVisibility>(() =>
|
||||
normalizeSftpColumnVisibility(
|
||||
localStorageAdapter.read<Partial<SftpColumnVisibility>>(STORAGE_KEY_SFTP_VISIBLE_COLUMNS),
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const syncVisibleColumns = (event: Event) => {
|
||||
if (event instanceof StorageEvent && event.key !== STORAGE_KEY_SFTP_VISIBLE_COLUMNS) return;
|
||||
if (event instanceof CustomEvent && event.detail?.key !== STORAGE_KEY_SFTP_VISIBLE_COLUMNS) return;
|
||||
const next = normalizeSftpColumnVisibility(
|
||||
localStorageAdapter.read<Partial<SftpColumnVisibility>>(STORAGE_KEY_SFTP_VISIBLE_COLUMNS),
|
||||
);
|
||||
setVisibleColumns(next);
|
||||
};
|
||||
|
||||
window.addEventListener("storage", syncVisibleColumns);
|
||||
window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, syncVisibleColumns);
|
||||
return () => {
|
||||
window.removeEventListener("storage", syncVisibleColumns);
|
||||
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, syncVisibleColumns);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (visibleColumns[sortField]) return;
|
||||
setSortField("name");
|
||||
setSortOrder("asc");
|
||||
}, [sortField, visibleColumns]);
|
||||
|
||||
const toggleColumnVisibility = useCallback((field: keyof ColumnWidths) => {
|
||||
if (field === "name") return;
|
||||
setVisibleColumns((current) => {
|
||||
const next = {
|
||||
...DEFAULT_SFTP_COLUMN_VISIBILITY,
|
||||
...current,
|
||||
name: true,
|
||||
[field]: !current[field],
|
||||
};
|
||||
localStorageAdapter.write(STORAGE_KEY_SFTP_VISIBLE_COLUMNS, next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const resizingRef = useRef<{
|
||||
field: keyof ColumnWidths;
|
||||
startX: number;
|
||||
startWidth: number;
|
||||
} | null>(null);
|
||||
|
||||
const handleSort = useCallback((field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortOrder((prev) => (prev === "asc" ? "desc" : "asc"));
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
}, [sortField]);
|
||||
|
||||
const rafIdRef = useRef<number | null>(null);
|
||||
const lastClientXRef = useRef(0);
|
||||
|
||||
const applyColumnWidth = useCallback(() => {
|
||||
if (!resizingRef.current) return;
|
||||
const { field, startX, startWidth } = resizingRef.current;
|
||||
const diff = lastClientXRef.current - startX;
|
||||
const limits: Record<keyof ColumnWidths, { min: number; max: number }> = {
|
||||
name: { min: 36, max: 78 },
|
||||
modified: { min: 18, max: 42 },
|
||||
size: { min: 5, max: 16 },
|
||||
type: { min: 6, max: 18 },
|
||||
};
|
||||
const { min, max } = limits[field];
|
||||
const newWidth = Math.max(
|
||||
min,
|
||||
Math.min(max, startWidth + diff / 8),
|
||||
);
|
||||
setColumnWidths((prev) => ({
|
||||
...prev,
|
||||
[field]: newWidth,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleResizeMove = useCallback((e: MouseEvent) => {
|
||||
if (!resizingRef.current) return;
|
||||
lastClientXRef.current = e.clientX;
|
||||
if (rafIdRef.current !== null) return;
|
||||
rafIdRef.current = requestAnimationFrame(() => {
|
||||
rafIdRef.current = null;
|
||||
applyColumnWidth();
|
||||
});
|
||||
}, [applyColumnWidth]);
|
||||
|
||||
const handleResizeEnd = useCallback(() => {
|
||||
if (rafIdRef.current !== null) cancelAnimationFrame(rafIdRef.current);
|
||||
applyColumnWidth();
|
||||
rafIdRef.current = null;
|
||||
resizingRef.current = null;
|
||||
document.removeEventListener("mousemove", handleResizeMove);
|
||||
document.removeEventListener("mouseup", handleResizeEnd);
|
||||
}, [applyColumnWidth, handleResizeMove]);
|
||||
|
||||
const handleResizeStart = (
|
||||
field: keyof ColumnWidths,
|
||||
e: React.MouseEvent,
|
||||
) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
lastClientXRef.current = e.clientX;
|
||||
resizingRef.current = {
|
||||
field,
|
||||
startX: e.clientX,
|
||||
startWidth: columnWidths[field],
|
||||
};
|
||||
document.addEventListener("mousemove", handleResizeMove);
|
||||
document.addEventListener("mouseup", handleResizeEnd);
|
||||
};
|
||||
|
||||
return {
|
||||
sortField,
|
||||
sortOrder,
|
||||
directoriesFirst,
|
||||
columnWidths,
|
||||
visibleColumns,
|
||||
handleSort,
|
||||
handleResizeStart,
|
||||
toggleColumnVisibility,
|
||||
toggleDirectoriesFirst,
|
||||
};
|
||||
};
|
||||
@@ -2,25 +2,6 @@ import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
|
||||
export function useWarmSftpTransferPool(params: {
|
||||
hostIds: readonly string[];
|
||||
activeHostId?: string;
|
||||
warmTransferPoolForHost?: (hostId: string) => void | Promise<void>;
|
||||
}) {
|
||||
const warmRef = useRef(params.warmTransferPoolForHost);
|
||||
warmRef.current = params.warmTransferPoolForHost;
|
||||
const hostIdsKey = [...new Set([
|
||||
...params.hostIds,
|
||||
...(params.activeHostId ? [params.activeHostId] : []),
|
||||
])].sort().join("\u0000");
|
||||
|
||||
useEffect(() => {
|
||||
for (const hostId of hostIdsKey.split("\u0000").filter(Boolean)) {
|
||||
void warmRef.current?.(hostId);
|
||||
}
|
||||
}, [hostIdsKey]);
|
||||
}
|
||||
|
||||
export function useReportSftpTransferOwnerActivity(params: {
|
||||
ownerId: string;
|
||||
activeTransfersCount: number;
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import { startTransition, useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
export interface AsyncRecordState<T> {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
updatedAt: number | null;
|
||||
}
|
||||
|
||||
type RecordMap<T> = Record<string, AsyncRecordState<T>>;
|
||||
|
||||
interface UseAsyncRecordCacheOptions<TItem, TValue> {
|
||||
items: TItem[];
|
||||
enabled: boolean;
|
||||
getKey: (item: TItem) => string;
|
||||
fetchRecord: (item: TItem) => Promise<TValue | null>;
|
||||
prefetchLimit?: number;
|
||||
prefetchDelayMs?: number;
|
||||
staleTimeMs?: number;
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function scheduleIdleTask(callback: () => void): () => void {
|
||||
if (typeof window.requestIdleCallback === 'function') {
|
||||
const id = window.requestIdleCallback(callback, { timeout: 1200 });
|
||||
return () => window.cancelIdleCallback(id);
|
||||
}
|
||||
|
||||
const id = window.setTimeout(callback, 80);
|
||||
return () => window.clearTimeout(id);
|
||||
}
|
||||
|
||||
function normalizeRecordError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error || 'Unknown error');
|
||||
}
|
||||
|
||||
function isRecordFresh<TValue>(record: AsyncRecordState<TValue> | undefined, staleTimeMs: number): boolean {
|
||||
if (!record || record.error || record.updatedAt === null) return false;
|
||||
if (!Number.isFinite(staleTimeMs)) return true;
|
||||
return Date.now() - record.updatedAt < staleTimeMs;
|
||||
}
|
||||
|
||||
const EMPTY_RECORDS = {};
|
||||
|
||||
export function useAsyncRecordCache<TItem, TValue>({
|
||||
items,
|
||||
enabled,
|
||||
getKey,
|
||||
fetchRecord,
|
||||
prefetchLimit = 64,
|
||||
prefetchDelayMs = 16,
|
||||
staleTimeMs = 30_000,
|
||||
}: UseAsyncRecordCacheOptions<TItem, TValue>) {
|
||||
const [records, setRecords] = useState<RecordMap<TValue>>(() => EMPTY_RECORDS);
|
||||
const recordsRef = useRef<RecordMap<TValue>>(records);
|
||||
const enabledRef = useRef(enabled);
|
||||
const inflightRef = useRef(new Set<string>());
|
||||
const requestVersionRef = useRef(new Map<string, number>());
|
||||
const queuedForceRef = useRef(new Set<string>());
|
||||
const loadRecordRef = useRef<(
|
||||
item: TItem,
|
||||
options?: { force?: boolean; urgent?: boolean },
|
||||
) => Promise<void>>(async () => {});
|
||||
|
||||
recordsRef.current = records;
|
||||
enabledRef.current = enabled;
|
||||
|
||||
const commitRecords = useCallback((
|
||||
updater: (prev: RecordMap<TValue>) => RecordMap<TValue>,
|
||||
urgent = false,
|
||||
) => {
|
||||
const apply = () => {
|
||||
setRecords((prev) => {
|
||||
const next = updater(prev);
|
||||
recordsRef.current = next;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (urgent) {
|
||||
apply();
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(apply);
|
||||
}, []);
|
||||
|
||||
const loadRecord = useCallback(async (
|
||||
item: TItem,
|
||||
options?: { force?: boolean; urgent?: boolean },
|
||||
) => {
|
||||
if (!enabledRef.current) return;
|
||||
const key = getKey(item);
|
||||
if (!key) return;
|
||||
if (inflightRef.current.has(key)) {
|
||||
if (options?.force) {
|
||||
queuedForceRef.current.add(key);
|
||||
requestVersionRef.current.set(key, (requestVersionRef.current.get(key) ?? 0) + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = recordsRef.current[key];
|
||||
if (!options?.force && isRecordFresh(existing, staleTimeMs)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestVersion = (requestVersionRef.current.get(key) ?? 0) + 1;
|
||||
requestVersionRef.current.set(key, requestVersion);
|
||||
inflightRef.current.add(key);
|
||||
commitRecords((prev) => ({
|
||||
...prev,
|
||||
[key]: {
|
||||
data: prev[key]?.data ?? null,
|
||||
loading: true,
|
||||
error: null,
|
||||
updatedAt: prev[key]?.updatedAt ?? null,
|
||||
},
|
||||
}), options?.urgent);
|
||||
|
||||
try {
|
||||
const data = await fetchRecord(item);
|
||||
if (requestVersionRef.current.get(key) !== requestVersion) return;
|
||||
commitRecords((prev) => ({
|
||||
...prev,
|
||||
[key]: {
|
||||
data,
|
||||
loading: false,
|
||||
error: null,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
if (requestVersionRef.current.get(key) !== requestVersion) return;
|
||||
commitRecords((prev) => ({
|
||||
...prev,
|
||||
[key]: {
|
||||
data: prev[key]?.data ?? null,
|
||||
loading: false,
|
||||
error: normalizeRecordError(error),
|
||||
updatedAt: prev[key]?.updatedAt ?? null,
|
||||
},
|
||||
}));
|
||||
} finally {
|
||||
inflightRef.current.delete(key);
|
||||
if (queuedForceRef.current.has(key)) {
|
||||
if (enabledRef.current) {
|
||||
queuedForceRef.current.delete(key);
|
||||
void loadRecordRef.current(item, { force: true, urgent: options?.urgent });
|
||||
} else {
|
||||
commitRecords((prev) => {
|
||||
const current = prev[key];
|
||||
if (!current?.loading) return prev;
|
||||
return {
|
||||
...prev,
|
||||
[key]: {
|
||||
...current,
|
||||
loading: false,
|
||||
},
|
||||
};
|
||||
}, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [commitRecords, fetchRecord, getKey, staleTimeMs]);
|
||||
|
||||
loadRecordRef.current = loadRecord;
|
||||
|
||||
useEffect(() => {
|
||||
const itemKeys = new Set(items.map(getKey).filter(Boolean));
|
||||
for (const key of queuedForceRef.current) {
|
||||
if (!itemKeys.has(key)) {
|
||||
queuedForceRef.current.delete(key);
|
||||
}
|
||||
}
|
||||
commitRecords((prev) => {
|
||||
let changed = false;
|
||||
const next: RecordMap<TValue> = {};
|
||||
for (const [key, value] of Object.entries(prev) as Array<[string, AsyncRecordState<TValue>]>) {
|
||||
if (!itemKeys.has(key)) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
next[key] = value;
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [commitRecords, getKey, items]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || queuedForceRef.current.size === 0) return;
|
||||
for (const item of items) {
|
||||
const key = getKey(item);
|
||||
if (!key || !queuedForceRef.current.has(key)) continue;
|
||||
queuedForceRef.current.delete(key);
|
||||
void loadRecord(item, { force: true, urgent: true });
|
||||
}
|
||||
}, [enabled, getKey, items, loadRecord]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || items.length === 0 || prefetchLimit <= 0) return undefined;
|
||||
|
||||
let cancelled = false;
|
||||
const candidates = items.slice(0, prefetchLimit);
|
||||
const cancelIdleTask = scheduleIdleTask(() => {
|
||||
void (async () => {
|
||||
for (const item of candidates) {
|
||||
if (cancelled) return;
|
||||
await loadRecord(item);
|
||||
if (prefetchDelayMs > 0) {
|
||||
await delay(prefetchDelayMs);
|
||||
}
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelIdleTask();
|
||||
};
|
||||
}, [enabled, items, loadRecord, prefetchDelayMs, prefetchLimit]);
|
||||
|
||||
const invalidateRecord = useCallback((key: string) => {
|
||||
requestVersionRef.current.set(key, (requestVersionRef.current.get(key) ?? 0) + 1);
|
||||
queuedForceRef.current.delete(key);
|
||||
commitRecords((prev) => {
|
||||
if (!(key in prev)) return prev;
|
||||
const { [key]: _removed, ...next } = prev;
|
||||
return next;
|
||||
}, true);
|
||||
}, [commitRecords]);
|
||||
|
||||
const invalidateMatching = useCallback((matches: (key: string) => boolean) => {
|
||||
for (const key of requestVersionRef.current.keys()) {
|
||||
if (matches(key)) {
|
||||
requestVersionRef.current.set(key, (requestVersionRef.current.get(key) ?? 0) + 1);
|
||||
queuedForceRef.current.delete(key);
|
||||
}
|
||||
}
|
||||
commitRecords((prev) => {
|
||||
let changed = false;
|
||||
const next: RecordMap<TValue> = {};
|
||||
for (const [key, value] of Object.entries(prev) as Array<[string, AsyncRecordState<TValue>]>) {
|
||||
if (matches(key)) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
next[key] = value;
|
||||
}
|
||||
return changed ? next : prev;
|
||||
}, true);
|
||||
}, [commitRecords]);
|
||||
|
||||
const refreshRecord = useCallback(
|
||||
(item: TItem) => loadRecord(item, { force: true, urgent: true }),
|
||||
[loadRecord],
|
||||
);
|
||||
|
||||
return {
|
||||
records,
|
||||
loadRecord,
|
||||
refreshRecord,
|
||||
invalidateRecord,
|
||||
invalidateMatching,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("./useAIChatStreaming.ts", import.meta.url), "utf8");
|
||||
|
||||
test("useAIChatStreaming imports streaming support from infrastructure", () => {
|
||||
assert.match(source, /from ['"]\.\.\/\.\.\/infrastructure\/ai\/aiChatStreamingSupport['"]/);
|
||||
assert.doesNotMatch(source, /from ['"].*components\//);
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* useAIChatStreaming — React UI layer for AI chat streaming.
|
||||
*
|
||||
* Turn orchestration lives in AgentRuntime + TurnDrivers; this hook only
|
||||
* manages streaming state, abort controllers, and UI callbacks.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type {
|
||||
AIPermissionMode,
|
||||
AIToolIntegrationMode,
|
||||
AISession,
|
||||
ChatMessage,
|
||||
ChatMessageAttachment,
|
||||
ExternalAgentConfig,
|
||||
ProviderConfig,
|
||||
WebSearchConfig,
|
||||
} from '../../infrastructure/ai/types';
|
||||
import type { ExecutorContext } from '../../infrastructure/ai/cattyAgent/executor';
|
||||
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime';
|
||||
import type {
|
||||
TurnSteerInput,
|
||||
TurnSteerResult,
|
||||
} from '../../infrastructure/ai/harness/turnDrivers/types';
|
||||
import { classifyError } from '../../infrastructure/ai/errorClassifier';
|
||||
import { latestAISessionsSnapshot } from './aiStateSnapshots';
|
||||
import {
|
||||
generateId,
|
||||
getNetcattyBridge,
|
||||
type DefaultTargetSessionHint,
|
||||
type TerminalSessionInfo,
|
||||
} from '../../infrastructure/ai/aiChatStreamingSupport';
|
||||
import { useAgentCompactionUi } from './useAgentCompactionUi';
|
||||
|
||||
export { getNetcattyBridge } from '../../infrastructure/ai/aiChatStreamingSupport';
|
||||
export type { ActiveCompactionUi } from './useAgentCompactionUi';
|
||||
export type { DefaultTargetSessionHint } from '../../infrastructure/ai/aiChatStreamingSupport';
|
||||
|
||||
const sharedStreamingSessionIds = new Set<string>();
|
||||
const sharedAbortControllers = new Map<string, AbortController>();
|
||||
const streamingSubscribers = new Set<() => void>();
|
||||
|
||||
/** Whether a chat session still has an active stream (used to keep panel mounted while hidden). */
|
||||
export function isAIChatSessionStreaming(sessionId: string | null | undefined): boolean {
|
||||
return !!sessionId && sharedStreamingSessionIds.has(sessionId);
|
||||
}
|
||||
|
||||
function emitStreamingStoreChange(): void {
|
||||
streamingSubscribers.forEach(listener => {
|
||||
try {
|
||||
listener();
|
||||
} catch (err) {
|
||||
console.error('[AIChatStreaming] Failed to notify streaming subscriber:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export interface UseAIChatStreamingParams {
|
||||
maxIterations: number;
|
||||
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
|
||||
updateLastMessage: (sessionId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
|
||||
updateMessageById: (sessionId: string, messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
|
||||
persistContextCompaction?: (
|
||||
sessionId: string,
|
||||
compaction: import('../../infrastructure/ai/types').AISessionContextCompaction,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface UseAIChatStreamingReturn {
|
||||
streamingSessionIds: Set<string>;
|
||||
setStreamingForScope: (key: string, val: boolean) => void;
|
||||
abortControllersRef: React.MutableRefObject<Map<string, AbortController>>;
|
||||
sendToCattyAgent: (
|
||||
sessionId: string,
|
||||
sendScopeKey: string,
|
||||
trimmed: string,
|
||||
abortController: AbortController,
|
||||
currentSession: AISession | undefined,
|
||||
assistantMsgId: string,
|
||||
context: SendToCattyContext,
|
||||
attachments?: ChatMessageAttachment[],
|
||||
) => Promise<void>;
|
||||
sendToExternalAgent: (
|
||||
sessionId: string,
|
||||
assistantMsgId: string,
|
||||
trimmed: string,
|
||||
agentConfig: ExternalAgentConfig,
|
||||
abortController: AbortController,
|
||||
attachedImages: Array<{ base64Data: string; mediaType: string; filename?: string; filePath?: string }>,
|
||||
context: SendToExternalContext,
|
||||
) => Promise<void>;
|
||||
steerExternalAgent: (input: TurnSteerInput) => Promise<TurnSteerResult>;
|
||||
reportStreamError: (sessionId: string, abortSignal: AbortSignal, err: unknown) => void;
|
||||
activeCompaction: import('./useAgentCompactionUi').ActiveCompactionUi | null;
|
||||
}
|
||||
|
||||
export interface SendToCattyContext {
|
||||
activeProvider: ProviderConfig | undefined;
|
||||
activeModelId: string;
|
||||
scopeType: 'terminal' | 'workspace';
|
||||
scopeTargetId?: string;
|
||||
scopeLabel?: string;
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
commandBlocklist?: string[];
|
||||
commandTimeout?: number;
|
||||
terminalSessions: TerminalSessionInfo[];
|
||||
webSearchConfig?: WebSearchConfig | null;
|
||||
getExecutorContext?: () => ExecutorContext;
|
||||
autoTitleSession: (sessionId: string, text: string) => void;
|
||||
titleText?: string;
|
||||
selectedUserSkillSlugs?: string[];
|
||||
permissionMode?: AIPermissionMode;
|
||||
forceCompaction?: boolean;
|
||||
}
|
||||
|
||||
export interface SendToExternalContext {
|
||||
existingSessionId?: string;
|
||||
updateExternalSessionId?: (sessionId: string, externalSessionId: string | undefined) => void;
|
||||
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
terminalSessions: TerminalSessionInfo[];
|
||||
defaultTargetSession?: DefaultTargetSessionHint;
|
||||
providers: ProviderConfig[];
|
||||
selectedAgentModel?: string;
|
||||
toolIntegrationMode: AIToolIntegrationMode;
|
||||
selectedUserSkillSlugs?: string[];
|
||||
permissionMode: AIPermissionMode;
|
||||
}
|
||||
|
||||
export function useAIChatStreaming({
|
||||
maxIterations,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
persistContextCompaction,
|
||||
}: UseAIChatStreamingParams): UseAIChatStreamingReturn {
|
||||
const [streamingSessionIds, setStreamingSessions] = useState<Set<string>>(
|
||||
() => new Set(sharedStreamingSessionIds),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const syncFromStore = () => {
|
||||
setStreamingSessions(new Set(sharedStreamingSessionIds));
|
||||
};
|
||||
streamingSubscribers.add(syncFromStore);
|
||||
syncFromStore();
|
||||
return () => {
|
||||
streamingSubscribers.delete(syncFromStore);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setStreamingForScope = useCallback((key: string, val: boolean) => {
|
||||
const hadKey = sharedStreamingSessionIds.has(key);
|
||||
if (val) {
|
||||
sharedStreamingSessionIds.add(key);
|
||||
} else {
|
||||
sharedStreamingSessionIds.delete(key);
|
||||
}
|
||||
if (hadKey !== val) {
|
||||
emitStreamingStoreChange();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const abortControllersRef = useRef<Map<string, AbortController>>(sharedAbortControllers);
|
||||
|
||||
const activeCompaction = useAgentCompactionUi();
|
||||
|
||||
const reportStreamError = useCallback((
|
||||
sessionId: string,
|
||||
abortSignal: AbortSignal,
|
||||
err: unknown,
|
||||
) => {
|
||||
if (abortSignal.aborted) return;
|
||||
console.error('[AIChatSidePanel] Stream error (full):', err);
|
||||
const errorInfo = classifyError(err);
|
||||
updateLastMessage(sessionId, msg => ({
|
||||
...msg,
|
||||
statusText: '',
|
||||
executionStatus: msg.executionStatus === 'running' ? 'failed' : msg.executionStatus,
|
||||
}));
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
errorInfo,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}, [updateLastMessage, addMessageToSession]);
|
||||
|
||||
const uiCallbacks = useCallback(() => ({
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
reportStreamError,
|
||||
setStreamingForScope,
|
||||
getLatestSession: (sessionId: string) => latestAISessionsSnapshot?.find(s => s.id === sessionId),
|
||||
persistContextCompaction,
|
||||
}), [addMessageToSession, updateLastMessage, updateMessageById, reportStreamError, setStreamingForScope, persistContextCompaction]);
|
||||
|
||||
const sendToExternalAgent = useCallback(async (
|
||||
sessionId: string,
|
||||
assistantMsgId: string,
|
||||
trimmed: string,
|
||||
agentConfig: ExternalAgentConfig,
|
||||
abortController: AbortController,
|
||||
attachedImages: Array<{ base64Data: string; mediaType: string; filename?: string; filePath?: string }>,
|
||||
context: SendToExternalContext,
|
||||
) => {
|
||||
const bridge = getNetcattyBridge();
|
||||
await getAgentRuntime().runTurn({
|
||||
backend: 'external-sdk',
|
||||
chatSessionId: sessionId,
|
||||
assistantMsgId,
|
||||
userText: trimmed,
|
||||
signal: abortController.signal,
|
||||
agentConfig,
|
||||
attachedImages,
|
||||
context,
|
||||
bridge,
|
||||
ui: uiCallbacks(),
|
||||
});
|
||||
}, [uiCallbacks]);
|
||||
|
||||
const steerExternalAgent = useCallback(async (input: TurnSteerInput) => {
|
||||
return getAgentRuntime().steerTurn(input);
|
||||
}, []);
|
||||
|
||||
const sendToCattyAgent = useCallback(async (
|
||||
sessionId: string,
|
||||
sendScopeKey: string,
|
||||
trimmed: string,
|
||||
abortController: AbortController,
|
||||
currentSession: AISession | undefined,
|
||||
assistantMsgId: string,
|
||||
context: SendToCattyContext,
|
||||
attachments?: ChatMessageAttachment[],
|
||||
) => {
|
||||
const bridge = getNetcattyBridge();
|
||||
try {
|
||||
await getAgentRuntime().runTurn({
|
||||
backend: 'catty',
|
||||
chatSessionId: sessionId,
|
||||
sendScopeKey,
|
||||
userText: trimmed,
|
||||
signal: abortController.signal,
|
||||
currentSession,
|
||||
assistantMsgId,
|
||||
context,
|
||||
attachments,
|
||||
maxIterations,
|
||||
bridge,
|
||||
ui: uiCallbacks(),
|
||||
});
|
||||
} finally {
|
||||
abortControllersRef.current.delete(sessionId);
|
||||
}
|
||||
}, [maxIterations, uiCallbacks]);
|
||||
|
||||
return {
|
||||
streamingSessionIds,
|
||||
setStreamingForScope,
|
||||
abortControllersRef,
|
||||
sendToCattyAgent,
|
||||
sendToExternalAgent,
|
||||
steerExternalAgent,
|
||||
reportStreamError,
|
||||
activeCompaction,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ContextPrepareTrigger } from '../../infrastructure/ai/harness/types';
|
||||
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime';
|
||||
import { CATTY_COMPACTION_STATUS_KEYS } from '../../infrastructure/ai/harness/compactionStatusKeys';
|
||||
|
||||
export interface ActiveCompactionUi {
|
||||
sessionId: string;
|
||||
trigger: ContextPrepareTrigger;
|
||||
}
|
||||
|
||||
export interface AgentContextUsage {
|
||||
sessionId: string;
|
||||
inputTokens: number;
|
||||
contextWindow: number;
|
||||
estimated: boolean;
|
||||
}
|
||||
|
||||
function statusKeyForTrigger(trigger: ContextPrepareTrigger): string {
|
||||
switch (trigger) {
|
||||
case 'step':
|
||||
return CATTY_COMPACTION_STATUS_KEYS.step;
|
||||
case '413-retry':
|
||||
return CATTY_COMPACTION_STATUS_KEYS.retry;
|
||||
default:
|
||||
return CATTY_COMPACTION_STATUS_KEYS.preTurn;
|
||||
}
|
||||
}
|
||||
|
||||
export function useAgentCompactionUi(): ActiveCompactionUi | null {
|
||||
const [active, setActive] = useState<ActiveCompactionUi | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = getAgentRuntime().subscribe((event) => {
|
||||
const sessionId = event.chatSessionId ?? event.sessionId;
|
||||
if (event.type === 'compaction_start') {
|
||||
setActive({ sessionId, trigger: event.trigger });
|
||||
return;
|
||||
}
|
||||
if (event.type === 'compaction' || event.type === 'turn_end') {
|
||||
setActive((prev) => (prev?.sessionId === sessionId ? null : prev));
|
||||
}
|
||||
});
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
return active;
|
||||
}
|
||||
|
||||
export function compactionStatusText(
|
||||
trigger: ContextPrepareTrigger,
|
||||
translate: (key: string, params?: Record<string, string | number>) => string,
|
||||
): string {
|
||||
return translate(statusKeyForTrigger(trigger));
|
||||
}
|
||||
|
||||
export function resolveCompactionStatusText(
|
||||
statusText: string | undefined,
|
||||
translate: (key: string) => string,
|
||||
): string | undefined {
|
||||
if (!statusText) return undefined;
|
||||
if (statusText.startsWith('ai.')) return translate(statusText);
|
||||
return statusText;
|
||||
}
|
||||
|
||||
export function useAgentContextUsage(sessionId: string | null | undefined): AgentContextUsage | null {
|
||||
const [usage, setUsage] = useState<AgentContextUsage | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
setUsage(null);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const unsubscribe = getAgentRuntime().subscribe((event) => {
|
||||
const eventSessionId = event.chatSessionId ?? event.sessionId;
|
||||
if (eventSessionId !== sessionId || event.type !== 'context_snapshot') return;
|
||||
const snapshot = event.snapshot;
|
||||
if (
|
||||
typeof snapshot.contextWindow !== 'number'
|
||||
|| typeof snapshot.estimatedInputTokens !== 'number'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setUsage({
|
||||
sessionId,
|
||||
inputTokens: Math.max(0, snapshot.estimatedInputTokens),
|
||||
contextWindow: Math.max(1, snapshot.contextWindow),
|
||||
estimated: true,
|
||||
});
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [sessionId]);
|
||||
|
||||
return usage?.sessionId === sessionId ? usage : null;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { buildAITerminalSessionInfo } from '../../components/terminalLayer/TerminalLayerSupport';
|
||||
import { buildAITerminalSessionInfo } from '../../domain/buildAITerminalSessionInfo';
|
||||
import { detectLocalOs } from '../../lib/localShell';
|
||||
import type { Host, PortForwardingRule, TerminalSession } from '../../types';
|
||||
import { STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED } from '../../infrastructure/config/storageKeys';
|
||||
|
||||
@@ -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 {
|
||||
@@ -144,12 +145,26 @@ export function readExternalMcpMode(): ExternalMcpMode {
|
||||
return normalizeExternalMcpMode(localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE));
|
||||
}
|
||||
|
||||
export function writeExternalMcpMode(mode: ExternalMcpMode): ExternalMcpMode {
|
||||
const normalized = normalizeExternalMcpMode(mode);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, normalized);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_MODE);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function readExternalMcpIdleTimeoutMinutes(): number {
|
||||
return normalizeExternalMcpIdleTimeoutMinutes(
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES),
|
||||
);
|
||||
}
|
||||
|
||||
export function writeExternalMcpIdleTimeoutMinutes(minutes: number): number {
|
||||
const normalized = normalizeExternalMcpIdleTimeoutMinutes(minutes);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES, normalized);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** Whether host_open should surface/focus the main window. Defaults to true (existing behavior). */
|
||||
export function readExternalMcpFocusOnHostOpen(): boolean {
|
||||
return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN) ?? true;
|
||||
@@ -187,6 +202,13 @@ export function readSessionIdleTimeoutMinutes(): number {
|
||||
);
|
||||
}
|
||||
|
||||
export function writeSessionIdleTimeoutMinutes(minutes: number): number {
|
||||
const normalized = normalizeSessionIdleTimeoutMinutes(minutes);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES, normalized);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function shouldStartExternalMcpOnStartup({
|
||||
enabled,
|
||||
mode,
|
||||
@@ -289,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\(\)/);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("./usePortForwardHostKeyVerification.ts", import.meta.url), "utf8");
|
||||
|
||||
test("usePortForwardHostKeyVerification stays below the UI layer", () => {
|
||||
assert.doesNotMatch(source, /from ["'].*components\//);
|
||||
assert.match(source, /from ["']\.\.\/\.\.\/domain\/portForwardHostKey["']/);
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { KnownHost } from "../../domain/models";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
import type { HostKeyInfo } from "../../components/terminal/TerminalHostKeyVerification";
|
||||
import type { HostKeyInfo } from "../../domain/hostKey";
|
||||
import {
|
||||
createKnownHostFromPortForwardHostKeyInfo,
|
||||
enqueuePortForwardHostKeyVerification,
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
toPendingPortForwardHostKeyVerification,
|
||||
type PendingPortForwardHostKeyVerification,
|
||||
type PortForwardHostKeyRequest,
|
||||
} from "../../components/port-forwarding/hostKeyVerification";
|
||||
} from "../../domain/portForwardHostKey";
|
||||
|
||||
export interface PortForwardHostKeyVerificationState {
|
||||
hostKeyInfo: HostKeyInfo;
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export interface DiskInfo {
|
||||
capacityKey?: string; // Filesystem or shared-pool identity for deduplication
|
||||
mountPoint: string;
|
||||
used: number; // Used in GB
|
||||
total: number; // Total in GB
|
||||
percent: number; // Usage percentage
|
||||
}
|
||||
|
||||
export interface NetInterfaceInfo {
|
||||
name: string; // Interface name (e.g., eth0, ens33)
|
||||
rxBytes: number; // Total received bytes
|
||||
txBytes: number; // Total transmitted bytes
|
||||
rxSpeed: number; // Receive speed (bytes/sec)
|
||||
txSpeed: number; // Transmit speed (bytes/sec)
|
||||
}
|
||||
|
||||
export interface ProcessInfo {
|
||||
pid: string;
|
||||
memPercent: number;
|
||||
command: string;
|
||||
}
|
||||
|
||||
export interface ServerStats {
|
||||
hostname?: string; // Remote hostname
|
||||
osName?: string; // Remote operating system name
|
||||
kernelRelease?: string; // Remote kernel release
|
||||
uptimeSeconds?: number | null;// Remote uptime in seconds
|
||||
loadAverage?: number[]; // 1/5/15 minute load average
|
||||
cpu: number | null; // CPU usage percentage (0-100)
|
||||
cpuCores: number | null; // Number of CPU cores
|
||||
cpuPerCore: number[]; // Per-core CPU usage array
|
||||
memTotal: number | null; // Total memory in MB
|
||||
memUsed: number | null; // Used memory in MB (excluding buffers/cache)
|
||||
memFree: number | null; // Free memory in MB
|
||||
memBuffers: number | null; // Buffers in MB
|
||||
memCached: number | null; // Cached in MB
|
||||
swapTotal: number | null; // Total swap in MB
|
||||
swapUsed: number | null; // Used swap in MB
|
||||
topProcesses: ProcessInfo[]; // Top 10 processes by memory
|
||||
diskPercent: number | null; // Disk usage percentage for root partition
|
||||
diskUsed: number | null; // Disk used in GB
|
||||
diskTotal: number | null; // Total disk in GB
|
||||
disks: DiskInfo[]; // All mounted disks
|
||||
netRxSpeed: number; // Total network receive speed (bytes/sec)
|
||||
netTxSpeed: number; // Total network transmit speed (bytes/sec)
|
||||
latencyMs: number | null; // TCP connection establishment latency to the SSH endpoint
|
||||
netInterfaces: NetInterfaceInfo[]; // Per-interface network stats
|
||||
lastUpdated: number | null; // Timestamp of last successful update
|
||||
}
|
||||
|
||||
interface UseServerStatsOptions {
|
||||
sessionId: string;
|
||||
enabled: boolean; // Whether stats collection is enabled (from settings)
|
||||
refreshInterval: number; // Refresh interval in seconds
|
||||
isSupportedOs: boolean; // Only collect stats for Linux/macOS servers
|
||||
isConnected: boolean; // Only collect when connected
|
||||
}
|
||||
|
||||
interface ServerStatsState {
|
||||
stats: ServerStats;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface ServerStatsClient {
|
||||
enabled: boolean;
|
||||
refreshInterval: number;
|
||||
isSupportedOs: boolean;
|
||||
isConnected: boolean;
|
||||
}
|
||||
|
||||
type ServerStatsListener = (state: ServerStatsState) => void;
|
||||
|
||||
interface SharedServerStatsSession {
|
||||
sessionId: string;
|
||||
state: ServerStatsState;
|
||||
listeners: Set<ServerStatsListener>;
|
||||
clients: Map<number, ServerStatsClient>;
|
||||
intervalRef: ReturnType<typeof setInterval> | null;
|
||||
initialTimerRef: ReturnType<typeof setTimeout> | null;
|
||||
connectedAt: number;
|
||||
hasFetched: boolean;
|
||||
fetchGeneration: number;
|
||||
consecutiveFailures: number;
|
||||
givenUp: boolean;
|
||||
pollIntervalMs: number | null;
|
||||
pollingActive: boolean;
|
||||
inflight: Promise<void> | null;
|
||||
inflightGeneration: number | null;
|
||||
}
|
||||
|
||||
const CONSECUTIVE_FAILURE_LIMIT = 3;
|
||||
const serverStatsSessions = new Map<string, SharedServerStatsSession>();
|
||||
let nextClientId = 1;
|
||||
|
||||
function createEmptyServerStats(): ServerStats {
|
||||
return {
|
||||
hostname: undefined,
|
||||
osName: undefined,
|
||||
kernelRelease: undefined,
|
||||
uptimeSeconds: null,
|
||||
loadAverage: [],
|
||||
cpu: null,
|
||||
cpuCores: null,
|
||||
cpuPerCore: [],
|
||||
memTotal: null,
|
||||
memUsed: null,
|
||||
memFree: null,
|
||||
memBuffers: null,
|
||||
memCached: null,
|
||||
swapTotal: null,
|
||||
swapUsed: null,
|
||||
topProcesses: [],
|
||||
diskPercent: null,
|
||||
diskUsed: null,
|
||||
diskTotal: null,
|
||||
disks: [],
|
||||
netRxSpeed: 0,
|
||||
netTxSpeed: 0,
|
||||
latencyMs: null,
|
||||
netInterfaces: [],
|
||||
lastUpdated: null,
|
||||
};
|
||||
}
|
||||
|
||||
function createInitialState(): ServerStatsState {
|
||||
return {
|
||||
stats: createEmptyServerStats(),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
function getSharedServerStatsSession(sessionId: string): SharedServerStatsSession {
|
||||
const existing = serverStatsSessions.get(sessionId);
|
||||
if (existing) return existing;
|
||||
|
||||
const session: SharedServerStatsSession = {
|
||||
sessionId,
|
||||
state: createInitialState(),
|
||||
listeners: new Set(),
|
||||
clients: new Map(),
|
||||
intervalRef: null,
|
||||
initialTimerRef: null,
|
||||
connectedAt: 0,
|
||||
hasFetched: false,
|
||||
fetchGeneration: 0,
|
||||
consecutiveFailures: 0,
|
||||
givenUp: false,
|
||||
pollIntervalMs: null,
|
||||
pollingActive: false,
|
||||
inflight: null,
|
||||
inflightGeneration: null,
|
||||
};
|
||||
serverStatsSessions.set(sessionId, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
function emitServerStatsState(session: SharedServerStatsSession): void {
|
||||
for (const listener of session.listeners) {
|
||||
listener(session.state);
|
||||
}
|
||||
}
|
||||
|
||||
function updateServerStatsState(session: SharedServerStatsSession, patch: Partial<ServerStatsState>): void {
|
||||
session.state = { ...session.state, ...patch };
|
||||
emitServerStatsState(session);
|
||||
}
|
||||
|
||||
function clearServerStatsTimers(session: SharedServerStatsSession): void {
|
||||
if (session.initialTimerRef) {
|
||||
clearTimeout(session.initialTimerRef);
|
||||
session.initialTimerRef = null;
|
||||
}
|
||||
if (session.intervalRef) {
|
||||
clearInterval(session.intervalRef);
|
||||
session.intervalRef = null;
|
||||
}
|
||||
session.pollingActive = false;
|
||||
session.pollIntervalMs = null;
|
||||
}
|
||||
|
||||
function resetServerStatsSession(session: SharedServerStatsSession): void {
|
||||
clearServerStatsTimers(session);
|
||||
session.connectedAt = 0;
|
||||
session.hasFetched = false;
|
||||
session.fetchGeneration += 1;
|
||||
session.consecutiveFailures = 0;
|
||||
session.givenUp = false;
|
||||
updateServerStatsState(session, createInitialState());
|
||||
}
|
||||
|
||||
function getActiveServerStatsClients(session: SharedServerStatsSession): ServerStatsClient[] {
|
||||
return Array.from(session.clients.values()).filter((client) => (
|
||||
client.enabled && client.isSupportedOs && client.isConnected
|
||||
));
|
||||
}
|
||||
|
||||
function normalizeServerStats(stats: Partial<ServerStats>): ServerStats {
|
||||
return {
|
||||
hostname: stats.hostname,
|
||||
osName: stats.osName,
|
||||
kernelRelease: stats.kernelRelease,
|
||||
uptimeSeconds: Number.isFinite(stats.uptimeSeconds) ? Number(stats.uptimeSeconds) : null,
|
||||
loadAverage: Array.isArray(stats.loadAverage) ? stats.loadAverage : [],
|
||||
cpu: stats.cpu ?? null,
|
||||
cpuCores: stats.cpuCores ?? null,
|
||||
cpuPerCore: stats.cpuPerCore || [],
|
||||
memTotal: stats.memTotal ?? null,
|
||||
memUsed: stats.memUsed ?? null,
|
||||
memFree: stats.memFree ?? null,
|
||||
memBuffers: stats.memBuffers ?? null,
|
||||
memCached: stats.memCached ?? null,
|
||||
swapTotal: stats.swapTotal ?? null,
|
||||
swapUsed: stats.swapUsed ?? null,
|
||||
topProcesses: stats.topProcesses || [],
|
||||
diskPercent: stats.diskPercent ?? null,
|
||||
diskUsed: stats.diskUsed ?? null,
|
||||
diskTotal: stats.diskTotal ?? null,
|
||||
disks: stats.disks || [],
|
||||
netRxSpeed: stats.netRxSpeed || 0,
|
||||
netTxSpeed: stats.netTxSpeed || 0,
|
||||
latencyMs: Number.isFinite(stats.latencyMs) ? Number(stats.latencyMs) : null,
|
||||
netInterfaces: stats.netInterfaces || [],
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function markServerStatsFailure(session: SharedServerStatsSession, message: string): void {
|
||||
session.consecutiveFailures += 1;
|
||||
updateServerStatsState(session, { error: message });
|
||||
if (session.consecutiveFailures >= CONSECUTIVE_FAILURE_LIMIT) {
|
||||
// Stop polling this session. This covers network devices that advertise
|
||||
// OpenSSH but do not support the POSIX stats shell command.
|
||||
session.givenUp = true;
|
||||
clearServerStatsTimers(session);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSharedServerStats(session: SharedServerStatsSession, force = false): Promise<void> {
|
||||
if (!session.sessionId) return;
|
||||
if (session.inflight && session.inflightGeneration === session.fetchGeneration) {
|
||||
await session.inflight;
|
||||
return;
|
||||
}
|
||||
if (session.givenUp && !force) return;
|
||||
if (getActiveServerStatsClients(session).length === 0) return;
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getServerStats) return;
|
||||
|
||||
if (force) {
|
||||
session.givenUp = false;
|
||||
session.consecutiveFailures = 0;
|
||||
}
|
||||
|
||||
const generation = session.fetchGeneration;
|
||||
session.inflightGeneration = generation;
|
||||
updateServerStatsState(session, { isLoading: true, error: null });
|
||||
|
||||
const request = (async () => {
|
||||
try {
|
||||
const result = await bridge.getServerStats(session.sessionId);
|
||||
|
||||
// Discard stale responses from before a polling restart or reconnect.
|
||||
if (generation !== session.fetchGeneration) return;
|
||||
|
||||
if (result.pending) {
|
||||
// Transient "not ready yet", e.g. a Mosh session whose SSH handshake
|
||||
// has not finished. Do not count this toward the hard-failure cutoff.
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.success && result.stats) {
|
||||
session.hasFetched = true;
|
||||
session.consecutiveFailures = 0;
|
||||
updateServerStatsState(session, {
|
||||
stats: normalizeServerStats(result.stats),
|
||||
error: null,
|
||||
});
|
||||
} else if (result.error) {
|
||||
markServerStatsFailure(session, result.error);
|
||||
} else {
|
||||
markServerStatsFailure(session, 'No stats returned');
|
||||
}
|
||||
} catch (err) {
|
||||
if (generation === session.fetchGeneration) {
|
||||
markServerStatsFailure(session, err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
} finally {
|
||||
if (generation === session.fetchGeneration) {
|
||||
updateServerStatsState(session, { isLoading: false });
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
session.inflight = request;
|
||||
try {
|
||||
await request;
|
||||
} finally {
|
||||
if (session.inflight === request) {
|
||||
session.inflight = null;
|
||||
session.inflightGeneration = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function reconcileSharedServerStatsSession(session: SharedServerStatsSession): void {
|
||||
const activeClients = getActiveServerStatsClients(session);
|
||||
|
||||
if (activeClients.length === 0) {
|
||||
resetServerStatsSession(session);
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.connectedAt === 0) {
|
||||
session.connectedAt = Date.now();
|
||||
}
|
||||
|
||||
const intervalMs = Math.max(5, Math.min(...activeClients.map((client) => client.refreshInterval))) * 1000;
|
||||
const shouldRestartPolling = !session.pollingActive || session.pollIntervalMs !== intervalMs;
|
||||
if (!shouldRestartPolling) return;
|
||||
if (session.givenUp) return;
|
||||
|
||||
clearServerStatsTimers(session);
|
||||
session.fetchGeneration += 1;
|
||||
session.pollingActive = true;
|
||||
session.pollIntervalMs = intervalMs;
|
||||
|
||||
const connectionAge = Date.now() - session.connectedAt;
|
||||
const needsWarmup = !session.hasFetched && connectionAge < 2000;
|
||||
|
||||
session.initialTimerRef = setTimeout(() => {
|
||||
session.initialTimerRef = null;
|
||||
void fetchSharedServerStats(session);
|
||||
}, needsWarmup ? 2000 : 0);
|
||||
session.intervalRef = setInterval(() => {
|
||||
void fetchSharedServerStats(session);
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
function maybeDisposeSharedServerStatsSession(session: SharedServerStatsSession): void {
|
||||
if (session.clients.size > 0 || session.listeners.size > 0) return;
|
||||
clearServerStatsTimers(session);
|
||||
session.fetchGeneration += 1;
|
||||
serverStatsSessions.delete(session.sessionId);
|
||||
}
|
||||
|
||||
export function useServerStats({
|
||||
sessionId,
|
||||
enabled,
|
||||
refreshInterval,
|
||||
isSupportedOs,
|
||||
isConnected,
|
||||
}: UseServerStatsOptions) {
|
||||
const clientIdRef = useRef<number | null>(null);
|
||||
if (clientIdRef.current === null) {
|
||||
clientIdRef.current = nextClientId;
|
||||
nextClientId += 1;
|
||||
}
|
||||
|
||||
const [state, setState] = useState<ServerStatsState>(() => getSharedServerStatsSession(sessionId).state);
|
||||
|
||||
useEffect(() => {
|
||||
const session = getSharedServerStatsSession(sessionId);
|
||||
const clientId = clientIdRef.current;
|
||||
if (clientId === null) return;
|
||||
|
||||
const listener: ServerStatsListener = (nextState) => {
|
||||
setState(nextState);
|
||||
};
|
||||
|
||||
session.listeners.add(listener);
|
||||
setState(session.state);
|
||||
|
||||
return () => {
|
||||
session.listeners.delete(listener);
|
||||
session.clients.delete(clientId);
|
||||
reconcileSharedServerStatsSession(session);
|
||||
maybeDisposeSharedServerStatsSession(session);
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
const session = getSharedServerStatsSession(sessionId);
|
||||
const clientId = clientIdRef.current;
|
||||
if (clientId === null) return;
|
||||
|
||||
session.clients.set(clientId, {
|
||||
enabled,
|
||||
refreshInterval,
|
||||
isSupportedOs,
|
||||
isConnected,
|
||||
});
|
||||
setState(session.state);
|
||||
reconcileSharedServerStatsSession(session);
|
||||
}, [sessionId, enabled, refreshInterval, isSupportedOs, isConnected]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
const session = getSharedServerStatsSession(sessionId);
|
||||
void fetchSharedServerStats(session, true).finally(() => {
|
||||
reconcileSharedServerStatsSession(session);
|
||||
});
|
||||
}, [sessionId]);
|
||||
|
||||
return {
|
||||
stats: state.stats,
|
||||
isLoading: state.isLoading,
|
||||
error: state.error,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
@@ -542,6 +542,7 @@ export const useSessionState = ({
|
||||
workspaces: workspacesRef.current,
|
||||
sessionIds,
|
||||
currentActiveTabId: activeTabStore.getActiveTabId(),
|
||||
preferredTabId: activeTabStore.getPreviousActiveTabId(),
|
||||
tabOrder: tabOrderRef.current,
|
||||
});
|
||||
|
||||
@@ -549,9 +550,10 @@ export const useSessionState = ({
|
||||
setSessions(result.sessions);
|
||||
setTabOrder(result.tabOrder);
|
||||
if (result.activeTabId) {
|
||||
setActiveTabId(result.activeTabId);
|
||||
// Closing must not rewrite previous-tab history to the closed tab.
|
||||
activeTabStore.setActiveTabId(result.activeTabId, { recordPrevious: false });
|
||||
}
|
||||
}, [setActiveTabId]);
|
||||
}, []);
|
||||
|
||||
const closeSession = useCallback((sessionId: string, e?: MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
@@ -619,17 +621,26 @@ export const useSessionState = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const submitWorkspaceRename = useCallback(() => {
|
||||
const submitWorkspaceRename = useCallback((workspaceId?: string, name?: string) => {
|
||||
if (workspaceId !== undefined && name !== undefined) {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
setWorkspaces(prev => prev.map(w => (
|
||||
w.id === workspaceId ? { ...w, title: trimmed, autoTitle: false } : w
|
||||
)));
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkspaceRenameValue(prevValue => {
|
||||
const name = prevValue.trim();
|
||||
if (!name) return prevValue;
|
||||
|
||||
const trimmed = prevValue.trim();
|
||||
if (!trimmed) return prevValue;
|
||||
|
||||
setWorkspaceRenameTarget(prevTarget => {
|
||||
if (!prevTarget) return prevTarget;
|
||||
setWorkspaces(prev => prev.map(w => w.id === prevTarget.id ? { ...w, title: name, autoTitle: false } : w));
|
||||
setWorkspaces(prev => prev.map(w => w.id === prevTarget.id ? { ...w, title: trimmed, autoTitle: false } : w));
|
||||
return null;
|
||||
});
|
||||
|
||||
|
||||
return '';
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
|
||||
|
||||
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,
|
||||
@@ -747,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]);
|
||||
|
||||
@@ -967,6 +979,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const persistedAppearanceChanged = previousAppearance === null
|
||||
|| hasPersistedAppearanceChanged(previousAppearance, appearanceRender);
|
||||
previousAppearanceRenderRef.current = appearanceRender;
|
||||
// Terminal leaves subscribe here so accent drag does not rebuild TerminalLayer.
|
||||
publishAppearanceChromeSnapshot({ accentMode, customAccent });
|
||||
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
|
||||
const apply = () => applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
|
||||
const transitionMode = appearanceTransitionModeRef.current;
|
||||
@@ -1631,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)
|
||||
@@ -1706,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,
|
||||
@@ -1795,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,
|
||||
@@ -1841,7 +1989,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
// Opaque version that changes when any synced setting changes, used by useAutoSync.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
settingsVersion: useMemo(() => Math.random(), [
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode,
|
||||
uiFontFamilyId, uiLanguage, customCSS,
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
|
||||
@@ -399,14 +399,6 @@ export const useSftpState = (
|
||||
[hosts, identities, keys, openPoolSftpSession, transferKnownHosts, transferPoolKeyCache, transferTerminalSettings],
|
||||
);
|
||||
|
||||
/**
|
||||
* @deprecated No-op. SSH transport idle park keeps connections warm; opening
|
||||
* a background transfer channel is unnecessary and could re-trigger MFA.
|
||||
*/
|
||||
const warmTransferPoolForHost = useCallback(async (_hostId: string) => {
|
||||
// Intentionally empty — unified transport registry owns keep-alive.
|
||||
}, []);
|
||||
|
||||
/** True after browse channels were soft-closed while this owner stayed mounted. */
|
||||
const browseParkedRef = useRef(false);
|
||||
const browseLifecycleGenRef = useRef(0);
|
||||
@@ -824,7 +816,6 @@ export const useSftpState = (
|
||||
rejectHostKeyVerification,
|
||||
acceptHostKeyVerification,
|
||||
acceptAndSaveHostKeyVerification,
|
||||
warmTransferPoolForHost,
|
||||
};
|
||||
const methodsRef = useRef(currentMethods);
|
||||
methodsRef.current = currentMethods;
|
||||
@@ -907,8 +898,6 @@ export const useSftpState = (
|
||||
rejectHostKeyVerification: () => methodsRef.current.rejectHostKeyVerification(),
|
||||
acceptHostKeyVerification: () => methodsRef.current.acceptHostKeyVerification(),
|
||||
acceptAndSaveHostKeyVerification: () => methodsRef.current.acceptAndSaveHostKeyVerification(),
|
||||
warmTransferPoolForHost: (...args: Parameters<typeof warmTransferPoolForHost>) =>
|
||||
methodsRef.current.warmTransferPoolForHost(...args),
|
||||
activeFileWatchCountRef,
|
||||
}), [activeFileWatchCountRef]); // activeFileWatchCountRef is a stable ref
|
||||
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
import type { I18nContextValue } from '../i18n/I18nProvider';
|
||||
import { sessionCapabilitiesStore } from './sessionCapabilitiesStore';
|
||||
import type { SessionCapabilities } from '../../domain/systemManager/types';
|
||||
import type { useSystemManagerBackend } from './useSystemManagerBackend';
|
||||
import { nextPollData } from '../../domain/systemManager/pollListStable';
|
||||
|
||||
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function normalizePollingErrorMessage(error: unknown, t: I18nContextValue['t']): string {
|
||||
const message = error instanceof Error ? error.message : String(error || 'Unknown error');
|
||||
const lower = message.toLowerCase();
|
||||
if (lower.includes('channel open failure') || lower.includes('unable to exec')) {
|
||||
return t('systemManager.errors.sshChannelUnavailable');
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
/** Stable i18n ref so polling fetchers do not reset when locale re-renders. */
|
||||
export function useStableTranslate(): I18nContextValue['t'] {
|
||||
const { t } = useI18n();
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
return useCallback(
|
||||
(key, values) => tRef.current(key, values),
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
export function useSessionCapabilities(
|
||||
sessionId: string | null,
|
||||
isConnected: boolean,
|
||||
backend: Backend,
|
||||
enabled: boolean,
|
||||
capabilitiesTtlMs: number,
|
||||
) {
|
||||
const ttlMsRef = useRef(capabilitiesTtlMs);
|
||||
ttlMsRef.current = capabilitiesTtlMs;
|
||||
|
||||
const [capabilities, setCapabilities] = useState<SessionCapabilities | undefined>(
|
||||
() => (sessionId ? sessionCapabilitiesStore.get(sessionId) : undefined),
|
||||
);
|
||||
const [probing, setProbing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return undefined;
|
||||
return sessionCapabilitiesStore.subscribe(sessionId, () => {
|
||||
setCapabilities(sessionCapabilitiesStore.get(sessionId));
|
||||
});
|
||||
}, [sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId || isConnected) return undefined;
|
||||
sessionCapabilitiesStore.delete(sessionId);
|
||||
return undefined;
|
||||
}, [sessionId, isConnected]);
|
||||
|
||||
const probe = useCallback(async (force = false) => {
|
||||
if (!sessionId || !isConnected) return;
|
||||
if (!force && sessionCapabilitiesStore.get(sessionId)) return;
|
||||
setProbing(true);
|
||||
try {
|
||||
const result = await backend.probeSystemCapabilities(sessionId);
|
||||
if (result.success && result.capabilities) {
|
||||
sessionCapabilitiesStore.set(sessionId, result.capabilities, ttlMsRef.current);
|
||||
}
|
||||
} finally {
|
||||
setProbing(false);
|
||||
}
|
||||
}, [backend, isConnected, sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId || !isConnected || !enabled) return undefined;
|
||||
void probe();
|
||||
return undefined;
|
||||
}, [enabled, sessionId, isConnected, probe]);
|
||||
|
||||
return { capabilities, probing, refreshCapabilities: () => probe(true) };
|
||||
}
|
||||
|
||||
/** Prefetch capabilities only for the given session ids (e.g. when System panel opens). */
|
||||
export function useSystemCapabilitiesWarmup(
|
||||
sessionIds: string[],
|
||||
backend: Backend,
|
||||
enabled: boolean,
|
||||
capabilitiesTtlMs: number,
|
||||
) {
|
||||
const backendRef = useRef(backend);
|
||||
backendRef.current = backend;
|
||||
const inflightRef = useRef(new Set<string>());
|
||||
const ttlMsRef = useRef(capabilitiesTtlMs);
|
||||
ttlMsRef.current = capabilitiesTtlMs;
|
||||
|
||||
const sessionKey = enabled ? sessionIds.slice().sort().join(',') : '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionKey) return undefined;
|
||||
for (const sessionId of sessionKey.split(',')) {
|
||||
if (!sessionId || sessionCapabilitiesStore.get(sessionId)) continue;
|
||||
if (inflightRef.current.has(sessionId)) continue;
|
||||
inflightRef.current.add(sessionId);
|
||||
void backendRef.current.probeSystemCapabilities(sessionId).then((result) => {
|
||||
inflightRef.current.delete(sessionId);
|
||||
if (result.success && result.capabilities) {
|
||||
sessionCapabilitiesStore.set(sessionId, result.capabilities, ttlMsRef.current);
|
||||
}
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
}, [sessionKey]);
|
||||
}
|
||||
|
||||
export function usePolling<T>(
|
||||
fetcher: () => Promise<T | null>,
|
||||
intervalMs: number,
|
||||
enabled: boolean,
|
||||
merge?: (prev: T | null, next: T) => T,
|
||||
options?: { poll?: boolean; resetKey?: string },
|
||||
) {
|
||||
const stableT = useStableTranslate();
|
||||
const resetKey = options?.resetKey ?? '';
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [dataKey, setDataKey] = useState(resetKey);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [errorKey, setErrorKey] = useState(resetKey);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingKey, setLoadingKey] = useState(resetKey);
|
||||
const failuresRef = useRef(0);
|
||||
const hasDataRef = useRef(false);
|
||||
const enabledRef = useRef(enabled);
|
||||
const generationRef = useRef(0);
|
||||
const runIdRef = useRef(0);
|
||||
const loadingRunIdRef = useRef(0);
|
||||
const inflightRef = useRef<{ generation: number; runId: number } | null>(null);
|
||||
const queuedRunRef = useRef<{
|
||||
options?: { withLoading?: boolean; minLoadingMs?: number };
|
||||
resolve: () => void;
|
||||
} | null>(null);
|
||||
const fetcherRef = useRef(fetcher);
|
||||
const mergeRef = useRef(merge);
|
||||
const pollRef = useRef(options?.poll ?? true);
|
||||
const resetKeyRef = useRef(resetKey);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
enabledRef.current = enabled;
|
||||
fetcherRef.current = fetcher;
|
||||
mergeRef.current = merge;
|
||||
pollRef.current = options?.poll ?? true;
|
||||
|
||||
const clearPollTimer = useCallback(() => {
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const pollDelayMs = useCallback(() => {
|
||||
if (failuresRef.current >= 3) return intervalMs * 4;
|
||||
return intervalMs;
|
||||
}, [intervalMs]);
|
||||
|
||||
const resolveQueuedRun = useCallback(() => {
|
||||
queuedRunRef.current?.resolve();
|
||||
queuedRunRef.current = null;
|
||||
}, []);
|
||||
|
||||
const run = useCallback(async (options?: { withLoading?: boolean; minLoadingMs?: number }) => {
|
||||
const generation = generationRef.current;
|
||||
const runResetKey = resetKeyRef.current;
|
||||
if (!enabledRef.current) return;
|
||||
if (inflightRef.current?.generation === generation) {
|
||||
if (options?.withLoading) {
|
||||
queuedRunRef.current?.resolve();
|
||||
loadingRunIdRef.current = 0;
|
||||
setLoadingKey(runResetKey);
|
||||
setLoading(true);
|
||||
return new Promise<void>((resolve) => {
|
||||
queuedRunRef.current = { options, resolve };
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const runId = ++runIdRef.current;
|
||||
inflightRef.current = { generation, runId };
|
||||
const showLoading = options?.withLoading ?? !hasDataRef.current;
|
||||
const startedAt = Date.now();
|
||||
const isCurrent = () => (
|
||||
generationRef.current === generation
|
||||
&& enabledRef.current
|
||||
&& inflightRef.current?.runId === runId
|
||||
&& resetKeyRef.current === runResetKey
|
||||
);
|
||||
if (showLoading) {
|
||||
loadingRunIdRef.current = runId;
|
||||
setLoadingKey(runResetKey);
|
||||
setLoading(true);
|
||||
}
|
||||
try {
|
||||
const result = await fetcherRef.current();
|
||||
if (!isCurrent()) return;
|
||||
if (result !== null) {
|
||||
setDataKey(runResetKey);
|
||||
setData((prev) => {
|
||||
const mergeFn = mergeRef.current;
|
||||
const next = mergeFn ? mergeFn(prev, result) : nextPollData(prev, result);
|
||||
if (next !== prev) hasDataRef.current = true;
|
||||
return next;
|
||||
});
|
||||
setErrorKey(runResetKey);
|
||||
setError(null);
|
||||
failuresRef.current = 0;
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isCurrent()) return;
|
||||
failuresRef.current += 1;
|
||||
setDataKey(runResetKey);
|
||||
setData(null);
|
||||
hasDataRef.current = false;
|
||||
setErrorKey(runResetKey);
|
||||
setError(normalizePollingErrorMessage(err, stableT));
|
||||
} finally {
|
||||
if (inflightRef.current?.runId === runId) {
|
||||
inflightRef.current = null;
|
||||
}
|
||||
if (showLoading) {
|
||||
const remaining = Math.max(0, (options?.minLoadingMs ?? 0) - (Date.now() - startedAt));
|
||||
if (remaining > 0) await delay(remaining);
|
||||
if (
|
||||
generationRef.current === generation
|
||||
&& enabledRef.current
|
||||
&& resetKeyRef.current === runResetKey
|
||||
&& loadingRunIdRef.current === runId
|
||||
) {
|
||||
loadingRunIdRef.current = 0;
|
||||
setLoadingKey(runResetKey);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
const queued = queuedRunRef.current;
|
||||
if (
|
||||
queued
|
||||
&& generationRef.current === generation
|
||||
&& enabledRef.current
|
||||
&& resetKeyRef.current === runResetKey
|
||||
) {
|
||||
queuedRunRef.current = null;
|
||||
await run(queued.options);
|
||||
queued.resolve();
|
||||
}
|
||||
}
|
||||
}, [stableT]);
|
||||
|
||||
const scheduleNextPoll = useCallback(() => {
|
||||
clearPollTimer();
|
||||
if (!enabledRef.current || !pollRef.current) return;
|
||||
const generation = generationRef.current;
|
||||
timerRef.current = setTimeout(() => {
|
||||
void run({ withLoading: false }).finally(() => {
|
||||
if (generationRef.current === generation) {
|
||||
scheduleNextPoll();
|
||||
}
|
||||
});
|
||||
}, pollDelayMs());
|
||||
}, [clearPollTimer, pollDelayMs, run]);
|
||||
|
||||
useEffect(() => {
|
||||
const resetChanged = resetKeyRef.current !== resetKey;
|
||||
resetKeyRef.current = resetKey;
|
||||
generationRef.current += 1;
|
||||
inflightRef.current = null;
|
||||
clearPollTimer();
|
||||
if (!enabled) {
|
||||
resolveQueuedRun();
|
||||
loadingRunIdRef.current = 0;
|
||||
setLoading(false);
|
||||
setLoadingKey(resetKey);
|
||||
setDataKey(resetKey);
|
||||
setData(null);
|
||||
setErrorKey(resetKey);
|
||||
setError(null);
|
||||
failuresRef.current = 0;
|
||||
hasDataRef.current = false;
|
||||
return undefined;
|
||||
}
|
||||
if (resetChanged) {
|
||||
resolveQueuedRun();
|
||||
loadingRunIdRef.current = 0;
|
||||
setLoading(false);
|
||||
setLoadingKey(resetKey);
|
||||
setDataKey(resetKey);
|
||||
setData(null);
|
||||
setErrorKey(resetKey);
|
||||
setError(null);
|
||||
failuresRef.current = 0;
|
||||
hasDataRef.current = false;
|
||||
}
|
||||
const generation = generationRef.current;
|
||||
void run({ withLoading: true }).finally(() => {
|
||||
if (generationRef.current === generation && pollRef.current) scheduleNextPoll();
|
||||
});
|
||||
return () => {
|
||||
generationRef.current += 1;
|
||||
resolveQueuedRun();
|
||||
loadingRunIdRef.current = 0;
|
||||
inflightRef.current = null;
|
||||
clearPollTimer();
|
||||
};
|
||||
}, [clearPollTimer, enabled, intervalMs, options?.poll, resetKey, resolveQueuedRun, run, scheduleNextPoll]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
failuresRef.current = 0;
|
||||
await run({ withLoading: true, minLoadingMs: 450 });
|
||||
}, [run]);
|
||||
|
||||
return {
|
||||
data: dataKey === resetKey ? data : null,
|
||||
error: errorKey === resetKey ? error : null,
|
||||
loading: loadingKey === resetKey ? loading : enabled,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { DockerContainerAction, DockerImageManageAction, TmuxManageAction } from '../../domain/systemManager/types';
|
||||
import type {
|
||||
DockerContainerAction,
|
||||
DockerImageManageAction,
|
||||
SystemdUnitAction,
|
||||
SystemdUnitInfo,
|
||||
TmuxManageAction,
|
||||
} from '../../domain/systemManager/types';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export function useSystemManagerBackend() {
|
||||
@@ -120,6 +126,35 @@ export function useSystemManagerBackend() {
|
||||
return bridge.listAccelerators(sessionId);
|
||||
}, []);
|
||||
|
||||
const listListeningPorts = useCallback(async (sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listListeningPorts) {
|
||||
return { success: false as const, error: 'listListeningPorts unavailable' };
|
||||
}
|
||||
return bridge.listListeningPorts(sessionId);
|
||||
}, []);
|
||||
|
||||
const listSystemServices = useCallback(async (sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listSystemServices) {
|
||||
return { success: false as const, error: 'listSystemServices unavailable' };
|
||||
}
|
||||
return bridge.listSystemServices(sessionId);
|
||||
}, []);
|
||||
|
||||
const systemServiceAction = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
unitName: string;
|
||||
action: SystemdUnitAction;
|
||||
scope?: SystemdUnitInfo['scope'];
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.systemServiceAction) {
|
||||
return { success: false as const, error: 'systemServiceAction unavailable' };
|
||||
}
|
||||
return bridge.systemServiceAction(options);
|
||||
}, []);
|
||||
|
||||
const dockerInspect = useCallback(async (options: { sessionId: string; containerId: string }) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.dockerInspect) {
|
||||
@@ -181,6 +216,9 @@ export function useSystemManagerBackend() {
|
||||
listDockerImages,
|
||||
getDockerStats,
|
||||
listAccelerators,
|
||||
listListeningPorts,
|
||||
listSystemServices,
|
||||
systemServiceAction,
|
||||
dockerInspect,
|
||||
dockerImageInspect,
|
||||
dockerAction,
|
||||
@@ -200,6 +238,9 @@ export function useSystemManagerBackend() {
|
||||
listDockerImages,
|
||||
getDockerStats,
|
||||
listAccelerators,
|
||||
listListeningPorts,
|
||||
listSystemServices,
|
||||
systemServiceAction,
|
||||
dockerInspect,
|
||||
dockerImageInspect,
|
||||
dockerAction,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("./useTerminalAiContexts.ts", import.meta.url), "utf8");
|
||||
|
||||
test("useTerminalAiContexts imports AI context primitives from domain", () => {
|
||||
assert.match(source, /from ['"]\.\.\/\.\.\/domain\/buildAITerminalSessionInfo['"]/);
|
||||
assert.doesNotMatch(source, /from ['"].*components\//);
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import type React from 'react';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
|
||||
import { retainStableAiPanelContexts } from '../../domain/aiPanelContextsEqual';
|
||||
import {
|
||||
buildAITerminalSessionInfo,
|
||||
type AIPanelContext,
|
||||
} from '../../domain/buildAITerminalSessionInfo';
|
||||
import { collectSessionIds } from '../../domain/workspace';
|
||||
import type { TerminalContextReader } from '../../domain/terminalContextRead';
|
||||
import { detectLocalOs } from '../../lib/localShell';
|
||||
import type { Host, PortForwardingRule, TerminalSession, Workspace } from '../../types';
|
||||
|
||||
interface UseTerminalAiContextsOptions {
|
||||
hosts: Host[];
|
||||
hostsRef: React.MutableRefObject<Host[]>;
|
||||
portForwardingRules: PortForwardingRule[];
|
||||
portForwardingRulesRef: React.MutableRefObject<PortForwardingRule[]>;
|
||||
mountedAiTabIds: string[];
|
||||
sessionHostsMap: Map<string, Host>;
|
||||
sessions: TerminalSession[];
|
||||
sessionsRef: React.MutableRefObject<TerminalSession[]>;
|
||||
terminalContextReadersRef: React.MutableRefObject<Map<string, TerminalContextReader>>;
|
||||
workspaces: Workspace[];
|
||||
workspacesRef: React.MutableRefObject<Workspace[]>;
|
||||
}
|
||||
|
||||
export function useTerminalAiContexts({
|
||||
hosts,
|
||||
hostsRef,
|
||||
portForwardingRules,
|
||||
portForwardingRulesRef,
|
||||
mountedAiTabIds,
|
||||
sessionHostsMap,
|
||||
sessions,
|
||||
sessionsRef,
|
||||
terminalContextReadersRef,
|
||||
workspaces,
|
||||
workspacesRef,
|
||||
}: UseTerminalAiContextsOptions) {
|
||||
const previousAiContextsRef = useRef<Map<string, AIPanelContext> | null>(null);
|
||||
const aiContextsByTabId = useMemo(() => {
|
||||
const localOs = detectLocalOs(navigator.userAgent || navigator.platform);
|
||||
const sessionById = new Map<string, TerminalSession>(sessions.map((session) => [session.id, session]));
|
||||
const workspaceById = new Map<string, Workspace>(workspaces.map((workspace) => [workspace.id, workspace]));
|
||||
const allHosts = hosts;
|
||||
const tabIds = new Set<string>(mountedAiTabIds);
|
||||
|
||||
const contexts = new Map<string, AIPanelContext>();
|
||||
|
||||
for (const tabId of tabIds) {
|
||||
const workspace = workspaceById.get(tabId);
|
||||
if (workspace) {
|
||||
const sessionIds = collectSessionIds(workspace.root);
|
||||
contexts.set(tabId, {
|
||||
scopeType: 'workspace',
|
||||
scopeTargetId: workspace.id,
|
||||
scopeHostIds: sessionIds
|
||||
.map((sessionId) => sessionById.get(sessionId)?.hostId)
|
||||
.filter((hostId): hostId is string => !!hostId),
|
||||
scopeLabel: workspace.title,
|
||||
terminalSessions: sessionIds.map((sessionId) =>
|
||||
buildAITerminalSessionInfo(
|
||||
sessionById.get(sessionId),
|
||||
sessionHostsMap.get(sessionId),
|
||||
localOs,
|
||||
{ allHosts, portForwardingRules },
|
||||
),
|
||||
),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const session = sessionById.get(tabId);
|
||||
if (!session) continue;
|
||||
|
||||
contexts.set(tabId, {
|
||||
scopeType: 'terminal',
|
||||
scopeTargetId: session.id,
|
||||
scopeHostIds: session.hostId ? [session.hostId] : [],
|
||||
scopeLabel: session.hostLabel ?? '',
|
||||
terminalSessions: [
|
||||
buildAITerminalSessionInfo(
|
||||
session,
|
||||
sessionHostsMap.get(session.id),
|
||||
localOs,
|
||||
{ allHosts, portForwardingRules },
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const retained = retainStableAiPanelContexts(previousAiContextsRef.current, contexts);
|
||||
previousAiContextsRef.current = retained;
|
||||
return retained;
|
||||
}, [sessions, workspaces, mountedAiTabIds, sessionHostsMap, hosts, portForwardingRules]);
|
||||
|
||||
const resolveAIExecutorContext = useCallback((scope: {
|
||||
type: 'terminal' | 'workspace';
|
||||
targetId?: string;
|
||||
label?: string;
|
||||
}) => {
|
||||
const latestWorkspaces = workspacesRef.current;
|
||||
const latestSessions = sessionsRef.current;
|
||||
const latestHosts = hostsRef.current;
|
||||
const localOs = detectLocalOs(navigator.userAgent || navigator.platform);
|
||||
const sessionIds = scope.type === 'workspace'
|
||||
? (() => {
|
||||
const workspace = scope.targetId ? latestWorkspaces.find((w) => w.id === scope.targetId) : undefined;
|
||||
return workspace?.root ? collectSessionIds(workspace.root) : [];
|
||||
})()
|
||||
: scope.targetId ? [scope.targetId] : [];
|
||||
|
||||
const workspaceName = scope.type === 'workspace'
|
||||
? latestWorkspaces.find((w) => w.id === scope.targetId)?.title ?? scope.label
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
sessions: sessionIds.map((sid) => {
|
||||
const session = latestSessions.find((s) => s.id === sid);
|
||||
const host = session ? sessionHostsMap.get(session.id) : undefined;
|
||||
return buildAITerminalSessionInfo(session, host, localOs, {
|
||||
allHosts: latestHosts,
|
||||
portForwardingRules: portForwardingRulesRef.current,
|
||||
});
|
||||
}),
|
||||
workspaceId: scope.type === 'workspace' ? scope.targetId : undefined,
|
||||
workspaceName,
|
||||
readTerminalContext: (request: Parameters<TerminalContextReader>[0]) => {
|
||||
const reader = terminalContextReadersRef.current.get(request.sessionId);
|
||||
if (!reader) {
|
||||
return Promise.resolve({
|
||||
ok: false as const,
|
||||
error: `Terminal session "${request.sessionId}" has no readable terminal buffer.`,
|
||||
});
|
||||
}
|
||||
return reader(request);
|
||||
},
|
||||
};
|
||||
}, [hostsRef, portForwardingRulesRef, sessionHostsMap, sessionsRef, terminalContextReadersRef, workspacesRef]);
|
||||
|
||||
return {
|
||||
aiContextsByTabId,
|
||||
resolveAIExecutorContext,
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { TerminalTheme } from '../../domain/models';
|
||||
import {
|
||||
@@ -42,7 +42,9 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
|
||||
|
||||
const [userIntent, setUserIntent] = useState<ThemeUserIntent>(idleThemeUserIntent());
|
||||
|
||||
const appearanceSettings = useMemo((): TerminalAppearanceSettings => ({
|
||||
// Theme-id settings without accent — keeps resolveFocusedAppearance identity
|
||||
// stable during color-picker drag (TerminalLayer memo depends on it).
|
||||
const appearanceSettingsBase = useMemo((): Omit<TerminalAppearanceSettings, 'accentMode' | 'customAccent'> => ({
|
||||
terminalThemeId,
|
||||
terminalThemeDarkId,
|
||||
terminalThemeLightId,
|
||||
@@ -50,8 +52,6 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
|
||||
resolvedTheme,
|
||||
lightUiThemeId,
|
||||
darkUiThemeId,
|
||||
accentMode,
|
||||
customAccent,
|
||||
}), [
|
||||
terminalThemeId,
|
||||
terminalThemeDarkId,
|
||||
@@ -60,24 +60,51 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
|
||||
resolvedTheme,
|
||||
lightUiThemeId,
|
||||
darkUiThemeId,
|
||||
]);
|
||||
|
||||
const accentRef = useRef({ accentMode, customAccent });
|
||||
accentRef.current = { accentMode, customAccent };
|
||||
|
||||
// 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,
|
||||
]);
|
||||
}), [appearanceSettingsBase, accentMode, customAccent]);
|
||||
|
||||
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
|
||||
// accent; Terminal panes also re-apply appearanceChromeStore at the leaf.
|
||||
const resolveFocusedAppearance = useCallback((hostScope: TerminalAppearanceHostScope): ResolvedAppearance => (
|
||||
resolveTerminalAppearance({
|
||||
userIntent,
|
||||
settings: appearanceSettings,
|
||||
settings: {
|
||||
...appearanceSettingsBase,
|
||||
accentMode: accentRef.current.accentMode,
|
||||
customAccent: accentRef.current.customAccent,
|
||||
},
|
||||
hostScope,
|
||||
customThemes,
|
||||
})
|
||||
), [userIntent, appearanceSettings, customThemes]);
|
||||
), [userIntent, appearanceSettingsBase, customThemes]);
|
||||
|
||||
const applyFollowAppSettingsForPick = useCallback((themeId: string) => {
|
||||
const update = getFollowAppTerminalThemeSelectionUpdate(themeId);
|
||||
@@ -110,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,
|
||||
@@ -122,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;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("./useVaultImportHandlers.ts", import.meta.url), "utf8");
|
||||
|
||||
test("useVaultImportHandlers stays below the UI layer", () => {
|
||||
assert.doesNotMatch(source, /from ["'].*components\//);
|
||||
assert.doesNotMatch(source, /from ["'].*\/toast["']/);
|
||||
assert.match(source, /notify: VaultImportNotifier/);
|
||||
assert.match(source, /from ["']\.\/vaultImportOptions["']/);
|
||||
});
|
||||
@@ -0,0 +1,715 @@
|
||||
import { startTransition, useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
readRememberedKeyPassphrases,
|
||||
rememberImportedKeyPassphrase,
|
||||
resolveDefaultKeyPassphraseAliases,
|
||||
} from "../defaultKeyPassphrases";
|
||||
import {
|
||||
countVaultImportDuplicates,
|
||||
ensureVaultImportPersisted,
|
||||
mergeVaultImportedGroups,
|
||||
rebaseVaultImportedHosts,
|
||||
resolveUniqueManagedImportGroupName,
|
||||
rollbackVaultImportedHosts,
|
||||
waitForVaultImportProgressPaint,
|
||||
type VaultHostPersistenceResult,
|
||||
type VaultImportProgress,
|
||||
} from "./vaultImportProgress";
|
||||
import { importVaultHostsInWorker } from "./vaultImportWorker";
|
||||
import {
|
||||
type VaultLockHandle,
|
||||
withVaultImportLock,
|
||||
} from "./vaultManagedImportLock";
|
||||
|
||||
/** Exit the import lock so a concurrent host save can finish, then retry. */
|
||||
const RETRY_VAULT_IMPORT_AFTER_CONCURRENT_EDIT = Symbol("retry-vault-import-after-concurrent-edit");
|
||||
import { sanitizeHost } from "../../domain/host";
|
||||
import {
|
||||
applyVaultImportDestination,
|
||||
applyVaultHostImport,
|
||||
buildVaultHostEndpointKey,
|
||||
buildVaultHostMergeKey,
|
||||
filterVaultImportKeyPassphrasesAgainstExisting,
|
||||
mergeVaultImportIssues,
|
||||
resolveVaultImportKeyPassphraseConflicts,
|
||||
type VaultImportFormat,
|
||||
} from "../../domain/vaultImport";
|
||||
import type { GroupConfig, Host, ManagedSource, SSHKey } from "../../types";
|
||||
import type { VaultImportNotifier, VaultImportOptions } from "./vaultImportOptions";
|
||||
|
||||
interface UseVaultImportHandlersOptions {
|
||||
customGroups: string[];
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
managedSources: ManagedSource[];
|
||||
notify: VaultImportNotifier;
|
||||
onReadPersistedHosts: () => Promise<Host[]>;
|
||||
onUpdateHosts: (hosts: Host[]) => VaultHostPersistenceResult | Promise<VaultHostPersistenceResult>;
|
||||
onUpdateKeys: (keys: SSHKey[]) => void;
|
||||
onReadPersistedManagedSources: () => ManagedSource[];
|
||||
onCommitVaultImportTransaction: (
|
||||
hosts: Host[],
|
||||
updateGroups: (current: string[]) => string[],
|
||||
updateSources: (current: ManagedSource[]) => ManagedSource[],
|
||||
updateGroupConfigs?: (current: GroupConfig[]) => GroupConfig[],
|
||||
expectedHosts?: Host[],
|
||||
lock?: VaultLockHandle | null,
|
||||
) => Promise<
|
||||
| {
|
||||
status: "persisted";
|
||||
groups: string[];
|
||||
sources: ManagedSource[];
|
||||
groupConfigs: GroupConfig[];
|
||||
}
|
||||
| { status: "superseded" }
|
||||
>;
|
||||
setIsImportOpen: (open: boolean) => void;
|
||||
t: (key: string, values?: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
export function useVaultImportHandlers({
|
||||
customGroups,
|
||||
hosts,
|
||||
keys,
|
||||
managedSources,
|
||||
notify,
|
||||
onReadPersistedHosts,
|
||||
onUpdateHosts,
|
||||
onUpdateKeys,
|
||||
onReadPersistedManagedSources,
|
||||
onCommitVaultImportTransaction,
|
||||
setIsImportOpen,
|
||||
t,
|
||||
}: UseVaultImportHandlersOptions) {
|
||||
const [importProgress, setImportProgress] = useState<VaultImportProgress | null>(null);
|
||||
const customGroupsRef = useRef(customGroups);
|
||||
const hostsRef = useRef(hosts);
|
||||
const keysRef = useRef(keys);
|
||||
const managedSourcesRef = useRef(managedSources);
|
||||
const activeImportAbortRef = useRef<AbortController | null>(null);
|
||||
const importCommitStartedRef = useRef(false);
|
||||
const importInFlightRef = useRef(false);
|
||||
customGroupsRef.current = customGroups;
|
||||
hostsRef.current = hosts;
|
||||
keysRef.current = keys;
|
||||
managedSourcesRef.current = managedSources;
|
||||
const resetImportProgress = useCallback(() => setImportProgress(null), []);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (!importCommitStartedRef.current) activeImportAbortRef.current?.abort();
|
||||
activeImportAbortRef.current = null;
|
||||
}, []);
|
||||
|
||||
const cancelImport = useCallback(() => {
|
||||
if (importCommitStartedRef.current) return;
|
||||
activeImportAbortRef.current?.abort();
|
||||
activeImportAbortRef.current = null;
|
||||
setImportProgress(null);
|
||||
setIsImportOpen(false);
|
||||
}, [setIsImportOpen]);
|
||||
|
||||
const handleImportFileSelected = useCallback(
|
||||
async (format: VaultImportFormat, files: File[], options?: VaultImportOptions) => {
|
||||
const file = files[0];
|
||||
if (!file) return;
|
||||
if (importInFlightRef.current) return;
|
||||
importInFlightRef.current = true;
|
||||
activeImportAbortRef.current?.abort();
|
||||
const abortController = new AbortController();
|
||||
activeImportAbortRef.current = abortController;
|
||||
importCommitStartedRef.current = false;
|
||||
const { signal } = abortController;
|
||||
const throwIfCancelled = () => {
|
||||
if (signal.aborted) {
|
||||
throw new DOMException("Vault import cancelled.", "AbortError");
|
||||
}
|
||||
};
|
||||
let rollbackSnapshot: {
|
||||
baselineHosts: Host[];
|
||||
appliedHosts: Host[];
|
||||
} | null = null;
|
||||
let rollbackPendingImport: () => Promise<void> = async () => undefined;
|
||||
const relativeRoot = file.webkitRelativePath?.split(/[\\/]+/).filter(Boolean)[0];
|
||||
const selectionName = files.length > 1 ? (relativeRoot || file.name) : file.name;
|
||||
const formatLabel =
|
||||
format === "putty"
|
||||
? "PuTTY"
|
||||
: format === "mobaxterm"
|
||||
? "MobaXterm"
|
||||
: format === "csv"
|
||||
? "CSV"
|
||||
: format === "securecrt"
|
||||
? "SecureCRT"
|
||||
: "ssh_config";
|
||||
const updateProgress = (next: Partial<VaultImportProgress>) => {
|
||||
setImportProgress((current) => ({
|
||||
status: "running",
|
||||
stage: "reading",
|
||||
percent: 5,
|
||||
formatLabel,
|
||||
fileName: selectionName,
|
||||
totalFiles: files.length,
|
||||
...current,
|
||||
...next,
|
||||
}));
|
||||
};
|
||||
|
||||
setIsImportOpen(false);
|
||||
setImportProgress({
|
||||
status: "running",
|
||||
stage: "reading",
|
||||
percent: 5,
|
||||
formatLabel,
|
||||
fileName: selectionName,
|
||||
completedFiles: 0,
|
||||
totalFiles: files.length,
|
||||
});
|
||||
|
||||
try {
|
||||
let result = await importVaultHostsInWorker({
|
||||
format,
|
||||
files,
|
||||
encoding: options?.encoding,
|
||||
signal,
|
||||
onProgress: (progress) => {
|
||||
if (!signal.aborted) updateProgress(progress);
|
||||
},
|
||||
});
|
||||
throwIfCancelled();
|
||||
const isManaged = format === "ssh_config" && options?.managed === true;
|
||||
if (!isManaged) {
|
||||
result = applyVaultImportDestination(
|
||||
result,
|
||||
options?.destination ?? { mode: "preserve" },
|
||||
// SecureCRT keeps distinct session files that share an endpoint;
|
||||
// only rewrite their group when the user picks an import location.
|
||||
{ collapseDuplicateEndpoints: format !== "securecrt" },
|
||||
);
|
||||
}
|
||||
updateProgress({ stage: "preparing", percent: 70 });
|
||||
await waitForVaultImportProgressPaint();
|
||||
throwIfCancelled();
|
||||
updateProgress({ stage: "saving", percent: 85 });
|
||||
await waitForVaultImportProgressPaint();
|
||||
throwIfCancelled();
|
||||
|
||||
const currentCustomGroups = customGroupsRef.current;
|
||||
const currentHosts = hostsRef.current;
|
||||
const currentManagedSources = managedSourcesRef.current;
|
||||
const persistHosts = async (
|
||||
nextHosts: Host[],
|
||||
options?: {
|
||||
baselineHosts?: Host[];
|
||||
persistAttempt?: (
|
||||
hosts: Host[],
|
||||
baselineHosts: Host[],
|
||||
) => Promise<VaultHostPersistenceResult>;
|
||||
prepareAttempt?: (attempt: { baselineHosts: Host[]; appliedHosts: Host[] }) => Host[];
|
||||
},
|
||||
) => {
|
||||
throwIfCancelled();
|
||||
importCommitStartedRef.current = true;
|
||||
let baselineHosts = options?.baselineHosts ?? currentHosts;
|
||||
let appliedHosts = nextHosts;
|
||||
while (true) {
|
||||
appliedHosts = options?.prepareAttempt?.({ baselineHosts, appliedHosts }) ?? appliedHosts;
|
||||
rollbackSnapshot = { baselineHosts, appliedHosts };
|
||||
let persisted: VaultHostPersistenceResult;
|
||||
if (options?.persistAttempt) {
|
||||
persisted = await options.persistAttempt(appliedHosts, baselineHosts);
|
||||
} else {
|
||||
let hostUpdate: VaultHostPersistenceResult | Promise<VaultHostPersistenceResult>;
|
||||
startTransition(() => {
|
||||
hostUpdate = onUpdateHosts(appliedHosts);
|
||||
});
|
||||
persisted = await hostUpdate!;
|
||||
}
|
||||
if (persisted !== "superseded") {
|
||||
if (persisted !== false) rollbackSnapshot = null;
|
||||
return persisted;
|
||||
}
|
||||
|
||||
// Locked import commits must release the shared lock before retrying
|
||||
// so a concurrent host save queued behind that lock can finish first.
|
||||
if (options?.persistAttempt) {
|
||||
throw RETRY_VAULT_IMPORT_AFTER_CONCURRENT_EDIT;
|
||||
}
|
||||
|
||||
const latestHosts = hostsRef.current;
|
||||
hostsRef.current = latestHosts;
|
||||
appliedHosts = rebaseVaultImportedHosts({
|
||||
currentHosts: latestHosts,
|
||||
baselineHosts,
|
||||
appliedHosts,
|
||||
});
|
||||
baselineHosts = latestHosts;
|
||||
}
|
||||
};
|
||||
|
||||
rollbackPendingImport = async () => {
|
||||
const snapshot = rollbackSnapshot;
|
||||
if (!snapshot) return;
|
||||
while (true) {
|
||||
const rollbackHosts = rollbackVaultImportedHosts({
|
||||
currentHosts: hostsRef.current,
|
||||
...snapshot,
|
||||
});
|
||||
let rollbackUpdate: VaultHostPersistenceResult | Promise<VaultHostPersistenceResult>;
|
||||
startTransition(() => {
|
||||
rollbackUpdate = onUpdateHosts(rollbackHosts);
|
||||
});
|
||||
const persisted = await rollbackUpdate!;
|
||||
if (persisted === "superseded") continue;
|
||||
if (persisted === false) {
|
||||
throw new Error(t("vault.import.progress.persistFailed"));
|
||||
}
|
||||
rollbackSnapshot = null;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const fileBaseName = file.name.replace(/\.[^/.]+$/, "");
|
||||
const requestedManagedGroup = options?.destination?.mode === "preserve"
|
||||
? null
|
||||
: options?.destination?.group ?? null;
|
||||
let managedGroupName = requestedManagedGroup ?? `${fileBaseName} - Managed`;
|
||||
|
||||
// Check if this file is already managed
|
||||
const bridge = (window as unknown as { netcatty?: { getPathForFile?: (file: File) => string | undefined } }).netcatty;
|
||||
// Try bridge.getPathForFile first, then fall back to file.path (Electron legacy)
|
||||
const filePath = bridge?.getPathForFile?.(file) || (file as File & { path?: string }).path;
|
||||
|
||||
if (isManaged && !filePath) {
|
||||
// Cannot proceed with managed import without a valid file path
|
||||
const message = t("vault.import.sshConfig.noFilePathDesc");
|
||||
updateProgress({
|
||||
status: "error",
|
||||
stage: "failed",
|
||||
error: message,
|
||||
});
|
||||
notify.error(
|
||||
message,
|
||||
t("vault.import.sshConfig.noFilePath"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isManaged) {
|
||||
const existingSource = currentManagedSources.find(s => s.filePath === filePath);
|
||||
if (existingSource) {
|
||||
const message = t("vault.import.sshConfig.alreadyManagedDesc", {
|
||||
group: existingSource.groupName,
|
||||
});
|
||||
updateProgress({
|
||||
status: "error",
|
||||
stage: "failed",
|
||||
error: message,
|
||||
});
|
||||
notify.error(
|
||||
message,
|
||||
t("vault.import.sshConfig.alreadyManaged"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Managed ssh_config rematch is endpoint-only; CSV/other imports treat
|
||||
// group as part of session identity so direct vs proxy copies can coexist.
|
||||
const makeKey = isManaged ? buildVaultHostEndpointKey : buildVaultHostMergeKey;
|
||||
|
||||
const existingKeys = new Set(currentHosts.map(makeKey));
|
||||
// Filter out duplicates for both managed and non-managed imports
|
||||
let newHosts = format === "securecrt"
|
||||
? result.hosts
|
||||
: result.hosts.filter((h) => !existingKeys.has(makeKey(h)));
|
||||
|
||||
// For managed imports, also update existing hosts to be managed
|
||||
let updatedExistingHosts: Host[] = [];
|
||||
if (isManaged) {
|
||||
const importedKeys = new Set(result.hosts.map(makeKey));
|
||||
updatedExistingHosts = currentHosts.filter((h) => importedKeys.has(makeKey(h)));
|
||||
}
|
||||
|
||||
if (isManaged && (newHosts.length > 0 || updatedExistingHosts.length > 0)) {
|
||||
while (true) {
|
||||
try {
|
||||
await withVaultImportLock("vault", async (lock) => {
|
||||
const latestPersistedSources = onReadPersistedManagedSources();
|
||||
managedSourcesRef.current = latestPersistedSources;
|
||||
const sourceClaim = latestPersistedSources.find((source) => source.filePath === filePath);
|
||||
if (sourceClaim) {
|
||||
throw new Error(t("vault.import.sshConfig.alreadyManagedDesc", {
|
||||
group: sourceClaim.groupName,
|
||||
}));
|
||||
}
|
||||
const managedBaselineHosts = await onReadPersistedHosts();
|
||||
hostsRef.current = managedBaselineHosts;
|
||||
const managedExistingKeys = new Set(managedBaselineHosts.map(makeKey));
|
||||
newHosts = result.hosts.filter((host) => !managedExistingKeys.has(makeKey(host)));
|
||||
const managedImportedKeys = new Set(result.hosts.map(makeKey));
|
||||
updatedExistingHosts = managedBaselineHosts.filter((host) => (
|
||||
!host.managedSourceId && managedImportedKeys.has(makeKey(host))
|
||||
));
|
||||
if (newHosts.length === 0 && updatedExistingHosts.length === 0) return;
|
||||
const sourceId = crypto.randomUUID();
|
||||
let newSource: ManagedSource = {
|
||||
id: sourceId,
|
||||
type: "ssh_config",
|
||||
filePath: filePath,
|
||||
groupName: managedGroupName,
|
||||
lastSyncedAt: Date.now(),
|
||||
};
|
||||
|
||||
newHosts = newHosts.map((h) => ({
|
||||
...h,
|
||||
group: managedGroupName,
|
||||
// Only SSH hosts can be managed (SSH config only supports SSH)
|
||||
managedSourceId: (!h.protocol || h.protocol === "ssh") ? sourceId : undefined,
|
||||
}));
|
||||
|
||||
// Update existing hosts to be managed (move to managed group)
|
||||
const existingHostIds = new Set(updatedExistingHosts.map(h => h.id));
|
||||
const updatedHosts = managedBaselineHosts.map((h) => {
|
||||
if (!existingHostIds.has(h.id)) return h;
|
||||
const canBeManaged = !h.protocol || h.protocol === "ssh";
|
||||
return {
|
||||
...h,
|
||||
group: managedGroupName,
|
||||
managedSourceId: canBeManaged ? sourceId : undefined,
|
||||
// Sanitize label for managed hosts
|
||||
label: canBeManaged && h.label ? h.label.replace(/\s/g, '') : h.label,
|
||||
};
|
||||
});
|
||||
|
||||
let nextGroups: string[] = [];
|
||||
const ensureManagedSourceStillAvailable = () => {
|
||||
const conflictingSource = managedSourcesRef.current.find((source) => (
|
||||
source.id !== sourceId && source.filePath === filePath
|
||||
));
|
||||
if (conflictingSource) {
|
||||
throw new Error(t("vault.import.sshConfig.alreadyManagedDesc", {
|
||||
group: conflictingSource.groupName,
|
||||
}));
|
||||
}
|
||||
};
|
||||
const prepareManagedAttempt = ({
|
||||
baselineHosts,
|
||||
appliedHosts,
|
||||
}: {
|
||||
baselineHosts: Host[];
|
||||
appliedHosts: Host[];
|
||||
}) => {
|
||||
ensureManagedSourceStillAvailable();
|
||||
managedGroupName = requestedManagedGroup ?? resolveUniqueManagedImportGroupName({
|
||||
baseName: fileBaseName,
|
||||
customGroups: customGroupsRef.current,
|
||||
hosts: baselineHosts,
|
||||
managedSources: managedSourcesRef.current,
|
||||
ownerSourceId: sourceId,
|
||||
});
|
||||
newSource = { ...newSource, groupName: managedGroupName };
|
||||
nextGroups = Array.from(new Set([
|
||||
...currentCustomGroups,
|
||||
...(requestedManagedGroup ? [] : result.groups),
|
||||
managedGroupName,
|
||||
]));
|
||||
return appliedHosts.map((host) => (
|
||||
host.managedSourceId === sourceId
|
||||
? { ...host, group: managedGroupName }
|
||||
: host
|
||||
));
|
||||
};
|
||||
const hostPersisted = await persistHosts(
|
||||
[...updatedHosts, ...newHosts].map((host: Host) => sanitizeHost(host)),
|
||||
{
|
||||
baselineHosts: managedBaselineHosts,
|
||||
prepareAttempt: prepareManagedAttempt,
|
||||
persistAttempt: async (hostsToCommit, baselineHosts) => {
|
||||
const transaction = await onCommitVaultImportTransaction(
|
||||
hostsToCommit,
|
||||
(latestPersistedGroups) => mergeVaultImportedGroups({
|
||||
currentGroups: latestPersistedGroups,
|
||||
baselineGroups: currentCustomGroups,
|
||||
appliedGroups: nextGroups,
|
||||
}),
|
||||
(latestPersistedSources) => {
|
||||
const conflictingSource = latestPersistedSources.find((source) => (
|
||||
source.id !== sourceId && source.filePath === filePath
|
||||
));
|
||||
if (conflictingSource) {
|
||||
throw new Error(t("vault.import.sshConfig.alreadyManagedDesc", {
|
||||
group: conflictingSource.groupName,
|
||||
}));
|
||||
}
|
||||
return [
|
||||
...latestPersistedSources.filter((source) => source.id !== newSource.id),
|
||||
newSource,
|
||||
];
|
||||
},
|
||||
undefined,
|
||||
baselineHosts,
|
||||
lock,
|
||||
);
|
||||
if (transaction.status === "superseded") return "superseded";
|
||||
customGroupsRef.current = transaction.groups;
|
||||
managedSourcesRef.current = transaction.sources;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
);
|
||||
await ensureVaultImportPersisted(
|
||||
hostPersisted,
|
||||
t("vault.import.progress.persistFailed"),
|
||||
undefined,
|
||||
rollbackPendingImport,
|
||||
);
|
||||
});
|
||||
break;
|
||||
} catch (error) {
|
||||
if (error !== RETRY_VAULT_IMPORT_AFTER_CONCURRENT_EDIT) throw error;
|
||||
// Drain concurrent locked writes outside the critical section.
|
||||
hostsRef.current = await onReadPersistedHosts();
|
||||
}
|
||||
}
|
||||
} else if (newHosts.length > 0) {
|
||||
let addedHostIds = new Set<string>();
|
||||
let addedHostKeyPaths = new Map<string, string>();
|
||||
while (true) {
|
||||
try {
|
||||
await withVaultImportLock("vault", async (lock) => {
|
||||
const importBaselineHosts = await onReadPersistedHosts();
|
||||
hostsRef.current = importBaselineHosts;
|
||||
const importBaselineGroups = customGroupsRef.current;
|
||||
const merged = applyVaultHostImport(
|
||||
importBaselineHosts,
|
||||
importBaselineGroups,
|
||||
result,
|
||||
{ skipDuplicates: format !== "securecrt" },
|
||||
);
|
||||
newHosts = merged.addedHosts;
|
||||
addedHostIds = new Set(merged.addedHosts.map((host) => host.id));
|
||||
addedHostKeyPaths = new Map(merged.addedHosts.flatMap((host) => {
|
||||
const keyPath = host.identityFilePaths?.find((path) => path.trim())?.trim();
|
||||
return keyPath ? [[host.id, keyPath] as const] : [];
|
||||
}));
|
||||
if (newHosts.length === 0) return;
|
||||
const hostPersisted = await persistHosts(merged.hosts, {
|
||||
baselineHosts: importBaselineHosts,
|
||||
persistAttempt: async (hostsToCommit, baselineHosts) => {
|
||||
const transaction = await onCommitVaultImportTransaction(
|
||||
hostsToCommit,
|
||||
(latestPersistedGroups) => mergeVaultImportedGroups({
|
||||
currentGroups: latestPersistedGroups,
|
||||
baselineGroups: importBaselineGroups,
|
||||
appliedGroups: merged.customGroups,
|
||||
}),
|
||||
(latestPersistedSources) => latestPersistedSources,
|
||||
undefined,
|
||||
baselineHosts,
|
||||
lock,
|
||||
);
|
||||
if (transaction.status === "superseded") return "superseded";
|
||||
customGroupsRef.current = transaction.groups;
|
||||
managedSourcesRef.current = transaction.sources;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
await ensureVaultImportPersisted(
|
||||
hostPersisted,
|
||||
t("vault.import.progress.persistFailed"),
|
||||
undefined,
|
||||
rollbackPendingImport,
|
||||
);
|
||||
});
|
||||
break;
|
||||
} catch (error) {
|
||||
if (error !== RETRY_VAULT_IMPORT_AFTER_CONCURRENT_EDIT) throw error;
|
||||
hostsRef.current = await onReadPersistedHosts();
|
||||
}
|
||||
}
|
||||
throwIfCancelled();
|
||||
const resolved = await resolveVaultImportKeyPassphraseConflicts(
|
||||
result.keyPassphraseCandidates ?? result.keyPassphrases ?? [],
|
||||
resolveDefaultKeyPassphraseAliases,
|
||||
addedHostIds,
|
||||
addedHostKeyPaths,
|
||||
);
|
||||
throwIfCancelled();
|
||||
const checked = await filterVaultImportKeyPassphrasesAgainstExisting(
|
||||
resolved.keyPassphrases,
|
||||
(keyPath) => readRememberedKeyPassphrases(keyPath, keysRef.current),
|
||||
);
|
||||
throwIfCancelled();
|
||||
result.issues = mergeVaultImportIssues(
|
||||
result.issues,
|
||||
resolved.issues,
|
||||
checked.issues,
|
||||
);
|
||||
for (const entry of checked.keyPassphrases) {
|
||||
try {
|
||||
const saved = await rememberImportedKeyPassphrase({
|
||||
keyPath: entry.keyPath,
|
||||
passphrase: entry.passphrase,
|
||||
keys: keysRef.current,
|
||||
getKeys: () => keysRef.current,
|
||||
updateKeys: onUpdateKeys,
|
||||
setCurrentKeys: (updatedKeys) => {
|
||||
keysRef.current = updatedKeys;
|
||||
},
|
||||
});
|
||||
throwIfCancelled();
|
||||
if (saved === "conflict") {
|
||||
result.issues.push({
|
||||
level: "warning",
|
||||
message: `CSV passphrase conflicts with an existing saved passphrase for KeyPath "${entry.keyPath}"; the existing passphrase was kept.`,
|
||||
});
|
||||
} else if (saved === "unreadable") {
|
||||
result.issues.push({
|
||||
level: "warning",
|
||||
message: `Could not verify the existing saved passphrase for KeyPath "${entry.keyPath}"; the imported passphrase was not saved.`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
throw error;
|
||||
}
|
||||
result.issues.push({
|
||||
level: "warning",
|
||||
message: `Could not save the passphrase for KeyPath "${entry.keyPath}".`,
|
||||
});
|
||||
}
|
||||
}
|
||||
result.issues = mergeVaultImportIssues(result.issues);
|
||||
}
|
||||
|
||||
// Count total hosts affected (new + converted to managed)
|
||||
const totalAffected = newHosts.length + (isManaged ? updatedExistingHosts.length : 0);
|
||||
|
||||
const skipped = result.stats.skipped;
|
||||
const duplicates = countVaultImportDuplicates({
|
||||
importedHostCount: result.hosts.length,
|
||||
newHostCount: newHosts.length,
|
||||
fileDuplicateCount: result.stats.duplicates,
|
||||
managed: isManaged,
|
||||
});
|
||||
const hasWarnings = skipped > 0 || duplicates > 0 || result.issues.length > 0;
|
||||
|
||||
if (result.stats.parsed === 0 && totalAffected === 0) {
|
||||
const message = t("vault.import.toast.noEntries", { format: formatLabel });
|
||||
updateProgress({
|
||||
status: "error",
|
||||
stage: "failed",
|
||||
error: message,
|
||||
});
|
||||
notify.error(
|
||||
message,
|
||||
t("vault.import.toast.failedTitle"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (totalAffected === 0) {
|
||||
updateProgress({
|
||||
status: "complete",
|
||||
stage: "complete",
|
||||
percent: 100,
|
||||
imported: 0,
|
||||
skipped,
|
||||
duplicates,
|
||||
});
|
||||
notify.warning(
|
||||
t("vault.import.toast.noNewHosts", { format: formatLabel }),
|
||||
t("vault.import.toast.completedTitle"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
throwIfCancelled();
|
||||
rollbackSnapshot = null;
|
||||
|
||||
if (isManaged) {
|
||||
notify.success(
|
||||
t("vault.import.sshConfig.managedSuccess", { count: totalAffected }),
|
||||
t("vault.import.toast.completedTitle"),
|
||||
);
|
||||
} else {
|
||||
const details = t("vault.import.toast.summary", {
|
||||
count: totalAffected,
|
||||
skipped,
|
||||
duplicates,
|
||||
});
|
||||
|
||||
if (hasWarnings) {
|
||||
const firstIssue = result.issues[0]?.message;
|
||||
notify.warning(
|
||||
firstIssue ? `${details} ${t("vault.import.toast.firstIssue", { issue: firstIssue })}` : details,
|
||||
t("vault.import.toast.completedTitle"),
|
||||
);
|
||||
} else {
|
||||
notify.success(details, t("vault.import.toast.completedTitle"));
|
||||
}
|
||||
}
|
||||
updateProgress({
|
||||
status: "complete",
|
||||
stage: "complete",
|
||||
percent: 100,
|
||||
imported: totalAffected,
|
||||
skipped,
|
||||
duplicates,
|
||||
});
|
||||
} catch (err) {
|
||||
let rollbackFailure: unknown;
|
||||
if (rollbackSnapshot) {
|
||||
try {
|
||||
await rollbackPendingImport();
|
||||
} catch (rollbackError) {
|
||||
rollbackFailure = rollbackError;
|
||||
console.error("[vault import] Failed to rollback imported hosts.", rollbackError);
|
||||
}
|
||||
}
|
||||
if (
|
||||
!rollbackFailure
|
||||
&& (
|
||||
signal.aborted
|
||||
|| (err instanceof DOMException && err.name === "AbortError")
|
||||
)
|
||||
) return;
|
||||
const originalMessage =
|
||||
err instanceof Error ? err.message : t("common.unknownError");
|
||||
const message = rollbackFailure
|
||||
? `${originalMessage} ${t("vault.import.progress.rollbackFailed")}`
|
||||
: originalMessage;
|
||||
updateProgress({
|
||||
status: "error",
|
||||
stage: "failed",
|
||||
error: message,
|
||||
});
|
||||
notify.error(message, t("vault.import.toast.failedTitle"));
|
||||
} finally {
|
||||
if (activeImportAbortRef.current === abortController) {
|
||||
activeImportAbortRef.current = null;
|
||||
}
|
||||
importCommitStartedRef.current = false;
|
||||
importInFlightRef.current = false;
|
||||
}
|
||||
},
|
||||
[
|
||||
notify,
|
||||
onReadPersistedHosts,
|
||||
onCommitVaultImportTransaction,
|
||||
onReadPersistedManagedSources,
|
||||
onUpdateHosts,
|
||||
onUpdateKeys,
|
||||
setIsImportOpen,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
cancelImport,
|
||||
handleImportFileSelected,
|
||||
importProgress,
|
||||
resetImportProgress,
|
||||
};
|
||||
}
|
||||
@@ -65,8 +65,18 @@ import {
|
||||
} from "../../domain/connectionLogTerminalData";
|
||||
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
|
||||
import { loadSanitizedShellHistory } from "./shellHistoryPersistence";
|
||||
import {
|
||||
publishConnectionLogsSnapshot,
|
||||
registerConnectionLogsActions,
|
||||
} from "./connectionLogsStore";
|
||||
import {
|
||||
publishNotesSnapshot,
|
||||
registerNotesActions,
|
||||
} from "./notesStore";
|
||||
import { commitVaultNotesWrite } from "./vaultNotesPersistence";
|
||||
import { publishShellHistorySnapshot } from "./shellHistoryStore";
|
||||
import { setVaultInitialized } from "./vaultInitStore";
|
||||
import { notify } from "../notification";
|
||||
import {
|
||||
decryptGroupConfigs,
|
||||
decryptHosts,
|
||||
@@ -280,9 +290,14 @@ export const useVaultState = () => {
|
||||
const customGroupsRef = useRef<string[]>([]);
|
||||
const managedSourcesRef = useRef<ManagedSource[]>([]);
|
||||
const hostsRef = useRef<Host[]>([]);
|
||||
const notesRef = useRef<VaultNote[]>([]);
|
||||
const noteGroupsRef = useRef<string[]>([]);
|
||||
const notesPersistFailureNotifiedAtRef = useRef(0);
|
||||
customGroupsRef.current = customGroups;
|
||||
managedSourcesRef.current = managedSources;
|
||||
hostsRef.current = hosts;
|
||||
notesRef.current = notes;
|
||||
noteGroupsRef.current = noteGroups;
|
||||
|
||||
// Write-version counters prevent out-of-order async writes from overwriting
|
||||
// newer data. Each update bumps the counter; the .then() callback only
|
||||
@@ -590,15 +605,37 @@ export const useVaultState = () => {
|
||||
}, []);
|
||||
|
||||
const updateNotes = useCallback((data: Partial<VaultNote>[]) => {
|
||||
const cleaned = normalizeVaultNotes(data);
|
||||
const { notes: cleaned, persisted } = commitVaultNotesWrite({
|
||||
data,
|
||||
write: (key, value) => localStorageAdapter.write(key, value),
|
||||
});
|
||||
// Keep the in-session catalog updated so an autosave quota failure does not
|
||||
// snap the editor back to stale props and discard the user's draft. Disk
|
||||
// may still be behind — surface that explicitly.
|
||||
notesRef.current = cleaned;
|
||||
setNotes(cleaned);
|
||||
localStorageAdapter.write(STORAGE_KEY_NOTES, cleaned);
|
||||
publishNotesSnapshot({ notes: cleaned, noteGroups: noteGroupsRef.current });
|
||||
if (!persisted) {
|
||||
const now = Date.now();
|
||||
// Debounced autosave can hit quota repeatedly; avoid toast spam.
|
||||
if (now - notesPersistFailureNotifiedAtRef.current > 10_000) {
|
||||
notesPersistFailureNotifiedAtRef.current = now;
|
||||
notify.error(
|
||||
"Notes could not be saved. Free some local storage space and try again.",
|
||||
"Notes",
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const updateNoteGroups = useCallback((data: unknown) => {
|
||||
const cleaned = normalizeNoteGroups(data);
|
||||
noteGroupsRef.current = cleaned;
|
||||
setNoteGroups(cleaned);
|
||||
localStorageAdapter.write(STORAGE_KEY_NOTE_GROUPS, cleaned);
|
||||
publishNotesSnapshot({ notes: notesRef.current, noteGroups: cleaned });
|
||||
}, []);
|
||||
|
||||
const updateCustomGroups = useCallback((
|
||||
@@ -828,6 +865,18 @@ export const useVaultState = () => {
|
||||
publishShellHistorySnapshot(shellHistory);
|
||||
}, [shellHistory]);
|
||||
|
||||
// Notes catalog for Notes / AI side panels — keep TerminalLayer off the hot path.
|
||||
useLayoutEffect(() => {
|
||||
publishNotesSnapshot({ notes, noteGroups });
|
||||
}, [notes, noteGroups]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
registerNotesActions({ updateNotes, updateNoteGroups });
|
||||
return () => {
|
||||
registerNotesActions(null);
|
||||
};
|
||||
}, [updateNotes, updateNoteGroups]);
|
||||
|
||||
const addShellHistoryEntry = useCallback(
|
||||
(entry: Omit<ShellHistoryEntry, "id" | "timestamp">) => {
|
||||
setShellHistory((prev) => {
|
||||
@@ -923,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 = {
|
||||
@@ -968,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) {
|
||||
@@ -981,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(
|
||||
@@ -1027,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);
|
||||
@@ -1047,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);
|
||||
@@ -1062,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);
|
||||
@@ -1072,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 =
|
||||
@@ -1148,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);
|
||||
@@ -1158,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(() => {
|
||||
@@ -1375,9 +1462,12 @@ export const useVaultState = () => {
|
||||
|
||||
const updateHostLastConnected = useCallback((hostId: string) => {
|
||||
setHosts((prev) => {
|
||||
const next = prev.map((h) =>
|
||||
h.id === hostId ? { ...h, lastConnectedAt: Date.now() } : h,
|
||||
);
|
||||
const idx = prev.findIndex((h) => h.id === hostId);
|
||||
if (idx < 0) return prev;
|
||||
const now = Date.now();
|
||||
if (prev[idx].lastConnectedAt === now) return prev;
|
||||
const next = prev.slice();
|
||||
next[idx] = { ...prev[idx], lastConnectedAt: now };
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
const encryptPromise = encryptHosts(next);
|
||||
hostsEncryptPendingRef.current = encryptPromise.then(() => undefined);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { VaultImportDestination } from "../../domain/vaultImport";
|
||||
import type { VaultImportFileEncoding } from "./vaultImportFile";
|
||||
|
||||
/** Options for a vault host-file import (kept out of UI modules). */
|
||||
export type VaultImportOptions = {
|
||||
managed?: boolean;
|
||||
filePath?: string;
|
||||
encoding?: VaultImportFileEncoding;
|
||||
destination?: VaultImportDestination;
|
||||
};
|
||||
|
||||
/** Notification sink so application/state hooks do not import UI toast. */
|
||||
export type VaultImportNotifier = {
|
||||
success: (message: string, title?: string) => void;
|
||||
error: (message: string, title?: string) => void;
|
||||
warning: (message: string, title?: string) => void;
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { STORAGE_KEY_NOTES } from "../../infrastructure/config/storageKeys.ts";
|
||||
import { commitVaultNotesWrite } from "./vaultNotesPersistence.ts";
|
||||
|
||||
test("commitVaultNotesWrite reports failure when the storage write returns false", () => {
|
||||
const writes: Array<{ key: string; value: unknown }> = [];
|
||||
const result = commitVaultNotesWrite({
|
||||
data: [{
|
||||
id: "note-1",
|
||||
title: "Draft",
|
||||
content: "body",
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
}],
|
||||
write: (key, value) => {
|
||||
writes.push({ key, value });
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.persisted, false);
|
||||
assert.equal(writes.length, 1);
|
||||
assert.equal(writes[0]?.key, STORAGE_KEY_NOTES);
|
||||
assert.equal(result.notes[0]?.title, "Draft");
|
||||
});
|
||||
|
||||
test("commitVaultNotesWrite returns persisted notes when the write succeeds", () => {
|
||||
const result = commitVaultNotesWrite({
|
||||
data: [{
|
||||
id: "note-1",
|
||||
title: "Saved",
|
||||
content: "ok",
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
}],
|
||||
write: () => true,
|
||||
});
|
||||
|
||||
assert.equal(result.persisted, true);
|
||||
assert.equal(result.notes[0]?.title, "Saved");
|
||||
});
|
||||
|
||||
test("useVaultState updateNotes surfaces storage write failures", () => {
|
||||
const source = readFileSync(new URL("./useVaultState.ts", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /commitVaultNotesWrite/);
|
||||
assert.match(source, /notify\.error/);
|
||||
assert.match(source, /!persisted|persisted === false/);
|
||||
assert.match(source, /return false/);
|
||||
assert.match(source, /notesPersistFailureNotifiedAtRef/);
|
||||
assert.match(source, /10_000/);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { normalizeVaultNotes } from "../../domain/notes";
|
||||
import type { VaultNote } from "../../domain/models";
|
||||
import { STORAGE_KEY_NOTES } from "../../infrastructure/config/storageKeys";
|
||||
|
||||
export type VaultNotesWriteResult = {
|
||||
notes: VaultNote[];
|
||||
persisted: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize and attempt to persist vault notes. Callers must check `persisted`
|
||||
* before treating the update as durable (QuotaExceededError returns false).
|
||||
*/
|
||||
export function commitVaultNotesWrite(input: {
|
||||
data: Partial<VaultNote>[];
|
||||
write: (key: string, value: VaultNote[]) => boolean;
|
||||
}): VaultNotesWriteResult {
|
||||
const notes = normalizeVaultNotes(input.data);
|
||||
const persisted = input.write(STORAGE_KEY_NOTES, notes);
|
||||
return { notes, persisted };
|
||||
}
|
||||
Loaded 100 of 282 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user