Files
Netcatty/application/state/systemSettingsEffects.ts
T
3490b0673d fix: StrictMode 双发清理 + 笔记页卡顿优化 (#2810)
* Cursor: Apply local changes for cloud agent

* fix(terminal): stop first SSH fail under StrictMode remount

Defer backend boot past the double-invoke, cancel superseded vault init,
gate start on vault hydration, and harden hotkey/attach cleanup so the
first connect no longer races a stale close or empty keychain.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* perf: finish StrictMode mega-subscriber plan (accent isolation + docs)

Stabilize published terminal themes across custom-accent drag, apply accent
at chrome/log leaves and CSS injection only, land Phase 0 design/plan docs,
and lock Host-bag / Bugbot-clean contracts for the isolation checklist.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* docs: add StrictMode mega-subscriber design and plan artifacts

Force-add Phase 0 spec/plan under docs/superpowers (gitignored locally)
so the delivery branch carries the approved design and plan copy.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: clear remaining StrictMode doubles and notes editor jank

Gate every AI send and reject concurrent runTurn, latch tray connect flush
and settings persistMounted remounts, and stop notes content drafts from
rebuilding MDXEditor/shell on each keystroke.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* perf: gate hidden notes panel subscribe and skip host-link annotate on keystrokes

Hidden terminal notes mounts keep a live snapshot read but stop listening to
publishes. Host-link DOM annotation follows hosts/mode (preview MutationObserver)
instead of every markdown value change.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: latch SSH transport idle TTL notify under StrictMode

Avoid duplicate main-process notifySettingsChanged on effect double-invoke
when the idle TTL value has not changed.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* perf: freeze inactive vault notes against hosts churn and prefetch MDXEditor

Gate the retained VaultNotesSection notesStore subscription while hidden,
ignore hosts identity in its memo compare when inactive, and warm the lazy
InlineMarkdownEditor chunk when notes become active.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: clear remaining AI StrictMode double-fire hazards

Latch Ask-AI terminal selection on requestId before draft mutation, hoist
Codex App Server IPC bridge to App (ref-counted), and make Catty
requestApproval join an existing waiter for the same toolCallId.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: remount-safe AI send latch and skip stale MCP approval IPC

Replace the component sendInFlightRef with a module key latch so StrictMode
panel remount cannot reopen a mid-flight send, and no-op resolveApproval when
the pending entry is already drained.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* test: allow gated useNotesStore in VaultNotesSection memo contract

The retained notes section now passes enabled:isActive so hidden mounts do
not subscribe; update the VaultView memo source assertion accordingly.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: keep note drafts across mode toggle and external note publishes

Flush ref-only content before edit/preview remount, and skip prop→editor
setMarkdown while a local draft diverges from the last synced value.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* chore: strip StrictMode sermon comments after Bugbot/Comment Sicko pass

Keep effect-order and public-export contracts; drop remount/perf narration
that the latches and tests already encode.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: remount-safe Ask-AI terminal selection latch

Move consumed requestIds to a module Set so StrictMode remount cannot
append the same selection chip twice before parent state clears.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* chore: drop remount latch comment

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 14:38:24 +00:00

279 lines
11 KiB
TypeScript

import { useEffect, useRef, type MutableRefObject } from 'react';
import {
STORAGE_KEY_AUTO_UPDATE_ENABLED,
STORAGE_KEY_CLOSE_TO_TRAY,
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_APP_ICON_VARIANT,
STORAGE_KEY_HTTP_NETWORK_PROXY,
} from '../../infrastructure/config/storageKeys';
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
import {
normalizeHttpNetworkProxySettings,
type HttpNetworkProxySettings,
} from '../../domain/httpNetworkProxy';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import {
parseWindowOpacityRecord,
serializeWindowOpacityRecord,
shouldApplyWindowOpacityRecord,
shouldBroadcastWindowOpacityChange,
type WindowOpacityMutationSource,
type WindowOpacityRecord,
} from './windowOpacitySync';
interface UseSystemSettingsEffectsParams {
enabled?: boolean;
toggleWindowHotkey: string;
globalHotkeyEnabled: boolean;
closeToTray: boolean;
windowOpacityRecord: WindowOpacityRecord;
windowOpacityMutationSourceRef: MutableRefObject<WindowOpacityMutationSource>;
appIconVariant: AppIconVariant;
autoUpdateEnabled: boolean;
httpNetworkProxy: HttpNetworkProxySettings;
persistMountedRef: MutableRefObject<boolean>;
setHotkeyRegistrationError: (error: string | null) => void;
setAutoUpdateEnabled: (enabled: boolean | ((prev: boolean) => boolean)) => void;
setAppIconVariant: (variant: AppIconVariant | ((prev: AppIconVariant) => AppIconVariant)) => void;
notifySettingsChanged: (key: string, value: unknown) => void;
}
export function useSystemSettingsEffects({
enabled = true,
toggleWindowHotkey,
globalHotkeyEnabled,
closeToTray,
windowOpacityRecord,
windowOpacityMutationSourceRef,
appIconVariant,
autoUpdateEnabled,
httpNetworkProxy,
persistMountedRef,
setHotkeyRegistrationError,
setAutoUpdateEnabled,
setAppIconVariant,
notifySettingsChanged,
}: UseSystemSettingsEffectsParams) {
const appIconApplyRequestIdRef = useRef(0);
// 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');
});
} else {
setHotkeyRegistrationError(null);
bridge.unregisterGlobalHotkey?.().catch((err) => {
console.warn('[GlobalHotkey] Failed to unregister hotkey:', err);
});
}
}
localStorageAdapter.writeString(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,
globalHotkeyEnabled,
notifySettingsChanged,
persistMountedRef,
setHotkeyRegistrationError,
]);
// Persist global hotkey enabled setting
useEffect(() => {
if (!enabled) return;
localStorageAdapter.writeString(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled);
}, [enabled, globalHotkeyEnabled, notifySettingsChanged, persistMountedRef]);
// Persist and sync close to tray setting
useEffect(() => {
if (!enabled) return;
// Update main process tray behavior (needed on mount)
const bridge = netcattyBridge.get();
if (bridge?.setCloseToTray) {
bridge.setCloseToTray(closeToTray).catch((err) => {
console.warn('[SystemTray] Failed to set close-to-tray:', err);
});
}
localStorageAdapter.writeString(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray ? 'true' : 'false');
// Skip IPC on initial mount
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
}, [enabled, closeToTray, notifySettingsChanged, persistMountedRef]);
// Persist and apply app-level HTTP(S) network proxy (cloud sync / AI)
useEffect(() => {
if (!enabled) return;
const normalized = normalizeHttpNetworkProxySettings(httpNetworkProxy);
localStorageAdapter.write(STORAGE_KEY_HTTP_NETWORK_PROXY, normalized);
const bridge = netcattyBridge.get();
if (bridge?.setHttpNetworkProxy) {
// Apply to main process; empty custom is treated as system there.
// Persist draft custom+empty so the URL field remains visible.
bridge.setHttpNetworkProxy(normalized).catch((err) => {
console.warn('[NetworkProxy] Failed to apply HTTP network proxy:', err);
});
}
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_HTTP_NETWORK_PROXY, normalized);
}, [enabled, httpNetworkProxy, notifySettingsChanged, persistMountedRef]);
// Persist and sync window opacity
useEffect(() => {
if (!enabled) return;
const bridge = netcattyBridge.get();
bridge?.setWindowOpacity?.(windowOpacityRecord.opacity).catch((err) => {
console.warn('[WindowOpacity] Failed to apply window opacity:', err);
});
// Never let a stale effect overwrite a newer revision already on disk.
const stored = parseWindowOpacityRecord(
localStorageAdapter.readString(STORAGE_KEY_WINDOW_OPACITY),
);
if (
shouldApplyWindowOpacityRecord(stored, windowOpacityRecord)
|| stored.version === windowOpacityRecord.version
) {
localStorageAdapter.writeString(
STORAGE_KEY_WINDOW_OPACITY,
serializeWindowOpacityRecord(windowOpacityRecord),
);
}
const decision = shouldBroadcastWindowOpacityChange(
windowOpacityMutationSourceRef.current,
persistMountedRef.current,
);
windowOpacityMutationSourceRef.current = decision.nextSource;
if (!decision.shouldBroadcast) return;
notifySettingsChanged(STORAGE_KEY_WINDOW_OPACITY, windowOpacityRecord);
}, [
enabled,
windowOpacityRecord,
windowOpacityMutationSourceRef,
notifySettingsChanged,
persistMountedRef,
]);
// Persist and sync app icon variant
useEffect(() => {
if (!enabled) return;
const storedBefore = resolveAppIconVariant(
localStorageAdapter.readString(STORAGE_KEY_APP_ICON_VARIANT) ?? '',
);
localStorageAdapter.writeString(STORAGE_KEY_APP_ICON_VARIANT, appIconVariant);
if (!persistMountedRef.current) {
// Still apply on initial mount before cross-window notify is enabled.
} else {
notifySettingsChanged(STORAGE_KEY_APP_ICON_VARIANT, appIconVariant);
}
const bridge = netcattyBridge.get();
if (!bridge?.setAppIconVariant) return;
const requestId = ++appIconApplyRequestIdRef.current;
let cancelled = false;
const revertVariant = () => {
localStorageAdapter.writeString(STORAGE_KEY_APP_ICON_VARIANT, storedBefore);
if (appIconVariant !== storedBefore) {
setAppIconVariant(storedBefore);
}
if (persistMountedRef.current) {
notifySettingsChanged(STORAGE_KEY_APP_ICON_VARIANT, storedBefore);
}
};
void bridge.setAppIconVariant(appIconVariant)
.then((applied) => {
if (cancelled || requestId !== appIconApplyRequestIdRef.current) return;
if (applied === false && storedBefore !== appIconVariant) {
console.warn('[AppIcon] Failed to apply app icon variant:', appIconVariant);
revertVariant();
}
})
.catch((err) => {
if (cancelled || requestId !== appIconApplyRequestIdRef.current) return;
if (storedBefore !== appIconVariant) {
console.warn('[AppIcon] Failed to apply app icon variant:', err);
revertVariant();
}
});
return () => {
cancelled = true;
};
}, [enabled, appIconVariant, notifySettingsChanged, persistMountedRef, setAppIconVariant]);
// Hydrate auto-update state from the main-process preference file on mount.
// This reconciles localStorage (renderer) with auto-update-pref.json (main)
// in case localStorage was cleared or is stale.
useEffect(() => {
if (!enabled) return;
const bridge = netcattyBridge.get();
void bridge?.getAutoUpdate?.().then((result) => {
if (result && typeof result.enabled === 'boolean') {
setAutoUpdateEnabled((prev) => {
if (prev === result.enabled) return prev;
// Sync localStorage with the main-process truth
localStorageAdapter.writeString(STORAGE_KEY_AUTO_UPDATE_ENABLED, result.enabled ? 'true' : 'false');
return result.enabled;
});
}
}).catch(() => { /* bridge unavailable */ });
}, [enabled, setAutoUpdateEnabled]);
// Persist auto-update enabled setting.
// Initial mount still writes localStorage, but skips cross-window/main-process IPC.
useEffect(() => {
if (!enabled) return;
localStorageAdapter.writeString(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled);
// Notify main process on user-initiated changes
const bridge = netcattyBridge.get();
bridge?.setAutoUpdate?.(autoUpdateEnabled).catch((err: unknown) => {
console.warn('[AutoUpdate] Failed to set auto-update:', err);
});
}, [enabled, autoUpdateEnabled, notifySettingsChanged, persistMountedRef]);
}