mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-08 17:10:08 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c63b40ddf | ||
|
|
490554d6a6 | ||
|
|
f7a8e1557b | ||
|
|
bd8f7ee7a6 | ||
|
|
71f6f51707 | ||
|
|
0288d1610a | ||
|
|
b701df8822 | ||
|
|
10d7ff9879 | ||
|
|
a43ddb6804 | ||
|
|
97ef84db06 | ||
|
|
8bce8c15a0 | ||
|
|
b7b35afa24 | ||
|
|
958972ffa0 | ||
|
|
d137662d02 | ||
|
|
10ca915370 | ||
|
|
33dc249d74 | ||
|
|
b490eb3273 | ||
|
|
55852918c2 | ||
|
|
cc85cc7ebe | ||
|
|
6e99c9c1dc | ||
|
|
1dac363d84 | ||
|
|
4eddbfab22 | ||
|
|
54d92fb326 | ||
|
|
73a9669bf7 | ||
|
|
5971e03ffe | ||
|
|
3123c33914 | ||
|
|
90e3f121ee | ||
|
|
be63983693 | ||
|
|
ad16f19268 | ||
|
|
77cb55315a | ||
|
|
54647b2f9a | ||
|
|
f3d5e1e88f | ||
|
|
2f96b86fed | ||
|
|
b29f74b925 | ||
|
|
3d154ca235 | ||
|
|
4acf254f72 | ||
|
|
69af65fd02 | ||
|
|
fdecf652bf | ||
|
|
76f3da2396 | ||
|
|
31d3f76c32 | ||
|
|
0c4360808a | ||
|
|
2a0b0cdeb0 | ||
|
|
c98eb84637 | ||
|
|
7b8c944510 | ||
|
|
3b7c3c0e82 | ||
|
|
5ef583ed59 | ||
|
|
d8c4e6934f | ||
|
|
a3e8ab8d71 | ||
|
|
a08b5fbcd0 | ||
|
|
2c009b3ba4 | ||
|
|
fee3662e82 | ||
|
|
adf0344f73 | ||
|
|
4d98289f96 | ||
|
|
4bf569a727 | ||
|
|
05bed3c72c | ||
|
|
32ede64554 | ||
|
|
8b9685e680 | ||
|
|
1a55378b44 | ||
|
|
b36dbc10ec | ||
|
|
81dce5a3e7 | ||
|
|
4e794d4af1 | ||
|
|
1205ef37ae | ||
|
|
f7d4cc14c2 | ||
|
|
d36d4f86b3 | ||
|
|
757fbcfd83 | ||
|
|
e0fdf48395 | ||
|
|
63f44c4ac9 | ||
|
|
64ba59db10 | ||
|
|
1617ef8956 | ||
|
|
8ee1cda924 | ||
|
|
ea91418f4e | ||
|
|
e6c46f8198 | ||
|
|
8eaab92d31 | ||
|
|
370b57fa67 | ||
|
|
8562a26315 | ||
|
|
0ac5c728b2 | ||
|
|
ff40b4fcff | ||
|
|
0ac30b5049 | ||
|
|
20c4c7791f | ||
|
|
d422a27715 | ||
|
|
9eb548a778 | ||
|
|
2c73442a87 | ||
|
|
4913a043da | ||
|
|
ba88ea7e4f | ||
|
|
1deea15e0f | ||
|
|
d920d076d4 | ||
|
|
95606cb62c | ||
|
|
083ee55455 | ||
|
|
c4edefdbe4 | ||
|
|
b7325218fb | ||
|
|
428df79d87 | ||
|
|
074acc3bed | ||
|
|
282d1570cd | ||
|
|
4cacfac04c | ||
|
|
a2532dde35 | ||
|
|
d91724e9ce | ||
|
|
51a5f8b7aa | ||
|
|
3bcc01f134 | ||
|
|
63da7edd7d | ||
|
|
fea8444adc | ||
|
|
545178b201 | ||
|
|
c75feb0c1a | ||
|
|
69c26101cd | ||
|
|
9b0cbf0a4c | ||
|
|
f44e200874 | ||
|
|
d7f12834df | ||
|
|
3f919955fa | ||
|
|
1fc22d4cad | ||
|
|
647970f697 | ||
|
|
2a52837657 | ||
|
|
d1912f539a | ||
|
|
7a0443d39a | ||
|
|
c85ab87262 | ||
|
|
90b05946d0 | ||
|
|
7c5b811c27 | ||
|
|
851e772026 | ||
|
|
820587b4f5 | ||
|
|
81a62b7ae2 | ||
|
|
79603979ef | ||
|
|
4e6c323559 | ||
|
|
1e0988d5f0 | ||
|
|
01d91f55a7 | ||
|
|
c7052d6150 | ||
|
|
0f073702f7 | ||
|
|
8a4bec65dc | ||
|
|
87430cc6fa | ||
|
|
b3ec94b6ef | ||
|
|
5967b8fe56 | ||
|
|
100394dce9 | ||
|
|
81e1f7797d | ||
|
|
1d81be94f4 | ||
|
|
ffb25f81c5 | ||
|
|
6d6d9cdf2f | ||
|
|
dbe1a7469b | ||
|
|
a3b4439723 | ||
|
|
8c0b9c5a47 | ||
|
|
c3dcb5c326 | ||
|
|
b6aa0fbfe5 | ||
|
|
9942d0825c | ||
|
|
1d11ae4c00 | ||
|
|
0d1fd79326 | ||
|
|
2433fbae47 | ||
|
|
9b142ebf3f | ||
|
|
25d6646ffe | ||
|
|
193e5e8251 | ||
|
|
25cb79f602 | ||
|
|
9e273aded1 | ||
|
|
5a3ffe119d | ||
|
|
54b30bab7d | ||
|
|
17b6130014 | ||
|
|
e3b1547d59 | ||
|
|
61ef47b39b | ||
|
|
b8bb91ec2e | ||
|
|
90db33c1b4 | ||
|
|
6c49baf84a | ||
|
|
46716f177c | ||
|
|
41f8924209 | ||
|
|
a4b83887f9 | ||
|
|
1d3484fe4c | ||
|
|
08032b01f7 | ||
|
|
0ea707f70f | ||
|
|
f6a49dbf0a | ||
|
|
ba3e76ea12 | ||
|
|
751aee07f3 | ||
|
|
41d3000334 | ||
|
|
4dcb06d39b | ||
|
|
8a49c5609d | ||
|
|
38f318bc57 | ||
|
|
564095d28f | ||
|
|
5be97aa5e0 | ||
|
|
c186258abd | ||
|
|
86df78ec79 | ||
|
|
43f8a39fef | ||
|
|
559f2a4260 | ||
|
|
6571c7847d | ||
|
|
cc20ca0832 | ||
|
|
8528ec9801 | ||
|
|
a4e8958086 | ||
|
|
a0581b5382 | ||
|
|
8f35fe4b03 | ||
|
|
fad39c00ca | ||
|
|
ad90aa9854 | ||
|
|
984328becf | ||
|
|
091ea93984 | ||
|
|
15b729ea6b | ||
|
|
408908079d | ||
|
|
faa683cd17 | ||
|
|
2afefa5260 | ||
|
|
02388f04b8 | ||
|
|
34d3d7b16d | ||
|
|
6d262276a7 | ||
|
|
b284774a1a | ||
|
|
4d54b12f56 | ||
|
|
e2c25508a1 | ||
|
|
b0fafede80 | ||
|
|
2072471467 |
No files matched your search
@@ -220,6 +220,12 @@ jobs:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Set up MSVC developer command prompt
|
||||
if: matrix.name == 'windows'
|
||||
uses: ilammy/msvc-dev-cmd@v1
|
||||
with:
|
||||
arch: x64
|
||||
|
||||
- name: Restore Electron download cache
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
|
||||
@@ -115,6 +115,7 @@ coverage
|
||||
!/build/icons
|
||||
!/build/installer.nsh
|
||||
/electron/native/**/build
|
||||
/electron/bridges/windowsHelloHelper/build/
|
||||
/release
|
||||
/out
|
||||
/public/monaco
|
||||
|
||||
@@ -4,6 +4,8 @@ import { initializeFonts } from './application/state/fontStore';
|
||||
import { initializeUIFonts } from './application/state/uiFontStore';
|
||||
import { useSettingsChromeStore } from './application/state/settingsChromeStore';
|
||||
import { I18nProvider } from './application/i18n/I18nProvider';
|
||||
import type { useSettingsState } from './application/state/useSettingsState';
|
||||
import type { useAppLockState } from './application/state/useAppLockState';
|
||||
import { ToastProvider } from './components/ui/toast';
|
||||
import { TooltipProvider } from './components/ui/tooltip';
|
||||
import { ScriptAutomationRoot } from './components/scripts/ScriptAutomationRoot';
|
||||
@@ -12,19 +14,20 @@ import { PluginAuthenticationHost } from './components/plugins/PluginAuthenticat
|
||||
import { useExternalMcpGrantPersister } from './components/ai/useExternalMcpGrantPersister';
|
||||
import { setupMcpApprovalBridge } from './infrastructure/ai/shared/approvalGate';
|
||||
import { setupCodexAppServerInteractionBridge } from './infrastructure/ai/shared/codexAppServerInteractions';
|
||||
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
|
||||
import { AppShell } from './application/app/AppShell';
|
||||
import { AppLocalStateProvider } from './application/app/AppLocalState';
|
||||
import { AppSideEffects } from './application/app/AppSideEffects';
|
||||
import { SessionPublisher } from './application/app/publishers/SessionPublisher';
|
||||
import { SettingsPublisher } from './application/app/publishers/SettingsPublisher';
|
||||
import { VaultPublisher } from './application/app/publishers/VaultPublisher';
|
||||
import { AppLockRuntimePublisher } from './application/app/publishers/AppLockRuntimePublisher';
|
||||
|
||||
// Initialize fonts eagerly at app startup
|
||||
initializeFonts();
|
||||
initializeUIFonts();
|
||||
|
||||
let rendererReadySent = false;
|
||||
type SettingsState = ReturnType<typeof useSettingsState>;
|
||||
type AppLockState = ReturnType<typeof useAppLockState>;
|
||||
|
||||
/**
|
||||
* Bridges the locale out of settings chrome without pulling App into a
|
||||
@@ -49,29 +52,16 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
function AppWithProviders() {
|
||||
/**
|
||||
* `AppLockGate` (index.tsx) owns `useSettingsState` / `useAppLockState` so the
|
||||
* lock overlay can render before app children mount, and it owns the
|
||||
* splash-removal + rendererReady signal (deep links must wait for unlock).
|
||||
* App forwards the gate's settings instance into SettingsPublisher so the
|
||||
* runtime slot/context still publish a single settings runtime.
|
||||
*/
|
||||
function AppWithProviders({ settings, appLock }: { settings: SettingsState; appLock: AppLockState }) {
|
||||
const isPeerSessionWindow = typeof window !== 'undefined' && window.location.hash.startsWith('#/session-window');
|
||||
|
||||
useEffect(() => {
|
||||
let splashRemovalTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const splash = document.getElementById('splash');
|
||||
if (splash) {
|
||||
splash.classList.add('fade-out');
|
||||
splashRemovalTimer = setTimeout(() => splash.remove(), 200);
|
||||
}
|
||||
if (!rendererReadySent) {
|
||||
rendererReadySent = true;
|
||||
netcattyBridge.get()?.rendererReady?.();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return () => {
|
||||
if (splashRemovalTimer !== undefined) clearTimeout(splashRemovalTimer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return setupMcpApprovalBridge();
|
||||
}, []);
|
||||
@@ -83,24 +73,23 @@ function AppWithProviders() {
|
||||
useExternalMcpGrantPersister();
|
||||
|
||||
return (
|
||||
<SettingsPublisher
|
||||
enableSettingsSync={!isPeerSessionWindow}
|
||||
enableSystemEffects={!isPeerSessionWindow}
|
||||
>
|
||||
<SettingsI18nProvider>
|
||||
<ToastProvider>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<ScriptAutomationRoot />
|
||||
<ExternalMcpApprovalsHost />
|
||||
<PluginAuthenticationHost />
|
||||
<VaultPublisher>
|
||||
<SessionPublisher persistSessionRestore={!isPeerSessionWindow}>
|
||||
<App />
|
||||
</SessionPublisher>
|
||||
</VaultPublisher>
|
||||
</TooltipProvider>
|
||||
</ToastProvider>
|
||||
</SettingsI18nProvider>
|
||||
<SettingsPublisher settings={settings}>
|
||||
<AppLockRuntimePublisher appLock={appLock} appLockEnabled={settings.appLockSettings.enabled}>
|
||||
<SettingsI18nProvider>
|
||||
<ToastProvider>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<ScriptAutomationRoot />
|
||||
<ExternalMcpApprovalsHost />
|
||||
<PluginAuthenticationHost />
|
||||
<VaultPublisher>
|
||||
<SessionPublisher persistSessionRestore={!isPeerSessionWindow}>
|
||||
<App />
|
||||
</SessionPublisher>
|
||||
</VaultPublisher>
|
||||
</TooltipProvider>
|
||||
</ToastProvider>
|
||||
</SettingsI18nProvider>
|
||||
</AppLockRuntimePublisher>
|
||||
</SettingsPublisher>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ 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 appLockGateSource = readFileSync(join(here, '../../components/AppLockGate.tsx'), 'utf8');
|
||||
|
||||
const hostsDir = join(here, 'hosts');
|
||||
const hostFiles = existsSync(hostsDir)
|
||||
@@ -79,10 +80,12 @@ test('App.tsx does not subscribe to vault/session/settings runtimes or mega hook
|
||||
assert.match(appSource, /<AppSideEffects\b/);
|
||||
});
|
||||
|
||||
test('publishers own the mega hooks and store fan-out', () => {
|
||||
test('publishers and the app-lock gate 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(appLockGateSource, /\buseSettingsState\s*\(/);
|
||||
assert.doesNotMatch(settingsPublisherSource, /\buseSettingsState\s*\(/);
|
||||
assert.match(settingsPublisherSource, /registerAppSettingsRuntime\(settings\)/);
|
||||
assert.match(vaultPublisherSource, /publishVaultSnapshot\(/);
|
||||
assert.match(vaultPublisherSource, /registerVaultSnapshotActions\(/);
|
||||
assert.match(vaultPublisherSource, /registerVaultSnapshotActions\(null\)/);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("./AppSideEffects.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("open-terminal requests wait behind App Lock and resume after unlock", () => {
|
||||
const handlerStart = source.indexOf("const _handleOpenTerminalPath");
|
||||
const handlerEnd = source.indexOf("useEffect(() =>", handlerStart);
|
||||
const handlerSource = source.slice(handlerStart, handlerEnd);
|
||||
const drainStart = source.indexOf("const pending = pendingDeepLinksWhileLockedRef.current.splice(0)");
|
||||
const drainEnd = source.indexOf("}, [appLockLocked]);", drainStart);
|
||||
const drainSource = source.slice(drainStart, drainEnd);
|
||||
|
||||
assert.match(handlerSource, /shouldDeferExternalActionWhileAppLocked/);
|
||||
assert.match(handlerSource, /kind: 'open-terminal-path'/);
|
||||
assert.match(drainSource, /_processOpenTerminalPath\(item\.payload\)/);
|
||||
});
|
||||
@@ -7,10 +7,12 @@ import { useManagedSourceSync } from '../state/useManagedSourceSync';
|
||||
import { usePortForwardingState } from '../state/usePortForwardingState';
|
||||
import { useUpdateCheck } from '../state/useUpdateCheck';
|
||||
import {
|
||||
useAppLockChrome,
|
||||
useAppSessionRuntime,
|
||||
useAppSettingsRuntime,
|
||||
useAppVaultRuntime,
|
||||
} from '../state/appRuntimeBridge';
|
||||
import { shouldDeferExternalActionWhileAppLocked } from '../../components/AppLockGate';
|
||||
import {
|
||||
getConnectionLogsSnapshot,
|
||||
subscribeConnectionLogs,
|
||||
@@ -101,6 +103,7 @@ const HOTKEY_DEBUG =
|
||||
|
||||
export function AppSideEffects() {
|
||||
const settings = useAppSettingsRuntime();
|
||||
const { locked: appLockLocked } = useAppLockChrome();
|
||||
const { t } = useI18n();
|
||||
const pluginViewTabs = usePluginViewTabs();
|
||||
|
||||
@@ -620,6 +623,20 @@ export function AppSideEffects() {
|
||||
workspaces,
|
||||
});
|
||||
|
||||
const pendingTrayPortForwardsWhileLockedRef = useRef<Array<{ ruleId: string; start: boolean }>>([]);
|
||||
|
||||
const _handleTrayTogglePortForwardMaybeDeferred = useEffectEvent((ruleId: string, start: boolean) => {
|
||||
// Saved-credential tunnels must not start/stop behind the lock overlay.
|
||||
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
|
||||
const pending = pendingTrayPortForwardsWhileLockedRef.current;
|
||||
const existing = pending.findIndex((item) => item.ruleId === ruleId);
|
||||
if (existing >= 0) pending.splice(existing, 1);
|
||||
pending.push({ ruleId, start });
|
||||
return;
|
||||
}
|
||||
_handleTrayTogglePortForward(ruleId, start);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isPeerSessionWindow) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -629,7 +646,7 @@ export function AppSideEffects() {
|
||||
_handleTrayJumpToSession(sessionId);
|
||||
});
|
||||
const unsubscribeToggle = bridge.onTrayTogglePortForward((ruleId, start) => {
|
||||
_handleTrayTogglePortForward(ruleId, start);
|
||||
_handleTrayTogglePortForwardMaybeDeferred(ruleId, start);
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -638,6 +655,14 @@ export function AppSideEffects() {
|
||||
};
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) return;
|
||||
const pending = pendingTrayPortForwardsWhileLockedRef.current.splice(0);
|
||||
for (const item of pending) {
|
||||
_handleTrayTogglePortForward(item.ruleId, item.start);
|
||||
}
|
||||
}, [appLockLocked]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPeerSessionWindow) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -1094,20 +1119,24 @@ export function AppSideEffects() {
|
||||
}, [closeLogView, editorTabs, executeHotkeyAction, logViews, pluginViewTabs, sessions, workspaces]);
|
||||
|
||||
useEffect(() => {
|
||||
// Cmd/Ctrl+W from the app menu arrives via IPC, not the keydown listener.
|
||||
// Gate it while locked so sessions/tabs cannot close behind the overlay.
|
||||
const unsubscribe = netcattyBridge.get()?.onWindowCommandCloseRequested?.(() => {
|
||||
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) return;
|
||||
void handleWindowCommandCloseRequest();
|
||||
});
|
||||
return () => unsubscribe?.();
|
||||
}, [handleWindowCommandCloseRequest]);
|
||||
}, [appLockLocked, handleWindowCommandCloseRequest]);
|
||||
|
||||
// Callback for terminal to invoke app-level hotkey actions
|
||||
const handleHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
|
||||
executeHotkeyAction(action, e);
|
||||
}, [executeHotkeyAction]);
|
||||
|
||||
// Global hotkey handler
|
||||
// Global hotkey handler — suppress while the app lock overlay is up so
|
||||
// capture-phase shortcuts cannot mutate sessions behind the lock screen.
|
||||
useEffect(() => {
|
||||
if (hotkeyScheme === 'disabled' || isHotkeyRecording) return;
|
||||
if (hotkeyScheme === 'disabled' || isHotkeyRecording || appLockLocked) return;
|
||||
|
||||
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
||||
_handleGlobalHotkeyKeyDown(e);
|
||||
@@ -1115,15 +1144,16 @@ export function AppSideEffects() {
|
||||
|
||||
window.addEventListener('keydown', handleGlobalKeyDown, true);
|
||||
return () => window.removeEventListener('keydown', handleGlobalKeyDown, true);
|
||||
}, [hotkeyScheme, isHotkeyRecording]);
|
||||
}, [hotkeyScheme, isHotkeyRecording, appLockLocked]);
|
||||
|
||||
useEffect(() => {
|
||||
if (appLockLocked) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
_handleEscapeKeyDown(e);
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, []);
|
||||
}, [appLockLocked]);
|
||||
|
||||
const handleDeleteHost = useCallback((hostId: string) => {
|
||||
const target = hosts.find(h => h.id === hostId);
|
||||
@@ -1390,7 +1420,17 @@ export function AppSideEffects() {
|
||||
getScriptSessionMeta: (sessionId) => sessions.find((session) => session.id === sessionId),
|
||||
});
|
||||
|
||||
const _handleSshDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
// Idle/background/manual locks keep children mounted under the overlay. Queue
|
||||
// deep links until unlock so saved-credential connects cannot start behind
|
||||
// the lock screen.
|
||||
const pendingDeepLinksWhileLockedRef = useRef<Array<
|
||||
| { kind: 'ssh'; payload: { url?: string } }
|
||||
| { kind: 'telnet'; payload: { url?: string } }
|
||||
| { kind: 'jms'; payload: { url?: string } }
|
||||
| { kind: 'open-terminal-path'; payload: { path?: string } }
|
||||
>>([]);
|
||||
|
||||
const _processSshDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
startupLaunchIntentReceivedRef.current = true;
|
||||
const rawUrl = payload?.url || '';
|
||||
const target = parseSshDeepLink(rawUrl);
|
||||
@@ -1439,6 +1479,14 @@ export function AppSideEffects() {
|
||||
setActiveTabId('vault');
|
||||
});
|
||||
|
||||
const _handleSshDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
|
||||
pendingDeepLinksWhileLockedRef.current.push({ kind: 'ssh', payload: payload || {} });
|
||||
return;
|
||||
}
|
||||
_processSshDeepLink(payload);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isPeerSessionWindow) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -1448,7 +1496,7 @@ export function AppSideEffects() {
|
||||
});
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
const _handleTelnetDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
const _processTelnetDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
startupLaunchIntentReceivedRef.current = true;
|
||||
const rawUrl = payload?.url || '';
|
||||
const target = parseTelnetDeepLink(rawUrl);
|
||||
@@ -1486,6 +1534,14 @@ export function AppSideEffects() {
|
||||
handleConnectToHost(ephemeralHost);
|
||||
});
|
||||
|
||||
const _handleTelnetDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
|
||||
pendingDeepLinksWhileLockedRef.current.push({ kind: 'telnet', payload: payload || {} });
|
||||
return;
|
||||
}
|
||||
_processTelnetDeepLink(payload);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isPeerSessionWindow) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -1495,7 +1551,7 @@ export function AppSideEffects() {
|
||||
});
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
const _handleJmsDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
const _processJmsDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
startupLaunchIntentReceivedRef.current = true;
|
||||
const rawUrl = payload?.url || '';
|
||||
const target = parseJmsDeepLink(rawUrl);
|
||||
@@ -1515,6 +1571,14 @@ export function AppSideEffects() {
|
||||
handleConnectToHost(ephemeralHost);
|
||||
});
|
||||
|
||||
const _handleJmsDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
|
||||
pendingDeepLinksWhileLockedRef.current.push({ kind: 'jms', payload: payload || {} });
|
||||
return;
|
||||
}
|
||||
_processJmsDeepLink(payload);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isPeerSessionWindow) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -1524,6 +1588,35 @@ export function AppSideEffects() {
|
||||
});
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
const _processOpenTerminalPath = useEffectEvent((payload: { path?: string }) => {
|
||||
startupLaunchIntentReceivedRef.current = true;
|
||||
const localStartDir = typeof payload?.path === 'string' ? payload.path : '';
|
||||
if (!localStartDir.trim()) return;
|
||||
handleCreateLocalTerminal(undefined, { localStartDir });
|
||||
});
|
||||
|
||||
const _handleOpenTerminalPath = useEffectEvent((payload: { path?: string }) => {
|
||||
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
|
||||
pendingDeepLinksWhileLockedRef.current.push({
|
||||
kind: 'open-terminal-path',
|
||||
payload: payload || {},
|
||||
});
|
||||
return;
|
||||
}
|
||||
_processOpenTerminalPath(payload);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) return;
|
||||
const pending = pendingDeepLinksWhileLockedRef.current.splice(0);
|
||||
for (const item of pending) {
|
||||
if (item.kind === 'ssh') _processSshDeepLink(item.payload);
|
||||
else if (item.kind === 'telnet') _processTelnetDeepLink(item.payload);
|
||||
else if (item.kind === 'jms') _processJmsDeepLink(item.payload);
|
||||
else _processOpenTerminalPath(item.payload);
|
||||
}
|
||||
}, [appLockLocked]);
|
||||
|
||||
useEffect(() => {
|
||||
setEphemeralHosts((prev) => {
|
||||
if (prev.length === 0) return prev;
|
||||
@@ -1535,13 +1628,6 @@ export function AppSideEffects() {
|
||||
});
|
||||
}, [sessions]);
|
||||
|
||||
const _handleOpenTerminalPath = useEffectEvent((payload: { path?: string }) => {
|
||||
startupLaunchIntentReceivedRef.current = true;
|
||||
const localStartDir = typeof payload?.path === 'string' ? payload.path : '';
|
||||
if (!localStartDir.trim()) return;
|
||||
handleCreateLocalTerminal(undefined, { localStartDir });
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isPeerSessionWindow) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
useSettingsChromeActions,
|
||||
useSettingsChromeStore,
|
||||
} from '../state/settingsChromeStore';
|
||||
import { getAppAppLockRuntime, useAppLockChrome } from '../state/appRuntimeBridge';
|
||||
import { useAppThemeStyle } from './useAppThemeStyle';
|
||||
import { useMainWindowInputFocusRecovery } from '../state/useMainWindowInputFocusRecovery';
|
||||
import { useExternalMcpToggleState } from '../state/useExternalMcpToggleState';
|
||||
@@ -301,6 +302,14 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
} = useSettingsChromeStore();
|
||||
const { setTheme, setWindowOpacity } = useSettingsChromeActions();
|
||||
|
||||
// App Lock chrome: narrow slice published by AppLockRuntimePublisher; the
|
||||
// lock action reads the full runtime imperatively so the callback stays
|
||||
// stable across gate re-renders.
|
||||
const { appLockEnabled } = useAppLockChrome();
|
||||
const handleLockApp = useCallback(() => {
|
||||
getAppAppLockRuntime()?.lockNow('manual');
|
||||
}, []);
|
||||
|
||||
const handleTerminalCommandExecuted = useCallback((
|
||||
command: string,
|
||||
hostId: string,
|
||||
@@ -485,6 +494,8 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onOpenQuickSwitcher={handleOpenQuickSwitcher}
|
||||
onThemeChange={setTheme}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
onLockApp={handleLockApp}
|
||||
appLockEnabled={appLockEnabled}
|
||||
externalMcpEnabled={externalMcpToggle.enabled}
|
||||
onToggleExternalMcp={externalMcpToggle.setEnabled}
|
||||
showExternalMcpToggle={!isPeerSessionWindow}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useLayoutEffect, useMemo, type ReactNode } from 'react';
|
||||
|
||||
import {
|
||||
AppLockChromeContext,
|
||||
registerAppAppLockRuntime,
|
||||
type AppAppLockRuntime,
|
||||
} from '../../state/appRuntimeBridge';
|
||||
|
||||
export type AppLockRuntimePublisherProps = {
|
||||
/** App-lock runtime owned by `AppLockGate` (index.tsx render prop). */
|
||||
appLock: AppAppLockRuntime;
|
||||
/** `settings.appLockSettings.enabled` from the gate's settings instance. */
|
||||
appLockEnabled: boolean;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Publishes the gate-owned app-lock runtime the same way SettingsPublisher
|
||||
* publishes settings: the full runtime goes on the `appRuntimeBridge` slot for
|
||||
* imperative callers (`getAppAppLockRuntime()`), and a narrow memoized chrome
|
||||
* slice goes on context so TopTabs / AppSideEffects re-render only when the
|
||||
* lock state actually changes — the full runtime changes identity on every
|
||||
* gate render.
|
||||
*/
|
||||
export function AppLockRuntimePublisher({
|
||||
appLock,
|
||||
appLockEnabled,
|
||||
children,
|
||||
}: AppLockRuntimePublisherProps) {
|
||||
useLayoutEffect(() => {
|
||||
registerAppAppLockRuntime(appLock);
|
||||
}, [appLock]);
|
||||
|
||||
// Only a real unmount clears the slot; see VaultPublisher for why.
|
||||
useLayoutEffect(() => () => {
|
||||
registerAppAppLockRuntime(null);
|
||||
}, []);
|
||||
|
||||
const chrome = useMemo(
|
||||
() => ({
|
||||
appLockEnabled,
|
||||
locked: appLock.locked,
|
||||
initialized: appLock.initialized,
|
||||
}),
|
||||
[appLockEnabled, appLock.locked, appLock.initialized],
|
||||
);
|
||||
|
||||
return (
|
||||
<AppLockChromeContext.Provider value={chrome}>
|
||||
{children}
|
||||
</AppLockChromeContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -3,33 +3,29 @@ import { useLayoutEffect, type ReactNode } from 'react';
|
||||
import {
|
||||
AppSettingsRuntimeContext,
|
||||
registerAppSettingsRuntime,
|
||||
type AppSettingsRuntime,
|
||||
} 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;
|
||||
/**
|
||||
* Pre-built settings runtime owned by an ancestor. `AppLockGate` (index.tsx)
|
||||
* owns `useSettingsState` so the lock overlay can render before app children
|
||||
* mount; the publisher only binds the runtime slot and context.
|
||||
*/
|
||||
settings: AppSettingsRuntime;
|
||||
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.
|
||||
* Publishes the gate-owned settings runtime 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.
|
||||
* happens inside `useSettingsState`, so this publisher only relocates the
|
||||
* binding out of the component that also builds the shell's domain bags.
|
||||
*/
|
||||
export function SettingsPublisher({
|
||||
enableSettingsSync,
|
||||
enableSystemEffects,
|
||||
children,
|
||||
}: SettingsPublisherProps) {
|
||||
const settings = useSettingsState({ enableSettingsSync, enableSystemEffects });
|
||||
|
||||
export function SettingsPublisher({ settings, children }: SettingsPublisherProps) {
|
||||
useLayoutEffect(() => {
|
||||
registerAppSettingsRuntime(settings);
|
||||
}, [settings]);
|
||||
|
||||
@@ -20,6 +20,10 @@ const portForwardingAutoStartSource = readFileSync(
|
||||
new URL('../state/usePortForwardingAutoStart.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const appLockBridgeSource = readFileSync(
|
||||
new URL('../state/useAppLockBridge.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
test('every renderer root mounts under StrictMode', () => {
|
||||
assert.match(indexSource, /import \{ StrictMode, Suspense, lazy \} from 'react'/);
|
||||
@@ -58,10 +62,10 @@ test('clone-session payload is consumed once even if the effect re-runs', () =>
|
||||
});
|
||||
|
||||
test('rendererReady is notified once per renderer process', () => {
|
||||
assert.match(appSource, /^let rendererReadySent = false;$/m);
|
||||
const guardAt = appSource.indexOf('if (!rendererReadySent) {');
|
||||
assert.match(appLockBridgeSource, /^let rendererReadySent = false;$/m);
|
||||
const guardAt = appLockBridgeSource.indexOf('if (rendererReadySent) return;');
|
||||
assert.notEqual(guardAt, -1);
|
||||
const guarded = appSource.slice(guardAt, guardAt + 200);
|
||||
const guarded = appLockBridgeSource.slice(guardAt, guardAt + 200);
|
||||
assert.match(guarded, /rendererReadySent = true;/);
|
||||
assert.match(guarded, /netcattyBridge\.get\(\)\?\.rendererReady\?\.\(\)/);
|
||||
assert.ok(
|
||||
|
||||
@@ -43,6 +43,10 @@ export const enAiMessages: Messages = {
|
||||
'ai.providers.style.google': 'Google-compatible',
|
||||
'ai.providers.style.inherited': 'auto',
|
||||
'ai.providers.style.help': 'Selects which API format requests use. Override when a third-party endpoint speaks a different dialect than its provider type suggests.',
|
||||
'ai.providers.openaiApi': 'OpenAI API format',
|
||||
'ai.providers.openaiApi.chat': 'Chat Completions',
|
||||
'ai.providers.openaiApi.responses': 'Responses',
|
||||
'ai.providers.openaiApi.help': 'Chat Completions works with most OpenAI-compatible endpoints. Responses can raise cache hit rates on relays that support /v1/responses.',
|
||||
'ai.providers.icon.change': 'Change icon',
|
||||
'ai.providers.icon.upload': 'Upload image',
|
||||
'ai.providers.icon.reset': 'Reset',
|
||||
@@ -54,6 +58,7 @@ export const enAiMessages: Messages = {
|
||||
'ai.providers.apiKey.decrypting': 'Decrypting...',
|
||||
'ai.providers.baseUrl': 'Base URL',
|
||||
'ai.providers.baseUrl.anthropicHelp': 'Anthropic-compatible: host with or without /v1 (for example https://gateway.example or https://gateway.example/v1). Detection and chat both use /v1/models and /v1/messages.',
|
||||
'ai.providers.baseUrl.ollamaHelp': 'Local Ollama: http://localhost:11434/v1 (no API key). Ollama Cloud: https://ollama.com/v1 plus your cloud API key.',
|
||||
'ai.providers.skipTLSVerify': 'Skip TLS certificate verification (for self-signed certs)',
|
||||
'ai.providers.defaultModel': 'Default Model',
|
||||
'ai.providers.defaultModel.placeholder': 'e.g. gpt-4o, claude-sonnet-4-20250514',
|
||||
@@ -442,15 +447,28 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.permObserver': 'Observer',
|
||||
'ai.chat.permConfirm': 'Confirm',
|
||||
'ai.chat.permAuto': 'Auto',
|
||||
'ai.chat.permObserverDesc': 'Read only (Cursor CLI: Ask)',
|
||||
'ai.chat.permConfirmDesc': 'Ask before Netcatty writes (Cursor CLI: agent + force)',
|
||||
'ai.chat.permAutoDesc': 'Execute freely (Cursor CLI: agent + force)',
|
||||
'ai.chat.permObserverDesc': 'Read only',
|
||||
'ai.chat.permConfirmDesc': 'Ask before writes',
|
||||
'ai.chat.permAutoDesc': 'Run freely',
|
||||
'ai.chat.emptyHint': 'Ask about your servers, run commands, or get help with configurations.',
|
||||
'ai.chat.placeholder': 'Message {agent} — @ to include context, / for commands',
|
||||
'ai.chat.placeholderDefault': 'Message Catty Agent...',
|
||||
'ai.chat.noModel': 'No model',
|
||||
'ai.chat.noProviderModel': 'No default model — set one in Settings → AI → Providers.',
|
||||
'ai.chat.selectProvider': 'Select provider',
|
||||
'ai.chat.selectProviderAndModel': 'Select provider and model',
|
||||
'ai.chat.selectModel': 'Select model',
|
||||
'ai.chat.searchModels': 'Search models',
|
||||
'ai.chat.providers': 'Providers',
|
||||
'ai.chat.models': 'Models',
|
||||
'ai.chat.pinned': 'Pinned',
|
||||
'ai.chat.useCustomModel': 'Use "{id}"',
|
||||
'ai.chat.thinkingLevel': 'Thinking',
|
||||
'ai.chat.thinkingOff': 'Off',
|
||||
'ai.chat.pinModel': 'Pin model',
|
||||
'ai.chat.unpinModel': 'Unpin model',
|
||||
'ai.chat.loadingModels': 'Loading models...',
|
||||
'ai.chat.noMatchingModels': 'No matching models',
|
||||
'ai.chat.recent': 'Recent',
|
||||
'ai.chat.viewAll': 'View All',
|
||||
'ai.chat.untitled': 'Untitled',
|
||||
|
||||
@@ -83,6 +83,32 @@ export const enCoreMessages: Messages = {
|
||||
'credentials.protectionUnavailable.message': 'Saved passwords and keys cannot be auto-decrypted on this device. Re-enter credentials before connecting.',
|
||||
'credentials.protectionUnavailable.action': 'Open Settings',
|
||||
|
||||
// App Lock
|
||||
'appLock.title': 'Netcatty is locked',
|
||||
'appLock.reason.default': 'Enter your lock password to continue.',
|
||||
'appLock.reason.startup': 'Netcatty locks each time this window opens.',
|
||||
'appLock.reason.idle': 'Netcatty locked after the inactivity timeout.',
|
||||
'appLock.reason.manual': 'Netcatty was locked manually.',
|
||||
'appLock.passwordLabel': 'Lock password',
|
||||
'appLock.passwordPlaceholder': 'Enter password to unlock',
|
||||
'appLock.unlock': 'Unlock',
|
||||
'appLock.unlocking': 'Unlocking...',
|
||||
'appLock.usePassword': 'Use password instead',
|
||||
'appLock.error.emptyPassword': 'Enter the lock password.',
|
||||
'appLock.error.incorrectPassword': 'Incorrect lock password.',
|
||||
'appLock.systemUnlock.unlockWith': 'Unlock with {label}',
|
||||
'appLock.systemUnlock.preparing': 'Preparing {label}...',
|
||||
'appLock.systemUnlock.verifying': 'Waiting for {label}...',
|
||||
'appLock.systemUnlock.error': 'System unlock was not completed. Use the lock password to continue.',
|
||||
'appLock.logoLabel': 'Netcatty',
|
||||
'appLock.reset.title': 'Reset App Lock',
|
||||
'appLock.reset.description': 'This removes the current lock password and turns off App Lock. Set a new password from Settings when you need it again.',
|
||||
'appLock.reset.cancel': 'Cancel',
|
||||
'appLock.reset.confirm': 'Reset App Lock',
|
||||
'appLock.reset.resetting': 'Resetting...',
|
||||
'appLock.reset.error': 'Could not reset App Lock. Try again.',
|
||||
'topTabs.lockApp': 'Lock Netcatty',
|
||||
|
||||
// Settings shell
|
||||
'settings.title': 'Settings',
|
||||
'settings.tab.application': 'Application',
|
||||
@@ -118,6 +144,59 @@ export const enCoreMessages: Messages = {
|
||||
'settings.system.credentials.unavailableHint': 'Credentials encrypted on another user profile or machine cannot be decrypted here. Re-enter and save credentials on this device.',
|
||||
'settings.system.credentials.portabilityHint': 'Cloud Sync is portable because it uses your master key encryption. Local safeStorage encryption is device/user scoped.',
|
||||
|
||||
// Settings > System > App Lock
|
||||
'settings.appLock.title': 'App Lock',
|
||||
'settings.appLock.description': 'Require a local lock password when Netcatty opens, after inactivity, or when you lock it manually.',
|
||||
'settings.appLock.setupTitle': 'Set a lock password',
|
||||
'settings.appLock.setupDescription': 'Create the password below. App Lock turns on immediately after it is saved.',
|
||||
'settings.appLock.manageTitle': 'App Lock is ready',
|
||||
'settings.appLock.enabledStatus': 'Enabled. Netcatty locks on startup, after inactivity, and when you lock it manually.',
|
||||
'settings.appLock.disabledStatus': 'Password saved, but App Lock is currently off.',
|
||||
'settings.appLock.disableTitle': 'Turn off App Lock',
|
||||
'settings.appLock.disableDescription': 'Enter the current lock password to stop locking Netcatty. The saved password will be removed; turning App Lock on again requires creating a new one.',
|
||||
'settings.appLock.disable': 'Turn off App Lock',
|
||||
'settings.appLock.disabling': 'Turning off...',
|
||||
'settings.appLock.setupPasswordTitle': 'Create lock password',
|
||||
'settings.appLock.setupPasswordDescription': 'This password is required to unlock Netcatty and to change or turn off App Lock later.',
|
||||
'settings.appLock.changePasswordTitle': 'Change lock password',
|
||||
'settings.appLock.changePasswordDescription': 'Enter the current lock password, then choose a new one.',
|
||||
'settings.appLock.enable': 'Enable app lock',
|
||||
'settings.appLock.enableDesc': 'Lock on startup and after the selected inactivity timeout.',
|
||||
'settings.appLock.timeout': 'Inactivity timeout',
|
||||
'settings.appLock.timeoutDesc': 'Lock after inactivity, or when the window is closed to the tray. Never skips both.',
|
||||
'settings.appLock.timeout.0': 'Never lock automatically',
|
||||
'settings.appLock.timeout.1': '1 minute',
|
||||
'settings.appLock.timeout.5': '5 minutes',
|
||||
'settings.appLock.timeout.15': '15 minutes',
|
||||
'settings.appLock.timeout.30': '30 minutes',
|
||||
'settings.appLock.timeout.60': '60 minutes',
|
||||
'settings.appLock.systemUnlock.label': '{label} unlock',
|
||||
'settings.appLock.systemUnlock.desc': 'Use {label} as an alternate way to unlock Netcatty on this device.',
|
||||
'settings.appLock.systemUnlock.unavailableDesc': '{label} is not available on this device right now.',
|
||||
'settings.appLock.systemUnlock.unavailable': 'System unlock is not available on this device.',
|
||||
'settings.appLock.systemUnlock.locked': 'Unlock Netcatty before changing system unlock.',
|
||||
'settings.appLock.systemUnlock.autoPrompt.label': 'Open {label} automatically',
|
||||
'settings.appLock.systemUnlock.autoPrompt.desc': 'Show the {label} prompt automatically when Netcatty is waiting to unlock.',
|
||||
'settings.appLock.currentPassword': 'Current password',
|
||||
'settings.appLock.currentPasswordPlaceholder': 'Required to disable or replace',
|
||||
'settings.appLock.currentPasswordForDisablePlaceholder': 'Current password to turn off App Lock',
|
||||
'settings.appLock.currentPasswordForChangePlaceholder': 'Current password to change it',
|
||||
'settings.appLock.newPassword': 'New password',
|
||||
'settings.appLock.newPasswordPlaceholder': 'Set lock password',
|
||||
'settings.appLock.confirmPassword': 'Confirm password',
|
||||
'settings.appLock.confirmPasswordPlaceholder': 'Repeat password',
|
||||
'settings.appLock.savePassword': 'Set and enable',
|
||||
'settings.appLock.savingPassword': 'Saving...',
|
||||
'settings.appLock.passwordSet': 'Password configured',
|
||||
'settings.appLock.replacePassword': 'Replace password',
|
||||
'settings.appLock.enableAfterPassword': 'Set a lock password before enabling app lock.',
|
||||
'settings.appLock.localOnlyHint': 'Device-local privacy lock. It is not synced and is not an OS security boundary.',
|
||||
'settings.appLock.validation.currentRequired': 'Enter the current lock password.',
|
||||
'settings.appLock.validation.newRequired': 'Enter a new lock password.',
|
||||
'settings.appLock.validation.confirmRequired': 'Confirm the new lock password.',
|
||||
'settings.appLock.validation.mismatch': 'The new passwords do not match.',
|
||||
'settings.appLock.validation.incorrect': 'The current lock password is incorrect.',
|
||||
|
||||
// Settings > System > Network Proxy
|
||||
'settings.system.networkProxy.title': 'Network Proxy',
|
||||
'settings.system.networkProxy.description': 'HTTP(S) proxy for cloud sync and AI providers. This is separate from SSH ProxyJump / ProxyCommand profiles.',
|
||||
@@ -610,6 +689,12 @@ Highlight the focused split pane:
|
||||
'settings.terminal.behavior.dynamicTabTitle.off': 'Disabled',
|
||||
'settings.terminal.behavior.dynamicTabTitle.agent': 'Agents only',
|
||||
'settings.terminal.behavior.dynamicTabTitle.all': 'All sessions',
|
||||
'settings.terminal.behavior.oscNotifications': 'OSC desktop notifications',
|
||||
'settings.terminal.behavior.oscNotifications.desc':
|
||||
'Show a system notification when a program emits OSC 9, OSC 777 notify, or kitty OSC 99. Used by Codex and other agents for turn-complete and approval alerts.',
|
||||
'settings.terminal.behavior.oscNotifications.off': 'Disabled',
|
||||
'settings.terminal.behavior.oscNotifications.unfocused': 'When unfocused',
|
||||
'settings.terminal.behavior.oscNotifications.always': 'Always',
|
||||
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 clipboard',
|
||||
'settings.terminal.behavior.osc52Clipboard.desc':
|
||||
'Allow remote programs (tmux, vim, etc.) to access the local clipboard via OSC-52 escape sequences.',
|
||||
|
||||
@@ -208,6 +208,7 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.columns.modified': 'Modified',
|
||||
'sftp.columns.size': 'Size',
|
||||
'sftp.columns.kind': 'Kind',
|
||||
'sftp.columns.owner': 'Owner',
|
||||
'sftp.columns.configure': 'Choose visible columns',
|
||||
'sftp.columns.actions': 'Actions',
|
||||
'sftp.sort.directoriesFirst': 'Folders first',
|
||||
@@ -229,6 +230,10 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.moveTo.confirm': 'Move',
|
||||
'sftp.moveTo.pathNotFound': 'Directory not found or inaccessible',
|
||||
'sftp.context.download': 'Download',
|
||||
'sftp.context.extract': 'Extract here',
|
||||
'sftp.extract.extracting': 'Extracting {fileName}...',
|
||||
'sftp.extract.success': 'Extracted {fileName}',
|
||||
'sftp.extract.error': 'Failed to extract {fileName}: {error}',
|
||||
'sftp.context.copyToOtherPane': 'Copy to other pane',
|
||||
'sftp.copyToOtherPane.unavailable': 'Open the two-pane SFTP view and connect the destination pane before copying files.',
|
||||
'sftp.copyCurrentPath': 'Copy current path',
|
||||
@@ -489,7 +494,7 @@ export const enVaultMessages: Messages = {
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': 'Transfer the file to the other pane\'s active host',
|
||||
'settings.sshDeepLink.title': 'SSH and Telnet links',
|
||||
'settings.sshDeepLink.enable': 'Open ssh:// and telnet:// links with Netcatty',
|
||||
'settings.sshDeepLink.enableDesc': 'Allow Netcatty to handle ssh:// and telnet:// links from browsers and other apps.',
|
||||
'settings.sshDeepLink.enableDesc': 'Allow Netcatty to handle ssh:// and telnet:// links from browsers and other apps, plus PuTTY-style launches such as -ssh user@host -P 22 -pw password.',
|
||||
'settings.explorerContextMenu.title': 'Windows Explorer',
|
||||
'settings.explorerContextMenu.enable': 'Show “Open in Netcatty” in folder context menus',
|
||||
'settings.explorerContextMenu.enableDesc': 'Adds a right-click item for folders and folder backgrounds so you can open a local terminal there. Applies immediately; no reinstall needed.',
|
||||
|
||||
@@ -43,6 +43,10 @@ export const esAiMessages: Messages = {
|
||||
'ai.providers.style.google': 'Compatible con Google',
|
||||
'ai.providers.style.inherited': 'auto',
|
||||
'ai.providers.style.help': 'Selecciona qué formato de API usan las peticiones. Anúlalo cuando un endpoint de terceros hable un dialecto distinto al que sugiere su tipo de proveedor.',
|
||||
'ai.providers.openaiApi': 'Formato de API de OpenAI',
|
||||
'ai.providers.openaiApi.chat': 'Chat Completions',
|
||||
'ai.providers.openaiApi.responses': 'Responses',
|
||||
'ai.providers.openaiApi.help': 'Chat Completions funciona con la mayoría de endpoints compatibles con OpenAI. Responses puede mejorar la tasa de aciertos de caché en relés que admiten /v1/responses.',
|
||||
'ai.providers.icon.change': 'Cambiar ícono',
|
||||
'ai.providers.icon.upload': 'Subir imagen',
|
||||
'ai.providers.icon.reset': 'Restablecer',
|
||||
@@ -54,6 +58,7 @@ export const esAiMessages: Messages = {
|
||||
'ai.providers.apiKey.decrypting': 'Descifrando...',
|
||||
'ai.providers.baseUrl': 'URL base',
|
||||
'ai.providers.baseUrl.anthropicHelp': 'Compatible con Anthropic: host con o sin /v1 (por ejemplo https://gateway.example o https://gateway.example/v1). La detección y el chat usan /v1/models y /v1/messages.',
|
||||
'ai.providers.baseUrl.ollamaHelp': 'Ollama local: http://localhost:11434/v1 (sin API key). Ollama Cloud: https://ollama.com/v1 y tu clave de Cloud.',
|
||||
'ai.providers.skipTLSVerify': 'Omitir la verificación del certificado TLS (para certificados autofirmados)',
|
||||
'ai.providers.defaultModel': 'Modelo predeterminado',
|
||||
'ai.providers.defaultModel.placeholder': 'p. ej. gpt-4o, claude-sonnet-4-20250514',
|
||||
@@ -442,15 +447,28 @@ export const esAiMessages: Messages = {
|
||||
'ai.chat.permObserver': 'Observador',
|
||||
'ai.chat.permConfirm': 'Confirmar',
|
||||
'ai.chat.permAuto': 'Automático',
|
||||
'ai.chat.permObserverDesc': 'Solo lectura (Cursor CLI: Ask)',
|
||||
'ai.chat.permConfirmDesc': 'Preguntar antes de que Netcatty escriba (Cursor CLI: agent + force)',
|
||||
'ai.chat.permAutoDesc': 'Ejecutar libremente (Cursor CLI: agent + force)',
|
||||
'ai.chat.permObserverDesc': 'Solo lectura',
|
||||
'ai.chat.permConfirmDesc': 'Preguntar antes de escribir',
|
||||
'ai.chat.permAutoDesc': 'Ejecutar libremente',
|
||||
'ai.chat.emptyHint': 'Pregunta sobre tus servidores, ejecuta comandos u obtén ayuda con las configuraciones.',
|
||||
'ai.chat.placeholder': 'Mensaje a {agent} — @ para incluir contexto, / para comandos',
|
||||
'ai.chat.placeholderDefault': 'Mensaje al agente Catty...',
|
||||
'ai.chat.noModel': 'Sin modelo',
|
||||
'ai.chat.noProviderModel': 'No hay modelo predeterminado: define uno en Configuración → IA → Proveedores.',
|
||||
'ai.chat.selectProvider': 'Seleccionar proveedor',
|
||||
'ai.chat.selectProviderAndModel': 'Seleccionar proveedor y modelo',
|
||||
'ai.chat.selectModel': 'Seleccionar modelo',
|
||||
'ai.chat.searchModels': 'Buscar modelos',
|
||||
'ai.chat.providers': 'Proveedores',
|
||||
'ai.chat.models': 'Modelos',
|
||||
'ai.chat.pinned': 'Fijados',
|
||||
'ai.chat.useCustomModel': 'Usar "{id}"',
|
||||
'ai.chat.thinkingLevel': 'Razonamiento',
|
||||
'ai.chat.thinkingOff': 'Off',
|
||||
'ai.chat.pinModel': 'Fijar modelo',
|
||||
'ai.chat.unpinModel': 'Quitar modelo fijado',
|
||||
'ai.chat.loadingModels': 'Cargando modelos...',
|
||||
'ai.chat.noMatchingModels': 'No hay modelos coincidentes',
|
||||
'ai.chat.recent': 'Recientes',
|
||||
'ai.chat.viewAll': 'Ver todo',
|
||||
'ai.chat.untitled': 'Sin título',
|
||||
|
||||
@@ -610,6 +610,12 @@ Resalta el panel dividido enfocado:
|
||||
'settings.terminal.behavior.dynamicTabTitle.off': 'Desactivado',
|
||||
'settings.terminal.behavior.dynamicTabTitle.agent': 'Solo agentes',
|
||||
'settings.terminal.behavior.dynamicTabTitle.all': 'Todas las sesiones',
|
||||
'settings.terminal.behavior.oscNotifications': 'Notificaciones de escritorio OSC',
|
||||
'settings.terminal.behavior.oscNotifications.desc':
|
||||
'Muestra una notificación del sistema cuando un programa emite OSC 9, OSC 777 notify o kitty OSC 99. Lo usan Codex y otros agentes para avisos de turno completado y de aprobación.',
|
||||
'settings.terminal.behavior.oscNotifications.off': 'Desactivado',
|
||||
'settings.terminal.behavior.oscNotifications.unfocused': 'Si no tiene el foco',
|
||||
'settings.terminal.behavior.oscNotifications.always': 'Siempre',
|
||||
'settings.terminal.behavior.osc52Clipboard': 'Portapapeles OSC-52',
|
||||
'settings.terminal.behavior.osc52Clipboard.desc':
|
||||
'Permite que los programas remotos (tmux, vim, etc.) accedan al portapapeles local mediante secuencias de escape OSC-52.',
|
||||
|
||||
@@ -208,6 +208,7 @@ export const esVaultMessages: Messages = {
|
||||
'sftp.columns.modified': 'Modificado',
|
||||
'sftp.columns.size': 'Tamaño',
|
||||
'sftp.columns.kind': 'Tipo',
|
||||
'sftp.columns.owner': 'Propietario',
|
||||
'sftp.columns.configure': 'Elegir columnas visibles',
|
||||
'sftp.columns.actions': 'Acciones',
|
||||
'sftp.sort.directoriesFirst': 'Carpetas primero',
|
||||
@@ -229,6 +230,10 @@ export const esVaultMessages: Messages = {
|
||||
'sftp.moveTo.confirm': 'Mover',
|
||||
'sftp.moveTo.pathNotFound': 'Directorio no encontrado o inaccesible',
|
||||
'sftp.context.download': 'Descargar',
|
||||
'sftp.context.extract': 'Extraer aquí',
|
||||
'sftp.extract.extracting': 'Extrayendo {fileName}...',
|
||||
'sftp.extract.success': 'Se extrajo {fileName}',
|
||||
'sftp.extract.error': 'No se pudo extraer {fileName}: {error}',
|
||||
'sftp.context.copyToOtherPane': 'Copiar al otro panel',
|
||||
'sftp.copyToOtherPane.unavailable': 'Abre la vista SFTP de dos paneles y conecta el panel de destino antes de copiar archivos.',
|
||||
'sftp.copyCurrentPath': 'Copiar ruta actual',
|
||||
@@ -489,7 +494,7 @@ export const esVaultMessages: Messages = {
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': 'Transferir el archivo al host activo del otro panel',
|
||||
'settings.sshDeepLink.title': 'Enlaces SSH y Telnet',
|
||||
'settings.sshDeepLink.enable': 'Abrir enlaces ssh:// y telnet:// con Netcatty',
|
||||
'settings.sshDeepLink.enableDesc': 'Permite que Netcatty maneje los enlaces ssh:// y telnet:// de los navegadores y otras aplicaciones.',
|
||||
'settings.sshDeepLink.enableDesc': 'Permite que Netcatty maneje los enlaces ssh:// y telnet:// de los navegadores y otras aplicaciones, además de lanzamientos al estilo PuTTY como -ssh user@host -P 22 -pw password.',
|
||||
'settings.explorerContextMenu.title': 'Explorador de Windows',
|
||||
'settings.explorerContextMenu.enable': 'Mostrar "Abrir en Netcatty" en los menús contextuales de carpetas',
|
||||
'settings.explorerContextMenu.enableDesc': 'Agrega un elemento de clic derecho para las carpetas y los fondos de carpeta, para que puedas abrir una terminal local allí. Se aplica de inmediato; no es necesario reinstalar.',
|
||||
|
||||
@@ -24,6 +24,10 @@ export const ruAiMessages: Messages = {
|
||||
'ai.providers.style.google': 'Совместимый с Google',
|
||||
'ai.providers.style.inherited': 'авто',
|
||||
'ai.providers.style.help': 'Определяет, какой формат API используется для запросов. Переопределите, если стороннее API использует другой диалект.',
|
||||
'ai.providers.openaiApi': 'Формат API OpenAI',
|
||||
'ai.providers.openaiApi.chat': 'Chat Completions',
|
||||
'ai.providers.openaiApi.responses': 'Responses',
|
||||
'ai.providers.openaiApi.help': 'Chat Completions работает с большинством OpenAI-совместимых эндпоинтов. Responses может повысить попадание в кэш на ретрансляторах с поддержкой /v1/responses.',
|
||||
'ai.providers.icon.change': 'Изменить иконку',
|
||||
'ai.providers.icon.upload': 'Загрузить изображение',
|
||||
'ai.providers.icon.reset': 'Сбросить',
|
||||
@@ -35,6 +39,7 @@ export const ruAiMessages: Messages = {
|
||||
'ai.providers.apiKey.decrypting': 'Расшифровка...',
|
||||
'ai.providers.baseUrl': 'Базовый URL',
|
||||
'ai.providers.baseUrl.anthropicHelp': 'Anthropic-совместимый: хост с /v1 или без (например https://gateway.example или https://gateway.example/v1). Проверка и чат используют /v1/models и /v1/messages.',
|
||||
'ai.providers.baseUrl.ollamaHelp': 'Локальный Ollama: http://localhost:11434/v1 (без API-ключа). Ollama Cloud: https://ollama.com/v1 и ключ Cloud.',
|
||||
'ai.providers.skipTLSVerify': 'Пропустить проверку TLS-сертификата (для самоподписанных сертификатов)',
|
||||
'ai.providers.defaultModel': 'Модель по умолчанию',
|
||||
'ai.providers.defaultModel.placeholder': 'например, gpt-4o, claude-sonnet-4-20250514',
|
||||
@@ -394,15 +399,28 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.permObserver': 'Наблюдатель',
|
||||
'ai.chat.permConfirm': 'Подтверждение',
|
||||
'ai.chat.permAuto': 'Авто',
|
||||
'ai.chat.permObserverDesc': 'Только чтение (Cursor CLI: Ask)',
|
||||
'ai.chat.permConfirmDesc': 'Спрашивать перед записью Netcatty (Cursor CLI: agent + force)',
|
||||
'ai.chat.permAutoDesc': 'Выполнять свободно (Cursor CLI: agent + force)',
|
||||
'ai.chat.permObserverDesc': 'Только чтение',
|
||||
'ai.chat.permConfirmDesc': 'Спрашивать перед записью',
|
||||
'ai.chat.permAutoDesc': 'Выполнять свободно',
|
||||
'ai.chat.emptyHint': 'Спрашивайте о ваших серверах, запускайте команды или получайте помощь с конфигурациями.',
|
||||
'ai.chat.placeholder': 'Сообщение {agent} — @ для добавления контекста, / для команд',
|
||||
'ai.chat.placeholderDefault': 'Сообщение агенту Catty...',
|
||||
'ai.chat.noModel': 'Нет модели',
|
||||
'ai.chat.noProviderModel': 'Модель по умолчанию не задана — настройте её в Настройки → AI → Провайдеры.',
|
||||
'ai.chat.selectProvider': 'Выберите провайдера',
|
||||
'ai.chat.selectProviderAndModel': 'Выберите провайдера и модель',
|
||||
'ai.chat.selectModel': 'Выберите модель',
|
||||
'ai.chat.searchModels': 'Поиск моделей',
|
||||
'ai.chat.providers': 'Провайдеры',
|
||||
'ai.chat.models': 'Модели',
|
||||
'ai.chat.pinned': 'Закреплённые',
|
||||
'ai.chat.useCustomModel': 'Использовать «{id}»',
|
||||
'ai.chat.thinkingLevel': 'Мышление',
|
||||
'ai.chat.thinkingOff': 'Выкл.',
|
||||
'ai.chat.pinModel': 'Закрепить модель',
|
||||
'ai.chat.unpinModel': 'Открепить модель',
|
||||
'ai.chat.loadingModels': 'Загрузка моделей...',
|
||||
'ai.chat.noMatchingModels': 'Нет подходящих моделей',
|
||||
'ai.chat.recent': 'Недавние',
|
||||
'ai.chat.viewAll': 'Показать всё',
|
||||
'ai.chat.untitled': 'Без названия',
|
||||
|
||||
@@ -82,6 +82,32 @@ export const ruCoreMessages: Messages = {
|
||||
'credentials.protectionUnavailable.message': 'Сохранённые пароли и ключи не могут быть автоматически расшифрованы на этом устройстве. Перед подключением введите учётные данные заново.',
|
||||
'credentials.protectionUnavailable.action': 'Открыть настройки',
|
||||
|
||||
// App Lock
|
||||
'appLock.title': 'Netcatty заблокирован',
|
||||
'appLock.reason.default': 'Введите пароль блокировки, чтобы продолжить.',
|
||||
'appLock.reason.startup': 'Netcatty блокируется при каждом открытии этого окна.',
|
||||
'appLock.reason.idle': 'Netcatty заблокирован после периода бездействия.',
|
||||
'appLock.reason.manual': 'Netcatty был заблокирован вручную.',
|
||||
'appLock.passwordLabel': 'Пароль блокировки',
|
||||
'appLock.passwordPlaceholder': 'Введите пароль для разблокировки',
|
||||
'appLock.unlock': 'Разблокировать',
|
||||
'appLock.unlocking': 'Разблокировка...',
|
||||
'appLock.usePassword': 'Использовать пароль',
|
||||
'appLock.error.emptyPassword': 'Введите пароль блокировки.',
|
||||
'appLock.error.incorrectPassword': 'Неверный пароль блокировки.',
|
||||
'appLock.systemUnlock.unlockWith': 'Разблокировать через {label}',
|
||||
'appLock.systemUnlock.preparing': 'Подготовка {label}...',
|
||||
'appLock.systemUnlock.verifying': 'Ожидание {label}...',
|
||||
'appLock.systemUnlock.error': 'Системная разблокировка не завершена. Используйте пароль блокировки, чтобы продолжить.',
|
||||
'appLock.logoLabel': 'Netcatty',
|
||||
'appLock.reset.title': 'Сбросить блокировку',
|
||||
'appLock.reset.description': 'Это удалит текущий пароль блокировки и отключит App Lock. Новый пароль можно создать позже в настройках.',
|
||||
'appLock.reset.cancel': 'Отмена',
|
||||
'appLock.reset.confirm': 'Сбросить App Lock',
|
||||
'appLock.reset.resetting': 'Сброс...',
|
||||
'appLock.reset.error': 'Не удалось сбросить App Lock. Попробуйте ещё раз.',
|
||||
'topTabs.lockApp': 'Заблокировать Netcatty',
|
||||
|
||||
// Settings shell
|
||||
'settings.title': 'Настройки',
|
||||
'settings.tab.application': 'Приложение',
|
||||
@@ -117,6 +143,59 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.system.credentials.unavailableHint': 'Учётные данные, зашифрованные в другом профиле пользователя или на другой машине, здесь расшифровать нельзя. Повторно введите и сохраните их на этом устройстве.',
|
||||
'settings.system.credentials.portabilityHint': 'Облачная синхронизация переносима, потому что использует шифрование вашим мастер-ключом. Локальное шифрование safeStorage привязано к устройству и пользователю.',
|
||||
|
||||
// Settings > System > App Lock
|
||||
'settings.appLock.title': 'Блокировка приложения',
|
||||
'settings.appLock.description': 'Требовать локальный пароль блокировки при открытии Netcatty, после бездействия или при ручной блокировке.',
|
||||
'settings.appLock.setupTitle': 'Задайте пароль блокировки',
|
||||
'settings.appLock.setupDescription': 'Создайте пароль ниже. Блокировка приложения включится сразу после сохранения.',
|
||||
'settings.appLock.manageTitle': 'Блокировка приложения готова',
|
||||
'settings.appLock.enabledStatus': 'Включена. Netcatty блокируется при запуске, после бездействия и при ручной блокировке.',
|
||||
'settings.appLock.disabledStatus': 'Пароль сохранён, но блокировка приложения сейчас выключена.',
|
||||
'settings.appLock.disableTitle': 'Отключить блокировку приложения',
|
||||
'settings.appLock.disableDescription': 'Введите текущий пароль блокировки, чтобы Netcatty перестал блокироваться. Сохранённый пароль будет удалён; для повторного включения нужно создать новый.',
|
||||
'settings.appLock.disable': 'Отключить блокировку',
|
||||
'settings.appLock.disabling': 'Отключение...',
|
||||
'settings.appLock.setupPasswordTitle': 'Создайте пароль блокировки',
|
||||
'settings.appLock.setupPasswordDescription': 'Этот пароль нужен для разблокировки Netcatty, а также для изменения или отключения блокировки позже.',
|
||||
'settings.appLock.changePasswordTitle': 'Изменить пароль блокировки',
|
||||
'settings.appLock.changePasswordDescription': 'Введите текущий пароль блокировки, затем выберите новый.',
|
||||
'settings.appLock.enable': 'Включить блокировку приложения',
|
||||
'settings.appLock.enableDesc': 'Блокировать при запуске и после выбранного периода бездействия.',
|
||||
'settings.appLock.timeout': 'Тайм-аут бездействия',
|
||||
'settings.appLock.timeoutDesc': 'Блокировать после бездействия или при закрытии в трей. «Никогда» отключает оба варианта.',
|
||||
'settings.appLock.timeout.0': 'Не блокировать автоматически',
|
||||
'settings.appLock.timeout.1': '1 минута',
|
||||
'settings.appLock.timeout.5': '5 минут',
|
||||
'settings.appLock.timeout.15': '15 минут',
|
||||
'settings.appLock.timeout.30': '30 минут',
|
||||
'settings.appLock.timeout.60': '60 минут',
|
||||
'settings.appLock.systemUnlock.label': 'Разблокировка через {label}',
|
||||
'settings.appLock.systemUnlock.desc': 'Используйте {label} как альтернативный способ разблокировать Netcatty на этом устройстве.',
|
||||
'settings.appLock.systemUnlock.unavailableDesc': '{label} сейчас недоступен на этом устройстве.',
|
||||
'settings.appLock.systemUnlock.unavailable': 'Системная разблокировка недоступна на этом устройстве.',
|
||||
'settings.appLock.systemUnlock.locked': 'Разблокируйте Netcatty перед изменением системной разблокировки.',
|
||||
'settings.appLock.systemUnlock.autoPrompt.label': 'Открывать {label} автоматически',
|
||||
'settings.appLock.systemUnlock.autoPrompt.desc': 'Автоматически показывать запрос {label}, когда Netcatty ожидает разблокировки.',
|
||||
'settings.appLock.currentPassword': 'Текущий пароль',
|
||||
'settings.appLock.currentPasswordPlaceholder': 'Нужен для отключения или замены',
|
||||
'settings.appLock.currentPasswordForDisablePlaceholder': 'Текущий пароль для отключения блокировки',
|
||||
'settings.appLock.currentPasswordForChangePlaceholder': 'Текущий пароль для изменения',
|
||||
'settings.appLock.newPassword': 'Новый пароль',
|
||||
'settings.appLock.newPasswordPlaceholder': 'Задайте пароль блокировки',
|
||||
'settings.appLock.confirmPassword': 'Подтвердите пароль',
|
||||
'settings.appLock.confirmPasswordPlaceholder': 'Повторите пароль',
|
||||
'settings.appLock.savePassword': 'Задать и включить',
|
||||
'settings.appLock.savingPassword': 'Сохранение...',
|
||||
'settings.appLock.passwordSet': 'Пароль настроен',
|
||||
'settings.appLock.replacePassword': 'Заменить пароль',
|
||||
'settings.appLock.enableAfterPassword': 'Задайте пароль блокировки перед включением.',
|
||||
'settings.appLock.localOnlyHint': 'Локальная приватная блокировка. Она не синхронизируется и не является системной границей безопасности.',
|
||||
'settings.appLock.validation.currentRequired': 'Введите текущий пароль блокировки.',
|
||||
'settings.appLock.validation.newRequired': 'Введите новый пароль блокировки.',
|
||||
'settings.appLock.validation.confirmRequired': 'Подтвердите новый пароль блокировки.',
|
||||
'settings.appLock.validation.mismatch': 'Новые пароли не совпадают.',
|
||||
'settings.appLock.validation.incorrect': 'Текущий пароль блокировки неверен.',
|
||||
|
||||
// Settings > System > Network Proxy
|
||||
'settings.system.networkProxy.title': 'Сетевой прокси',
|
||||
'settings.system.networkProxy.description': 'HTTP(S)-прокси для облачной синхронизации и AI-провайдеров. Не связан с SSH ProxyJump / ProxyCommand.',
|
||||
@@ -567,6 +646,12 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.behavior.dynamicTabTitle.off': 'Отключено',
|
||||
'settings.terminal.behavior.dynamicTabTitle.agent': 'Только агенты',
|
||||
'settings.terminal.behavior.dynamicTabTitle.all': 'Все сеансы',
|
||||
'settings.terminal.behavior.oscNotifications': 'Системные уведомления OSC',
|
||||
'settings.terminal.behavior.oscNotifications.desc':
|
||||
'Показывать системное уведомление, когда программа отправляет OSC 9, OSC 777 notify или kitty OSC 99. Так Codex и другие агенты сообщают о завершении хода и запросе подтверждения.',
|
||||
'settings.terminal.behavior.oscNotifications.off': 'Отключено',
|
||||
'settings.terminal.behavior.oscNotifications.unfocused': 'Когда окно не в фокусе',
|
||||
'settings.terminal.behavior.oscNotifications.always': 'Всегда',
|
||||
'settings.terminal.behavior.osc52Clipboard': 'Буфер обмена OSC-52',
|
||||
'settings.terminal.behavior.osc52Clipboard.desc':
|
||||
'Разрешить удалённым программам (tmux, vim и т. д.) доступ к локальному буферу обмена через escape-последовательности OSC-52.',
|
||||
|
||||
@@ -245,6 +245,7 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.columns.modified': 'Изменён',
|
||||
'sftp.columns.size': 'Размер',
|
||||
'sftp.columns.kind': 'Тип',
|
||||
'sftp.columns.owner': 'Владелец',
|
||||
'sftp.columns.configure': 'Выбрать видимые столбцы',
|
||||
'sftp.columns.actions': 'Действия',
|
||||
'sftp.sort.directoriesFirst': 'Папки сверху',
|
||||
@@ -266,6 +267,10 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.moveTo.confirm': 'Переместить',
|
||||
'sftp.moveTo.pathNotFound': 'Каталог не найден или недоступен',
|
||||
'sftp.context.download': 'Скачать',
|
||||
'sftp.context.extract': 'Распаковать здесь',
|
||||
'sftp.extract.extracting': 'Распаковка {fileName}...',
|
||||
'sftp.extract.success': 'Распакован {fileName}',
|
||||
'sftp.extract.error': 'Не удалось распаковать {fileName}: {error}',
|
||||
'sftp.context.copyToOtherPane': 'Копировать в другую панель',
|
||||
'sftp.copyToOtherPane.unavailable': 'Откройте двухпанельный режим SFTP и подключите панель назначения перед копированием файлов.',
|
||||
'sftp.copyCurrentPath': 'Копировать текущий путь',
|
||||
@@ -525,7 +530,7 @@ export const ruVaultMessages: Messages = {
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': 'Передать файл на активный хост другой панели',
|
||||
'settings.sshDeepLink.title': 'SSH- и Telnet-ссылки',
|
||||
'settings.sshDeepLink.enable': 'Открывать ssh:// и telnet:// ссылки в Netcatty',
|
||||
'settings.sshDeepLink.enableDesc': 'Разрешить Netcatty обрабатывать ssh:// и telnet:// ссылки из браузеров и других приложений.',
|
||||
'settings.sshDeepLink.enableDesc': 'Разрешить Netcatty обрабатывать ssh:// и telnet:// ссылки из браузеров и других приложений, а также запуски в стиле PuTTY, например -ssh user@host -P 22 -pw password.',
|
||||
'settings.explorerContextMenu.title': 'Проводник Windows',
|
||||
'settings.explorerContextMenu.enable': 'Показывать «Open in Netcatty» в контекстном меню папок',
|
||||
'settings.explorerContextMenu.enableDesc': 'Добавляет пункт в меню папок и фона папки, чтобы открыть локальный терминал в этом каталоге. Применяется сразу, переустановка не нужна.',
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings.ts";
|
||||
import en from "./en.ts";
|
||||
import { HOST_ICON_COLORS, HOST_ICON_IDS } from "../../../domain/hostIcon.ts";
|
||||
import zhCN from "./zh-CN.ts";
|
||||
import ru from "./ru.ts";
|
||||
@@ -13,6 +14,12 @@ const LOCALIZED_SETTINGS_LOCALES = [
|
||||
{ name: "es", messages: es },
|
||||
];
|
||||
|
||||
const APP_LOCK_LOCALES = [
|
||||
{ name: "en", messages: en },
|
||||
{ name: "zh-CN", messages: zhCN },
|
||||
{ name: "ru", messages: ru },
|
||||
];
|
||||
|
||||
test("localized settings include names for every default shortcut", () => {
|
||||
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
|
||||
const missing = DEFAULT_KEY_BINDINGS
|
||||
@@ -61,6 +68,21 @@ test("localized settings include network proxy labels", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include OSC desktop notification labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.behavior.oscNotifications",
|
||||
"settings.terminal.behavior.oscNotifications.desc",
|
||||
"settings.terminal.behavior.oscNotifications.off",
|
||||
"settings.terminal.behavior.oscNotifications.unfocused",
|
||||
"settings.terminal.behavior.oscNotifications.always",
|
||||
];
|
||||
|
||||
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing OSC notification labels`);
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include terminal font weight option labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.font.weight.thin",
|
||||
@@ -80,6 +102,73 @@ test("localized settings include terminal font weight option labels", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("all app lock strings are translated in every supported locale", () => {
|
||||
const keys = [
|
||||
"appLock.title",
|
||||
"appLock.reason.default",
|
||||
"appLock.reason.startup",
|
||||
"appLock.reason.idle",
|
||||
"appLock.reason.manual",
|
||||
"appLock.passwordLabel",
|
||||
"appLock.passwordPlaceholder",
|
||||
"appLock.unlock",
|
||||
"appLock.unlocking",
|
||||
"appLock.error.emptyPassword",
|
||||
"appLock.error.incorrectPassword",
|
||||
"appLock.systemUnlock.unlockWith",
|
||||
"appLock.systemUnlock.error",
|
||||
"appLock.logoLabel",
|
||||
"appLock.reset.title",
|
||||
"appLock.reset.description",
|
||||
"appLock.reset.cancel",
|
||||
"appLock.reset.confirm",
|
||||
"appLock.reset.resetting",
|
||||
"appLock.reset.error",
|
||||
"topTabs.lockApp",
|
||||
"settings.appLock.title",
|
||||
"settings.appLock.description",
|
||||
"settings.appLock.enable",
|
||||
"settings.appLock.enableDesc",
|
||||
"settings.appLock.timeout",
|
||||
"settings.appLock.timeoutDesc",
|
||||
"settings.appLock.timeout.0",
|
||||
"settings.appLock.timeout.1",
|
||||
"settings.appLock.timeout.5",
|
||||
"settings.appLock.timeout.15",
|
||||
"settings.appLock.timeout.30",
|
||||
"settings.appLock.timeout.60",
|
||||
"settings.appLock.systemUnlock.label",
|
||||
"settings.appLock.systemUnlock.desc",
|
||||
"settings.appLock.systemUnlock.unavailableDesc",
|
||||
"settings.appLock.systemUnlock.unavailable",
|
||||
"settings.appLock.systemUnlock.locked",
|
||||
"settings.appLock.systemUnlock.autoPrompt.label",
|
||||
"settings.appLock.systemUnlock.autoPrompt.desc",
|
||||
"settings.appLock.currentPassword",
|
||||
"settings.appLock.currentPasswordPlaceholder",
|
||||
"settings.appLock.newPassword",
|
||||
"settings.appLock.newPasswordPlaceholder",
|
||||
"settings.appLock.confirmPassword",
|
||||
"settings.appLock.confirmPasswordPlaceholder",
|
||||
"settings.appLock.savePassword",
|
||||
"settings.appLock.savingPassword",
|
||||
"settings.appLock.passwordSet",
|
||||
"settings.appLock.replacePassword",
|
||||
"settings.appLock.enableAfterPassword",
|
||||
"settings.appLock.localOnlyHint",
|
||||
"settings.appLock.validation.currentRequired",
|
||||
"settings.appLock.validation.newRequired",
|
||||
"settings.appLock.validation.confirmRequired",
|
||||
"settings.appLock.validation.mismatch",
|
||||
"settings.appLock.validation.incorrect",
|
||||
];
|
||||
|
||||
for (const locale of APP_LOCK_LOCALES) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing app lock labels`);
|
||||
}
|
||||
});
|
||||
|
||||
test("localized vault messages include host icon labels", () => {
|
||||
const keys = [
|
||||
"hostDetails.icon.title",
|
||||
|
||||
@@ -43,6 +43,10 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.providers.style.google': 'Google 兼容',
|
||||
'ai.providers.style.inherited': '默认',
|
||||
'ai.providers.style.help': '决定请求使用哪种 API 格式。当第三方端点的协议与其提供商类型不一致时,可手动覆盖。',
|
||||
'ai.providers.openaiApi': 'OpenAI 请求格式',
|
||||
'ai.providers.openaiApi.chat': 'Chat Completions',
|
||||
'ai.providers.openaiApi.responses': 'Responses',
|
||||
'ai.providers.openaiApi.help': '默认 Chat Completions,兼容大多数 OpenAI 兼容接口。部分中转站在 Responses(/v1/responses)上缓存命中率更高。',
|
||||
'ai.providers.icon.change': '修改图标',
|
||||
'ai.providers.icon.upload': '上传图片',
|
||||
'ai.providers.icon.reset': '恢复默认',
|
||||
@@ -54,6 +58,7 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.providers.apiKey.decrypting': '解密中...',
|
||||
'ai.providers.baseUrl': 'Base URL',
|
||||
'ai.providers.baseUrl.anthropicHelp': 'Anthropic 兼容:可填不带或带 /v1 的主机(例如 https://gateway.example 或 https://gateway.example/v1)。检测与聊天都会请求 /v1/models、/v1/messages。',
|
||||
'ai.providers.baseUrl.ollamaHelp': '本地 Ollama:http://localhost:11434/v1(无需 API Key)。Ollama Cloud:https://ollama.com/v1,并填写 Cloud API Key。',
|
||||
'ai.providers.skipTLSVerify': '跳过 TLS 证书验证(用于自签名证书)',
|
||||
'ai.providers.defaultModel': '默认模型',
|
||||
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
|
||||
@@ -442,15 +447,28 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.permObserver': '观察',
|
||||
'ai.chat.permConfirm': '确认',
|
||||
'ai.chat.permAuto': '自动',
|
||||
'ai.chat.permObserverDesc': '只读(Cursor CLI:Ask)',
|
||||
'ai.chat.permConfirmDesc': 'Netcatty 写操作前确认(Cursor CLI:agent + force)',
|
||||
'ai.chat.permAutoDesc': '自由执行(Cursor CLI:agent + force)',
|
||||
'ai.chat.permObserverDesc': '只读',
|
||||
'ai.chat.permConfirmDesc': '写操作前确认',
|
||||
'ai.chat.permAutoDesc': '自由执行',
|
||||
'ai.chat.emptyHint': '询问服务器相关问题、执行命令或获取配置帮助。',
|
||||
'ai.chat.placeholder': '向 {agent} 发送消息 — @ 引用上下文,/ 使用命令',
|
||||
'ai.chat.placeholderDefault': '向 Catty Agent 发送消息...',
|
||||
'ai.chat.noModel': '未选择模型',
|
||||
'ai.chat.noProviderModel': '未配置默认模型——前往 设置 → AI → 提供商 设置。',
|
||||
'ai.chat.selectProvider': '选择提供商',
|
||||
'ai.chat.selectProviderAndModel': '选择提供商和模型',
|
||||
'ai.chat.selectModel': '选择模型',
|
||||
'ai.chat.searchModels': '搜索模型',
|
||||
'ai.chat.providers': '提供商',
|
||||
'ai.chat.models': '模型',
|
||||
'ai.chat.pinned': '已固定',
|
||||
'ai.chat.useCustomModel': '使用 “{id}”',
|
||||
'ai.chat.thinkingLevel': '思考强度',
|
||||
'ai.chat.thinkingOff': '关闭',
|
||||
'ai.chat.pinModel': '固定模型',
|
||||
'ai.chat.unpinModel': '取消固定',
|
||||
'ai.chat.loadingModels': '正在加载模型...',
|
||||
'ai.chat.noMatchingModels': '没有匹配的模型',
|
||||
'ai.chat.recent': '最近',
|
||||
'ai.chat.viewAll': '查看全部',
|
||||
'ai.chat.untitled': '无标题',
|
||||
|
||||
@@ -66,6 +66,32 @@ export const zhCNCoreMessages: Messages = {
|
||||
'credentials.protectionUnavailable.message': '当前设备无法自动解密已保存的密码和密钥。连接前请重新输入凭据。',
|
||||
'credentials.protectionUnavailable.action': '打开设置',
|
||||
|
||||
// App Lock
|
||||
'appLock.title': 'Netcatty 已锁定',
|
||||
'appLock.reason.default': '输入锁定密码以继续。',
|
||||
'appLock.reason.startup': '每次打开此窗口时,Netcatty 都会锁定。',
|
||||
'appLock.reason.idle': 'Netcatty 已因长时间无操作而锁定。',
|
||||
'appLock.reason.manual': 'Netcatty 已被手动锁定。',
|
||||
'appLock.passwordLabel': '锁定密码',
|
||||
'appLock.passwordPlaceholder': '输入密码解锁',
|
||||
'appLock.unlock': '解锁',
|
||||
'appLock.unlocking': '正在解锁...',
|
||||
'appLock.usePassword': '使用密码解锁',
|
||||
'appLock.error.emptyPassword': '请输入锁定密码。',
|
||||
'appLock.error.incorrectPassword': '锁定密码不正确。',
|
||||
'appLock.systemUnlock.unlockWith': '使用 {label} 解锁',
|
||||
'appLock.systemUnlock.preparing': '正在准备 {label}...',
|
||||
'appLock.systemUnlock.verifying': '正在等待 {label}...',
|
||||
'appLock.systemUnlock.error': '系统解锁未完成。请使用锁定密码继续。',
|
||||
'appLock.logoLabel': 'Netcatty',
|
||||
'appLock.reset.title': '重置应用锁定',
|
||||
'appLock.reset.description': '这会删除当前锁定密码并关闭应用锁定。需要再次使用时,请到设置里创建新密码。',
|
||||
'appLock.reset.cancel': '取消',
|
||||
'appLock.reset.confirm': '重置应用锁定',
|
||||
'appLock.reset.resetting': '正在重置...',
|
||||
'appLock.reset.error': '无法重置应用锁定,请重试。',
|
||||
'topTabs.lockApp': '锁定 Netcatty',
|
||||
|
||||
// Settings shell
|
||||
'settings.title': '设置',
|
||||
'settings.tab.application': '应用',
|
||||
@@ -115,6 +141,59 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.system.credentials.unavailableHint': '在其他用户或机器上加密的凭据无法在此处解密。请在当前设备重新输入并保存凭据。',
|
||||
'settings.system.credentials.portabilityHint': '云同步可跨设备,因为使用主密钥加密;本地 safeStorage 加密仅绑定当前系统用户/设备。',
|
||||
|
||||
// Settings > System > App Lock
|
||||
'settings.appLock.title': '应用锁定',
|
||||
'settings.appLock.description': '在 Netcatty 打开、长时间无操作或手动锁定后,要求输入本地锁定密码。',
|
||||
'settings.appLock.setupTitle': '设置锁定密码',
|
||||
'settings.appLock.setupDescription': '在下方创建密码,保存后会立即启用应用锁定。',
|
||||
'settings.appLock.manageTitle': '应用锁定已准备就绪',
|
||||
'settings.appLock.enabledStatus': '已启用。Netcatty 会在启动、长时间无操作或手动锁定时要求输入密码。',
|
||||
'settings.appLock.disabledStatus': '密码已保存,但应用锁定当前关闭。',
|
||||
'settings.appLock.disableTitle': '关闭应用锁定',
|
||||
'settings.appLock.disableDescription': '输入当前锁定密码后停止锁定 Netcatty。已保存的密码会被删除;以后重新启用时需要创建新密码。',
|
||||
'settings.appLock.disable': '关闭应用锁定',
|
||||
'settings.appLock.disabling': '正在关闭...',
|
||||
'settings.appLock.setupPasswordTitle': '创建锁定密码',
|
||||
'settings.appLock.setupPasswordDescription': '此密码用于解锁 Netcatty,也用于以后修改或关闭应用锁定。',
|
||||
'settings.appLock.changePasswordTitle': '修改锁定密码',
|
||||
'settings.appLock.changePasswordDescription': '先输入当前锁定密码,再设置新密码。',
|
||||
'settings.appLock.enable': '启用应用锁定',
|
||||
'settings.appLock.enableDesc': '启动时锁定,并在达到所选无操作时间后锁定。',
|
||||
'settings.appLock.timeout': '无操作超时',
|
||||
'settings.appLock.timeoutDesc': '无操作时锁定,关闭到托盘时也会锁定。选择“不自动锁定”则两者都不会锁定。',
|
||||
'settings.appLock.timeout.0': '不自动锁定',
|
||||
'settings.appLock.timeout.1': '1 分钟',
|
||||
'settings.appLock.timeout.5': '5 分钟',
|
||||
'settings.appLock.timeout.15': '15 分钟',
|
||||
'settings.appLock.timeout.30': '30 分钟',
|
||||
'settings.appLock.timeout.60': '60 分钟',
|
||||
'settings.appLock.systemUnlock.label': '{label} 解锁',
|
||||
'settings.appLock.systemUnlock.desc': '在此设备上使用 {label} 作为解锁 Netcatty 的另一种方式。',
|
||||
'settings.appLock.systemUnlock.unavailableDesc': '此设备当前无法使用 {label}。',
|
||||
'settings.appLock.systemUnlock.unavailable': '此设备当前无法使用系统解锁。',
|
||||
'settings.appLock.systemUnlock.locked': '请先解锁 Netcatty,再更改系统解锁。',
|
||||
'settings.appLock.systemUnlock.autoPrompt.label': '自动弹出 {label}',
|
||||
'settings.appLock.systemUnlock.autoPrompt.desc': '当 Netcatty 等待解锁时,自动显示 {label} 验证提示。',
|
||||
'settings.appLock.currentPassword': '当前密码',
|
||||
'settings.appLock.currentPasswordPlaceholder': '禁用或替换时需要',
|
||||
'settings.appLock.currentPasswordForDisablePlaceholder': '用于关闭应用锁定的当前密码',
|
||||
'settings.appLock.currentPasswordForChangePlaceholder': '用于修改密码的当前密码',
|
||||
'settings.appLock.newPassword': '新密码',
|
||||
'settings.appLock.newPasswordPlaceholder': '设置锁定密码',
|
||||
'settings.appLock.confirmPassword': '确认密码',
|
||||
'settings.appLock.confirmPasswordPlaceholder': '再次输入密码',
|
||||
'settings.appLock.savePassword': '设置并启用',
|
||||
'settings.appLock.savingPassword': '正在保存...',
|
||||
'settings.appLock.passwordSet': '密码已配置',
|
||||
'settings.appLock.replacePassword': '替换密码',
|
||||
'settings.appLock.enableAfterPassword': '请先设置锁定密码,再启用应用锁定。',
|
||||
'settings.appLock.localOnlyHint': '仅限本设备的隐私锁。不会同步,也不是系统级安全边界。',
|
||||
'settings.appLock.validation.currentRequired': '请输入当前锁定密码。',
|
||||
'settings.appLock.validation.newRequired': '请输入新的锁定密码。',
|
||||
'settings.appLock.validation.confirmRequired': '请确认新的锁定密码。',
|
||||
'settings.appLock.validation.mismatch': '两次输入的新密码不一致。',
|
||||
'settings.appLock.validation.incorrect': '当前锁定密码不正确。',
|
||||
|
||||
// Settings > System > Network Proxy
|
||||
'settings.system.networkProxy.title': '网络代理',
|
||||
'settings.system.networkProxy.description': '用于云同步与 AI 服务商的 HTTP(S) 代理。与 SSH 的 ProxyJump / ProxyCommand 配置无关。',
|
||||
@@ -813,6 +892,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.columns.modified': '修改时间',
|
||||
'sftp.columns.size': '大小',
|
||||
'sftp.columns.kind': '类型',
|
||||
'sftp.columns.owner': '所有者',
|
||||
'sftp.columns.actions': '操作',
|
||||
'sftp.sort.directoriesFirst': '目录置顶',
|
||||
'sftp.emptyDirectory': '空目录',
|
||||
@@ -833,6 +913,10 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.moveTo.confirm': '移动',
|
||||
'sftp.moveTo.pathNotFound': '目录不存在或无法访问',
|
||||
'sftp.context.download': '下载',
|
||||
'sftp.context.extract': '解压到当前目录',
|
||||
'sftp.extract.extracting': '正在解压 {fileName}...',
|
||||
'sftp.extract.success': '已解压 {fileName}',
|
||||
'sftp.extract.error': '解压 {fileName} 失败:{error}',
|
||||
'sftp.context.copyToOtherPane': '复制到另一侧',
|
||||
'sftp.copyToOtherPane.unavailable': '请打开双栏 SFTP 文件管理,并连接目标侧后再复制文件。',
|
||||
'sftp.copyCurrentPath': '复制当前路径',
|
||||
|
||||
@@ -131,7 +131,7 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': '将文件传输到另一窗格的活动主机',
|
||||
'settings.sshDeepLink.title': 'SSH 和 Telnet 链接',
|
||||
'settings.sshDeepLink.enable': '用 Netcatty 打开 ssh:// 和 telnet:// 链接',
|
||||
'settings.sshDeepLink.enableDesc': '允许 Netcatty 接管来自浏览器和其他应用的 ssh://、telnet:// 链接。',
|
||||
'settings.sshDeepLink.enableDesc': '允许 Netcatty 接管来自浏览器和其他应用的 ssh://、telnet:// 链接,以及堡垒机常用的 PuTTY 启动参数(例如 -ssh user@host -P 22 -pw password)。',
|
||||
'settings.explorerContextMenu.title': 'Windows 资源管理器',
|
||||
'settings.explorerContextMenu.enable': '在文件夹右键菜单中显示 “Open in Netcatty”',
|
||||
'settings.explorerContextMenu.enableDesc': '为文件夹和文件夹空白处添加右键项,可直接在该目录打开本地终端。开关立即生效,无需重装。',
|
||||
@@ -338,6 +338,12 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.behavior.dynamicTabTitle.off': '关闭',
|
||||
'settings.terminal.behavior.dynamicTabTitle.agent': '仅 Agent',
|
||||
'settings.terminal.behavior.dynamicTabTitle.all': '全部会话',
|
||||
'settings.terminal.behavior.oscNotifications': 'OSC 桌面通知',
|
||||
'settings.terminal.behavior.oscNotifications.desc':
|
||||
'当程序发出 OSC 9、OSC 777 notify 或 kitty OSC 99 时弹出系统通知。Codex 等 Agent 用它提示回合完成或等待审批。',
|
||||
'settings.terminal.behavior.oscNotifications.off': '关闭',
|
||||
'settings.terminal.behavior.oscNotifications.unfocused': '未聚焦时',
|
||||
'settings.terminal.behavior.oscNotifications.always': '始终',
|
||||
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 剪贴板',
|
||||
'settings.terminal.behavior.osc52Clipboard.desc':
|
||||
'允许远程程序(tmux、vim 等)通过 OSC-52 转义序列访问本地剪贴板。',
|
||||
|
||||
@@ -43,6 +43,10 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.providers.style.google': 'Google 相容',
|
||||
'ai.providers.style.inherited': '預設',
|
||||
'ai.providers.style.help': '決定請求使用哪種 API 格式。當第三方端點的協定與其供應商型別不一致時,可手動覆蓋。',
|
||||
'ai.providers.openaiApi': 'OpenAI 請求格式',
|
||||
'ai.providers.openaiApi.chat': 'Chat Completions',
|
||||
'ai.providers.openaiApi.responses': 'Responses',
|
||||
'ai.providers.openaiApi.help': '預設 Chat Completions,相容大多數 OpenAI 相容介面。部分中繼站在 Responses(/v1/responses)上快取命中率較高。',
|
||||
'ai.providers.icon.change': '修改圖示',
|
||||
'ai.providers.icon.upload': '上傳圖片',
|
||||
'ai.providers.icon.reset': '恢復預設',
|
||||
@@ -54,6 +58,7 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.providers.apiKey.decrypting': '解密中...',
|
||||
'ai.providers.baseUrl': 'Base URL',
|
||||
'ai.providers.baseUrl.anthropicHelp': 'Anthropic 相容:可填不帶或帶 /v1 的主機(例如 https://gateway.example 或 https://gateway.example/v1)。偵測與聊天都會請求 /v1/models、/v1/messages。',
|
||||
'ai.providers.baseUrl.ollamaHelp': '本機 Ollama:http://localhost:11434/v1(不需 API Key)。Ollama Cloud:https://ollama.com/v1,並填寫 Cloud API Key。',
|
||||
'ai.providers.skipTLSVerify': '跳過 TLS 憑證驗證(用於自我簽署憑證)',
|
||||
'ai.providers.defaultModel': '預設模型',
|
||||
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
|
||||
@@ -441,15 +446,28 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.permObserver': '觀察',
|
||||
'ai.chat.permConfirm': '確認',
|
||||
'ai.chat.permAuto': '自動',
|
||||
'ai.chat.permObserverDesc': '只讀(Cursor CLI:Ask)',
|
||||
'ai.chat.permConfirmDesc': 'Netcatty 寫入前確認(Cursor CLI:agent + force)',
|
||||
'ai.chat.permAutoDesc': '自由執行(Cursor CLI:agent + force)',
|
||||
'ai.chat.permObserverDesc': '只讀',
|
||||
'ai.chat.permConfirmDesc': '寫入前確認',
|
||||
'ai.chat.permAutoDesc': '自由執行',
|
||||
'ai.chat.emptyHint': '詢問伺服器相關問題、執行指令或取得設定協助。',
|
||||
'ai.chat.placeholder': '向 {agent} 傳送訊息 — @ 引用上下文,/ 使用指令',
|
||||
'ai.chat.placeholderDefault': '向 Catty Agent 傳送訊息...',
|
||||
'ai.chat.noModel': '未選擇模型',
|
||||
'ai.chat.noProviderModel': '未設定預設模型——前往 設定 → AI → 供應商 設定。',
|
||||
'ai.chat.selectProvider': '選擇供應商',
|
||||
'ai.chat.selectProviderAndModel': '選擇供應商與模型',
|
||||
'ai.chat.selectModel': '選擇模型',
|
||||
'ai.chat.searchModels': '搜尋模型',
|
||||
'ai.chat.providers': '供應商',
|
||||
'ai.chat.models': '模型',
|
||||
'ai.chat.pinned': '已釘選',
|
||||
'ai.chat.useCustomModel': '使用「{id}」',
|
||||
'ai.chat.thinkingLevel': '思考強度',
|
||||
'ai.chat.thinkingOff': '關閉',
|
||||
'ai.chat.pinModel': '釘選模型',
|
||||
'ai.chat.unpinModel': '取消釘選',
|
||||
'ai.chat.loadingModels': '正在載入模型...',
|
||||
'ai.chat.noMatchingModels': '沒有符合的模型',
|
||||
'ai.chat.recent': '最近',
|
||||
'ai.chat.viewAll': '檢視全部',
|
||||
'ai.chat.untitled': '無標題',
|
||||
|
||||
@@ -813,6 +813,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sftp.columns.modified': '修改時間',
|
||||
'sftp.columns.size': '大小',
|
||||
'sftp.columns.kind': '型別',
|
||||
'sftp.columns.owner': '擁有者',
|
||||
'sftp.columns.actions': '操作',
|
||||
'sftp.sort.directoriesFirst': '資料夾置頂',
|
||||
'sftp.emptyDirectory': '空目錄',
|
||||
@@ -833,6 +834,10 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sftp.moveTo.confirm': '移動',
|
||||
'sftp.moveTo.pathNotFound': '目錄不存在或無法存取',
|
||||
'sftp.context.download': '下載',
|
||||
'sftp.context.extract': '解壓到目前目錄',
|
||||
'sftp.extract.extracting': '正在解壓 {fileName}...',
|
||||
'sftp.extract.success': '已解壓 {fileName}',
|
||||
'sftp.extract.error': '解壓 {fileName} 失敗:{error}',
|
||||
'sftp.context.copyToOtherPane': '複製到另一側',
|
||||
'sftp.copyToOtherPane.unavailable': '請開啟雙欄 SFTP 檔案管理,並連線目標側後再複製檔案。',
|
||||
'sftp.copyCurrentPath': '複製目前路徑',
|
||||
|
||||
@@ -131,7 +131,7 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': '將檔案傳輸到另一窗格的使用中主機',
|
||||
'settings.sshDeepLink.title': 'SSH 和 Telnet 連結',
|
||||
'settings.sshDeepLink.enable': '用 Netcatty 開啟 ssh:// 和 telnet:// 連結',
|
||||
'settings.sshDeepLink.enableDesc': '允許 Netcatty 接管來自瀏覽器和其他應用程式的 ssh://、telnet:// 連結。',
|
||||
'settings.sshDeepLink.enableDesc': '允許 Netcatty 接管來自瀏覽器和其他應用程式的 ssh://、telnet:// 連結,以及堡壘機常用的 PuTTY 啟動參數(例如 -ssh user@host -P 22 -pw password)。',
|
||||
'settings.explorerContextMenu.title': 'Windows 檔案總管',
|
||||
'settings.explorerContextMenu.enable': '在資料夾右鍵選單中顯示 “Open in Netcatty”',
|
||||
'settings.explorerContextMenu.enableDesc': '為資料夾與資料夾空白處加入右鍵項目,可直接在該目錄開啟本機終端機。開關立即生效,無需重裝。',
|
||||
@@ -338,6 +338,12 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.behavior.dynamicTabTitle.off': '關閉',
|
||||
'settings.terminal.behavior.dynamicTabTitle.agent': '僅 Agent',
|
||||
'settings.terminal.behavior.dynamicTabTitle.all': '全部工作階段',
|
||||
'settings.terminal.behavior.oscNotifications': 'OSC 桌面通知',
|
||||
'settings.terminal.behavior.oscNotifications.desc':
|
||||
'當程式發出 OSC 9、OSC 777 notify 或 kitty OSC 99 時顯示系統通知。Codex 等 Agent 會用它提示回合完成或等待核准。',
|
||||
'settings.terminal.behavior.oscNotifications.off': '關閉',
|
||||
'settings.terminal.behavior.oscNotifications.unfocused': '未聚焦時',
|
||||
'settings.terminal.behavior.oscNotifications.always': '一律',
|
||||
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 剪貼簿',
|
||||
'settings.terminal.behavior.osc52Clipboard.desc':
|
||||
'允許遠端程式(tmux、vim 等)透過 OSC-52 轉義序列存取本機剪貼簿。',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
import type { useAppLockState } from './useAppLockState';
|
||||
import type { useSessionState } from './useSessionState';
|
||||
import type { useSettingsState } from './useSettingsState';
|
||||
import type { useVaultState } from './useVaultState';
|
||||
@@ -7,6 +8,25 @@ import type { useVaultState } from './useVaultState';
|
||||
export type AppVaultRuntime = ReturnType<typeof useVaultState>;
|
||||
export type AppSessionRuntime = ReturnType<typeof useSessionState>;
|
||||
export type AppSettingsRuntime = ReturnType<typeof useSettingsState>;
|
||||
export type AppAppLockRuntime = ReturnType<typeof useAppLockState>;
|
||||
|
||||
/**
|
||||
* Narrow app-lock projection published on React context. The full runtime
|
||||
* changes identity on every AppLockGate render; chrome consumers (TopTabs lock
|
||||
* button, AppSideEffects deferral gates) only need these primitives, and
|
||||
* imperative callers read the full runtime through `getAppAppLockRuntime()`.
|
||||
*/
|
||||
export type AppLockChromeValue = {
|
||||
appLockEnabled: boolean;
|
||||
locked: boolean;
|
||||
initialized: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_APP_LOCK_CHROME: AppLockChromeValue = {
|
||||
appLockEnabled: false,
|
||||
locked: false,
|
||||
initialized: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* The slice of the vault runtime that `VaultPublisher` puts on the React
|
||||
@@ -69,6 +89,7 @@ class RuntimeSlot<T> {
|
||||
const vaultSlot = new RuntimeSlot<AppVaultRuntime>();
|
||||
const sessionSlot = new RuntimeSlot<AppSessionRuntime>();
|
||||
const settingsSlot = new RuntimeSlot<AppSettingsRuntime>();
|
||||
const appLockSlot = new RuntimeSlot<AppAppLockRuntime>();
|
||||
|
||||
export function registerAppVaultRuntime(runtime: AppVaultRuntime | null): void {
|
||||
vaultSlot.set(runtime);
|
||||
@@ -106,9 +127,22 @@ export function subscribeAppSettingsRuntime(listener: Listener): () => void {
|
||||
return settingsSlot.subscribe(listener);
|
||||
}
|
||||
|
||||
export function registerAppAppLockRuntime(runtime: AppAppLockRuntime | null): void {
|
||||
appLockSlot.set(runtime);
|
||||
}
|
||||
|
||||
export function getAppAppLockRuntime(): AppAppLockRuntime | null {
|
||||
return appLockSlot.get();
|
||||
}
|
||||
|
||||
export function subscribeAppAppLockRuntime(listener: Listener): () => void {
|
||||
return appLockSlot.subscribe(listener);
|
||||
}
|
||||
|
||||
export const AppVaultRuntimeContext = createContext<AppVaultContextValue | null>(null);
|
||||
export const AppSessionRuntimeContext = createContext<AppSessionRuntime | null>(null);
|
||||
export const AppSettingsRuntimeContext = createContext<AppSettingsRuntime | null>(null);
|
||||
export const AppLockChromeContext = createContext<AppLockChromeValue | null>(null);
|
||||
|
||||
/** Read the vault runtime owned by `VaultPublisher`. */
|
||||
export function useAppVaultRuntime(): AppVaultContextValue {
|
||||
@@ -136,3 +170,12 @@ export function useAppSettingsRuntime(): AppSettingsRuntime {
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the narrow app-lock chrome slice published by `AppLockRuntimePublisher`.
|
||||
* Falls back to an "unlocked / disabled" default so windows without a
|
||||
* publisher (isolated mounts, tests) behave as if App Lock is off.
|
||||
*/
|
||||
export function useAppLockChrome(): AppLockChromeValue {
|
||||
return useContext(AppLockChromeContext) ?? DEFAULT_APP_LOCK_CHROME;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
handleTerminalOscNotification,
|
||||
showOscDesktopNotification,
|
||||
} from "./oscDesktopNotifications.ts";
|
||||
|
||||
const installNotificationBridge = () => {
|
||||
const calls: Array<{ title: string; body: string; sessionId?: string }> = [];
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
const previousDocument = (globalThis as { document?: { hasFocus: () => boolean } }).document;
|
||||
|
||||
(globalThis as { document: { hasFocus: () => boolean } }).document = { hasFocus: () => true };
|
||||
(globalThis as { window: { netcatty: { showSystemNotification: (payload: { title: string; body: string; sessionId?: string }) => Promise<{ shown: boolean }> } } }).window = {
|
||||
netcatty: {
|
||||
showSystemNotification: async (payload) => {
|
||||
calls.push(payload);
|
||||
return { shown: true };
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
calls,
|
||||
restore() {
|
||||
if (previousWindow === undefined) delete (globalThis as { window?: unknown }).window;
|
||||
else (globalThis as { window: unknown }).window = previousWindow;
|
||||
if (previousDocument === undefined) delete (globalThis as { document?: unknown }).document;
|
||||
else (globalThis as { document: typeof previousDocument }).document = previousDocument;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
test("showOscDesktopNotification skips disabled and focused-unfocused modes", () => {
|
||||
const fixture = installNotificationBridge();
|
||||
try {
|
||||
showOscDesktopNotification({
|
||||
notification: { title: "", body: "hidden", protocol: "osc9" },
|
||||
mode: "off",
|
||||
sessionFocused: false,
|
||||
sessionId: "s1",
|
||||
fallbackTitle: "host",
|
||||
});
|
||||
showOscDesktopNotification({
|
||||
notification: { title: "", body: "quiet", protocol: "osc9" },
|
||||
mode: "unfocused",
|
||||
sessionFocused: true,
|
||||
sessionId: "s1",
|
||||
fallbackTitle: "host",
|
||||
});
|
||||
showOscDesktopNotification({
|
||||
notification: { title: "Codex", body: "Turn complete", protocol: "osc9" },
|
||||
mode: "always",
|
||||
sessionFocused: true,
|
||||
sessionId: "s1",
|
||||
fallbackTitle: "host",
|
||||
});
|
||||
assert.deepEqual(fixture.calls, [{
|
||||
title: "Codex",
|
||||
body: "Turn complete",
|
||||
sessionId: "s1",
|
||||
}]);
|
||||
} finally {
|
||||
fixture.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("handleTerminalOscNotification does not mark activity when notifications are off", () => {
|
||||
const fixture = installNotificationBridge();
|
||||
let activity = 0;
|
||||
try {
|
||||
assert.equal(handleTerminalOscNotification({
|
||||
notification: { title: "", body: "hidden", protocol: "osc9" },
|
||||
mode: "off",
|
||||
sessionFocused: false,
|
||||
sessionId: "s-off",
|
||||
fallbackTitle: "host",
|
||||
onSessionActivity: () => { activity += 1; },
|
||||
}), false);
|
||||
assert.equal(activity, 0);
|
||||
assert.equal(fixture.calls.length, 0);
|
||||
|
||||
assert.equal(handleTerminalOscNotification({
|
||||
notification: { title: "Codex", body: "Turn complete", protocol: "osc9" },
|
||||
mode: "always",
|
||||
sessionFocused: false,
|
||||
sessionId: "s-on",
|
||||
fallbackTitle: "host",
|
||||
onSessionActivity: () => { activity += 1; },
|
||||
}), true);
|
||||
assert.equal(activity, 1);
|
||||
assert.equal(fixture.calls.length, 1);
|
||||
} finally {
|
||||
fixture.restore();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
DEFAULT_OSC_NOTIFICATION_TITLE,
|
||||
OscNotificationLimiter,
|
||||
resolveOscNotificationPresentation,
|
||||
shouldShowOscDesktopNotification,
|
||||
type OscNotification,
|
||||
} from "../../domain/terminalOscNotifications";
|
||||
import type { OscNotificationMode } from "../../domain/models/terminal";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
|
||||
const sessionLimiters = new Map<string, OscNotificationLimiter>();
|
||||
|
||||
const limiterForSession = (sessionId: string): OscNotificationLimiter => {
|
||||
const existing = sessionLimiters.get(sessionId);
|
||||
if (existing) return existing;
|
||||
const limiter = new OscNotificationLimiter();
|
||||
sessionLimiters.set(sessionId, limiter);
|
||||
return limiter;
|
||||
};
|
||||
|
||||
export function showOscDesktopNotification(options: {
|
||||
notification: OscNotification;
|
||||
mode: OscNotificationMode | undefined;
|
||||
sessionFocused: boolean;
|
||||
sessionId: string;
|
||||
fallbackTitle?: string;
|
||||
}): boolean {
|
||||
if (!shouldShowOscDesktopNotification(options.mode, {
|
||||
windowFocused: typeof document !== "undefined" && document.hasFocus(),
|
||||
sessionFocused: options.sessionFocused,
|
||||
})) {
|
||||
return false;
|
||||
}
|
||||
if (!limiterForSession(options.sessionId).allow(options.sessionId)) return false;
|
||||
|
||||
const presented = resolveOscNotificationPresentation(
|
||||
options.notification,
|
||||
options.fallbackTitle || DEFAULT_OSC_NOTIFICATION_TITLE,
|
||||
);
|
||||
void netcattyBridge.get()?.showSystemNotification?.({
|
||||
title: presented.title,
|
||||
body: presented.body,
|
||||
sessionId: options.sessionId,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export function handleTerminalOscNotification(options: {
|
||||
notification: OscNotification;
|
||||
mode: OscNotificationMode | undefined;
|
||||
sessionFocused: boolean;
|
||||
sessionId: string;
|
||||
fallbackTitle?: string;
|
||||
onSessionActivity?: () => void;
|
||||
}): boolean {
|
||||
if (options.mode === "off") return false;
|
||||
options.onSessionActivity?.();
|
||||
showOscDesktopNotification(options);
|
||||
return true;
|
||||
}
|
||||
@@ -111,6 +111,8 @@ test("session peer windows do not run main-window startup effects", () => {
|
||||
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");
|
||||
const appLockGateSource = readFileSync(new URL("../../components/AppLockGate.tsx", import.meta.url), "utf8");
|
||||
const indexSource = readFileSync(new URL("../../index.tsx", import.meta.url), "utf8");
|
||||
const settingsStateSource = readFileSync(new URL("./useSettingsState.ts", import.meta.url), "utf8");
|
||||
const settingsIpcSyncSource = readFileSync(new URL("./settingsIpcSync.ts", import.meta.url), "utf8");
|
||||
const storageSyncSource = readFileSync(new URL("./settingsStorageSync.ts", import.meta.url), "utf8");
|
||||
@@ -119,12 +121,17 @@ 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'\)/);
|
||||
// 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(
|
||||
assert.match(appLockGateSource, /settingsOptions\?: Parameters<typeof useSettingsState>\[0\]/);
|
||||
assert.match(appLockGateSource, /deps\.useSettingsState\(settingsOptions\)/);
|
||||
assert.match(indexSource, /const isPeerSessionWindow = window\.location\.hash\.startsWith\('#\/session-window'\)/);
|
||||
assert.match(indexSource, /const settingsOptions = isPeerSessionWindow\s*\?\s*\{ enableSettingsSync: false, enableSystemEffects: false \}/);
|
||||
assert.match(indexSource, /<AppLockGate settingsOptions=\{settingsOptions\}>/);
|
||||
// AppLockGate owns useSettingsState; App forwards the gate's instance into
|
||||
// SettingsPublisher so the runtime slot/context still publish it.
|
||||
assert.match(appRootSource, /<SettingsPublisher settings=\{settings\}>/);
|
||||
assert.doesNotMatch(
|
||||
readFileSync(new URL("../app/publishers/SettingsPublisher.tsx", import.meta.url), "utf8"),
|
||||
/useSettingsState\(\{ enableSettingsSync, enableSystemEffects \}\)/,
|
||||
/useSettingsState\(/,
|
||||
);
|
||||
assert.match(appSource, /useAppStartupEffects\(\{[^}]*enabled: !isPeerSessionWindow/s);
|
||||
assert.match(appSource, /useUpdateCheck\(\{[^}]*enabled: !isPeerSessionWindow/s);
|
||||
|
||||
@@ -125,6 +125,30 @@ test("closing a workspace session dissolves the workspace when one terminal rema
|
||||
);
|
||||
});
|
||||
|
||||
test("closing the focused session hands focus to a remaining session (#3046)", () => {
|
||||
const result = closeSessionWorkspaceLayoutState(
|
||||
[workspace(["s1", "s2", "s3", "s4"])],
|
||||
"ws-1",
|
||||
"s1",
|
||||
);
|
||||
|
||||
assert.equal(result.workspaces.length, 1);
|
||||
assert.equal(result.workspaces[0].focusedSessionId, "s2");
|
||||
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s2", "s3", "s4"]);
|
||||
});
|
||||
|
||||
test("closing a non-focused session keeps the focused session", () => {
|
||||
const result = closeSessionWorkspaceLayoutState(
|
||||
[workspace(["s1", "s2", "s3", "s4"])],
|
||||
"ws-1",
|
||||
"s3",
|
||||
);
|
||||
|
||||
assert.equal(result.workspaces.length, 1);
|
||||
assert.equal(result.workspaces[0].focusedSessionId, "s1");
|
||||
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s1", "s2", "s4"]);
|
||||
});
|
||||
|
||||
test("closing one split pane keeps the other terminal as an orphan tab", () => {
|
||||
const layoutResult = closeSessionWorkspaceLayoutState(
|
||||
[workspace(["s1", "s2"])],
|
||||
|
||||
@@ -96,7 +96,24 @@ export function closeSessionWorkspaceLayoutState(
|
||||
return null;
|
||||
}
|
||||
|
||||
return { ...workspace, root: prunedRoot };
|
||||
// Closing the focused session must hand focus to a remaining one, or a
|
||||
// focus-mode workspace is left with a dangling focusedSessionId: no pane
|
||||
// qualifies as visible and the continuously rendered hidden panes all
|
||||
// paint on top of each other (#3046).
|
||||
const nextFocusedSessionId =
|
||||
workspace.focusedSessionId && !remainingSessionIds.includes(workspace.focusedSessionId)
|
||||
? remainingSessionIds[0]
|
||||
: workspace.focusedSessionId;
|
||||
const nextFocusSessionOrder = workspace.focusSessionOrder?.filter(
|
||||
(candidateId) => remainingSessionIds.includes(candidateId),
|
||||
);
|
||||
|
||||
return {
|
||||
...workspace,
|
||||
root: prunedRoot,
|
||||
focusedSessionId: nextFocusedSessionId,
|
||||
focusSessionOrder: nextFocusSessionOrder,
|
||||
};
|
||||
})
|
||||
.filter((workspace): workspace is Workspace => Boolean(workspace));
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type SortField = "name" | "size" | "modified" | "type";
|
||||
export type SortField = "name" | "size" | "modified" | "type" | "owner";
|
||||
export type SortOrder = "asc" | "desc";
|
||||
|
||||
export interface ColumnWidths {
|
||||
@@ -6,6 +6,7 @@ export interface ColumnWidths {
|
||||
modified: number;
|
||||
size: number;
|
||||
type: number;
|
||||
owner: number;
|
||||
}
|
||||
|
||||
export type SftpColumnVisibility = Record<keyof ColumnWidths, boolean>;
|
||||
@@ -15,6 +16,7 @@ export const DEFAULT_SFTP_COLUMN_VISIBILITY: SftpColumnVisibility = {
|
||||
modified: true,
|
||||
size: true,
|
||||
type: true,
|
||||
owner: true,
|
||||
};
|
||||
|
||||
export const normalizeSftpColumnVisibility = (value: unknown): SftpColumnVisibility => {
|
||||
@@ -28,5 +30,6 @@ export const normalizeSftpColumnVisibility = (value: unknown): SftpColumnVisibil
|
||||
modified: stored.modified !== false,
|
||||
size: stored.size !== false,
|
||||
type: stored.type !== false,
|
||||
owner: stored.owner !== false,
|
||||
};
|
||||
};
|
||||
@@ -28,6 +28,7 @@ export const useSftpDirectoryListing = () => {
|
||||
lastModifiedFormatted: formatDate(lastModified),
|
||||
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
|
||||
hidden: f.hidden,
|
||||
owner: f.owner,
|
||||
};
|
||||
});
|
||||
},
|
||||
@@ -50,6 +51,7 @@ export const useSftpDirectoryListing = () => {
|
||||
lastModified,
|
||||
lastModifiedFormatted: formatDate(lastModified),
|
||||
permissions: f.permissions,
|
||||
owner: f.owner,
|
||||
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -31,10 +31,11 @@ export const useSftpPaneSorting = (): UseSftpPaneSortingResult => {
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>("asc");
|
||||
const { directoriesFirst, toggleDirectoriesFirst } = useSftpDirectoriesFirst();
|
||||
const [columnWidths, setColumnWidths] = useState<ColumnWidths>({
|
||||
name: 56,
|
||||
modified: 28,
|
||||
name: 50,
|
||||
modified: 24,
|
||||
size: 7,
|
||||
type: 9,
|
||||
owner: 10,
|
||||
});
|
||||
const [visibleColumns, setVisibleColumns] = useState<SftpColumnVisibility>(() =>
|
||||
normalizeSftpColumnVisibility(
|
||||
@@ -107,6 +108,7 @@ export const useSftpPaneSorting = (): UseSftpPaneSortingResult => {
|
||||
modified: { min: 18, max: 42 },
|
||||
size: { min: 5, max: 16 },
|
||||
type: { min: 6, max: 18 },
|
||||
owner: { min: 6, max: 20 },
|
||||
};
|
||||
const { min, max } = limits[field];
|
||||
const newWidth = Math.max(
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
||||
STORAGE_KEY_AI_AGENT_THINKING_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
STORAGE_KEY_AI_QUICK_MESSAGES,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
@@ -79,6 +80,28 @@ import {
|
||||
retargetWorkspaceActiveChatAfterMemberLoss,
|
||||
seedWorkspaceAIActiveSessionFromMembers,
|
||||
} from '../../domain/workspaceAiScopeHandoff';
|
||||
|
||||
function providerPatchIsNoop(
|
||||
current: ProviderConfig,
|
||||
updates: Partial<ProviderConfig>,
|
||||
): boolean {
|
||||
for (const key of Object.keys(updates) as Array<keyof ProviderConfig>) {
|
||||
const nextValue = updates[key];
|
||||
const prevValue = current[key];
|
||||
if (nextValue === prevValue) continue;
|
||||
if (key === 'modelContextWindows') {
|
||||
const prevWindows = (prevValue ?? {}) as Record<string, number>;
|
||||
const nextWindows = (nextValue ?? {}) as Record<string, number>;
|
||||
const nextKeys = Object.keys(nextWindows);
|
||||
if (nextKeys.length !== Object.keys(prevWindows).length) return false;
|
||||
if (nextKeys.some((modelId) => prevWindows[modelId] !== nextWindows[modelId])) return false;
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function useAIState() {
|
||||
// ── Provider Config ──
|
||||
const [providers, setProvidersRaw] = useState<ProviderConfig[]>(() =>
|
||||
@@ -174,6 +197,9 @@ export function useAIState() {
|
||||
useEffect(() => {
|
||||
agentProviderMapRef.current = agentProviderMap;
|
||||
}, [agentProviderMap]);
|
||||
const [agentThinkingMap, setAgentThinkingMapRaw] = useState<Record<string, string>>(() =>
|
||||
localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_THINKING_MAP) ?? {}
|
||||
);
|
||||
|
||||
// ── Web Search Config ──
|
||||
const [webSearchConfig, setWebSearchConfigRaw] = useState<WebSearchConfig | null>(() =>
|
||||
@@ -270,6 +296,7 @@ export function useAIState() {
|
||||
|
||||
const setAgentModel = useCallback((agentId: string, modelId: string) => {
|
||||
setAgentModelMapRaw(prev => {
|
||||
if (prev[agentId] === modelId) return prev;
|
||||
const next = { ...prev, [agentId]: modelId };
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_MODEL_MAP, next);
|
||||
return next;
|
||||
@@ -280,17 +307,36 @@ export function useAIState() {
|
||||
setAgentProviderMapRaw(prev => {
|
||||
// Empty string clears the per-agent override and lets the agent fall
|
||||
// back to the global `activeProviderId`.
|
||||
const next = { ...prev };
|
||||
if (providerId) {
|
||||
next[agentId] = providerId;
|
||||
} else {
|
||||
delete next[agentId];
|
||||
if (prev[agentId] === providerId) return prev;
|
||||
const next = { ...prev, [agentId]: providerId };
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, next);
|
||||
return next;
|
||||
}
|
||||
if (!(agentId in prev)) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[agentId];
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setAgentThinking = useCallback((agentId: string, thinkingLevel: string) => {
|
||||
setAgentThinkingMapRaw((prev) => {
|
||||
if (thinkingLevel) {
|
||||
if (prev[agentId] === thinkingLevel) return prev;
|
||||
const next = { ...prev, [agentId]: thinkingLevel };
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_THINKING_MAP, next);
|
||||
return next;
|
||||
}
|
||||
if (!(agentId in prev)) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[agentId];
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_THINKING_MAP, next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setWebSearchConfig = useCallback((config: WebSearchConfig | null) => {
|
||||
setWebSearchConfigRaw(config);
|
||||
if (config) {
|
||||
@@ -314,6 +360,7 @@ export function useAIState() {
|
||||
const setProviders = useCallback((value: ProviderConfig[] | ((prev: ProviderConfig[]) => ProviderConfig[])) => {
|
||||
setProvidersRaw(prev => {
|
||||
const next = typeof value === 'function' ? value(prev) : value;
|
||||
if (next === prev) return prev;
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PROVIDERS, next);
|
||||
return next;
|
||||
});
|
||||
@@ -493,6 +540,9 @@ export function useAIState() {
|
||||
case STORAGE_KEY_AI_AGENT_PROVIDER_MAP:
|
||||
setAgentProviderMapRaw(localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_PROVIDER_MAP) ?? {});
|
||||
break;
|
||||
case STORAGE_KEY_AI_AGENT_THINKING_MAP:
|
||||
setAgentThinkingMapRaw(localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_THINKING_MAP) ?? {});
|
||||
break;
|
||||
case STORAGE_KEY_AI_ACTIVE_SESSION_MAP: {
|
||||
const nextActiveSessionIdMap =
|
||||
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {};
|
||||
@@ -1113,7 +1163,15 @@ export function useAIState() {
|
||||
}, [setProviders]);
|
||||
|
||||
const updateProvider = useCallback((id: string, updates: Partial<ProviderConfig>) => {
|
||||
setProviders(prev => prev.map(p => p.id === id ? { ...p, ...updates } : p));
|
||||
setProviders((prev) => {
|
||||
const index = prev.findIndex((provider) => provider.id === id);
|
||||
if (index < 0) return prev;
|
||||
const current = prev[index];
|
||||
if (providerPatchIsNoop(current, updates)) return prev;
|
||||
const next = prev.slice();
|
||||
next[index] = { ...current, ...updates };
|
||||
return next;
|
||||
});
|
||||
}, [setProviders]);
|
||||
|
||||
const removeProvider = useCallback((id: string) => {
|
||||
@@ -1186,6 +1244,8 @@ export function useAIState() {
|
||||
setAgentModel,
|
||||
agentProviderMap,
|
||||
setAgentProvider,
|
||||
agentThinkingMap,
|
||||
setAgentThinking,
|
||||
webSearchConfig,
|
||||
setWebSearchConfig,
|
||||
quickMessages,
|
||||
@@ -1244,6 +1304,8 @@ export function useAIState() {
|
||||
setAgentModel,
|
||||
agentProviderMap,
|
||||
setAgentProvider,
|
||||
agentThinkingMap,
|
||||
setAgentThinking,
|
||||
webSearchConfig,
|
||||
setWebSearchConfig,
|
||||
quickMessages,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import type { RuntimeAppLockReason } from './useAppLockRuntime';
|
||||
|
||||
let rendererReadySent = false;
|
||||
|
||||
export function useAppLockBridge() {
|
||||
const getRuntimeState = useCallback(async () => {
|
||||
return netcattyBridge.get()?.getAppLockRuntimeState?.();
|
||||
}, []);
|
||||
|
||||
const getSettings = useCallback(async () => {
|
||||
return netcattyBridge.get()?.getAppLockSettings?.();
|
||||
}, []);
|
||||
|
||||
const setRuntimeLocked = useCallback(async (reason: Exclude<RuntimeAppLockReason, null>) => {
|
||||
return netcattyBridge.get()?.setAppLockRuntimeLocked?.(reason);
|
||||
}, []);
|
||||
|
||||
const requestUnlock = useCallback(async (password: string) => {
|
||||
return netcattyBridge.get()?.requestAppLockUnlock?.(password) ?? { ok: false as const, error: 'incorrect' as const };
|
||||
}, []);
|
||||
|
||||
const requestReset = useCallback(async (currentPassword: string) => {
|
||||
return netcattyBridge.get()?.requestAppLockReset?.(currentPassword);
|
||||
}, []);
|
||||
|
||||
const reportActivity = useCallback(async () => {
|
||||
await netcattyBridge.get()?.reportAppLockActivity?.();
|
||||
}, []);
|
||||
|
||||
const onRuntimeStateChanged = useCallback((listener: (state: unknown) => void) => {
|
||||
try {
|
||||
return netcattyBridge.get()?.onAppLockRuntimeStateChanged?.(listener) ?? (() => {});
|
||||
} catch {
|
||||
return () => {};
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onSettingsChanged = useCallback((listener: (settings: unknown) => void) => {
|
||||
try {
|
||||
return netcattyBridge.get()?.onAppLockSettingsChanged?.(listener) ?? (() => {});
|
||||
} catch {
|
||||
return () => {};
|
||||
}
|
||||
}, []);
|
||||
|
||||
const notifyRendererReady = useCallback(() => {
|
||||
if (rendererReadySent) return;
|
||||
rendererReadySent = true;
|
||||
try {
|
||||
netcattyBridge.get()?.rendererReady?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onAppLockReopen = useCallback((listener: () => void) => {
|
||||
try {
|
||||
return netcattyBridge.get()?.onAppLockReopen?.(listener) ?? (() => {});
|
||||
} catch {
|
||||
return () => {};
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
getRuntimeState,
|
||||
getSettings,
|
||||
setRuntimeLocked,
|
||||
requestUnlock,
|
||||
requestReset,
|
||||
reportActivity,
|
||||
onRuntimeStateChanged,
|
||||
onSettingsChanged,
|
||||
notifyRendererReady,
|
||||
onAppLockReopen,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
normalizeRuntimeAppLockState,
|
||||
selectPreferredRuntimeAppLockState,
|
||||
} from "./useAppLockRuntime.ts";
|
||||
|
||||
test("normalizeRuntimeAppLockState defaults safely", () => {
|
||||
assert.deepEqual(normalizeRuntimeAppLockState(null), {
|
||||
initialized: false,
|
||||
locked: false,
|
||||
reason: null,
|
||||
version: 0,
|
||||
lastLockedAt: null,
|
||||
lastUnlockedAt: null,
|
||||
lastActivityAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizeRuntimeAppLockState preserves a valid runtime payload", () => {
|
||||
assert.deepEqual(
|
||||
normalizeRuntimeAppLockState({
|
||||
initialized: true,
|
||||
locked: true,
|
||||
reason: "startup",
|
||||
version: 3,
|
||||
lastLockedAt: 123,
|
||||
lastUnlockedAt: null,
|
||||
lastActivityAt: 456,
|
||||
}),
|
||||
{
|
||||
initialized: true,
|
||||
locked: true,
|
||||
reason: "startup",
|
||||
version: 3,
|
||||
lastLockedAt: 123,
|
||||
lastUnlockedAt: null,
|
||||
lastActivityAt: 456,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("selectPreferredRuntimeAppLockState keeps the newer runtime version", () => {
|
||||
const current = normalizeRuntimeAppLockState({
|
||||
initialized: true,
|
||||
locked: false,
|
||||
reason: null,
|
||||
version: 4,
|
||||
lastLockedAt: 100,
|
||||
lastUnlockedAt: 200,
|
||||
lastActivityAt: 300,
|
||||
});
|
||||
const staleIncoming = normalizeRuntimeAppLockState({
|
||||
initialized: true,
|
||||
locked: true,
|
||||
reason: "startup",
|
||||
version: 3,
|
||||
lastLockedAt: 50,
|
||||
lastUnlockedAt: null,
|
||||
lastActivityAt: 60,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
selectPreferredRuntimeAppLockState(current, staleIncoming),
|
||||
current,
|
||||
);
|
||||
});
|
||||
|
||||
test("selectPreferredRuntimeAppLockState prefers initialized state when versions tie", () => {
|
||||
const current = normalizeRuntimeAppLockState({
|
||||
initialized: true,
|
||||
locked: false,
|
||||
reason: null,
|
||||
version: 0,
|
||||
lastLockedAt: null,
|
||||
lastUnlockedAt: 200,
|
||||
lastActivityAt: 300,
|
||||
});
|
||||
const uninitializedIncoming = normalizeRuntimeAppLockState({
|
||||
initialized: false,
|
||||
locked: false,
|
||||
reason: null,
|
||||
version: 0,
|
||||
lastLockedAt: null,
|
||||
lastUnlockedAt: null,
|
||||
lastActivityAt: null,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
selectPreferredRuntimeAppLockState(current, uninitializedIncoming),
|
||||
current,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export type RuntimeAppLockReason = 'startup' | 'idle' | 'manual' | 'background' | null;
|
||||
|
||||
export interface RuntimeAppLockState {
|
||||
initialized: boolean;
|
||||
locked: boolean;
|
||||
reason: RuntimeAppLockReason;
|
||||
version: number;
|
||||
lastLockedAt: number | null;
|
||||
lastUnlockedAt: number | null;
|
||||
lastActivityAt: number | null;
|
||||
}
|
||||
|
||||
export function normalizeRuntimeAppLockState(input: unknown): RuntimeAppLockState {
|
||||
const record = input && typeof input === 'object' ? input as Record<string, unknown> : null;
|
||||
const reason = record?.reason;
|
||||
|
||||
return {
|
||||
initialized: record?.initialized === true,
|
||||
locked: record?.locked === true,
|
||||
reason:
|
||||
reason === 'startup' ||
|
||||
reason === 'idle' ||
|
||||
reason === 'manual' ||
|
||||
reason === 'background'
|
||||
? reason
|
||||
: null,
|
||||
version: typeof record?.version === 'number' && Number.isFinite(record.version) ? record.version : 0,
|
||||
lastLockedAt: typeof record?.lastLockedAt === 'number' && Number.isFinite(record.lastLockedAt) ? record.lastLockedAt : null,
|
||||
lastUnlockedAt: typeof record?.lastUnlockedAt === 'number' && Number.isFinite(record.lastUnlockedAt) ? record.lastUnlockedAt : null,
|
||||
lastActivityAt: typeof record?.lastActivityAt === 'number' && Number.isFinite(record.lastActivityAt) ? record.lastActivityAt : null,
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_RUNTIME_APP_LOCK_STATE: RuntimeAppLockState = normalizeRuntimeAppLockState(null);
|
||||
|
||||
export function selectPreferredRuntimeAppLockState(
|
||||
current: RuntimeAppLockState,
|
||||
incoming: RuntimeAppLockState,
|
||||
): RuntimeAppLockState {
|
||||
if (incoming.version > current.version) return incoming;
|
||||
if (incoming.version < current.version) return current;
|
||||
if (incoming.initialized && !current.initialized) return incoming;
|
||||
if (!incoming.initialized && current.initialized) return current;
|
||||
return incoming;
|
||||
}
|
||||
|
||||
export function useAppLockRuntime(
|
||||
bridge: Pick<
|
||||
NonNullable<NetcattyBridge>,
|
||||
'getAppLockRuntimeState' | 'onAppLockRuntimeStateChanged'
|
||||
> | null | undefined,
|
||||
) {
|
||||
const [runtimeState, setRuntimeState] = useState<RuntimeAppLockState>(DEFAULT_RUNTIME_APP_LOCK_STATE);
|
||||
const refreshRuntimeState = useCallback(async () => {
|
||||
const nextState = await bridge?.getAppLockRuntimeState?.();
|
||||
const normalized = normalizeRuntimeAppLockState(nextState);
|
||||
setRuntimeState((current) => selectPreferredRuntimeAppLockState(current, normalized));
|
||||
return normalized;
|
||||
}, [bridge]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const unsubscribe = bridge?.onAppLockRuntimeStateChanged?.((nextState) => {
|
||||
if (cancelled) return;
|
||||
const normalized = normalizeRuntimeAppLockState(nextState);
|
||||
setRuntimeState((current) => selectPreferredRuntimeAppLockState(current, normalized));
|
||||
}) ?? (() => {});
|
||||
|
||||
void refreshRuntimeState().catch(() => {
|
||||
if (cancelled) return;
|
||||
setRuntimeState((current) => selectPreferredRuntimeAppLockState(
|
||||
current,
|
||||
DEFAULT_RUNTIME_APP_LOCK_STATE,
|
||||
));
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, [bridge, refreshRuntimeState]);
|
||||
|
||||
return {
|
||||
runtimeState,
|
||||
setRuntimeState,
|
||||
refreshRuntimeState,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { AppLockPasswordVerifier, AppLockSettings } from "../../domain/appLock.ts";
|
||||
import {
|
||||
createOptimisticUnlockedRuntimeState,
|
||||
DEFAULT_APP_LOCK_SYSTEM_UNLOCK_STATUS,
|
||||
getIdleLockDelayMs,
|
||||
normalizeAppLockSystemUnlockStatus,
|
||||
normalizeAppLockSystemUnlockResult,
|
||||
resolveUnlockAttempt,
|
||||
shouldLockAfterIdle,
|
||||
shouldLockOnStartup,
|
||||
} from "./useAppLockState.ts";
|
||||
import { selectPreferredRuntimeAppLockState } from "./useAppLockRuntime.ts";
|
||||
|
||||
const verifier: AppLockPasswordVerifier = {
|
||||
version: 1,
|
||||
algorithm: "PBKDF2-SHA256",
|
||||
iterations: 210000,
|
||||
salt: Buffer.alloc(16, 1).toString("base64"),
|
||||
hash: Buffer.alloc(32, 2).toString("base64"),
|
||||
};
|
||||
|
||||
test("shouldLockOnStartup locks only when enabled with a verifier", () => {
|
||||
const enabled: AppLockSettings = {
|
||||
enabled: true,
|
||||
timeoutMinutes: 15,
|
||||
systemUnlockEnabled: false,
|
||||
systemUnlockAutoPromptEnabled: false,
|
||||
passwordVerifier: verifier,
|
||||
};
|
||||
|
||||
assert.equal(shouldLockOnStartup(enabled), true);
|
||||
assert.equal(shouldLockOnStartup({ ...enabled, enabled: false }), false);
|
||||
assert.equal(shouldLockOnStartup({ ...enabled, passwordVerifier: null }), false);
|
||||
});
|
||||
|
||||
test("shouldLockAfterIdle honors the configured timeout", () => {
|
||||
const settings: AppLockSettings = {
|
||||
enabled: true,
|
||||
timeoutMinutes: 5,
|
||||
systemUnlockEnabled: false,
|
||||
systemUnlockAutoPromptEnabled: false,
|
||||
passwordVerifier: verifier,
|
||||
};
|
||||
|
||||
assert.equal(shouldLockAfterIdle(settings, 1_000, 1_000 + 5 * 60_000 - 1), false);
|
||||
assert.equal(shouldLockAfterIdle(settings, 1_000, 1_000 + 5 * 60_000), true);
|
||||
assert.equal(shouldLockAfterIdle({ ...settings, timeoutMinutes: 0 }, 1_000, 1_000 + 60 * 60_000), false);
|
||||
assert.equal(shouldLockAfterIdle({ ...settings, enabled: false }, 1_000, 1_000 + 60 * 60_000), false);
|
||||
assert.equal(shouldLockAfterIdle({ ...settings, passwordVerifier: null }, 1_000, 1_000 + 60 * 60_000), false);
|
||||
});
|
||||
|
||||
test("getIdleLockDelayMs schedules the next check after remaining idle time", () => {
|
||||
const settings: AppLockSettings = {
|
||||
enabled: true,
|
||||
timeoutMinutes: 5,
|
||||
systemUnlockEnabled: false,
|
||||
systemUnlockAutoPromptEnabled: false,
|
||||
passwordVerifier: verifier,
|
||||
};
|
||||
|
||||
assert.equal(getIdleLockDelayMs(settings, 1_000, 1_000), 5 * 60_000);
|
||||
assert.equal(getIdleLockDelayMs(settings, 1_000, 1_000 + 4 * 60_000), 60_000);
|
||||
assert.equal(getIdleLockDelayMs(settings, 1_000, 1_000 + 5 * 60_000), 0);
|
||||
assert.equal(getIdleLockDelayMs({ ...settings, timeoutMinutes: 0 }, 1_000, 1_000), null);
|
||||
assert.equal(getIdleLockDelayMs({ ...settings, enabled: false }, 1_000, 1_000), null);
|
||||
assert.equal(getIdleLockDelayMs({ ...settings, passwordVerifier: null }, 1_000, 1_000), null);
|
||||
});
|
||||
|
||||
test("createOptimisticUnlockedRuntimeState clears stale locked state after successful unlock", () => {
|
||||
const nextState = createOptimisticUnlockedRuntimeState(
|
||||
{
|
||||
initialized: false,
|
||||
locked: true,
|
||||
reason: "startup",
|
||||
version: 7,
|
||||
lastLockedAt: 2_000,
|
||||
lastUnlockedAt: null,
|
||||
lastActivityAt: 1_000,
|
||||
},
|
||||
5_000,
|
||||
);
|
||||
|
||||
assert.deepEqual(nextState, {
|
||||
initialized: true,
|
||||
locked: false,
|
||||
reason: null,
|
||||
// Optimistic unlock must not invent a higher version than main has sent.
|
||||
version: 7,
|
||||
lastLockedAt: 2_000,
|
||||
lastUnlockedAt: 5_000,
|
||||
lastActivityAt: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("createOptimisticUnlockedRuntimeState keeps observed version so concurrent re-lock can win", () => {
|
||||
const optimistic = createOptimisticUnlockedRuntimeState(
|
||||
{
|
||||
initialized: true,
|
||||
locked: true,
|
||||
reason: "startup",
|
||||
version: 7,
|
||||
lastLockedAt: 2_000,
|
||||
lastUnlockedAt: null,
|
||||
lastActivityAt: 1_000,
|
||||
},
|
||||
5_000,
|
||||
);
|
||||
|
||||
assert.equal(optimistic.version, 7);
|
||||
assert.equal(optimistic.locked, false);
|
||||
// A concurrent main re-lock at version 8 must outrank the optimistic unlock.
|
||||
const preferred = selectPreferredRuntimeAppLockState(optimistic, {
|
||||
...optimistic,
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
version: 8,
|
||||
lastLockedAt: 6_000,
|
||||
});
|
||||
assert.equal(preferred.locked, true);
|
||||
assert.equal(preferred.version, 8);
|
||||
});
|
||||
|
||||
test("normalizes app lock system unlock bridge status and results", () => {
|
||||
assert.deepEqual(DEFAULT_APP_LOCK_SYSTEM_UNLOCK_STATUS, {
|
||||
supported: false,
|
||||
available: false,
|
||||
enabled: false,
|
||||
platform: "unsupported",
|
||||
label: null,
|
||||
reason: null,
|
||||
});
|
||||
assert.deepEqual(
|
||||
normalizeAppLockSystemUnlockStatus({
|
||||
supported: true,
|
||||
available: true,
|
||||
enabled: true,
|
||||
platform: "win32",
|
||||
label: "Windows Hello",
|
||||
reason: "",
|
||||
}),
|
||||
{
|
||||
supported: true,
|
||||
available: true,
|
||||
enabled: true,
|
||||
platform: "win32",
|
||||
label: "Windows Hello",
|
||||
reason: null,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(normalizeAppLockSystemUnlockResult({ ok: true }), { ok: true });
|
||||
assert.deepEqual(normalizeAppLockSystemUnlockResult({ ok: false, error: "cancelled" }), {
|
||||
ok: false,
|
||||
error: "cancelled",
|
||||
});
|
||||
assert.deepEqual(normalizeAppLockSystemUnlockResult({ ok: false, error: "unknown" }), {
|
||||
ok: false,
|
||||
error: "failed",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
test("resolveUnlockAttempt validates empty, incorrect, and correct passwords", async () => {
|
||||
const originalWindow = globalThis.window;
|
||||
globalThis.window = {
|
||||
netcatty: {
|
||||
requestAppLockUnlock: async (password: string) =>
|
||||
password === "secret"
|
||||
? { ok: true as const }
|
||||
: { ok: false as const, error: "incorrect" as const },
|
||||
},
|
||||
} as typeof window;
|
||||
|
||||
try {
|
||||
assert.deepEqual(await resolveUnlockAttempt(""), { ok: false, error: "empty" });
|
||||
assert.deepEqual(await resolveUnlockAttempt("wrong"), { ok: false, error: "incorrect" });
|
||||
assert.deepEqual(await resolveUnlockAttempt("secret"), { ok: true });
|
||||
} finally {
|
||||
globalThis.window = originalWindow;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
normalizeAppLockSettings,
|
||||
type AppLockSettings,
|
||||
} from '../../domain/appLock';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import {
|
||||
normalizeRuntimeAppLockState,
|
||||
useAppLockRuntime,
|
||||
type RuntimeAppLockReason,
|
||||
type RuntimeAppLockState,
|
||||
} from './useAppLockRuntime';
|
||||
|
||||
export type AppLockReason = RuntimeAppLockReason;
|
||||
export type AppLockUnlockResult =
|
||||
| { ok: true }
|
||||
| { ok: false; error: 'empty' | 'incorrect' };
|
||||
export interface AppLockSystemUnlockStatus {
|
||||
supported: boolean;
|
||||
available: boolean;
|
||||
enabled: boolean;
|
||||
platform: 'darwin' | 'win32' | 'unsupported';
|
||||
label: 'Touch ID' | 'Windows Hello' | null;
|
||||
reason: string | null;
|
||||
}
|
||||
export type AppLockSystemUnlockResult =
|
||||
| { ok: true }
|
||||
| { ok: false; error: 'disabled' | 'not-locked' | 'unsupported' | 'unavailable' | 'cancelled' | 'failed' };
|
||||
|
||||
export const DEFAULT_APP_LOCK_SYSTEM_UNLOCK_STATUS: AppLockSystemUnlockStatus = {
|
||||
supported: false,
|
||||
available: false,
|
||||
enabled: false,
|
||||
platform: 'unsupported',
|
||||
label: null,
|
||||
reason: null,
|
||||
};
|
||||
|
||||
export function normalizeAppLockSystemUnlockStatus(input: unknown): AppLockSystemUnlockStatus {
|
||||
const record = input && typeof input === 'object' ? input as Record<string, unknown> : null;
|
||||
const platform = record?.platform === 'darwin' || record?.platform === 'win32'
|
||||
? record.platform
|
||||
: 'unsupported';
|
||||
const label = record?.label === 'Touch ID' || record?.label === 'Windows Hello'
|
||||
? record.label
|
||||
: null;
|
||||
return {
|
||||
supported: record?.supported === true,
|
||||
available: record?.available === true,
|
||||
enabled: record?.enabled === true,
|
||||
platform,
|
||||
label,
|
||||
reason: typeof record?.reason === 'string' && record.reason ? record.reason : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeAppLockSystemUnlockResult(input: unknown): AppLockSystemUnlockResult {
|
||||
const record = input && typeof input === 'object' ? input as Record<string, unknown> : null;
|
||||
if (record?.ok === true) return { ok: true };
|
||||
const error = record?.error;
|
||||
if (
|
||||
error === 'disabled' ||
|
||||
error === 'not-locked' ||
|
||||
error === 'unsupported' ||
|
||||
error === 'unavailable' ||
|
||||
error === 'cancelled' ||
|
||||
error === 'failed'
|
||||
) {
|
||||
return { ok: false, error };
|
||||
}
|
||||
return { ok: false, error: 'failed' };
|
||||
}
|
||||
|
||||
export function shouldLockOnStartup(settings: AppLockSettings): boolean {
|
||||
const normalized = normalizeAppLockSettings(settings);
|
||||
return normalized.enabled && normalized.passwordVerifier !== null;
|
||||
}
|
||||
|
||||
export function shouldLockAfterIdle(
|
||||
settings: AppLockSettings,
|
||||
lastActivityAt: number,
|
||||
now: number,
|
||||
): boolean {
|
||||
const normalized = normalizeAppLockSettings(settings);
|
||||
if (!normalized.enabled || !normalized.passwordVerifier) return false;
|
||||
if (normalized.timeoutMinutes <= 0) return false;
|
||||
return now - lastActivityAt >= normalized.timeoutMinutes * 60_000;
|
||||
}
|
||||
|
||||
export function getIdleLockDelayMs(
|
||||
settings: AppLockSettings,
|
||||
lastActivityAt: number,
|
||||
now: number,
|
||||
): number | null {
|
||||
const normalized = normalizeAppLockSettings(settings);
|
||||
if (!normalized.enabled || !normalized.passwordVerifier) return null;
|
||||
if (normalized.timeoutMinutes <= 0) return null;
|
||||
const timeoutMs = normalized.timeoutMinutes * 60_000;
|
||||
return Math.max(0, timeoutMs - (now - lastActivityAt));
|
||||
}
|
||||
|
||||
export async function resolveUnlockAttempt(password: string): Promise<AppLockUnlockResult> {
|
||||
if (!password) return { ok: false, error: 'empty' };
|
||||
try {
|
||||
return await netcattyBridge.get()?.requestAppLockUnlock?.(password) ?? { ok: false, error: 'incorrect' };
|
||||
} catch {
|
||||
return { ok: false, error: 'incorrect' };
|
||||
}
|
||||
}
|
||||
|
||||
export function createOptimisticUnlockedRuntimeState(
|
||||
input: RuntimeAppLockState,
|
||||
now: number,
|
||||
): RuntimeAppLockState {
|
||||
const current = normalizeRuntimeAppLockState(input);
|
||||
if (current.initialized && !current.locked && current.reason === null) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
initialized: true,
|
||||
locked: false,
|
||||
reason: null,
|
||||
// Keep the observed main-process version. Bumping here can outrank a real
|
||||
// concurrent re-lock and leave the renderer unlocked after refresh (Codex P2).
|
||||
version: current.version,
|
||||
lastUnlockedAt: now,
|
||||
lastActivityAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
export function useAppLockState(settings: AppLockSettings) {
|
||||
const normalizedSettings = useMemo(() => normalizeAppLockSettings(settings), [settings]);
|
||||
const systemUnlockRefreshKey = `${normalizedSettings.enabled}:${normalizedSettings.systemUnlockEnabled}:${Boolean(normalizedSettings.passwordVerifier)}`;
|
||||
const bridge = netcattyBridge.get();
|
||||
const { runtimeState, refreshRuntimeState, setRuntimeState } = useAppLockRuntime(bridge);
|
||||
const [systemUnlockStatus, setSystemUnlockStatus] = useState<AppLockSystemUnlockStatus>(
|
||||
DEFAULT_APP_LOCK_SYSTEM_UNLOCK_STATUS,
|
||||
);
|
||||
const normalizedRuntimeState = useMemo(
|
||||
() => normalizeRuntimeAppLockState(runtimeState),
|
||||
[runtimeState],
|
||||
);
|
||||
const effectiveRuntimeState = useMemo(() => {
|
||||
if (normalizedRuntimeState.initialized) return normalizedRuntimeState;
|
||||
if (shouldLockOnStartup(normalizedSettings)) {
|
||||
return {
|
||||
...normalizedRuntimeState,
|
||||
locked: true,
|
||||
reason: 'startup' as const,
|
||||
};
|
||||
}
|
||||
return normalizedRuntimeState;
|
||||
}, [normalizedRuntimeState, normalizedSettings]);
|
||||
|
||||
const lockNow = useCallback((reason: AppLockReason = 'manual') => {
|
||||
if (!shouldLockOnStartup(normalizedSettings) || !reason) return;
|
||||
void bridge?.setAppLockRuntimeLocked?.(reason);
|
||||
}, [bridge, normalizedSettings]);
|
||||
|
||||
const recordActivity = useCallback(() => {
|
||||
if (effectiveRuntimeState.locked) return;
|
||||
void bridge?.reportAppLockActivity?.();
|
||||
}, [bridge, effectiveRuntimeState.locked]);
|
||||
|
||||
const unlock = useCallback(async (password: string): Promise<AppLockUnlockResult> => {
|
||||
const result = await resolveUnlockAttempt(password);
|
||||
if (result.ok) {
|
||||
const unlockedAt = Date.now();
|
||||
setRuntimeState((current) => createOptimisticUnlockedRuntimeState(current, unlockedAt));
|
||||
await refreshRuntimeState().catch(() => {});
|
||||
}
|
||||
return result;
|
||||
}, [refreshRuntimeState, setRuntimeState]);
|
||||
|
||||
const refreshSystemUnlockStatus = useCallback(async () => {
|
||||
try {
|
||||
const nextStatus = await bridge?.getAppLockSystemUnlockStatus?.();
|
||||
const normalized = normalizeAppLockSystemUnlockStatus(nextStatus);
|
||||
setSystemUnlockStatus(normalized);
|
||||
return normalized;
|
||||
} catch {
|
||||
setSystemUnlockStatus(DEFAULT_APP_LOCK_SYSTEM_UNLOCK_STATUS);
|
||||
return DEFAULT_APP_LOCK_SYSTEM_UNLOCK_STATUS;
|
||||
}
|
||||
}, [bridge]);
|
||||
|
||||
const unlockWithSystemAuth = useCallback(async (): Promise<AppLockSystemUnlockResult> => {
|
||||
const result = normalizeAppLockSystemUnlockResult(await bridge?.requestAppLockSystemUnlock?.());
|
||||
if (result.ok) {
|
||||
const unlockedAt = Date.now();
|
||||
setRuntimeState((current) => createOptimisticUnlockedRuntimeState(current, unlockedAt));
|
||||
await refreshRuntimeState().catch(() => {});
|
||||
}
|
||||
await refreshSystemUnlockStatus().catch(() => {});
|
||||
return result;
|
||||
}, [bridge, refreshRuntimeState, refreshSystemUnlockStatus, setRuntimeState]);
|
||||
|
||||
const reset = useCallback(async (currentPassword: string) => {
|
||||
if (typeof bridge?.requestAppLockReset !== 'function') {
|
||||
throw new Error('App Lock reset bridge is unavailable');
|
||||
}
|
||||
const result = await bridge.requestAppLockReset(currentPassword);
|
||||
if (result && typeof result === 'object' && 'ok' in result && result.ok === false) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
const unlockedAt = Date.now();
|
||||
setRuntimeState((current) => createOptimisticUnlockedRuntimeState(current, unlockedAt));
|
||||
await refreshRuntimeState().catch(() => {});
|
||||
}, [bridge, refreshRuntimeState, setRuntimeState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldLockOnStartup(normalizedSettings) && effectiveRuntimeState.locked) {
|
||||
const unlockedAt = Date.now();
|
||||
void bridge?.requestAppLockUnlock?.('')
|
||||
?.then((result) => {
|
||||
if (result?.ok !== true) return;
|
||||
setRuntimeState((current) => createOptimisticUnlockedRuntimeState(current, unlockedAt));
|
||||
return refreshRuntimeState();
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [bridge, effectiveRuntimeState.locked, normalizedSettings, refreshRuntimeState, setRuntimeState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldLockOnStartup(normalizedSettings)) return undefined;
|
||||
|
||||
const events: Array<keyof WindowEventMap> = ['pointerdown', 'keydown', 'wheel', 'touchstart', 'focus'];
|
||||
for (const eventName of events) {
|
||||
window.addEventListener(eventName, recordActivity, { passive: true });
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const eventName of events) {
|
||||
window.removeEventListener(eventName, recordActivity);
|
||||
}
|
||||
};
|
||||
}, [normalizedSettings, recordActivity]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldLockOnStartup(normalizedSettings)) return undefined;
|
||||
void bridge?.reportAppLockActivity?.();
|
||||
return undefined;
|
||||
}, [bridge, normalizedSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshSystemUnlockStatus();
|
||||
}, [refreshSystemUnlockStatus, systemUnlockRefreshKey]);
|
||||
|
||||
return {
|
||||
initialized: effectiveRuntimeState.initialized,
|
||||
locked: effectiveRuntimeState.locked,
|
||||
lockReason: effectiveRuntimeState.reason,
|
||||
lockNow,
|
||||
unlock,
|
||||
unlockWithSystemAuth,
|
||||
systemUnlockStatus,
|
||||
refreshSystemUnlockStatus,
|
||||
reset,
|
||||
recordActivity,
|
||||
resync: refreshRuntimeState,
|
||||
};
|
||||
}
|
||||
@@ -11,6 +11,11 @@ import {
|
||||
registerTerminalSettingsActions,
|
||||
} from './terminalSettingsStore';
|
||||
import { SyncConfig, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
|
||||
import {
|
||||
normalizeAppLockTimeoutMinutes,
|
||||
type AppLockSettings,
|
||||
type AppLockTimeoutMinutes,
|
||||
} from '../../domain/appLock';
|
||||
import {
|
||||
DEFAULT_HTTP_NETWORK_PROXY,
|
||||
areHttpNetworkProxySettingsEqual,
|
||||
@@ -338,6 +343,13 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const stored = readStoredString(STORAGE_KEY_UI_LANGUAGE);
|
||||
return resolveSupportedLocale(stored || DEFAULT_UI_LOCALE);
|
||||
});
|
||||
const [appLockSettings, setAppLockSettingsState] = useState<AppLockSettings>({
|
||||
enabled: false,
|
||||
timeoutMinutes: 15,
|
||||
systemUnlockEnabled: false,
|
||||
systemUnlockAutoPromptEnabled: false,
|
||||
passwordVerifier: null,
|
||||
});
|
||||
const [terminalSettings, setTerminalSettingsState] = useState<TerminalSettings>(() => {
|
||||
const stored = localStorageAdapter.read<TerminalSettings>(STORAGE_KEY_TERM_SETTINGS);
|
||||
return normalizeTerminalSettings(stored);
|
||||
@@ -787,6 +799,53 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
notifySettingsChanged(STORAGE_KEY_WORKSPACE_FOCUS_STYLE, style);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setAppLockTimeoutMinutes = useCallback((timeoutMinutes: AppLockTimeoutMinutes) => {
|
||||
void netcattyBridge.get()?.setAppLockTimeoutMinutes?.(normalizeAppLockTimeoutMinutes(timeoutMinutes))
|
||||
?.then((next) => {
|
||||
if (next) setAppLockSettingsState(next);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const requestAppLockEnable = useCallback(async () => {
|
||||
const next = await netcattyBridge.get()?.requestAppLockEnable?.();
|
||||
if (next && !('ok' in next)) {
|
||||
setAppLockSettingsState(next);
|
||||
}
|
||||
return next ?? appLockSettings;
|
||||
}, [appLockSettings]);
|
||||
|
||||
const requestAppLockDisable = useCallback(async (currentPassword: string) => {
|
||||
const next = await netcattyBridge.get()?.requestAppLockDisable?.(currentPassword);
|
||||
if (next && !('ok' in next)) {
|
||||
setAppLockSettingsState(next);
|
||||
}
|
||||
return next ?? appLockSettings;
|
||||
}, [appLockSettings]);
|
||||
|
||||
const requestAppLockPasswordChange = useCallback(async (input: {
|
||||
currentPassword?: string;
|
||||
nextPassword: string;
|
||||
}) => {
|
||||
const next = await netcattyBridge.get()?.requestAppLockPasswordChange?.(input);
|
||||
if (next && !('ok' in next)) {
|
||||
setAppLockSettingsState(next);
|
||||
}
|
||||
return next ?? appLockSettings;
|
||||
}, [appLockSettings]);
|
||||
|
||||
const setAppLockSystemUnlockEnabled = useCallback(async (input: {
|
||||
enabled: boolean;
|
||||
currentPassword?: string;
|
||||
autoPromptEnabled?: boolean;
|
||||
}) => {
|
||||
const next = await netcattyBridge.get()?.setAppLockSystemUnlockEnabled?.(input);
|
||||
if (next && !('ok' in next)) {
|
||||
setAppLockSettingsState(next);
|
||||
}
|
||||
return next ?? appLockSettings;
|
||||
}, [appLockSettings]);
|
||||
|
||||
const syncAppearanceFromStorage = useCallback((incoming?: AppearanceSyncEvent) => {
|
||||
const current = appearanceStateRef.current;
|
||||
const nextAppearance = resolveAppearanceSyncState(
|
||||
@@ -863,6 +922,10 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const storedLang = readStoredString(STORAGE_KEY_UI_LANGUAGE);
|
||||
if (storedLang) setUiLanguage(storedLang as UILanguage);
|
||||
|
||||
void netcattyBridge.get()?.getAppLockSettings?.().then((next) => {
|
||||
if (next) setAppLockSettingsState(next);
|
||||
}).catch(() => {});
|
||||
|
||||
// Terminal
|
||||
const storedTermTheme = readStoredString(STORAGE_KEY_TERM_THEME);
|
||||
if (storedTermTheme) setTerminalThemeId(storedTermTheme);
|
||||
@@ -1155,6 +1218,25 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
};
|
||||
}, [enableSettingsSync]);
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
let sawPushedSettings = false;
|
||||
|
||||
const unsubscribe = bridge?.onAppLockSettingsChanged?.((next) => {
|
||||
sawPushedSettings = true;
|
||||
setAppLockSettingsState(next);
|
||||
}) ?? (() => {});
|
||||
|
||||
void bridge?.getAppLockSettings?.().then((next) => {
|
||||
if (!next || sawPushedSettings) return;
|
||||
setAppLockSettingsState(next);
|
||||
}).catch(() => {});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useSettingsStorageSync({
|
||||
enabled: enableSettingsSync,
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
@@ -1914,6 +1996,12 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
updateSyncConfig,
|
||||
uiLanguage,
|
||||
setUiLanguage,
|
||||
appLockSettings,
|
||||
setAppLockTimeoutMinutes,
|
||||
requestAppLockEnable,
|
||||
requestAppLockDisable,
|
||||
requestAppLockPasswordChange,
|
||||
setAppLockSystemUnlockEnabled,
|
||||
terminalThemeId,
|
||||
setTerminalThemeId,
|
||||
followAppTerminalTheme,
|
||||
|
||||
@@ -234,6 +234,7 @@ test("buildSyncPayload includes AI configuration settings", () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_MAX_ITERATIONS, "10");
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP, JSON.stringify({ codex: "gpt-test" }));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP, JSON.stringify({ catty: "openai-main" }));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_THINKING_MAP, JSON.stringify({ catty: "high" }));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify(webSearch));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION, "false");
|
||||
|
||||
@@ -254,6 +255,7 @@ test("buildSyncPayload includes AI configuration settings", () => {
|
||||
maxIterations: 10,
|
||||
agentModelMap: { codex: "gpt-test" },
|
||||
agentProviderMap: { catty: "openai-main" },
|
||||
agentThinkingMap: { catty: "high" },
|
||||
webSearchConfig: webSearchWithoutKey,
|
||||
showTerminalSelectionAction: false,
|
||||
});
|
||||
|
||||
@@ -102,6 +102,7 @@ import {
|
||||
STORAGE_KEY_AI_MAX_ITERATIONS,
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
||||
STORAGE_KEY_AI_AGENT_THINKING_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
STORAGE_KEY_AI_QUICK_MESSAGES,
|
||||
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
|
||||
@@ -249,7 +250,7 @@ const SYNCABLE_TERMINAL_KEYS = [
|
||||
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
||||
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
|
||||
'autoUploadClipboardImageOnPaste',
|
||||
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'dynamicTabTitleMode',
|
||||
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'oscNotifications', 'dynamicTabTitleMode',
|
||||
'autoCloseOnExit',
|
||||
'showHostInfoBar', 'hostInfoBarTitleMode', 'showServerStats',
|
||||
'serverStatsRefreshInterval',
|
||||
@@ -313,6 +314,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_AI_MAX_ITERATIONS,
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
||||
STORAGE_KEY_AI_AGENT_THINKING_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
STORAGE_KEY_AI_QUICK_MESSAGES,
|
||||
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
|
||||
@@ -562,6 +564,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (agentModelMap) ai.agentModelMap = agentModelMap;
|
||||
const agentProviderMap = readRecordSetting<Record<string, string>>(STORAGE_KEY_AI_AGENT_PROVIDER_MAP);
|
||||
if (agentProviderMap) ai.agentProviderMap = agentProviderMap;
|
||||
const agentThinkingMap = readRecordSetting<Record<string, string>>(STORAGE_KEY_AI_AGENT_THINKING_MAP);
|
||||
if (agentThinkingMap) ai.agentThinkingMap = agentThinkingMap;
|
||||
const webSearchConfig = readRecordSetting(STORAGE_KEY_AI_WEB_SEARCH);
|
||||
if (webSearchConfig) ai.webSearchConfig = stripDeviceBoundApiKey(webSearchConfig);
|
||||
const quickMessages = readArraySetting(STORAGE_KEY_AI_QUICK_MESSAGES);
|
||||
@@ -796,6 +800,7 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
|
||||
if (ai.maxIterations != null) localStorageAdapter.writeNumber(STORAGE_KEY_AI_MAX_ITERATIONS, ai.maxIterations);
|
||||
if (ai.agentModelMap != null) localStorageAdapter.write(STORAGE_KEY_AI_AGENT_MODEL_MAP, ai.agentModelMap);
|
||||
if (ai.agentProviderMap != null) localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, ai.agentProviderMap);
|
||||
if (ai.agentThinkingMap != null) localStorageAdapter.write(STORAGE_KEY_AI_AGENT_THINKING_MAP, ai.agentThinkingMap);
|
||||
if (ai.webSearchConfig !== undefined) {
|
||||
if (ai.webSearchConfig === null) {
|
||||
localStorageAdapter.remove(STORAGE_KEY_AI_WEB_SEARCH);
|
||||
@@ -847,6 +852,7 @@ function notifyAIStateAfterSync(ai: NonNullable<SyncPayload['settings']>['ai']):
|
||||
if (ai.commandTimeout != null) touched.push(STORAGE_KEY_AI_COMMAND_TIMEOUT);
|
||||
if (ai.maxIterations != null) touched.push(STORAGE_KEY_AI_MAX_ITERATIONS);
|
||||
if (ai.agentModelMap != null) touched.push(STORAGE_KEY_AI_AGENT_MODEL_MAP);
|
||||
if (ai.agentThinkingMap != null) touched.push(STORAGE_KEY_AI_AGENT_THINKING_MAP);
|
||||
// agentProviderMap is *always* potentially mutated because the reconcile
|
||||
// step may have pruned it even if the payload didn't ship one.
|
||||
touched.push(STORAGE_KEY_AI_AGENT_PROVIDER_MAP);
|
||||
|
||||
@@ -72,7 +72,9 @@ interface AIChatPanelContentProps {
|
||||
cattyConfiguredProviders: ProviderConfig[];
|
||||
effectiveActiveProvider?: ProviderConfig;
|
||||
effectiveActiveModelId?: string;
|
||||
handleAgentProviderModelSelect: (providerId: string, modelId: string) => void;
|
||||
handleAgentProviderModelSelect: (providerId: string, modelId: string, contextWindow?: number) => void;
|
||||
selectedCattyThinking?: string;
|
||||
handleCattyThinkingSelect?: (level: string) => void;
|
||||
files: UploadedFile[];
|
||||
addFiles: (inputFiles: File[]) => Promise<void>;
|
||||
removeFile: (fileId: string) => void;
|
||||
@@ -140,6 +142,8 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
effectiveActiveProvider,
|
||||
effectiveActiveModelId,
|
||||
handleAgentProviderModelSelect,
|
||||
selectedCattyThinking,
|
||||
handleCattyThinkingSelect,
|
||||
files,
|
||||
addFiles,
|
||||
removeFile,
|
||||
@@ -350,6 +354,9 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
selectedModelId={selectedAgentModel}
|
||||
onModelSelect={handleAgentModelSelect}
|
||||
providerSwitcher={providerSwitcher}
|
||||
pickerScope={currentAgentId}
|
||||
thinkingLevel={currentAgentId === 'catty' ? selectedCattyThinking : undefined}
|
||||
onThinkingLevelChange={currentAgentId === 'catty' ? handleCattyThinkingSelect : undefined}
|
||||
files={files}
|
||||
onAddFiles={addFiles}
|
||||
onRemoveFile={removeFile}
|
||||
|
||||
@@ -102,6 +102,8 @@ const baseProps = (overrides: Partial<AIChatSidePanelProps> = {}): AIChatSidePan
|
||||
setAgentModel: () => undefined,
|
||||
agentProviderMap: {},
|
||||
setAgentProvider: () => undefined,
|
||||
agentThinkingMap: {},
|
||||
setAgentThinking: () => undefined,
|
||||
globalPermissionMode: 'auto',
|
||||
scopeType: 'terminal',
|
||||
scopeTargetId: 'terminal-1',
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
tryBeginSendForKey,
|
||||
} from './ai/draftSendGate';
|
||||
import { draftsByScopeEqualIgnoringComposerText, selectDraftForAgentSwitch } from '../application/state/aiDraftState';
|
||||
import { sanitizeContextWindow } from '../infrastructure/ai/contextCompaction';
|
||||
import {
|
||||
buildPromptWithTerminalSelectionAttachments,
|
||||
isTerminalSelectionAttachment,
|
||||
@@ -67,6 +68,9 @@ import {
|
||||
buildSdkRuntimeModelCacheKey,
|
||||
sdkRuntimeModelCache,
|
||||
generateId,
|
||||
agentModelPresetsShallowEqual,
|
||||
mergeFallbackThinkingLevels,
|
||||
normalizeStoredAgentModelSelection,
|
||||
normalizeSdkRuntimeModelPresets,
|
||||
shouldAdoptSdkCurrentModel,
|
||||
shouldLoadSdkRuntimeModels,
|
||||
@@ -271,6 +275,9 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
setAgentModel,
|
||||
agentProviderMap,
|
||||
setAgentProvider,
|
||||
agentThinkingMap,
|
||||
setAgentThinking,
|
||||
updateProvider,
|
||||
globalPermissionMode,
|
||||
setGlobalPermissionMode,
|
||||
commandBlocklist,
|
||||
@@ -760,12 +767,26 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
&& Boolean(effectiveActiveProvider)
|
||||
&& Boolean(effectiveActiveModelId.trim());
|
||||
|
||||
const providersRef = useRef(providers);
|
||||
providersRef.current = providers;
|
||||
|
||||
const handleAgentProviderModelSelect = useCallback(
|
||||
(providerId: string, modelId: string) => {
|
||||
(providerId: string, modelId: string, contextWindow?: number) => {
|
||||
setAgentProvider(currentAgentId, providerId);
|
||||
setAgentModel(currentAgentId, modelId);
|
||||
const sanitized = sanitizeContextWindow(contextWindow);
|
||||
if (!updateProvider || sanitized == null) return;
|
||||
const provider = providersRef.current.find((item) => item.id === providerId);
|
||||
if (!provider) return;
|
||||
if (provider.modelContextWindows?.[modelId] === sanitized) return;
|
||||
updateProvider(providerId, {
|
||||
modelContextWindows: {
|
||||
...(provider.modelContextWindows ?? {}),
|
||||
[modelId]: sanitized,
|
||||
},
|
||||
});
|
||||
},
|
||||
[currentAgentId, setAgentProvider, setAgentModel],
|
||||
[currentAgentId, setAgentProvider, setAgentModel, updateProvider],
|
||||
);
|
||||
|
||||
const providerDisplayName = effectiveActiveProvider?.name ?? '';
|
||||
@@ -833,12 +854,19 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const externalAgentsRef = useRef(externalAgents);
|
||||
externalAgentsRef.current = externalAgents;
|
||||
|
||||
const applySdkRuntimeModelCatalog = useCallback((
|
||||
agentId: string,
|
||||
catalog: SdkRuntimeModelCatalog,
|
||||
options: { adoptCurrentModel?: boolean } = {},
|
||||
) => {
|
||||
const runtimePresets = normalizeSdkRuntimeModelPresets(catalog.models, catalog.currentModelId);
|
||||
const agent = externalAgentsRef.current.find((item) => item.id === agentId);
|
||||
const runtimePresets = mergeFallbackThinkingLevels(
|
||||
normalizeSdkRuntimeModelPresets(catalog.models, catalog.currentModelId),
|
||||
getAgentModelPresets(agent?.command, getExternalAgentSdkBackend(agent)),
|
||||
);
|
||||
const storedModelId = agentModelMapRef.current[agentId];
|
||||
if (runtimePresets.length === 0) {
|
||||
setRuntimeAgentModelPresets((prev) => {
|
||||
@@ -847,10 +875,20 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
return rest;
|
||||
});
|
||||
} else {
|
||||
setRuntimeAgentModelPresets((prev) => ({
|
||||
...prev,
|
||||
[agentId]: runtimePresets,
|
||||
}));
|
||||
setRuntimeAgentModelPresets((prev) => (
|
||||
agentModelPresetsShallowEqual(prev[agentId], runtimePresets)
|
||||
? prev
|
||||
: { ...prev, [agentId]: runtimePresets }
|
||||
));
|
||||
}
|
||||
|
||||
const normalizedStoredModelId = normalizeStoredAgentModelSelection(
|
||||
storedModelId,
|
||||
runtimePresets,
|
||||
);
|
||||
if (normalizedStoredModelId && normalizedStoredModelId !== storedModelId) {
|
||||
setAgentModel(agentId, normalizedStoredModelId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -988,7 +1026,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
const selectedAgentModel = useMemo(() => {
|
||||
const stored = agentModelMap[currentAgentId];
|
||||
if (shouldUseStoredAgentModel(stored, agentModelPresets, currentAgentConfig)) {
|
||||
return stored;
|
||||
return normalizeStoredAgentModelSelection(stored, agentModelPresets) ?? stored;
|
||||
}
|
||||
if (agentModelPresets.length > 0) {
|
||||
// Cursor CLI login defaults to `auto` (subscription quota routing).
|
||||
@@ -1013,6 +1051,11 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
setAgentModel(currentAgentId, modelId);
|
||||
}, [currentAgentId, setAgentModel]);
|
||||
|
||||
const selectedCattyThinking = agentThinkingMap.catty;
|
||||
const handleCattyThinkingSelect = useCallback((level: string) => {
|
||||
setAgentThinking('catty', level);
|
||||
}, [setAgentThinking]);
|
||||
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
clearScopeDraft();
|
||||
@@ -1148,7 +1191,13 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
const catalog = await loadSdkRuntimeModelCatalog(runtimeTarget);
|
||||
if (catalog) {
|
||||
applySdkRuntimeModelCatalog(runtimeTarget.agentId, catalog, { adoptCurrentModel: true });
|
||||
const runtimePresets = normalizeSdkRuntimeModelPresets(catalog.models, catalog.currentModelId);
|
||||
const runtimePresets = mergeFallbackThinkingLevels(
|
||||
normalizeSdkRuntimeModelPresets(catalog.models, catalog.currentModelId),
|
||||
getAgentModelPresets(
|
||||
currentAgentConfig.command,
|
||||
getExternalAgentSdkBackend(currentAgentConfig),
|
||||
),
|
||||
);
|
||||
const storedModelId = agentModelMapRef.current[sendAgentId];
|
||||
if (
|
||||
catalog.currentModelId
|
||||
@@ -1156,9 +1205,12 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
) {
|
||||
sendSelectedAgentModel = catalog.currentModelId;
|
||||
} else if (shouldUseStoredAgentModel(storedModelId, runtimePresets)) {
|
||||
sendSelectedAgentModel = storedModelId ?? sendSelectedAgentModel;
|
||||
sendSelectedAgentModel = normalizeStoredAgentModelSelection(
|
||||
storedModelId,
|
||||
runtimePresets,
|
||||
) ?? storedModelId ?? sendSelectedAgentModel;
|
||||
} else if (runtimePresets[0]) {
|
||||
sendSelectedAgentModel = runtimePresets[0].id;
|
||||
sendSelectedAgentModel = resolveAgentModelSelection(runtimePresets[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1293,6 +1345,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
await sendToCattyAgent(sessionId, sendScopeKey, modelPrompt, abortController, currentSession ?? undefined, assistantMsgId, {
|
||||
activeProvider: sendActiveProvider,
|
||||
activeModelId: sendActiveModelId,
|
||||
reasoningEffort: selectedCattyThinking,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeLabel,
|
||||
@@ -1315,7 +1368,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isStreaming, activeProvider, effectiveActiveProvider, effectiveActiveModelId, scopeKey, currentAgentId,
|
||||
isStreaming, activeProvider, effectiveActiveProvider, effectiveActiveModelId, selectedCattyThinking, scopeKey, currentAgentId,
|
||||
activeModelId, externalAgents,
|
||||
createSession, addMessageToSession, updateMessageById, updateLastMessage,
|
||||
setStreamingForScope,
|
||||
@@ -1357,6 +1410,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
{
|
||||
activeProvider: effectiveActiveProvider,
|
||||
activeModelId: effectiveActiveModelId,
|
||||
reasoningEffort: selectedCattyThinking,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeLabel,
|
||||
@@ -1390,6 +1444,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
currentAgentId,
|
||||
effectiveActiveModelId,
|
||||
effectiveActiveProvider,
|
||||
selectedCattyThinking,
|
||||
globalPermissionMode,
|
||||
isStreaming,
|
||||
scopeKey,
|
||||
@@ -1642,6 +1697,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
effectiveActiveProvider={effectiveActiveProvider}
|
||||
effectiveActiveModelId={effectiveActiveModelId}
|
||||
handleAgentProviderModelSelect={handleAgentProviderModelSelect}
|
||||
selectedCattyThinking={selectedCattyThinking}
|
||||
handleCattyThinkingSelect={handleCattyThinkingSelect}
|
||||
files={files}
|
||||
addFiles={addFiles}
|
||||
removeFile={removeFile}
|
||||
@@ -1699,6 +1756,9 @@ const AI_CHAT_SIDE_PANEL_AI_STATE_KEYS = [
|
||||
'setAgentModel',
|
||||
'agentProviderMap',
|
||||
'setAgentProvider',
|
||||
'agentThinkingMap',
|
||||
'setAgentThinking',
|
||||
'updateProvider',
|
||||
'globalPermissionMode',
|
||||
'setGlobalPermissionMode',
|
||||
'commandBlocklist',
|
||||
|
||||
@@ -68,6 +68,9 @@ export interface AIChatSidePanelProps {
|
||||
setAgentModel: (agentId: string, modelId: string) => void;
|
||||
agentProviderMap: Record<string, string>;
|
||||
setAgentProvider: (agentId: string, providerId: string) => void;
|
||||
agentThinkingMap: Record<string, string>;
|
||||
setAgentThinking: (agentId: string, thinkingLevel: string) => void;
|
||||
updateProvider?: (id: string, updates: Partial<ProviderConfig>) => void;
|
||||
|
||||
// Safety
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
|
||||
@@ -4,8 +4,12 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildCursorListModelsAgentEnv,
|
||||
buildSdkRuntimeModelCacheKey,
|
||||
canonicalizeEffortEncodedModelId,
|
||||
createSdkRuntimeModelCache,
|
||||
agentModelPresetsShallowEqual,
|
||||
mergeFallbackThinkingLevels,
|
||||
modelPresetsContainId,
|
||||
normalizeStoredAgentModelSelection,
|
||||
normalizeSdkRuntimeModelPresets,
|
||||
shouldAdoptSdkCurrentModel,
|
||||
shouldLoadSdkRuntimeModels,
|
||||
@@ -66,6 +70,46 @@ test('Cursor auth mode changes invalidate the SDK model cache key', () => {
|
||||
assert.notEqual(apiKey, cliLogin);
|
||||
});
|
||||
|
||||
test('canonicalizeEffortEncodedModelId collapses Cursor query-string effort', () => {
|
||||
assert.equal(canonicalizeEffortEncodedModelId('gpt-5?effort=low'), 'gpt-5/low');
|
||||
assert.equal(canonicalizeEffortEncodedModelId('gpt-5/high'), 'gpt-5/high');
|
||||
assert.equal(canonicalizeEffortEncodedModelId('gpt-5?effort=low&mode=fast'), 'gpt-5?effort=low&mode=fast');
|
||||
});
|
||||
|
||||
test('mergeFallbackThinkingLevels fills missing runtime effort catalogs', () => {
|
||||
const merged = mergeFallbackThinkingLevels(
|
||||
[{ id: 'gpt-5.5', name: 'GPT-5.5' }, { id: 'auto', name: 'Auto' }],
|
||||
[{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'medium', 'high'], defaultThinkingLevel: 'medium' }],
|
||||
);
|
||||
assert.deepEqual(merged[0]?.thinkingLevels, ['low', 'medium', 'high']);
|
||||
assert.equal(merged[0]?.defaultThinkingLevel, 'medium');
|
||||
assert.equal(merged[1]?.thinkingLevels, undefined);
|
||||
const partial = mergeFallbackThinkingLevels(
|
||||
[{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low'] }],
|
||||
[{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'medium', 'high'], defaultThinkingLevel: 'medium' }],
|
||||
);
|
||||
assert.deepEqual(partial[0]?.thinkingLevels, ['low']);
|
||||
assert.deepEqual(mergeFallbackThinkingLevels([], [{ id: 'gpt-5.5', name: 'GPT-5.5' }]), []);
|
||||
const alreadyFilled = [{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low'] }];
|
||||
assert.equal(
|
||||
mergeFallbackThinkingLevels(
|
||||
alreadyFilled,
|
||||
[{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'medium', 'high'] }],
|
||||
),
|
||||
alreadyFilled,
|
||||
);
|
||||
});
|
||||
|
||||
test('agentModelPresetsShallowEqual ignores array identity when contents match', () => {
|
||||
const left = [{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'high'] }];
|
||||
const right = [{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'high'] }];
|
||||
assert.equal(agentModelPresetsShallowEqual(left, right), true);
|
||||
assert.equal(
|
||||
agentModelPresetsShallowEqual(left, [{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['high'] }]),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('modelPresetsContainId matches plain and thinking-level model ids', () => {
|
||||
const presets: AgentModelPreset[] = [
|
||||
{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'high'] },
|
||||
@@ -73,6 +117,7 @@ test('modelPresetsContainId matches plain and thinking-level model ids', () => {
|
||||
];
|
||||
|
||||
assert.equal(modelPresetsContainId(presets, 'gpt-5.5/high'), true);
|
||||
assert.equal(modelPresetsContainId(presets, 'gpt-5.5?effort=high'), true);
|
||||
assert.equal(modelPresetsContainId(presets, 'claude-sonnet'), true);
|
||||
assert.equal(modelPresetsContainId(presets, 'gpt-5.5/medium'), false);
|
||||
});
|
||||
@@ -125,6 +170,55 @@ test('shouldAdoptSdkCurrentModel keeps SDK defaults when no runtime list is retu
|
||||
assert.equal(shouldAdoptSdkCurrentModel(null, undefined, []), false);
|
||||
});
|
||||
|
||||
test('normalizeStoredAgentModelSelection rewrites Cursor query effort but not extra params', () => {
|
||||
const presets: AgentModelPreset[] = [{
|
||||
id: 'gpt-5.5',
|
||||
name: 'GPT-5.5',
|
||||
thinkingLevels: ['low', 'medium', 'high'],
|
||||
defaultThinkingLevel: 'medium',
|
||||
}];
|
||||
assert.equal(normalizeStoredAgentModelSelection('gpt-5.5?effort=low', presets), 'gpt-5.5/low');
|
||||
assert.equal(normalizeStoredAgentModelSelection('gpt-5.5/high', presets), 'gpt-5.5/high');
|
||||
assert.equal(
|
||||
normalizeStoredAgentModelSelection('gpt-5.5?effort=low&mode=fast', presets),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeStoredAgentModelSelection keeps bare CodeBuddy ids unsuffixed', () => {
|
||||
const presets: AgentModelPreset[] = [{
|
||||
id: 'glm-5.1',
|
||||
name: 'GLM 5.1',
|
||||
thinkingLevels: ['low', 'medium', 'high', 'xhigh'],
|
||||
defaultThinkingLevel: 'medium',
|
||||
encodeDefaultThinking: false,
|
||||
}];
|
||||
assert.equal(normalizeStoredAgentModelSelection('glm-5.1', presets), 'glm-5.1');
|
||||
assert.equal(normalizeStoredAgentModelSelection('glm-5.1/high', presets), 'glm-5.1/high');
|
||||
});
|
||||
|
||||
test('legacy plain model selections keep their model and gain its default reasoning effort', () => {
|
||||
const presets: AgentModelPreset[] = [{
|
||||
id: 'grok-4.5',
|
||||
name: 'Grok 4.5',
|
||||
thinkingLevels: ['high', 'medium', 'low'],
|
||||
defaultThinkingLevel: 'high',
|
||||
}, {
|
||||
id: 'grok-4.6',
|
||||
name: 'Grok 4.6',
|
||||
thinkingLevels: ['xhigh', 'high', 'medium', 'low'],
|
||||
defaultThinkingLevel: 'high',
|
||||
}];
|
||||
|
||||
assert.equal(modelPresetsContainId(presets, 'grok-4.5'), true);
|
||||
assert.equal(normalizeStoredAgentModelSelection('grok-4.5', presets), 'grok-4.5/high');
|
||||
assert.equal(normalizeStoredAgentModelSelection('grok-4.5/medium', presets), 'grok-4.5/medium');
|
||||
assert.equal(
|
||||
shouldAdoptSdkCurrentModel('grok-4.6/high', 'grok-4.5', presets),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeSdkRuntimeModelPresets preserves SDK current model without a catalog', () => {
|
||||
assert.deepEqual(normalizeSdkRuntimeModelPresets([], 'custom/provider-model'), [
|
||||
{ id: 'custom/provider-model', name: 'custom/provider-model' },
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { AgentModelPreset, ExternalAgentConfig } from '../infrastructure/ai/types';
|
||||
import {
|
||||
resolveAgentModelSelection,
|
||||
type AgentModelPreset,
|
||||
type ExternalAgentConfig,
|
||||
} from '../infrastructure/ai/types';
|
||||
import { getExternalAgentSdkBackend } from '../infrastructure/ai/managedAgents';
|
||||
import { canonicalizeEffortEncodedModelId } from '../infrastructure/ai/composerPicker';
|
||||
|
||||
export { canonicalizeEffortEncodedModelId };
|
||||
|
||||
export type SdkRuntimeModelCatalog = {
|
||||
currentModelId: string | null;
|
||||
@@ -175,17 +182,75 @@ export function createSdkRuntimeModelCache(options: SdkRuntimeModelCacheOptions
|
||||
|
||||
export const sdkRuntimeModelCache = createSdkRuntimeModelCache();
|
||||
|
||||
export function mergeFallbackThinkingLevels(
|
||||
runtime: AgentModelPreset[],
|
||||
fallbacks: AgentModelPreset[],
|
||||
): AgentModelPreset[] {
|
||||
if (runtime.length === 0 || fallbacks.length === 0) return runtime;
|
||||
const byId = new Map(fallbacks.map((preset) => [preset.id, preset]));
|
||||
let changed = false;
|
||||
const next = runtime.map((preset) => {
|
||||
if (preset.thinkingLevels?.length) return preset;
|
||||
const fallback = byId.get(preset.id);
|
||||
if (!fallback?.thinkingLevels?.length) return preset;
|
||||
changed = true;
|
||||
return {
|
||||
...preset,
|
||||
thinkingLevels: [...fallback.thinkingLevels],
|
||||
...(fallback.defaultThinkingLevel
|
||||
? { defaultThinkingLevel: fallback.defaultThinkingLevel }
|
||||
: {}),
|
||||
...(fallback.encodeDefaultThinking === false
|
||||
? { encodeDefaultThinking: false }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
return changed ? next : runtime;
|
||||
}
|
||||
|
||||
export function agentModelPresetsShallowEqual(
|
||||
left: AgentModelPreset[] | undefined,
|
||||
right: AgentModelPreset[] | undefined,
|
||||
): boolean {
|
||||
if (left === right) return true;
|
||||
if (!left || !right || left.length !== right.length) return false;
|
||||
return left.every((preset, index) => {
|
||||
const other = right[index];
|
||||
return (
|
||||
preset.id === other.id
|
||||
&& preset.name === other.name
|
||||
&& preset.defaultThinkingLevel === other.defaultThinkingLevel
|
||||
&& (preset.thinkingLevels ?? []).join('\0') === (other.thinkingLevels ?? []).join('\0')
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function modelPresetMatchesId(preset: AgentModelPreset, modelId: string): boolean {
|
||||
const canonical = canonicalizeEffortEncodedModelId(modelId);
|
||||
if (preset.thinkingLevels?.length) {
|
||||
return preset.thinkingLevels.some((level) => `${preset.id}/${level}` === modelId);
|
||||
return preset.id === canonical
|
||||
|| preset.thinkingLevels.some((level) => `${preset.id}/${level}` === canonical);
|
||||
}
|
||||
return preset.id === modelId;
|
||||
return preset.id === canonical;
|
||||
}
|
||||
|
||||
export function modelPresetsContainId(presets: AgentModelPreset[], modelId: string): boolean {
|
||||
return presets.some((preset) => modelPresetMatchesId(preset, modelId));
|
||||
}
|
||||
|
||||
export function normalizeStoredAgentModelSelection(
|
||||
storedModelId: string | null | undefined,
|
||||
presets: AgentModelPreset[],
|
||||
): string | undefined {
|
||||
if (!storedModelId) return undefined;
|
||||
const canonical = canonicalizeEffortEncodedModelId(storedModelId);
|
||||
const preset = presets.find((candidate) => modelPresetMatchesId(candidate, canonical));
|
||||
if (!preset) return undefined;
|
||||
return canonical === preset.id
|
||||
? resolveAgentModelSelection(preset)
|
||||
: canonical;
|
||||
}
|
||||
|
||||
export function shouldLoadSdkRuntimeModels(agent?: ExternalAgentConfig): boolean {
|
||||
const sdkBackend = getExternalAgentSdkBackend(agent);
|
||||
return (sdkBackend === 'codex' && agent?.codexRuntime === 'app-server')
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,146 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
shouldDeferExternalActionWhileAppLocked,
|
||||
shouldNotifyAppLockGateRendererReady,
|
||||
shouldRenderAppLockGateChildren,
|
||||
} from "./AppLockGate.tsx";
|
||||
|
||||
test("shouldRenderAppLockGateChildren withholds startup-locked route children until unlock", () => {
|
||||
assert.equal(
|
||||
shouldRenderAppLockGateChildren({
|
||||
initialized: true,
|
||||
locked: true,
|
||||
lockReason: "startup",
|
||||
hasRenderedChildren: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRenderAppLockGateChildren({
|
||||
initialized: true,
|
||||
locked: false,
|
||||
lockReason: null,
|
||||
hasRenderedChildren: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldRenderAppLockGateChildren force-renders terminal popup children before init", () => {
|
||||
assert.equal(
|
||||
shouldRenderAppLockGateChildren({
|
||||
initialized: false,
|
||||
locked: true,
|
||||
lockReason: "startup",
|
||||
hasRenderedChildren: false,
|
||||
forceRenderChildren: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldRenderAppLockGateChildren withholds first mount for idle and background locks", () => {
|
||||
assert.equal(
|
||||
shouldRenderAppLockGateChildren({
|
||||
initialized: true,
|
||||
locked: true,
|
||||
lockReason: "idle",
|
||||
hasRenderedChildren: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRenderAppLockGateChildren({
|
||||
initialized: true,
|
||||
locked: true,
|
||||
lockReason: "background",
|
||||
hasRenderedChildren: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRenderAppLockGateChildren({
|
||||
initialized: true,
|
||||
locked: true,
|
||||
lockReason: "manual",
|
||||
hasRenderedChildren: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldRenderAppLockGateChildren withholds children before runtime initialization", () => {
|
||||
assert.equal(
|
||||
shouldRenderAppLockGateChildren({
|
||||
initialized: false,
|
||||
locked: false,
|
||||
lockReason: null,
|
||||
hasRenderedChildren: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldRenderAppLockGateChildren keeps mounted children for manual and idle locks", () => {
|
||||
assert.equal(
|
||||
shouldRenderAppLockGateChildren({
|
||||
initialized: true,
|
||||
locked: true,
|
||||
lockReason: "manual",
|
||||
hasRenderedChildren: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRenderAppLockGateChildren({
|
||||
initialized: true,
|
||||
locked: true,
|
||||
lockReason: "idle",
|
||||
hasRenderedChildren: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldRenderAppLockGateChildren keeps existing children mounted for reopen locks", () => {
|
||||
assert.equal(
|
||||
shouldRenderAppLockGateChildren({
|
||||
initialized: true,
|
||||
locked: true,
|
||||
lockReason: "startup",
|
||||
hasRenderedChildren: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldNotifyAppLockGateRendererReady waits until locked children can mount", () => {
|
||||
assert.equal(
|
||||
shouldNotifyAppLockGateRendererReady({
|
||||
notifyRendererReady: true,
|
||||
renderChildren: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldNotifyAppLockGateRendererReady({
|
||||
notifyRendererReady: true,
|
||||
renderChildren: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldNotifyAppLockGateRendererReady({
|
||||
notifyRendererReady: false,
|
||||
renderChildren: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldDeferExternalActionWhileAppLocked queues actions only while locked", () => {
|
||||
assert.equal(shouldDeferExternalActionWhileAppLocked({ locked: true }), true);
|
||||
assert.equal(shouldDeferExternalActionWhileAppLocked({ locked: false }), false);
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { I18nProvider } from '../application/i18n/I18nProvider';
|
||||
import { useAppLockBridge } from '../application/state/useAppLockBridge';
|
||||
import { type AppLockReason, useAppLockState } from '../application/state/useAppLockState';
|
||||
import { useSettingsState } from '../application/state/useSettingsState';
|
||||
import { ToastProvider } from './ui/toast';
|
||||
import { TooltipProvider } from './ui/tooltip';
|
||||
import { AppLockOverlay } from './AppLockOverlay';
|
||||
|
||||
type SettingsState = ReturnType<typeof useSettingsState>;
|
||||
type AppLockState = ReturnType<typeof useAppLockState>;
|
||||
|
||||
export interface AppLockGateRenderContext {
|
||||
settings: SettingsState;
|
||||
appLock: AppLockState;
|
||||
}
|
||||
|
||||
interface AppLockGateProps {
|
||||
children: (ctx: AppLockGateRenderContext) => React.ReactNode;
|
||||
notifyRendererReady?: boolean;
|
||||
/**
|
||||
* Mount route children immediately (even before app-lock init / while locked).
|
||||
* Terminal popups need this so their one-shot config IPC listeners register
|
||||
* before main's post-loadURL send is only held in preload pending state —
|
||||
* AppLockGate otherwise withholds children until async lock state resolves.
|
||||
*/
|
||||
forceRenderChildren?: boolean;
|
||||
settingsOptions?: Parameters<typeof useSettingsState>[0];
|
||||
}
|
||||
|
||||
interface AppLockGateDeps {
|
||||
useSettingsState: typeof useSettingsState;
|
||||
useAppLockState: typeof useAppLockState;
|
||||
useAppLockBridge: typeof useAppLockBridge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Withhold first content mount while the gate is locked for any reason
|
||||
* (startup / idle / background / manual). A new renderer that reports ready
|
||||
* before unlock would let main deliver ssh:// deep links into App handlers and
|
||||
* start connections behind the lock overlay. Once children have mounted (e.g.
|
||||
* idle re-lock after use), keep them mounted under the overlay.
|
||||
*/
|
||||
export function shouldRenderAppLockGateChildren(input: {
|
||||
initialized: boolean;
|
||||
locked: boolean;
|
||||
lockReason: AppLockReason | null;
|
||||
hasRenderedChildren: boolean;
|
||||
forceRenderChildren?: boolean;
|
||||
}): boolean {
|
||||
if (input.forceRenderChildren) return true;
|
||||
if (!input.initialized && !input.hasRenderedChildren) return false;
|
||||
if (input.locked && !input.hasRenderedChildren) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-link delivery waits on rendererReady. Only mark the renderer ready once
|
||||
* app children are allowed to mount, so unlock (not the 15s readiness timeout)
|
||||
* is what unblocks pending ssh:// links after a locked first paint.
|
||||
*/
|
||||
export function shouldNotifyAppLockGateRendererReady(input: {
|
||||
notifyRendererReady: boolean;
|
||||
renderChildren: boolean;
|
||||
}): boolean {
|
||||
return input.notifyRendererReady && input.renderChildren;
|
||||
}
|
||||
|
||||
/** Queue external actions (e.g. ssh:// deep links) while the app lock is held. */
|
||||
export function shouldDeferExternalActionWhileAppLocked(input: {
|
||||
locked: boolean;
|
||||
}): boolean {
|
||||
return input.locked === true;
|
||||
}
|
||||
|
||||
export function createAppLockGate(deps: AppLockGateDeps): React.FC<AppLockGateProps> {
|
||||
const AppLockGateImpl: React.FC<AppLockGateProps> = ({
|
||||
children,
|
||||
notifyRendererReady = true,
|
||||
forceRenderChildren = false,
|
||||
settingsOptions,
|
||||
}) => {
|
||||
const settings = deps.useSettingsState(settingsOptions);
|
||||
const appLock = deps.useAppLockState(settings.appLockSettings);
|
||||
const {
|
||||
notifyRendererReady: notifyAppLockRendererReady,
|
||||
onAppLockReopen,
|
||||
} = deps.useAppLockBridge();
|
||||
const hasRenderedChildrenRef = useRef(false);
|
||||
const [reopenSignal, setReopenSignal] = useState(0);
|
||||
const renderChildren = shouldRenderAppLockGateChildren({
|
||||
initialized: appLock.initialized,
|
||||
locked: appLock.locked,
|
||||
lockReason: appLock.lockReason,
|
||||
hasRenderedChildren: hasRenderedChildrenRef.current,
|
||||
forceRenderChildren,
|
||||
});
|
||||
if (renderChildren) {
|
||||
hasRenderedChildrenRef.current = true;
|
||||
}
|
||||
const shouldNotifyRendererReady = shouldNotifyAppLockGateRendererReady({
|
||||
notifyRendererReady,
|
||||
renderChildren,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const splash = document.getElementById('splash');
|
||||
if (splash) {
|
||||
splash.classList.add('fade-out');
|
||||
setTimeout(() => splash.remove(), 200);
|
||||
}
|
||||
if (shouldNotifyRendererReady) {
|
||||
notifyAppLockRendererReady();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [notifyAppLockRendererReady, shouldNotifyRendererReady]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onAppLockReopen(() => {
|
||||
setReopenSignal((current) => current + 1);
|
||||
void appLock.resync?.();
|
||||
});
|
||||
return () => unsubscribe?.();
|
||||
}, [appLock, onAppLockReopen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleVisibilityOrFocus = () => {
|
||||
void appLock.resync?.();
|
||||
};
|
||||
window.addEventListener('focus', handleVisibilityOrFocus);
|
||||
document.addEventListener('visibilitychange', handleVisibilityOrFocus);
|
||||
return () => {
|
||||
window.removeEventListener('focus', handleVisibilityOrFocus);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityOrFocus);
|
||||
};
|
||||
}, [appLock]);
|
||||
|
||||
// When re-locked after first mount, children stay mounted under the overlay.
|
||||
// Mark them inert so pointer/keyboard/AT cannot reach underlying controls
|
||||
// (sessions, editors, side panels) while the lock screen is up.
|
||||
const lockedBackground = appLock.locked === true;
|
||||
|
||||
return (
|
||||
<I18nProvider locale={settings.uiLanguage}>
|
||||
<ToastProvider>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
{renderChildren ? (
|
||||
<div
|
||||
// React 19 supports the HTML inert attribute; when true, the
|
||||
// subtree is non-interactive and removed from sequential focus.
|
||||
inert={lockedBackground ? true : undefined}
|
||||
aria-hidden={lockedBackground || undefined}
|
||||
className={lockedBackground ? 'pointer-events-none' : undefined}
|
||||
data-app-lock-background={lockedBackground ? 'locked' : 'unlocked'}
|
||||
>
|
||||
{children({ settings, appLock })}
|
||||
</div>
|
||||
) : null}
|
||||
<AppLockOverlay
|
||||
locked={appLock.locked}
|
||||
reason={appLock.lockReason}
|
||||
onUnlock={appLock.unlock}
|
||||
systemUnlockStatus={appLock.systemUnlockStatus}
|
||||
onSystemUnlock={appLock.unlockWithSystemAuth}
|
||||
onResetAppLock={appLock.reset}
|
||||
autoPromptSystemUnlock={settings.appLockSettings.systemUnlockAutoPromptEnabled}
|
||||
reopenSignal={reopenSignal}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</ToastProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
};
|
||||
|
||||
return AppLockGateImpl;
|
||||
}
|
||||
|
||||
export const AppLockGate = createAppLockGate({
|
||||
useSettingsState,
|
||||
useAppLockState,
|
||||
useAppLockBridge,
|
||||
});
|
||||
@@ -0,0 +1,857 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
|
||||
import { APP_LOCK_AUTO_PROMPT_DELAY_MS, AppLockOverlay } from "./AppLockOverlay.tsx";
|
||||
import {
|
||||
createDomRenderer,
|
||||
dispatchDomEvent,
|
||||
flushEffects,
|
||||
installDomEnvironment,
|
||||
runWithAct,
|
||||
} from "./test-support/renderReactDom.tsx";
|
||||
|
||||
async function waitForAutoPrompt(dom: ReturnType<typeof installDomEnvironment>) {
|
||||
await runWithAct(async () => {
|
||||
await new Promise((resolve) => dom.window.setTimeout(resolve, APP_LOCK_AUTO_PROMPT_DELAY_MS + 50));
|
||||
});
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
}
|
||||
|
||||
test("AppLockOverlay shows incorrect-password error and clears it after editing", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
const unlockAttempts: string[] = [];
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
onUnlock: async (password) => {
|
||||
unlockAttempts.push(password);
|
||||
return { ok: false, error: "incorrect" as const };
|
||||
},
|
||||
onResetAppLock: async () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
|
||||
const input = dom.document.getElementById("app-lock-password") as HTMLInputElement | null;
|
||||
const form = dom.document.querySelector("form");
|
||||
const passwordLabel = dom.document.querySelector('label[for="app-lock-password"]');
|
||||
const lockIcon = dom.document.querySelector('[data-testid="app-lock-password-lock-icon"]');
|
||||
assert.ok(input);
|
||||
assert.ok(form);
|
||||
assert.ok(passwordLabel);
|
||||
assert.ok(lockIcon);
|
||||
assert.equal(input.getAttribute("placeholder"), "Enter password to unlock");
|
||||
assert.match(passwordLabel.getAttribute("class") ?? "", /sr-only/);
|
||||
assert.equal(lockIcon.getAttribute("aria-hidden"), "true");
|
||||
assert.doesNotMatch(lockIcon.getAttribute("class") ?? "", /border-l/);
|
||||
|
||||
const setInputValue = Object.getOwnPropertyDescriptor(
|
||||
dom.window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
assert.ok(setInputValue);
|
||||
setInputValue.call(input, "wrong");
|
||||
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
|
||||
await dispatchDomEvent(form, new dom.window.Event("submit", { bubbles: true, cancelable: true }));
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
assert.deepEqual(unlockAttempts, ["wrong"]);
|
||||
assert.match(dom.document.body.textContent ?? "", /Incorrect lock password/i);
|
||||
|
||||
setInputValue.call(input, "wrong-again");
|
||||
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
|
||||
await flushEffects();
|
||||
|
||||
assert.doesNotMatch(dom.document.body.textContent ?? "", /Incorrect lock password/i);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay reveals reset action after clicking Netcatty logo five times", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
const resetAttempts: string[] = [];
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
onUnlock: async () => ({ ok: false, error: "incorrect" as const }),
|
||||
onResetAppLock: async (currentPassword) => {
|
||||
resetAttempts.push(currentPassword);
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
|
||||
const input = dom.document.getElementById("app-lock-password") as HTMLInputElement | null;
|
||||
assert.ok(input);
|
||||
const setInputValue = Object.getOwnPropertyDescriptor(
|
||||
dom.window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
assert.ok(setInputValue);
|
||||
setInputValue.call(input, "secret");
|
||||
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
|
||||
|
||||
assert.doesNotMatch(dom.document.body.textContent ?? "", /Reset App Lock/i);
|
||||
const logoButton = dom.document.querySelector("[data-testid='app-lock-logo-easter-egg']");
|
||||
assert.ok(logoButton);
|
||||
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
}
|
||||
|
||||
assert.match(dom.document.body.textContent ?? "", /Reset App Lock/i);
|
||||
|
||||
const resetButton = [...dom.document.querySelectorAll("button")]
|
||||
.find((button) => /Reset App Lock/i.test(button.textContent ?? ""));
|
||||
assert.ok(resetButton);
|
||||
await dispatchDomEvent(resetButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
assert.deepEqual(resetAttempts, ["secret"]);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay only reveals reset after five quick logo clicks", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
onUnlock: async () => ({ ok: false, error: "incorrect" as const }),
|
||||
onResetAppLock: async () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
|
||||
const logoButton = dom.document.querySelector("[data-testid='app-lock-logo-easter-egg']");
|
||||
assert.ok(logoButton);
|
||||
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
await new Promise((resolve) => dom.window.setTimeout(resolve, 1600));
|
||||
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
|
||||
assert.doesNotMatch(dom.document.body.textContent ?? "", /Reset App Lock/i);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay reset controls do not submit the unlock form", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let unlockCount = 0;
|
||||
const resetAttempts: string[] = [];
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
onUnlock: async () => {
|
||||
unlockCount += 1;
|
||||
return { ok: false, error: "incorrect" as const };
|
||||
},
|
||||
onResetAppLock: async (currentPassword) => {
|
||||
resetAttempts.push(currentPassword);
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
|
||||
const input = dom.document.getElementById("app-lock-password") as HTMLInputElement | null;
|
||||
assert.ok(input);
|
||||
const setInputValue = Object.getOwnPropertyDescriptor(
|
||||
dom.window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
assert.ok(setInputValue);
|
||||
setInputValue.call(input, "secret");
|
||||
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
|
||||
|
||||
assert.doesNotMatch(dom.document.body.textContent ?? "", /forgot password/i);
|
||||
const logoButton = dom.document.querySelector("[data-testid='app-lock-logo-easter-egg']");
|
||||
assert.ok(logoButton);
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
}
|
||||
await flushEffects();
|
||||
|
||||
const cancelButton = [...dom.document.querySelectorAll("button")]
|
||||
.find((button) => /Cancel/i.test(button.textContent ?? ""));
|
||||
assert.ok(cancelButton);
|
||||
await dispatchDomEvent(cancelButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
assert.equal(unlockCount, 0);
|
||||
assert.deepEqual(resetAttempts, []);
|
||||
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
}
|
||||
await flushEffects();
|
||||
const resetButton = [...dom.document.querySelectorAll("button")]
|
||||
.find((button) => /Reset App Lock/i.test(button.textContent ?? ""));
|
||||
assert.ok(resetButton);
|
||||
await dispatchDomEvent(resetButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
assert.equal(unlockCount, 0);
|
||||
assert.deepEqual(resetAttempts, ["secret"]);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay renders platform-specific system unlock button", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let systemUnlockCount = 0;
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
onUnlock: async () => ({ ok: false, error: "incorrect" as const }),
|
||||
systemUnlockStatus: {
|
||||
supported: true,
|
||||
available: true,
|
||||
enabled: true,
|
||||
platform: "win32",
|
||||
label: "Windows Hello",
|
||||
reason: null,
|
||||
},
|
||||
onSystemUnlock: async () => {
|
||||
systemUnlockCount += 1;
|
||||
return { ok: true as const };
|
||||
},
|
||||
onResetAppLock: async () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
assert.equal(systemUnlockCount, 0);
|
||||
assert.equal(dom.document.getElementById("app-lock-password"), null);
|
||||
assert.ok(dom.document.querySelector('[data-testid="app-lock-system-unlock-windows-icon"]'));
|
||||
|
||||
const button = [...dom.document.querySelectorAll("button")]
|
||||
.find((candidate) => /Unlock with Windows Hello/i.test(candidate.textContent ?? ""));
|
||||
assert.ok(button);
|
||||
await dispatchDomEvent(button, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
assert.equal(systemUnlockCount, 1);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay localizes the system unlock button label", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "zh-CN" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
onUnlock: async () => ({ ok: false, error: "incorrect" as const }),
|
||||
systemUnlockStatus: {
|
||||
supported: true,
|
||||
available: true,
|
||||
enabled: true,
|
||||
platform: "darwin",
|
||||
label: "Touch ID",
|
||||
reason: null,
|
||||
},
|
||||
onSystemUnlock: async () => ({ ok: true as const }),
|
||||
onResetAppLock: async () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
|
||||
assert.match(dom.document.body.textContent ?? "", /使用 Touch ID 解锁/i);
|
||||
assert.doesNotMatch(dom.document.body.textContent ?? "", /Unlock with Touch ID/i);
|
||||
assert.equal(dom.document.getElementById("app-lock-password"), null);
|
||||
assert.ok(dom.document.querySelector('[data-testid="app-lock-system-unlock-touch-id-icon"]'));
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay does not automatically request system unlock by default", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let systemUnlockCount = 0;
|
||||
|
||||
try {
|
||||
const props = {
|
||||
locked: true,
|
||||
reason: "manual" as const,
|
||||
onUnlock: async () => ({ ok: false as const, error: "incorrect" as const }),
|
||||
systemUnlockStatus: {
|
||||
supported: true,
|
||||
available: true,
|
||||
enabled: true,
|
||||
platform: "darwin" as const,
|
||||
label: "Touch ID" as const,
|
||||
reason: null,
|
||||
},
|
||||
onSystemUnlock: async () => {
|
||||
systemUnlockCount += 1;
|
||||
return { ok: true as const };
|
||||
},
|
||||
onResetAppLock: async () => {},
|
||||
};
|
||||
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, props),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
assert.equal(systemUnlockCount, 0);
|
||||
assert.equal(dom.document.getElementById("app-lock-password"), null);
|
||||
assert.match(dom.document.body.textContent ?? "", /Use password instead/i);
|
||||
|
||||
const button = [...dom.document.querySelectorAll("button")]
|
||||
.find((candidate) => /Unlock with Touch ID/i.test(candidate.textContent ?? ""));
|
||||
assert.ok(button);
|
||||
await dispatchDomEvent(button, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
assert.equal(systemUnlockCount, 1);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay reveals password fallback and cancels a delayed automatic prompt", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let systemUnlockCount = 0;
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
autoPromptSystemUnlock: true,
|
||||
onUnlock: async () => ({ ok: false as const, error: "incorrect" as const }),
|
||||
systemUnlockStatus: {
|
||||
supported: true,
|
||||
available: true,
|
||||
enabled: true,
|
||||
platform: "darwin" as const,
|
||||
label: "Touch ID" as const,
|
||||
reason: null,
|
||||
},
|
||||
onSystemUnlock: async () => {
|
||||
systemUnlockCount += 1;
|
||||
return { ok: true as const };
|
||||
},
|
||||
onResetAppLock: async () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
|
||||
assert.equal(dom.document.getElementById("app-lock-password"), null);
|
||||
assert.match(dom.document.body.textContent ?? "", /Preparing Touch ID/i);
|
||||
const usePasswordButton = [...dom.document.querySelectorAll("button")]
|
||||
.find((candidate) => /Use password instead/i.test(candidate.textContent ?? ""));
|
||||
assert.ok(usePasswordButton);
|
||||
await dispatchDomEvent(usePasswordButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
|
||||
assert.ok(dom.document.getElementById("app-lock-password"));
|
||||
await waitForAutoPrompt(dom);
|
||||
assert.equal(systemUnlockCount, 0);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay shows animated feedback while system unlock is in flight", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let resolveSystemUnlock: ((result: { ok: true }) => void) | null = null;
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
onUnlock: async () => ({ ok: false as const, error: "incorrect" as const }),
|
||||
systemUnlockStatus: {
|
||||
supported: true,
|
||||
available: true,
|
||||
enabled: true,
|
||||
platform: "darwin" as const,
|
||||
label: "Touch ID" as const,
|
||||
reason: null,
|
||||
},
|
||||
onSystemUnlock: async () => new Promise<{ ok: true }>((resolve) => {
|
||||
resolveSystemUnlock = resolve;
|
||||
}),
|
||||
onResetAppLock: async () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
|
||||
const systemButton = [...dom.document.querySelectorAll("button")]
|
||||
.find((candidate) => /Unlock with Touch ID/i.test(candidate.textContent ?? ""));
|
||||
assert.ok(systemButton);
|
||||
await dispatchDomEvent(systemButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
|
||||
assert.match(dom.document.body.textContent ?? "", /Waiting for Touch ID/i);
|
||||
assert.ok(dom.document.querySelector('[data-testid="app-lock-system-unlock-loading"]'));
|
||||
assert.ok(resolveSystemUnlock);
|
||||
await runWithAct(async () => {
|
||||
resolveSystemUnlock?.({ ok: true });
|
||||
});
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay automatically requests system unlock once when auto prompt is enabled", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let systemUnlockCount = 0;
|
||||
|
||||
try {
|
||||
const props = {
|
||||
locked: true,
|
||||
reason: "manual" as const,
|
||||
onUnlock: async () => ({ ok: false as const, error: "incorrect" as const }),
|
||||
systemUnlockStatus: {
|
||||
supported: true,
|
||||
available: true,
|
||||
enabled: true,
|
||||
platform: "darwin" as const,
|
||||
label: "Touch ID" as const,
|
||||
reason: null,
|
||||
},
|
||||
autoPromptSystemUnlock: true,
|
||||
onSystemUnlock: async () => {
|
||||
systemUnlockCount += 1;
|
||||
return { ok: true as const };
|
||||
},
|
||||
onResetAppLock: async () => {},
|
||||
};
|
||||
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, props),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
assert.equal(systemUnlockCount, 0);
|
||||
assert.match(dom.document.body.textContent ?? "", /Preparing Touch ID/i);
|
||||
assert.ok(dom.document.querySelector('[data-testid="app-lock-system-unlock-loading"]'));
|
||||
|
||||
await waitForAutoPrompt(dom);
|
||||
assert.equal(systemUnlockCount, 1);
|
||||
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, props),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
await waitForAutoPrompt(dom);
|
||||
|
||||
assert.equal(systemUnlockCount, 1);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay waits until the document is visible before auto system unlock", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let systemUnlockCount = 0;
|
||||
|
||||
try {
|
||||
Object.defineProperty(dom.document, "visibilityState", {
|
||||
configurable: true,
|
||||
value: "hidden",
|
||||
});
|
||||
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
onUnlock: async () => ({ ok: false as const, error: "incorrect" as const }),
|
||||
systemUnlockStatus: {
|
||||
supported: true,
|
||||
available: true,
|
||||
enabled: true,
|
||||
platform: "darwin" as const,
|
||||
label: "Touch ID" as const,
|
||||
reason: null,
|
||||
},
|
||||
autoPromptSystemUnlock: true,
|
||||
onSystemUnlock: async () => {
|
||||
systemUnlockCount += 1;
|
||||
return { ok: true as const };
|
||||
},
|
||||
onResetAppLock: async () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
assert.equal(systemUnlockCount, 0);
|
||||
|
||||
Object.defineProperty(dom.document, "visibilityState", {
|
||||
configurable: true,
|
||||
value: "visible",
|
||||
});
|
||||
await dispatchDomEvent(dom.document, new dom.window.Event("visibilitychange"));
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
assert.equal(systemUnlockCount, 0);
|
||||
assert.match(dom.document.body.textContent ?? "", /Preparing Touch ID/i);
|
||||
await waitForAutoPrompt(dom);
|
||||
assert.equal(systemUnlockCount, 1);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay waits for window focus before auto system unlock", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let focused = false;
|
||||
let systemUnlockCount = 0;
|
||||
|
||||
Object.defineProperty(dom.document, "hasFocus", {
|
||||
configurable: true,
|
||||
value: () => focused,
|
||||
});
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
onUnlock: async () => ({ ok: false as const, error: "incorrect" as const }),
|
||||
systemUnlockStatus: {
|
||||
supported: true,
|
||||
available: true,
|
||||
enabled: true,
|
||||
platform: "darwin" as const,
|
||||
label: "Touch ID" as const,
|
||||
reason: null,
|
||||
},
|
||||
autoPromptSystemUnlock: true,
|
||||
onSystemUnlock: async () => {
|
||||
systemUnlockCount += 1;
|
||||
return { ok: true as const };
|
||||
},
|
||||
onResetAppLock: async () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
await waitForAutoPrompt(dom);
|
||||
assert.equal(systemUnlockCount, 0);
|
||||
|
||||
focused = true;
|
||||
await dispatchDomEvent(dom.window, new dom.window.FocusEvent("focus"));
|
||||
await flushEffects();
|
||||
assert.match(dom.document.body.textContent ?? "", /Preparing Touch ID/i);
|
||||
await waitForAutoPrompt(dom);
|
||||
assert.equal(systemUnlockCount, 1);
|
||||
|
||||
await dispatchDomEvent(dom.window, new dom.window.FocusEvent("focus"));
|
||||
await waitForAutoPrompt(dom);
|
||||
assert.equal(systemUnlockCount, 1);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay retries a reopen presentation after an in-flight auto prompt finishes", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let systemUnlockCount = 0;
|
||||
let resolveFirstUnlock: ((result: { ok: false; error: "cancelled" }) => void) | null = null;
|
||||
|
||||
try {
|
||||
const createOverlay = (reopenSignal: number) => React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "background",
|
||||
reopenSignal,
|
||||
autoPromptSystemUnlock: true,
|
||||
onUnlock: async () => ({ ok: false as const, error: "incorrect" as const }),
|
||||
systemUnlockStatus: {
|
||||
supported: true,
|
||||
available: true,
|
||||
enabled: true,
|
||||
platform: "darwin" as const,
|
||||
label: "Touch ID" as const,
|
||||
reason: null,
|
||||
},
|
||||
onSystemUnlock: async () => {
|
||||
systemUnlockCount += 1;
|
||||
if (systemUnlockCount === 1) {
|
||||
return new Promise<{ ok: false; error: "cancelled" }>((resolve) => {
|
||||
resolveFirstUnlock = resolve;
|
||||
});
|
||||
}
|
||||
return { ok: false as const, error: "cancelled" as const };
|
||||
},
|
||||
onResetAppLock: async () => {},
|
||||
}),
|
||||
);
|
||||
|
||||
await renderer.render(createOverlay(1));
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
await waitForAutoPrompt(dom);
|
||||
assert.equal(systemUnlockCount, 1);
|
||||
|
||||
await renderer.render(createOverlay(2));
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
assert.equal(systemUnlockCount, 1);
|
||||
|
||||
assert.ok(resolveFirstUnlock);
|
||||
await runWithAct(async () => {
|
||||
resolveFirstUnlock?.({ ok: false, error: "cancelled" });
|
||||
});
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
await waitForAutoPrompt(dom);
|
||||
|
||||
assert.equal(systemUnlockCount, 2);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay hides system unlock when unavailable", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
onUnlock: async () => ({ ok: false, error: "incorrect" as const }),
|
||||
systemUnlockStatus: {
|
||||
supported: true,
|
||||
available: false,
|
||||
enabled: true,
|
||||
platform: "darwin",
|
||||
label: "Touch ID",
|
||||
reason: "unavailable",
|
||||
},
|
||||
onSystemUnlock: async () => ({ ok: true as const }),
|
||||
onResetAppLock: async () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
|
||||
assert.doesNotMatch(dom.document.body.textContent ?? "", /Unlock with Touch ID/i);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay keeps password fallback after system unlock failure", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
const unlockAttempts: string[] = [];
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
onUnlock: async (password) => {
|
||||
unlockAttempts.push(password);
|
||||
return { ok: true as const };
|
||||
},
|
||||
systemUnlockStatus: {
|
||||
supported: true,
|
||||
available: true,
|
||||
enabled: true,
|
||||
platform: "darwin",
|
||||
label: "Touch ID",
|
||||
reason: null,
|
||||
},
|
||||
onSystemUnlock: async () => ({ ok: false as const, error: "cancelled" as const }),
|
||||
onResetAppLock: async () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
|
||||
assert.equal(dom.document.getElementById("app-lock-password"), null);
|
||||
const systemButton = [...dom.document.querySelectorAll("button")]
|
||||
.find((candidate) => /Unlock with Touch ID/i.test(candidate.textContent ?? ""));
|
||||
assert.ok(systemButton);
|
||||
await dispatchDomEvent(systemButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
assert.match(dom.document.body.textContent ?? "", /System unlock was not completed/i);
|
||||
|
||||
const input = dom.document.getElementById("app-lock-password") as HTMLInputElement | null;
|
||||
const form = dom.document.querySelector("form");
|
||||
assert.ok(input);
|
||||
assert.ok(form);
|
||||
const setInputValue = Object.getOwnPropertyDescriptor(
|
||||
dom.window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
assert.ok(setInputValue);
|
||||
setInputValue.call(input, "secret");
|
||||
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
|
||||
await dispatchDomEvent(form, new dom.window.Event("submit", { bubbles: true, cancelable: true }));
|
||||
await flushEffects();
|
||||
|
||||
assert.deepEqual(unlockAttempts, ["secret"]);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay keeps a window drag region while lock controls stay interactive", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
onUnlock: async () => ({ ok: false, error: "incorrect" as const }),
|
||||
onResetAppLock: async () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
|
||||
const overlay = dom.document.querySelector("[data-app-lock-overlay]");
|
||||
const form = overlay?.querySelector("form");
|
||||
assert.ok(overlay);
|
||||
assert.ok(form);
|
||||
assert.match(overlay.getAttribute("class") ?? "", /\bapp-drag\b/);
|
||||
assert.match(form.getAttribute("class") ?? "", /\bapp-no-drag\b/);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
getAppLockErrorMessageKey,
|
||||
} from "./AppLockOverlay.tsx";
|
||||
|
||||
test("getAppLockErrorMessageKey maps unlock errors to localized message keys", () => {
|
||||
assert.equal(getAppLockErrorMessageKey("empty"), "appLock.error.emptyPassword");
|
||||
assert.equal(getAppLockErrorMessageKey("incorrect"), "appLock.error.incorrectPassword");
|
||||
assert.equal(getAppLockErrorMessageKey(null), null);
|
||||
});
|
||||
|
||||
test("AppLockOverlay marks the backdrop as a window drag region", () => {
|
||||
const source = readFileSync(new URL("./AppLockOverlay.tsx", import.meta.url), "utf8");
|
||||
assert.match(
|
||||
source,
|
||||
/className="fixed inset-0 flex items-center justify-center bg-background px-6 text-foreground app-drag"/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/className="flex w-full max-w-\[360px\] flex-col items-center gap-5 app-no-drag"/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,522 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Fingerprint, KeyRound, Loader2, LockKeyhole } from 'lucide-react';
|
||||
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import type {
|
||||
AppLockReason,
|
||||
AppLockSystemUnlockResult,
|
||||
AppLockSystemUnlockStatus,
|
||||
AppLockUnlockResult,
|
||||
} from '../application/state/useAppLockState';
|
||||
import { AppLogo } from './AppLogo';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
|
||||
const RESET_REVEAL_CLICK_COUNT = 5;
|
||||
const RESET_REVEAL_WINDOW_MS = 1500;
|
||||
export const APP_LOCK_AUTO_PROMPT_DELAY_MS = 1200;
|
||||
|
||||
function isWindowInteractive(): boolean {
|
||||
return typeof document === 'undefined'
|
||||
|| (
|
||||
document.visibilityState !== 'hidden'
|
||||
&& (typeof document.hasFocus !== 'function' || document.hasFocus())
|
||||
);
|
||||
}
|
||||
|
||||
interface AppLockOverlayProps {
|
||||
locked: boolean;
|
||||
reason: AppLockReason | null;
|
||||
onUnlock: (password: string) => Promise<AppLockUnlockResult>;
|
||||
systemUnlockStatus?: AppLockSystemUnlockStatus;
|
||||
onSystemUnlock?: () => Promise<AppLockSystemUnlockResult>;
|
||||
onResetAppLock: (currentPassword: string) => Promise<void>;
|
||||
autoPromptSystemUnlock?: boolean;
|
||||
reopenSignal?: number;
|
||||
}
|
||||
|
||||
export function getAppLockErrorMessageKey(error: AppLockUnlockResult['error'] | null): string | null {
|
||||
switch (error) {
|
||||
case 'empty':
|
||||
return 'appLock.error.emptyPassword';
|
||||
case 'incorrect':
|
||||
return 'appLock.error.incorrectPassword';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const AppLockOverlay: React.FC<AppLockOverlayProps> = ({
|
||||
locked,
|
||||
reason,
|
||||
onUnlock,
|
||||
systemUnlockStatus,
|
||||
onSystemUnlock,
|
||||
onResetAppLock,
|
||||
autoPromptSystemUnlock = false,
|
||||
reopenSignal = 0,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const passwordRef = useRef<HTMLInputElement>(null);
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<AppLockUnlockResult['error'] | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isSystemUnlocking, setIsSystemUnlocking] = useState(false);
|
||||
const [isAutoPromptPending, setIsAutoPromptPending] = useState(false);
|
||||
const [systemUnlockError, setSystemUnlockError] = useState(false);
|
||||
const [passwordFallbackRequested, setPasswordFallbackRequested] = useState(false);
|
||||
// Value is only read inside functional updates; keep the setter alone.
|
||||
const [, setLogoClickCount] = useState(0);
|
||||
const [lastLogoClickAt, setLastLogoClickAt] = useState<number | null>(null);
|
||||
const [showReset, setShowReset] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
const [resetError, setResetError] = useState(false);
|
||||
const [windowInteractive, setWindowInteractive] = useState(() => isWindowInteractive());
|
||||
const lastAutoUnlockPresentationRef = useRef<string | null>(null);
|
||||
const autoUnlockTimerRef = useRef<number | null>(null);
|
||||
const canUseSystemUnlock = Boolean(
|
||||
systemUnlockStatus?.enabled
|
||||
&& systemUnlockStatus.available
|
||||
&& systemUnlockStatus.label
|
||||
&& onSystemUnlock,
|
||||
);
|
||||
const showPasswordUnlock = !canUseSystemUnlock
|
||||
|| passwordFallbackRequested
|
||||
|| systemUnlockError
|
||||
|| showReset;
|
||||
|
||||
useEffect(() => {
|
||||
const updateWindowInteractive = () => {
|
||||
setWindowInteractive(isWindowInteractive());
|
||||
};
|
||||
|
||||
updateWindowInteractive();
|
||||
document.addEventListener('visibilitychange', updateWindowInteractive);
|
||||
window.addEventListener('focus', updateWindowInteractive);
|
||||
window.addEventListener('blur', updateWindowInteractive);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', updateWindowInteractive);
|
||||
window.removeEventListener('focus', updateWindowInteractive);
|
||||
window.removeEventListener('blur', updateWindowInteractive);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const overlayRootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!locked) {
|
||||
if (autoUnlockTimerRef.current !== null) {
|
||||
window.clearTimeout(autoUnlockTimerRef.current);
|
||||
autoUnlockTimerRef.current = null;
|
||||
}
|
||||
setPassword('');
|
||||
setError(null);
|
||||
setIsSubmitting(false);
|
||||
setIsSystemUnlocking(false);
|
||||
setIsAutoPromptPending(false);
|
||||
setSystemUnlockError(false);
|
||||
setPasswordFallbackRequested(false);
|
||||
setLogoClickCount(0);
|
||||
setLastLogoClickAt(null);
|
||||
setShowReset(false);
|
||||
setIsResetting(false);
|
||||
setResetError(false);
|
||||
lastAutoUnlockPresentationRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (!showPasswordUnlock) return;
|
||||
const timeout = window.setTimeout(() => passwordRef.current?.focus(), 0);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [locked, showPasswordUnlock]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!locked || reason !== 'background' || reopenSignal <= 0) return;
|
||||
setPasswordFallbackRequested(false);
|
||||
setSystemUnlockError(false);
|
||||
}, [locked, reason, reopenSignal]);
|
||||
|
||||
// Soft focus trap + stop ALL app-level keydown while locked.
|
||||
// inert does not block window capture (shortkey/hotkey recorders) or window
|
||||
// bubble (Snippets Ctrl/Cmd+S). Use window capture + stopImmediate so peer
|
||||
// window handlers never run, without stopPropagation so the lock password
|
||||
// input still receives the event. Document bubble then stops before window
|
||||
// bubble listeners (Codex P2 on 81e1f779).
|
||||
useEffect(() => {
|
||||
if (!locked) return;
|
||||
|
||||
const onWindowCapture = (event: KeyboardEvent) => {
|
||||
// Suppress other window-level keydown handlers registered after us.
|
||||
event.stopImmediatePropagation();
|
||||
|
||||
if (event.key !== 'Tab') return;
|
||||
const root = overlayRootRef.current;
|
||||
if (!root) return;
|
||||
const focusable = root.querySelectorAll<HTMLElement>(
|
||||
'button:not([disabled]), input:not([disabled]), [href], select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
if (focusable.length === 0) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (!active || !root.contains(active)) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
return;
|
||||
}
|
||||
if (event.shiftKey && active === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && active === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
// Unconditional: even when focus is on body, window bubble shortcuts must
|
||||
// not fire. Runs on document bubble before window bubble.
|
||||
const onDocumentBubble = (event: KeyboardEvent) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onWindowCapture, true);
|
||||
document.addEventListener('keydown', onDocumentBubble, false);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onWindowCapture, true);
|
||||
document.removeEventListener('keydown', onDocumentBubble, false);
|
||||
};
|
||||
}, [locked]);
|
||||
|
||||
// Context menus portal at z-index max (2147483647). Hide it while locked so
|
||||
// open right-click menus cannot sit above the lock screen (Codex P2).
|
||||
useEffect(() => {
|
||||
if (!locked) return;
|
||||
const portal = document.getElementById('netcatty-context-menu-root');
|
||||
if (!portal) return;
|
||||
const prev = portal.style.visibility;
|
||||
portal.style.visibility = 'hidden';
|
||||
portal.style.pointerEvents = 'none';
|
||||
return () => {
|
||||
portal.style.visibility = prev;
|
||||
portal.style.pointerEvents = 'none'; // portal default is none; children re-enable
|
||||
};
|
||||
}, [locked]);
|
||||
|
||||
const clearAutoUnlockTimer = useCallback(() => {
|
||||
if (autoUnlockTimerRef.current !== null) {
|
||||
window.clearTimeout(autoUnlockTimerRef.current);
|
||||
autoUnlockTimerRef.current = null;
|
||||
}
|
||||
setIsAutoPromptPending(false);
|
||||
}, []);
|
||||
|
||||
const handleSystemUnlock = useCallback(async () => {
|
||||
if (isSystemUnlocking || !onSystemUnlock) return;
|
||||
clearAutoUnlockTimer();
|
||||
setIsSystemUnlocking(true);
|
||||
setSystemUnlockError(false);
|
||||
try {
|
||||
const result = await onSystemUnlock();
|
||||
if (!result.ok) {
|
||||
setSystemUnlockError(true);
|
||||
setPasswordFallbackRequested(true);
|
||||
}
|
||||
} catch {
|
||||
setSystemUnlockError(true);
|
||||
setPasswordFallbackRequested(true);
|
||||
} finally {
|
||||
setIsSystemUnlocking(false);
|
||||
}
|
||||
}, [clearAutoUnlockTimer, isSystemUnlocking, onSystemUnlock]);
|
||||
|
||||
const requestAutomaticSystemUnlock = useCallback(() => {
|
||||
if (isSystemUnlocking || !onSystemUnlock) return false;
|
||||
void handleSystemUnlock();
|
||||
return true;
|
||||
}, [handleSystemUnlock, isSystemUnlocking, onSystemUnlock]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!locked) return;
|
||||
if (!autoPromptSystemUnlock) return;
|
||||
if (!windowInteractive) return;
|
||||
if (showReset) return;
|
||||
if (isSystemUnlocking) return;
|
||||
if (!systemUnlockStatus?.enabled || !systemUnlockStatus.available || !systemUnlockStatus.label) return;
|
||||
if (!onSystemUnlock) return;
|
||||
|
||||
const presentationKey = reason === 'background'
|
||||
? `background:${reopenSignal}`
|
||||
: `foreground:${reason ?? 'default'}:${locked}`;
|
||||
if (reason === 'background' && reopenSignal <= 0) return;
|
||||
if (
|
||||
passwordFallbackRequested
|
||||
&& lastAutoUnlockPresentationRef.current === presentationKey
|
||||
) return;
|
||||
if (lastAutoUnlockPresentationRef.current === presentationKey) return;
|
||||
if (autoUnlockTimerRef.current !== null) return;
|
||||
|
||||
if (passwordFallbackRequested || systemUnlockError) {
|
||||
setPasswordFallbackRequested(false);
|
||||
setSystemUnlockError(false);
|
||||
}
|
||||
setIsAutoPromptPending(true);
|
||||
const timer = window.setTimeout(() => {
|
||||
if (autoUnlockTimerRef.current !== timer) return;
|
||||
autoUnlockTimerRef.current = null;
|
||||
setIsAutoPromptPending(false);
|
||||
if (!requestAutomaticSystemUnlock()) return;
|
||||
lastAutoUnlockPresentationRef.current = presentationKey;
|
||||
}, APP_LOCK_AUTO_PROMPT_DELAY_MS);
|
||||
autoUnlockTimerRef.current = timer;
|
||||
|
||||
return () => {
|
||||
if (autoUnlockTimerRef.current !== timer) return;
|
||||
window.clearTimeout(timer);
|
||||
autoUnlockTimerRef.current = null;
|
||||
setIsAutoPromptPending(false);
|
||||
};
|
||||
}, [
|
||||
locked,
|
||||
autoPromptSystemUnlock,
|
||||
reason,
|
||||
reopenSignal,
|
||||
windowInteractive,
|
||||
onSystemUnlock,
|
||||
systemUnlockStatus?.available,
|
||||
systemUnlockStatus?.enabled,
|
||||
systemUnlockStatus?.label,
|
||||
passwordFallbackRequested,
|
||||
systemUnlockError,
|
||||
showReset,
|
||||
isSystemUnlocking,
|
||||
requestAutomaticSystemUnlock,
|
||||
]);
|
||||
|
||||
if (!locked) return null;
|
||||
|
||||
const errorKey = getAppLockErrorMessageKey(error);
|
||||
const SystemUnlockIcon = systemUnlockStatus?.platform === 'darwin' ? Fingerprint : KeyRound;
|
||||
const isSystemButtonLoading = isAutoPromptPending || isSystemUnlocking;
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (isSubmitting) return;
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
const result = await onUnlock(password);
|
||||
if (!result.ok) {
|
||||
setError(result.error);
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
setPassword('');
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
const handleLogoClick = () => {
|
||||
const now = Date.now();
|
||||
setLogoClickCount((current) => {
|
||||
const isQuickSequence = lastLogoClickAt !== null && now - lastLogoClickAt <= RESET_REVEAL_WINDOW_MS;
|
||||
const nextCount = isQuickSequence ? current + 1 : 1;
|
||||
if (nextCount >= RESET_REVEAL_CLICK_COUNT) {
|
||||
setShowReset(true);
|
||||
}
|
||||
return nextCount;
|
||||
});
|
||||
setLastLogoClickAt(now);
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
if (isResetting) return;
|
||||
setIsResetting(true);
|
||||
setResetError(false);
|
||||
try {
|
||||
await onResetAppLock(password);
|
||||
} catch {
|
||||
setResetError(true);
|
||||
setIsResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
// Overlay covers the title bar, and is the only chrome on a locked first
|
||||
// paint, so the backdrop owns the window drag region.
|
||||
<div
|
||||
ref={overlayRootRef}
|
||||
className="fixed inset-0 flex items-center justify-center bg-background px-6 text-foreground app-drag"
|
||||
style={{ zIndex: 2147483647 }}
|
||||
role="dialog"
|
||||
data-state="open"
|
||||
data-app-lock-overlay=""
|
||||
aria-modal="true"
|
||||
aria-labelledby="app-lock-title"
|
||||
>
|
||||
<form
|
||||
className="flex w-full max-w-[360px] flex-col items-center gap-5 app-no-drag"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-24 w-24 items-center justify-center border-0 bg-transparent p-0 shadow-none transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
aria-label={t('appLock.logoLabel')}
|
||||
data-testid="app-lock-logo-easter-egg"
|
||||
onClick={handleLogoClick}
|
||||
>
|
||||
<AppLogo className="h-full w-full" />
|
||||
</button>
|
||||
|
||||
<h1 id="app-lock-title" className="text-center text-xl font-semibold tracking-normal">
|
||||
{t('appLock.title')}
|
||||
</h1>
|
||||
|
||||
{showPasswordUnlock && (
|
||||
<>
|
||||
<div className="w-full space-y-2">
|
||||
<Label htmlFor="app-lock-password" className="sr-only">
|
||||
{t('appLock.passwordLabel')}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
ref={passwordRef}
|
||||
id="app-lock-password"
|
||||
type="password"
|
||||
value={password}
|
||||
autoComplete="current-password"
|
||||
placeholder={t('appLock.passwordPlaceholder')}
|
||||
disabled={isSubmitting}
|
||||
aria-invalid={Boolean(errorKey)}
|
||||
aria-describedby={errorKey ? 'app-lock-error' : undefined}
|
||||
className="pr-12"
|
||||
onChange={(event) => {
|
||||
setPassword(event.target.value);
|
||||
if (error) setError(null);
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="pointer-events-none absolute inset-y-px right-px flex w-10 items-center justify-center rounded-r-md bg-muted/30 text-muted-foreground"
|
||||
data-testid="app-lock-password-lock-icon"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<LockKeyhole className="h-4 w-4" strokeWidth={2.25} />
|
||||
</span>
|
||||
</div>
|
||||
{errorKey && (
|
||||
<p id="app-lock-error" className="text-sm text-destructive">
|
||||
{t(errorKey)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
||||
{isSubmitting ? t('appLock.unlocking') : t('appLock.unlock')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{canUseSystemUnlock && systemUnlockStatus?.label && (
|
||||
<div className="w-full space-y-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={showPasswordUnlock ? 'outline' : 'default'}
|
||||
className="w-full"
|
||||
disabled={isSystemButtonLoading}
|
||||
data-testid="app-lock-system-unlock-button"
|
||||
onClick={() => void handleSystemUnlock()}
|
||||
>
|
||||
{isSystemButtonLoading ? (
|
||||
<Loader2
|
||||
className="mr-2 h-4 w-4 animate-spin"
|
||||
data-testid="app-lock-system-unlock-loading"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<SystemUnlockIcon
|
||||
className="mr-2 h-4 w-4"
|
||||
data-testid={systemUnlockStatus.platform === 'darwin'
|
||||
? 'app-lock-system-unlock-touch-id-icon'
|
||||
: 'app-lock-system-unlock-windows-icon'}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span aria-live="polite">
|
||||
{isAutoPromptPending
|
||||
? t('appLock.systemUnlock.preparing').replace('{label}', systemUnlockStatus.label)
|
||||
: isSystemUnlocking
|
||||
? t('appLock.systemUnlock.verifying').replace('{label}', systemUnlockStatus.label)
|
||||
: t('appLock.systemUnlock.unlockWith').replace('{label}', systemUnlockStatus.label)}
|
||||
</span>
|
||||
</Button>
|
||||
{!showPasswordUnlock && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto w-full py-1 text-muted-foreground"
|
||||
onClick={() => {
|
||||
lastAutoUnlockPresentationRef.current = reason === 'background'
|
||||
? `background:${reopenSignal}`
|
||||
: `foreground:${reason ?? 'default'}:${locked}`;
|
||||
clearAutoUnlockTimer();
|
||||
setPasswordFallbackRequested(true);
|
||||
setSystemUnlockError(false);
|
||||
}}
|
||||
>
|
||||
{t('appLock.usePassword')}
|
||||
</Button>
|
||||
)}
|
||||
{systemUnlockError && (
|
||||
<p className="text-sm text-destructive">
|
||||
{t('appLock.systemUnlock.error')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showReset && (
|
||||
<div
|
||||
className="w-full rounded-lg border border-border/70 bg-card p-3 text-left shadow-sm"
|
||||
role="region"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">{t('appLock.reset.title')}</p>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
{t('appLock.reset.description')}
|
||||
</p>
|
||||
</div>
|
||||
{resetError && (
|
||||
<p className="mt-2 text-xs text-destructive">
|
||||
{t('appLock.reset.error')}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
disabled={isResetting}
|
||||
onClick={() => {
|
||||
setShowReset(false);
|
||||
setLogoClickCount(0);
|
||||
setLastLogoClickAt(null);
|
||||
setResetError(false);
|
||||
}}
|
||||
>
|
||||
{t('appLock.reset.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
disabled={isResetting}
|
||||
onClick={handleReset}
|
||||
>
|
||||
{isResetting ? t('appLock.reset.resetting') : t('appLock.reset.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
function readComponentSource(path: string) {
|
||||
return readFileSync(new URL(path, import.meta.url), "utf8");
|
||||
}
|
||||
|
||||
test("settings, tray, and terminal popup routes do not create fallback settings owners", () => {
|
||||
const settingsPage = readComponentSource("./SettingsPage.tsx");
|
||||
const trayPanel = readComponentSource("./TrayPanel.tsx");
|
||||
const terminalPopupPage = readComponentSource("./TerminalPopupPage.tsx");
|
||||
|
||||
assert.doesNotMatch(settingsPage, /fallbackSettings\s*=\s*useSettingsState\(\)/);
|
||||
assert.doesNotMatch(settingsPage, /settings\?:\s*SettingsState/);
|
||||
assert.doesNotMatch(settingsPage, /providedSettings\s*\?\?/);
|
||||
|
||||
assert.doesNotMatch(trayPanel, /fallbackSettings\s*=\s*useSettingsState\(\)/);
|
||||
assert.doesNotMatch(trayPanel, /settings\?:\s*SettingsState/);
|
||||
assert.doesNotMatch(trayPanel, /providedSettings\s*\?\?/);
|
||||
|
||||
assert.doesNotMatch(terminalPopupPage, /fallbackSettings\s*=\s*useSettingsState\(\)/);
|
||||
assert.doesNotMatch(terminalPopupPage, /settings\?:\s*SettingsState/);
|
||||
assert.doesNotMatch(terminalPopupPage, /providedSettings\s*\?\?/);
|
||||
});
|
||||
|
||||
test("terminal popups wait for app-lock initialization before starting a terminal", () => {
|
||||
const indexSource = readComponentSource("../index.tsx");
|
||||
assert.match(
|
||||
indexSource,
|
||||
/allowTerminalStart=\{appLock\.initialized\s*&&\s*!appLock\.locked\}/,
|
||||
);
|
||||
});
|
||||
@@ -4,7 +4,6 @@
|
||||
*/
|
||||
import { AppWindow, Cloud, FileType, HardDrive, Keyboard, Palette, Puzzle, Sparkles, TerminalSquare, X } from "lucide-react";
|
||||
import React, { lazy, Suspense, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { useAISettingsState } from "../application/state/useAISettingsState";
|
||||
import { useAvailableFonts } from "../application/state/fontStore";
|
||||
import { usePortForwardingState } from "../application/state/usePortForwardingState";
|
||||
@@ -13,6 +12,7 @@ import { useWindowControls } from "../application/state/useWindowControls";
|
||||
import { useUpdateCheck } from "../application/state/useUpdateCheck";
|
||||
import { I18nProvider, useI18n } from "../application/i18n/I18nProvider";
|
||||
import { sanitizePortForwardingRulesForSync } from "../application/syncPayload";
|
||||
import type { AppLockGateRenderContext } from "./AppLockGate";
|
||||
import { toast } from "./ui/toast";
|
||||
import { SettingsTabContent } from "./settings/settings-ui";
|
||||
import { SettingsFocusProvider, useSettingsFocus } from "./settings/SettingsFocusContext";
|
||||
@@ -64,7 +64,8 @@ class AITabErrorBoundary extends React.Component<
|
||||
}
|
||||
}
|
||||
|
||||
type SettingsState = ReturnType<typeof useSettingsState>;
|
||||
type SettingsState = AppLockGateRenderContext["settings"];
|
||||
type AppLockState = AppLockGateRenderContext["appLock"];
|
||||
|
||||
const settingsTabTriggerClassName =
|
||||
"w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors overflow-hidden";
|
||||
@@ -298,7 +299,7 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
|
||||
);
|
||||
};
|
||||
|
||||
const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }) => {
|
||||
const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLockState }> = ({ settings, appLock }) => {
|
||||
const { t } = useI18n();
|
||||
const { request, clearFocus, openSearch } = useSettingsFocus();
|
||||
const { notifyRendererReady, closeSettingsWindow, onWindowCommandCloseRequested } = useWindowControls();
|
||||
@@ -606,6 +607,12 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
{mountedTabs.has("system") && (
|
||||
<SettingsLazyTab value="system">
|
||||
<LazySettingsSystemTab
|
||||
appLockSettings={settings.appLockSettings}
|
||||
setAppLockTimeoutMinutes={settings.setAppLockTimeoutMinutes}
|
||||
requestAppLockDisable={settings.requestAppLockDisable}
|
||||
requestAppLockPasswordChange={settings.requestAppLockPasswordChange}
|
||||
appLockSystemUnlockStatus={appLock?.systemUnlockStatus}
|
||||
setAppLockSystemUnlockEnabled={settings.setAppLockSystemUnlockEnabled}
|
||||
sessionLogsEnabled={settings.sessionLogsEnabled}
|
||||
setSessionLogsEnabled={settings.setSessionLogsEnabled}
|
||||
sessionLogsDir={settings.sessionLogsDir}
|
||||
@@ -662,13 +669,17 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
);
|
||||
};
|
||||
|
||||
export default function SettingsPage() {
|
||||
const settings = useSettingsState();
|
||||
|
||||
export default function SettingsPage({
|
||||
settings,
|
||||
appLock,
|
||||
}: {
|
||||
settings: SettingsState;
|
||||
appLock: AppLockState;
|
||||
}) {
|
||||
return (
|
||||
<I18nProvider locale={settings.uiLanguage}>
|
||||
<SettingsFocusProvider>
|
||||
<SettingsPageContent settings={settings} />
|
||||
<SettingsPageContent settings={settings} appLock={appLock} />
|
||||
</SettingsFocusProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
vaultPrimaryIconClass,
|
||||
vaultSnippetIconClass,
|
||||
} from './vault/VaultEntityIcon';
|
||||
import { isAppLockOverlayActive } from '../infrastructure/appLockOverlayDom';
|
||||
import { VaultDeleteConfirmDialog } from './vault/VaultDeleteConfirmDialog';
|
||||
import {
|
||||
clearVaultDropIndicator as clearSnippetDropIndicator,
|
||||
@@ -622,6 +623,9 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
if (!isRecordingShortkey) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// App lock overlay installs later; skip while locked so password keys
|
||||
// never mutate shortkeys (Codex P2).
|
||||
if (isAppLockOverlayActive()) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -774,6 +778,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if (isAppLockOverlayActive()) return;
|
||||
if (rightPanelMode !== 'edit-snippet') return;
|
||||
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 's') return;
|
||||
event.preventDefault();
|
||||
|
||||
+38
-4
@@ -120,6 +120,8 @@ import {
|
||||
import { isVaultInitialized } from "@/application/state/vaultInitStore.ts";
|
||||
import { useVaultSnapshotField } from "@/application/state/vaultSnapshotStore.ts";
|
||||
import { netcattyBridge } from "@/infrastructure/services/netcattyBridge.ts";
|
||||
import { handleTerminalOscNotification } from "@/application/state/oscDesktopNotifications.ts";
|
||||
import { OscNotificationStreamScanner } from "@/domain/terminalOscNotifications.ts";
|
||||
import { ScriptExecutionOverlay } from "./terminal/ScriptExecutionOverlay";
|
||||
import { isScriptSnippet } from "@/domain/snippetScript.ts";
|
||||
import { snippetCanRunInTerminal } from "@/domain/snippetTargets.ts";
|
||||
@@ -605,6 +607,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onTerminalDataCaptureRef.current = onTerminalDataCapture;
|
||||
const isVisibleRef = useRef(isVisible);
|
||||
isVisibleRef.current = isVisible;
|
||||
const isFocusedRef = useRef(!!isFocused);
|
||||
isFocusedRef.current = !!isFocused;
|
||||
const oscNotificationScannerRef = useRef(new OscNotificationStreamScanner());
|
||||
const hibernateEnabled =
|
||||
resolveTerminalHibernateEnabledForProtocol(terminalSettings, effectiveTerminalProtocol) &&
|
||||
!kittyKeyboardProtocolEnabledForSession &&
|
||||
@@ -1001,6 +1006,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onBroadcastInputRef.current(text, sessionId);
|
||||
}
|
||||
|
||||
// ESC-prefixed writes (Esc+. yank-last-arg) are shell editor commands.
|
||||
// Walking printable bytes would append "." and leave history/completions
|
||||
// tracking a stale line.
|
||||
if (text.startsWith("\x1b")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update command buffer for onCommandExecuted tracking
|
||||
for (const ch of text) {
|
||||
if (handledSubmittedInput) {
|
||||
@@ -1899,15 +1911,27 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
flushTerminalSessionFlowAck(backendId);
|
||||
terminalBackend.setSessionFlowPaused?.(backendId, false);
|
||||
hibernatePendingBufferRef.current = "";
|
||||
oscNotificationScannerRef.current = new OscNotificationStreamScanner();
|
||||
disposeDataRef.current = terminalBackend.onSessionData(
|
||||
backendId,
|
||||
(chunk, meta) => {
|
||||
observeTerminalInputPrompt(chunk, meta);
|
||||
const scanned = oscNotificationScannerRef.current.consume(chunk);
|
||||
for (const notification of scanned.notifications) {
|
||||
handleTerminalOscNotification({
|
||||
notification,
|
||||
mode: terminalSettingsRef.current?.oscNotifications,
|
||||
sessionFocused: isFocusedRef.current,
|
||||
sessionId,
|
||||
fallbackTitle: host.label || host.hostname || "Netcatty",
|
||||
onSessionActivity: () => onTerminalBell?.(sessionId),
|
||||
});
|
||||
}
|
||||
hibernatePendingBufferRef.current = hibernatePendingCapDisabledRef.current
|
||||
? hibernatePendingBufferRef.current + chunk
|
||||
? hibernatePendingBufferRef.current + scanned.remainder
|
||||
: appendHibernatePendingBuffer(
|
||||
hibernatePendingBufferRef.current,
|
||||
chunk,
|
||||
scanned.remainder,
|
||||
);
|
||||
const pluginPipelineIngressBytes = Number.isFinite(meta?.pluginPipelineIngressBytes)
|
||||
? Math.max(0, Number(meta.pluginPipelineIngressBytes))
|
||||
@@ -1937,7 +1961,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onSessionExitRef.current?.(sessionId, evt);
|
||||
scheduleAutoReconnect({ evt });
|
||||
});
|
||||
}, [observeTerminalInputPrompt, scheduleAutoReconnect, sessionId, terminalBackend]);
|
||||
}, [host.hostname, host.label, observeTerminalInputPrompt, onTerminalBell, scheduleAutoReconnect, sessionId, terminalBackend]);
|
||||
|
||||
const clearHibernateRetry = useCallback(() => {
|
||||
if (hibernateRetryTimerRef.current === null) return;
|
||||
@@ -3920,6 +3944,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onBell: () => {
|
||||
onTerminalBell?.(sessionId);
|
||||
},
|
||||
onOscNotification: (notification) => {
|
||||
handleTerminalOscNotification({
|
||||
notification,
|
||||
mode: terminalSettingsRef.current?.oscNotifications,
|
||||
sessionFocused: isFocusedRef.current,
|
||||
sessionId,
|
||||
fallbackTitle: host.label || host.hostname || "Netcatty",
|
||||
onSessionActivity: () => onTerminalBell?.(sessionId),
|
||||
});
|
||||
},
|
||||
onOsc52ReadRequest: handleOsc52ReadRequest,
|
||||
onAutocompleteKeyEvent: (e: KeyboardEvent) => autocompleteKeyEventRef.current?.(e) ?? true,
|
||||
onAutocompleteInput: (data: string) => autocompleteInputRef.current?.(data),
|
||||
@@ -4035,7 +4069,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return false;
|
||||
},
|
||||
takePendingBuffer: () => {
|
||||
const pending = hibernatePendingBufferRef.current;
|
||||
const pending = hibernatePendingBufferRef.current + oscNotificationScannerRef.current.flush();
|
||||
hibernatePendingBufferRef.current = "";
|
||||
return pending;
|
||||
},
|
||||
|
||||
@@ -149,7 +149,7 @@ test('popup provider tree mounts the plugin authentication host', () => {
|
||||
assert.match(source, /import \{ PluginAuthenticationHost \} from '\.\/plugins\/PluginAuthenticationHost';/);
|
||||
assert.match(
|
||||
source,
|
||||
/<I18nProvider locale=\{settings\.uiLanguage\}>\s+<TerminalPopupPageInner \/>\s+<PluginAuthenticationHost \/>\s+<\/I18nProvider>/,
|
||||
/<I18nProvider locale=\{settings\.uiLanguage\}>\s+<TerminalPopupPageInner\s+settings=\{settings\}\s+allowTerminalStart=\{allowTerminalStart\}\s+\/>\s+<PluginAuthenticationHost \/>\s+<\/I18nProvider>/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Copy, Minus, Square, Unplug, X } from 'lucide-react';
|
||||
import React, { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { I18nProvider, useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useSettingsState } from '../application/state/useSettingsState';
|
||||
import { useTerminalPopupWindow } from '../application/state/useTerminalPopupWindow';
|
||||
import { useVaultState } from '../application/state/useVaultState';
|
||||
import { useWindowControls } from '../application/state/useWindowControls';
|
||||
@@ -18,6 +17,7 @@ import type { KnownHost } from '../types';
|
||||
import { getEffectiveKnownHosts } from '../infrastructure/syncHelpers';
|
||||
import { detectLocalOs } from '../lib/localShell';
|
||||
import { cn } from '../lib/utils';
|
||||
import type { AppLockGateRenderContext } from './AppLockGate';
|
||||
import { PluginAuthenticationHost } from './plugins/PluginAuthenticationHost';
|
||||
|
||||
const Terminal = lazy(() => import('./Terminal'));
|
||||
@@ -28,6 +28,7 @@ const POPUP_STARTUP_REVEAL_MIN_DELAY_MS = 1500;
|
||||
const POPUP_STARTUP_REVEAL_MAX_DELAY_MS = 12000;
|
||||
|
||||
type PopupThemeVars = React.CSSProperties & Record<string, string>;
|
||||
type SettingsState = AppLockGateRenderContext["settings"];
|
||||
|
||||
const buildPopupThemeVars = (theme: TerminalTheme): PopupThemeVars => {
|
||||
const { colors } = theme;
|
||||
@@ -250,7 +251,14 @@ export function resolveTerminalPopupReuseId(config: TerminalPopupPayload): strin
|
||||
return config.sourceSession.reuseConnectionFromSessionId;
|
||||
}
|
||||
|
||||
function TerminalPopupPageInner() {
|
||||
function TerminalPopupPageInner({
|
||||
settings,
|
||||
allowTerminalStart = true,
|
||||
}: {
|
||||
settings: SettingsState;
|
||||
/** When false (app locked), keep config listeners mounted but do not start Terminal. */
|
||||
allowTerminalStart?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
close,
|
||||
@@ -260,7 +268,6 @@ function TerminalPopupPageInner() {
|
||||
setWindowTitle,
|
||||
} = useTerminalPopupWindow();
|
||||
const { notifyRendererReady, onWindowCommandCloseRequested } = useWindowControls();
|
||||
const settings = useSettingsState();
|
||||
const {
|
||||
isInitialized: vaultInitialized,
|
||||
hosts,
|
||||
@@ -338,9 +345,8 @@ function TerminalPopupPageInner() {
|
||||
void setWindowTitle(payload.title);
|
||||
}
|
||||
});
|
||||
// Main delivers the popup payload as soon as the renderer reports ready
|
||||
// (and destroys the window if it never does) — so report ready only after
|
||||
// the config listener above is registered.
|
||||
// Popup config may already be buffered by preload, so register the listener
|
||||
// before reporting renderer readiness or the buffered payload can be lost.
|
||||
notifyRendererReady();
|
||||
return unsubscribe;
|
||||
}, [notifyRendererReady, onPopupConfig, setWindowTitle]);
|
||||
@@ -379,7 +385,15 @@ function TerminalPopupPageInner() {
|
||||
return resolveTerminalPopupReuseId(config);
|
||||
}, [config]);
|
||||
|
||||
const ready = Boolean(config && host && vaultInitialized);
|
||||
// Defer *first* backend start while app-locked so startup commands/credentials
|
||||
// do not run behind the overlay. Once a session has started, keep it mounted
|
||||
// across re-locks so the popup is not torn down mid-session (Codex P2).
|
||||
const readyToStart = Boolean(config && host && vaultInitialized && allowTerminalStart);
|
||||
const [sessionStarted, setSessionStarted] = useState(false);
|
||||
useEffect(() => {
|
||||
if (readyToStart) setSessionStarted(true);
|
||||
}, [readyToStart]);
|
||||
const ready = sessionStarted || readyToStart;
|
||||
const startupRevealDelayMs = useMemo(() => {
|
||||
// Attach mode shows the live session immediately (no startup command).
|
||||
if (isAttachMode) return 0;
|
||||
@@ -516,11 +530,19 @@ function TerminalPopupPageInner() {
|
||||
);
|
||||
}
|
||||
|
||||
export default function TerminalPopupPage() {
|
||||
const settings = useSettingsState();
|
||||
export default function TerminalPopupPage({
|
||||
settings,
|
||||
allowTerminalStart = true,
|
||||
}: {
|
||||
settings: SettingsState;
|
||||
allowTerminalStart?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<I18nProvider locale={settings.uiLanguage}>
|
||||
<TerminalPopupPageInner />
|
||||
<TerminalPopupPageInner
|
||||
settings={settings}
|
||||
allowTerminalStart={allowTerminalStart}
|
||||
/>
|
||||
<PluginAuthenticationHost />
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
+24
-1
@@ -1,4 +1,4 @@
|
||||
import { Folder, FolderLock, Menu, MoreHorizontal, Plus, Settings, Sparkles } from 'lucide-react';
|
||||
import { Folder, FolderLock, Lock, Menu, MoreHorizontal, Plus, Settings, Sparkles } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { fromEditorTabId, isEditorTabId, toEditorTabId, useActiveTabId } from '../application/state/activeTabStore';
|
||||
import { topTabsSessionsEqual } from '../domain/topTabsSessionsEqual';
|
||||
@@ -147,6 +147,8 @@ interface TopTabsProps {
|
||||
onOpenQuickSwitcher: () => void;
|
||||
onThemeChange: (theme: 'dark' | 'light' | 'system') => void;
|
||||
onOpenSettings: () => void;
|
||||
onLockApp?: () => void;
|
||||
appLockEnabled?: boolean;
|
||||
externalMcpEnabled: boolean;
|
||||
onToggleExternalMcp: (enabled: boolean) => void;
|
||||
showExternalMcpToggle?: boolean;
|
||||
@@ -196,6 +198,8 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onOpenQuickSwitcher,
|
||||
onThemeChange,
|
||||
onOpenSettings,
|
||||
onLockApp,
|
||||
appLockEnabled,
|
||||
externalMcpEnabled,
|
||||
onToggleExternalMcp,
|
||||
showExternalMcpToggle = true,
|
||||
@@ -1198,6 +1202,22 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
className="h-7 w-7 shrink-0 top-tab-utility-btn"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
/>
|
||||
{appLockEnabled && onLockApp && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0 app-no-drag top-tab-utility-btn"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
onClick={onLockApp}
|
||||
>
|
||||
<Lock size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('topTabs.lockApp')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<TopTabsQuickControls
|
||||
theme={theme}
|
||||
themePreference={themePreference}
|
||||
@@ -1209,6 +1229,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
setWindowOpacity={setWindowOpacity}
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
/>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -1259,6 +1280,8 @@ export const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean
|
||||
prev.onAppendHostToWorkspace === next.onAppendHostToWorkspace &&
|
||||
prev.onCopyWorkspace === next.onCopyWorkspace &&
|
||||
prev.onOpenSettings === next.onOpenSettings &&
|
||||
prev.onLockApp === next.onLockApp &&
|
||||
prev.appLockEnabled === next.appLockEnabled &&
|
||||
prev.externalMcpEnabled === next.externalMcpEnabled &&
|
||||
prev.onToggleExternalMcp === next.onToggleExternalMcp &&
|
||||
prev.showExternalMcpToggle === next.showExternalMcpToggle &&
|
||||
|
||||
@@ -4,6 +4,14 @@ import { readFileSync } from "node:fs";
|
||||
|
||||
const source = readFileSync(new URL("./TrayPanel.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("tray panel does not paint a CSS drop shadow on the host card", () => {
|
||||
// BrowserWindow already has hasShadow. CSS shadow-lg on a transparent
|
||||
// macOS overlay double-composites as an extra outline under the card.
|
||||
const rootOpenTag = source.match(/<div id="tray-panel-root"[^>]*>/);
|
||||
assert.ok(rootOpenTag, "expected tray-panel-root");
|
||||
assert.doesNotMatch(rootOpenTag[0], /shadow-lg|shadow-md|shadow-xl/);
|
||||
});
|
||||
|
||||
test("tray session close actions are discoverable without mouse hover", () => {
|
||||
const keyboardVisible = source.match(/focus-visible:opacity-100/g) ?? [];
|
||||
const touchVisible = source.match(/\[@media\(hover:none\)\]:opacity-100/g) ?? [];
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider";
|
||||
import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { useTrayPanelBackend } from "../application/state/useTrayPanelBackend";
|
||||
import { useActiveTabId } from "../application/state/activeTabStore";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
@@ -19,6 +18,7 @@ import { getEffectiveKnownHosts } from "../infrastructure/syncHelpers";
|
||||
import { PortForwardHostKeyTrayPrompt } from "./port-forwarding";
|
||||
import { X, Maximize2, ChevronRight, ChevronDown, Power } from "lucide-react";
|
||||
import { AppLogo } from "./AppLogo";
|
||||
import type { AppLockGateRenderContext } from "./AppLockGate";
|
||||
|
||||
const StatusDot: React.FC<{ status: "success" | "warning" | "error" | "neutral"; spinning?: boolean }> = ({
|
||||
status,
|
||||
@@ -270,7 +270,7 @@ const TrayPanelContent: React.FC<TrayPanelContentProps> = ({ terminalSettings })
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id="tray-panel-root" className="w-full h-full bg-background/95 supports-[backdrop-filter]:backdrop-blur-sm border border-border/60 rounded-lg shadow-lg overflow-hidden flex flex-col">
|
||||
<div id="tray-panel-root" className="w-full h-full bg-background/95 supports-[backdrop-filter]:backdrop-blur-sm border border-border/60 rounded-lg overflow-hidden flex flex-col">
|
||||
<div className="px-3 py-2 border-b border-border/60 flex items-center justify-between app-no-drag">
|
||||
<div className="flex items-center gap-2">
|
||||
<AppLogo className="w-5 h-5" />
|
||||
@@ -514,8 +514,9 @@ const TrayPanelContent: React.FC<TrayPanelContentProps> = ({ terminalSettings })
|
||||
);
|
||||
};
|
||||
|
||||
const TrayPanel: React.FC = () => {
|
||||
const settings = useSettingsState();
|
||||
type SettingsState = AppLockGateRenderContext["settings"];
|
||||
|
||||
const TrayPanel: React.FC<{ settings: SettingsState }> = ({ settings }) => {
|
||||
return (
|
||||
<I18nProvider locale={settings.uiLanguage}>
|
||||
<TrayPanelContent terminalSettings={settings.terminalSettings} />
|
||||
|
||||
@@ -154,7 +154,7 @@ test('renders separate steer and stop actions for a running Codex App Server tur
|
||||
assert.match(html, /aria-label="ai\.codex\.steer\.addInstruction"/);
|
||||
assert.match(html, /placeholder="ai\.codex\.steer\.placeholder"/);
|
||||
assert.match(html, /aria-label="Stop"/);
|
||||
assert.match(html, /disabled=""[^>]*aria-label="Select model"/);
|
||||
assert.match(html, /disabled=""[^>]*aria-label="ai\.chat\.selectModel"/);
|
||||
});
|
||||
|
||||
test('allows terminal-selection-only steering submissions', () => {
|
||||
@@ -222,6 +222,108 @@ test('slash picker system commands clear the local composer', () => {
|
||||
assert.match(source, /if \(command === 'stop'\) onStop\?\.\(\);\s*commitComposerText\(''\);/s);
|
||||
});
|
||||
|
||||
test('keeps thinking on a separate chip instead of mixing it into the model list', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<TooltipProvider>
|
||||
<ChatInput
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onSend={() => {}}
|
||||
agentName="Codex"
|
||||
modelPresets={[{
|
||||
id: 'gpt-5.5',
|
||||
name: 'GPT-5.5',
|
||||
thinkingLevels: ['low', 'medium', 'high'],
|
||||
}]}
|
||||
selectedModelId="gpt-5.5/high"
|
||||
onModelSelect={() => {}}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
assert.match(html, /aria-label="ai\.chat\.selectModel"/);
|
||||
assert.match(html, /aria-label="ai\.chat\.thinkingLevel"/);
|
||||
assert.match(html, />High</);
|
||||
assert.doesNotMatch(html, /GPT-5\.5 \/ High/);
|
||||
});
|
||||
|
||||
test('Catty composer exposes a provider switcher without a mixed thinking submenu', () => {
|
||||
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /ComposerModelPicker/);
|
||||
assert.match(source, /ComposerThinkingChip/);
|
||||
assert.match(source, /cattyReasoningLevelsForSelection/);
|
||||
assert.match(source, /if \(!thinkingLevel\) return;/);
|
||||
assert.doesNotMatch(source, /showThinkingLevels/);
|
||||
});
|
||||
|
||||
test('Catty thinking chip is hidden for OpenAI models that reject reasoningEffort', () => {
|
||||
const openai = {
|
||||
id: 'p1',
|
||||
providerId: 'openai' as const,
|
||||
name: 'OpenAI',
|
||||
enabled: true,
|
||||
};
|
||||
const gpt4o = renderToStaticMarkup(
|
||||
<TooltipProvider>
|
||||
<ChatInput
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onSend={() => {}}
|
||||
agentName="Catty Agent"
|
||||
thinkingLevel="high"
|
||||
onThinkingLevelChange={() => {}}
|
||||
providerSwitcher={{
|
||||
providers: [openai],
|
||||
selectedProviderId: 'p1',
|
||||
selectedModelId: 'gpt-4o',
|
||||
onSelect: () => {},
|
||||
}}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
assert.doesNotMatch(gpt4o, /aria-label="ai\.chat\.thinkingLevel"/);
|
||||
|
||||
const gpt51Chat = renderToStaticMarkup(
|
||||
<TooltipProvider>
|
||||
<ChatInput
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onSend={() => {}}
|
||||
agentName="Catty Agent"
|
||||
thinkingLevel="high"
|
||||
onThinkingLevelChange={() => {}}
|
||||
providerSwitcher={{
|
||||
providers: [openai],
|
||||
selectedProviderId: 'p1',
|
||||
selectedModelId: 'gpt-5.1-chat-latest',
|
||||
onSelect: () => {},
|
||||
}}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
assert.doesNotMatch(gpt51Chat, /aria-label="ai\.chat\.thinkingLevel"/);
|
||||
|
||||
const gpt55 = renderToStaticMarkup(
|
||||
<TooltipProvider>
|
||||
<ChatInput
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onSend={() => {}}
|
||||
agentName="Catty Agent"
|
||||
thinkingLevel="high"
|
||||
onThinkingLevelChange={() => {}}
|
||||
providerSwitcher={{
|
||||
providers: [openai],
|
||||
selectedProviderId: 'p1',
|
||||
selectedModelId: 'gpt-5.5',
|
||||
onSelect: () => {},
|
||||
}}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
assert.match(gpt55, /aria-label="ai\.chat\.thinkingLevel"/);
|
||||
});
|
||||
|
||||
test('ChatInput wires /compact through getSystemSlashCommand and canCompact', () => {
|
||||
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /getSystemSlashCommand/);
|
||||
|
||||
+178
-181
@@ -7,6 +7,27 @@
|
||||
*/
|
||||
|
||||
import { ArrowUp, AtSign, Check, ChevronDown, ChevronRight, Cpu, Eye, FileText, ImageIcon, Loader2, MessageSquare, Package, Plus, ShieldCheck, SquareTerminal, X, Zap } from 'lucide-react';
|
||||
import {
|
||||
resolveModelSelectionWithThinking,
|
||||
resolveThinkingSelection,
|
||||
type ComposerModelPrefs,
|
||||
} from '../../infrastructure/ai/composerPicker';
|
||||
import {
|
||||
cattyReasoningLevelsForSelection,
|
||||
resolveVisibleCattyThinkingLevel,
|
||||
} from '../../infrastructure/ai/cattyReasoning';
|
||||
import {
|
||||
readComposerModelPrefs,
|
||||
rememberComposerRecentModel,
|
||||
subscribeComposerModelPrefs,
|
||||
toggleComposerPinnedModel,
|
||||
} from '../../infrastructure/ai/composerModelPrefs';
|
||||
import {
|
||||
ComposerModelPicker,
|
||||
COMPOSER_MODEL_PICKER_WIDTH,
|
||||
COMPOSER_PROVIDER_PICKER_WIDTH,
|
||||
} from './ComposerModelPicker';
|
||||
import { ComposerThinkingChip } from './ComposerThinkingChip';
|
||||
import {
|
||||
buildSlashCommandItems,
|
||||
filterQuickMessages,
|
||||
@@ -32,11 +53,11 @@ import {
|
||||
PromptInputTools,
|
||||
} from '../ai-elements/prompt-input';
|
||||
import type { PromptInputStatus } from '../ai-elements/prompt-input';
|
||||
import { formatThinkingLabel } from '../../infrastructure/ai/types';
|
||||
import type { AgentModelPreset, AIPermissionMode, ProviderConfig, UploadedFile } from '../../infrastructure/ai/types';
|
||||
import { ProviderIconBadge } from '../settings/tabs/ai/ProviderIconBadge';
|
||||
import { VariableSizeVirtualList, type VariableSizeVirtualListHandle } from '../ui/VariableSizeVirtualList';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { isAppLockOverlayActive } from '../../infrastructure/appLockOverlayDom';
|
||||
import type { AgentContextUsage } from '../../application/state/useAgentCompactionUi';
|
||||
import { markAiComposerActivity, setAiComposerComposing } from './aiMarkdownWarmup';
|
||||
import {
|
||||
@@ -51,12 +72,8 @@ import {
|
||||
resolveVisibleChatInputMaxHeight,
|
||||
} from './chatInputResize';
|
||||
|
||||
// Keep in sync with the popover's Tailwind max-width below.
|
||||
const MODEL_PICKER_MAX_WIDTH = 360;
|
||||
// Slightly wider for the provider picker so the per-row default-model
|
||||
// caption doesn't truncate.
|
||||
const PROVIDER_PICKER_MAX_WIDTH = 320;
|
||||
const PERMISSION_PICKER_WIDTH = 250;
|
||||
const PERMISSION_PICKER_WIDTH = 200;
|
||||
const THINKING_PICKER_WIDTH = 168;
|
||||
const MENU_VIEWPORT_GUTTER = 8;
|
||||
const CONTEXT_USAGE_RING_RADIUS = 10;
|
||||
const CONTEXT_USAGE_RING_CIRCUMFERENCE = 2 * Math.PI * CONTEXT_USAGE_RING_RADIUS;
|
||||
@@ -69,11 +86,8 @@ function formatContextTokens(tokens: number): string {
|
||||
|
||||
/**
|
||||
* Provider picker payload used by Catty Agent. When set, the model chip
|
||||
* switches to a flat provider list (provider icon + name + the provider's
|
||||
* configured default model as caption) in place of the generic Cpu glyph
|
||||
* + model-preset dropdown. Each provider exposes a single model — its
|
||||
* `defaultModel` — so a two-level menu would be empty noise; picking a
|
||||
* provider implicitly picks its model.
|
||||
* opens a single-column model list. The current provider sits on a
|
||||
* one-line header that drills into a second-level provider list.
|
||||
*/
|
||||
export interface ProviderSwitcherConfig {
|
||||
/** Every configured provider — Settings-level visibility, not the
|
||||
@@ -85,7 +99,7 @@ export interface ProviderSwitcherConfig {
|
||||
/** Currently bound model id under the selected provider. */
|
||||
selectedModelId?: string;
|
||||
/** Fires when the user picks a (providerId, modelId) pair. */
|
||||
onSelect: (providerId: string, modelId: string) => void;
|
||||
onSelect: (providerId: string, modelId: string, contextWindow?: number) => void;
|
||||
}
|
||||
|
||||
type ComposerHasTextStore = {
|
||||
@@ -223,6 +237,11 @@ interface ChatInputProps {
|
||||
* `modelPresets` dropdown because their provider is wired inside the CLI.
|
||||
*/
|
||||
providerSwitcher?: ProviderSwitcherConfig;
|
||||
/** Scope key for recent/pinned model prefs. */
|
||||
pickerScope?: string;
|
||||
/** Catty-only reasoning effort, stored separately from the model id. */
|
||||
thinkingLevel?: string;
|
||||
onThinkingLevelChange?: (level: string) => void;
|
||||
/** Hidden retained panels must not leave body-portaled menus open. */
|
||||
parked?: boolean;
|
||||
}
|
||||
@@ -260,6 +279,9 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
permissionMode,
|
||||
onPermissionModeChange,
|
||||
providerSwitcher,
|
||||
pickerScope = 'default',
|
||||
thinkingLevel,
|
||||
onThinkingLevelChange,
|
||||
parked = false,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
@@ -276,11 +298,11 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
const [composerMaxHeight, setComposerMaxHeight] = useState(CHAT_INPUT_MAX_HEIGHT);
|
||||
const composerDesiredHeightRef = useRef<number | null>(null);
|
||||
// Consolidate menu state into a single discriminated union to prevent multiple menus open simultaneously
|
||||
type ActiveMenu = 'model' | 'attach' | 'atMention' | 'slashCommand' | 'perm' | null;
|
||||
type ActiveMenu = 'model' | 'thinking' | 'attach' | 'atMention' | 'slashCommand' | 'perm' | null;
|
||||
const [activeMenu, setActiveMenu] = useState<ActiveMenu>(null);
|
||||
const [menuPos, setMenuPos] = useState<{ left: number; bottom: number } | null>(null);
|
||||
const [inputPanelPos, setInputPanelPos] = useState<{ left: number; bottom: number; width: number } | null>(null);
|
||||
const [hoveredModelId, setHoveredModelId] = useState<string | null>(null);
|
||||
const [modelPrefs, setModelPrefs] = useState<ComposerModelPrefs>(() => readComposerModelPrefs(pickerScope));
|
||||
const [slashQuery, setSlashQuery] = useState('');
|
||||
const [slashRange, setSlashRange] = useState<{ start: number; end: number } | null>(null);
|
||||
// Active highlight index for @ mention / slash skill keyboard navigation
|
||||
@@ -288,6 +310,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
|
||||
// Derived booleans for readability
|
||||
const showModelPicker = activeMenu === 'model';
|
||||
const showThinkingPicker = activeMenu === 'thinking';
|
||||
const showAttachMenu = activeMenu === 'attach';
|
||||
const showAtMention = activeMenu === 'atMention';
|
||||
const showSlashCommandPicker = activeMenu === 'slashCommand';
|
||||
@@ -297,11 +320,16 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
setActiveMenu(null);
|
||||
setMenuPos(null);
|
||||
setInputPanelPos(null);
|
||||
setHoveredModelId(null);
|
||||
setSlashQuery('');
|
||||
setSlashRange(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const refresh = () => setModelPrefs(readComposerModelPrefs(pickerScope));
|
||||
refresh();
|
||||
return subscribeComposerModelPrefs(refresh);
|
||||
}, [pickerScope]);
|
||||
|
||||
useEffect(() => {
|
||||
if (parked) closeAllMenus();
|
||||
}, [closeAllMenus, parked]);
|
||||
@@ -585,8 +613,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (lockTurnConfiguration && (showModelPicker || showPermPicker)) closeAllMenus();
|
||||
}, [closeAllMenus, lockTurnConfiguration, showModelPicker, showPermPicker]);
|
||||
if (lockTurnConfiguration && (showModelPicker || showThinkingPicker || showPermPicker)) closeAllMenus();
|
||||
}, [closeAllMenus, lockTurnConfiguration, showModelPicker, showThinkingPicker, showPermPicker]);
|
||||
|
||||
const quickMessageSlugSet = useMemo(
|
||||
() => new Set(quickMessages.map((message) => message.slug)),
|
||||
@@ -761,7 +789,10 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSlashCommandPicker || !menuPos) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => handleSlashCommandKeyDown(event);
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (isAppLockOverlayActive()) return;
|
||||
handleSlashCommandKeyDown(event);
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown, true);
|
||||
return () => window.removeEventListener('keydown', onKeyDown, true);
|
||||
}, [handleSlashCommandKeyDown, menuPos, showSlashCommandPicker]);
|
||||
@@ -863,21 +894,12 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
// split on the first '/'. Match against the full id first; only treat the
|
||||
// trailing segment as a thinking level when we find a preset whose
|
||||
// declared thinkingLevels make the combined form equal to selectedModelId.
|
||||
const { selectedPreset, selectedThinking } = (() => {
|
||||
if (!selectedModelId) return { selectedPreset: undefined, selectedThinking: undefined };
|
||||
const direct = modelPresets.find(m => m.id === selectedModelId);
|
||||
if (direct) return { selectedPreset: direct, selectedThinking: undefined };
|
||||
const viaThinking = modelPresets.find(
|
||||
m => m.thinkingLevels?.some(level => `${m.id}/${level}` === selectedModelId),
|
||||
);
|
||||
if (viaThinking) {
|
||||
const thinking = selectedModelId.slice(viaThinking.id.length + 1);
|
||||
return { selectedPreset: viaThinking, selectedThinking: thinking };
|
||||
}
|
||||
return { selectedPreset: undefined, selectedThinking: undefined };
|
||||
})();
|
||||
const { preset: selectedPreset, thinking: selectedPresetThinking } = resolveThinkingSelection(
|
||||
selectedModelId,
|
||||
modelPresets,
|
||||
);
|
||||
const selectedBaseModelId = selectedPreset?.id;
|
||||
// Provider switcher mode (Catty Agent): two-column popover, chip carries
|
||||
// Provider switcher mode (Catty Agent): single-column popover, chip carries
|
||||
// the provider's icon + name + model name. Falls back to the existing
|
||||
// single-list model dropdown for external SDK agents.
|
||||
const hasProviderSwitcher = !!providerSwitcher && providerSwitcher.providers.length > 0;
|
||||
@@ -897,13 +919,54 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
: '';
|
||||
const modelLabel = hasProviderSwitcher
|
||||
? providerSwitcherChipLabel
|
||||
: (selectedPreset
|
||||
? selectedPreset.name + (selectedThinking ? ` / ${formatThinkingLabel(selectedThinking)}` : '')
|
||||
: modelName || providerName || t('ai.chat.noModel'));
|
||||
const modelChipMaxWidth = hasProviderSwitcher
|
||||
? 'max-w-[180px]'
|
||||
: (selectedThinking ? 'max-w-[148px]' : 'max-w-[82px]');
|
||||
: (selectedPreset?.name || modelName || providerName || t('ai.chat.noModel'));
|
||||
const modelChipMaxWidth = hasProviderSwitcher ? 'max-w-[168px]' : 'max-w-[96px]';
|
||||
const hasModelPicker = hasProviderSwitcher || (modelPresets.length > 0 && !!onModelSelect);
|
||||
const thinkingLevels = useMemo(
|
||||
() => (
|
||||
hasProviderSwitcher && onThinkingLevelChange
|
||||
? cattyReasoningLevelsForSelection(
|
||||
selectedSwitcherProvider,
|
||||
providerSwitcher?.selectedModelId,
|
||||
)
|
||||
: (selectedPreset?.thinkingLevels ?? [])
|
||||
),
|
||||
[
|
||||
hasProviderSwitcher,
|
||||
onThinkingLevelChange,
|
||||
selectedSwitcherProvider,
|
||||
providerSwitcher?.selectedModelId,
|
||||
selectedPreset?.thinkingLevels,
|
||||
],
|
||||
);
|
||||
const selectedThinking = hasProviderSwitcher
|
||||
? thinkingLevel
|
||||
: selectedPresetThinking;
|
||||
const visibleThinking = hasProviderSwitcher
|
||||
? (selectedThinking
|
||||
? resolveVisibleCattyThinkingLevel(thinkingLevels, selectedThinking)
|
||||
: undefined)
|
||||
: selectedThinking;
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasProviderSwitcher || !onThinkingLevelChange) return;
|
||||
if (!thinkingLevels.length) return;
|
||||
if (!thinkingLevel) return;
|
||||
if (thinkingLevels.includes(thinkingLevel)) return;
|
||||
const next = resolveVisibleCattyThinkingLevel(thinkingLevels, thinkingLevel);
|
||||
if (next && next !== thinkingLevel) onThinkingLevelChange(next);
|
||||
}, [
|
||||
hasProviderSwitcher,
|
||||
onThinkingLevelChange,
|
||||
providerSwitcher?.selectedProviderId,
|
||||
providerSwitcher?.selectedModelId,
|
||||
thinkingLevel,
|
||||
thinkingLevels,
|
||||
]);
|
||||
const showThinkingChip = thinkingLevels.length > 0 && (!hasProviderSwitcher || !!onThinkingLevelChange);
|
||||
const popoverMaxWidth = hasProviderSwitcher
|
||||
? COMPOSER_PROVIDER_PICKER_WIDTH
|
||||
: COMPOSER_MODEL_PICKER_WIDTH;
|
||||
const contextUsagePercent = contextUsage
|
||||
? Math.min(100, Math.max(0, (contextUsage.inputTokens / contextUsage.contextWindow) * 100))
|
||||
: 0;
|
||||
@@ -919,7 +982,6 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
.replace('{used}', formatContextTokens(contextUsage.inputTokens))
|
||||
.replace('{max}', formatContextTokens(contextUsage.contextWindow))
|
||||
: '';
|
||||
const popoverMaxWidth = hasProviderSwitcher ? PROVIDER_PICKER_MAX_WIDTH : MODEL_PICKER_MAX_WIDTH;
|
||||
const chipClassName =
|
||||
'inline-flex h-6 items-center gap-1 rounded-full px-1.5 text-[10.5px] text-foreground/72';
|
||||
const selectedSkillChipClassName =
|
||||
@@ -1289,9 +1351,6 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
const left = Math.max(8, Math.min(rect.left, window.innerWidth - popoverMaxWidth - 8));
|
||||
setMenuPos({ left, bottom: window.innerHeight - rect.top + 6 });
|
||||
}
|
||||
if (selectedPreset?.thinkingLevels?.length) {
|
||||
setHoveredModelId(selectedPreset.id);
|
||||
}
|
||||
setActiveMenu('model');
|
||||
} else {
|
||||
closeAllMenus();
|
||||
@@ -1299,7 +1358,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
}}
|
||||
disabled={lockTurnConfiguration}
|
||||
className={`${chipClassName} min-w-0 ${hasModelPicker && !lockTurnConfiguration ? 'cursor-pointer hover:bg-muted/24 transition-colors' : 'opacity-60'}`}
|
||||
aria-label={hasProviderSwitcher ? 'Select provider and model' : 'Select model'}
|
||||
aria-label={hasProviderSwitcher ? t('ai.chat.selectProviderAndModel') : t('ai.chat.selectModel')}
|
||||
aria-expanded={showModelPicker}
|
||||
>
|
||||
{hasProviderSwitcher && selectedSwitcherProvider ? (
|
||||
@@ -1353,144 +1412,79 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
</Tooltip>
|
||||
)}
|
||||
{showModelPicker && hasModelPicker && menuPos && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
|
||||
<div className="fixed inset-0 z-[999] cursor-default" onClick={closeAllMenus} />
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label={hasProviderSwitcher ? 'Select provider and model' : 'Select model'}
|
||||
className="fixed z-[1000] w-max min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
|
||||
<>
|
||||
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label={hasProviderSwitcher ? t('ai.chat.selectProviderAndModel') : t('ai.chat.selectModel')}
|
||||
className="fixed z-[1000] overflow-hidden rounded-lg border border-border/50 bg-popover shadow-lg"
|
||||
style={{ left: menuPos.left, bottom: menuPos.bottom, maxWidth: popoverMaxWidth }}
|
||||
onMouseLeave={() => setHoveredModelId(null)}
|
||||
>
|
||||
{hasProviderSwitcher ? (
|
||||
<div className="min-w-[260px] max-h-[320px] overflow-y-auto">
|
||||
{providerSwitcher!.providers.map((p) => {
|
||||
const isSelected = providerSwitcher!.selectedProviderId === p.id;
|
||||
const defaultModel = p.defaultModel?.trim() ?? '';
|
||||
const hasModel = defaultModel.length > 0;
|
||||
// Rows without a defaultModel are inert — picking
|
||||
// one would save a binding with an empty model id
|
||||
// and produce a confusing model error at send time.
|
||||
// User has to set a defaultModel in Settings first.
|
||||
const disabled = !hasModel;
|
||||
const modelCaption = hasModel
|
||||
? defaultModel
|
||||
: t('ai.chat.noProviderModel');
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
aria-disabled={disabled}
|
||||
disabled={disabled}
|
||||
title={disabled ? t('ai.chat.noProviderModel') : undefined}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
providerSwitcher!.onSelect(p.id, defaultModel);
|
||||
closeAllMenus();
|
||||
}}
|
||||
className={`w-full flex items-center gap-2.5 px-2.5 py-2 text-left transition-colors ${
|
||||
disabled
|
||||
? 'opacity-55 cursor-not-allowed'
|
||||
: 'hover:bg-muted/30 cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<ProviderIconBadge provider={p} size="md" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate text-[12px] text-foreground/85">{p.name}</div>
|
||||
<div className={`truncate text-[10.5px] ${hasModel ? 'text-muted-foreground/70 font-mono' : 'text-muted-foreground/55 italic'}`}>
|
||||
{modelCaption}
|
||||
</div>
|
||||
</div>
|
||||
{isSelected && <Check size={12} className="text-primary shrink-0" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-w-[260px] max-h-[320px] overflow-y-auto">
|
||||
{modelPresets.map(preset => {
|
||||
const isSelected = preset.id === selectedBaseModelId;
|
||||
const hasThinking = preset.thinkingLevels && preset.thinkingLevels.length > 0;
|
||||
const showThinkingLevels = hasThinking && hoveredModelId === preset.id;
|
||||
return (
|
||||
<div
|
||||
key={preset.id}
|
||||
onMouseEnter={() => setHoveredModelId(hasThinking ? preset.id : null)}
|
||||
onFocus={() => { if (hasThinking) setHoveredModelId(preset.id); }}
|
||||
onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setHoveredModelId(null); }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
aria-expanded={hasThinking ? showThinkingLevels : undefined}
|
||||
onClick={() => {
|
||||
if (!hasThinking) {
|
||||
onModelSelect?.(preset.id);
|
||||
closeAllMenus();
|
||||
return;
|
||||
}
|
||||
setHoveredModelId(showThinkingLevels ? null : preset.id);
|
||||
}}
|
||||
className="w-full min-w-0 flex items-center gap-1.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer"
|
||||
>
|
||||
{isSelected ? <Check size={11} className="text-primary shrink-0" /> : <span className="w-[11px] shrink-0" />}
|
||||
<span className="flex-1 min-w-0 truncate text-foreground/85">{preset.name}</span>
|
||||
{hasThinking && (
|
||||
<ChevronRight
|
||||
size={10}
|
||||
className={`text-muted-foreground/50 shrink-0 transition-transform ${showThinkingLevels ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
{/* Inline thinking levels — flyout submenus get clipped by overflow-y-auto above. */}
|
||||
{showThinkingLevels && (
|
||||
<div role="listbox" aria-label="Thinking level" className="border-t border-border/30 bg-muted/10 py-0.5">
|
||||
{preset.thinkingLevels!.map(level => {
|
||||
const fullId = `${preset.id}/${level}`;
|
||||
const isLevelSelected = selectedModelId === fullId;
|
||||
return (
|
||||
<button
|
||||
key={level}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isLevelSelected}
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
onModelSelect?.(fullId);
|
||||
closeAllMenus();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onModelSelect?.(fullId);
|
||||
closeAllMenus();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeAllMenus();
|
||||
}
|
||||
}}
|
||||
className="w-full flex items-center gap-1.5 pl-7 pr-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
{isLevelSelected ? <Check size={11} className="text-primary shrink-0" /> : <span className="w-[11px] shrink-0" />}
|
||||
<span className="text-foreground/85">{formatThinkingLabel(level)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<ComposerModelPicker
|
||||
providers={hasProviderSwitcher ? providerSwitcher!.providers : undefined}
|
||||
selectedProviderId={providerSwitcher?.selectedProviderId}
|
||||
selectedModelId={hasProviderSwitcher ? providerSwitcher?.selectedModelId : selectedBaseModelId}
|
||||
modelPresets={hasProviderSwitcher ? undefined : modelPresets}
|
||||
prefs={modelPrefs}
|
||||
onSelectProviderModel={(providerId, modelId, contextWindow) => {
|
||||
providerSwitcher?.onSelect(providerId, modelId, contextWindow);
|
||||
setModelPrefs(rememberComposerRecentModel(pickerScope, { providerId, modelId }));
|
||||
closeAllMenus();
|
||||
}}
|
||||
onSelectModel={(modelId) => {
|
||||
const preset = modelPresets.find((item) => item.id === modelId);
|
||||
const nextId = preset
|
||||
? resolveModelSelectionWithThinking(preset, selectedPresetThinking)
|
||||
: modelId;
|
||||
onModelSelect?.(nextId);
|
||||
setModelPrefs(rememberComposerRecentModel(pickerScope, { modelId }));
|
||||
closeAllMenus();
|
||||
}}
|
||||
onTogglePinned={(entry) => {
|
||||
setModelPrefs(toggleComposerPinnedModel(pickerScope, entry));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
{showThinkingChip && (
|
||||
<ComposerThinkingChip
|
||||
levels={thinkingLevels}
|
||||
selectedLevel={visibleThinking}
|
||||
disabled={lockTurnConfiguration}
|
||||
open={showThinkingPicker}
|
||||
menuPos={showThinkingPicker ? menuPos : null}
|
||||
onToggle={(rect) => {
|
||||
if (lockTurnConfiguration) return;
|
||||
if (!showThinkingPicker) {
|
||||
if (rect) {
|
||||
const left = Math.max(
|
||||
MENU_VIEWPORT_GUTTER,
|
||||
Math.min(rect.left, window.innerWidth - THINKING_PICKER_WIDTH - MENU_VIEWPORT_GUTTER),
|
||||
);
|
||||
setMenuPos({ left, bottom: window.innerHeight - rect.top + 6 });
|
||||
}
|
||||
setActiveMenu('thinking');
|
||||
} else {
|
||||
closeAllMenus();
|
||||
}
|
||||
}}
|
||||
onSelect={(level) => {
|
||||
if (hasProviderSwitcher) {
|
||||
onThinkingLevelChange?.(level);
|
||||
} else if (selectedPreset) {
|
||||
onModelSelect?.(
|
||||
level && selectedPreset.thinkingLevels?.includes(level)
|
||||
? `${selectedPreset.id}/${level}`
|
||||
: selectedPreset.id,
|
||||
);
|
||||
}
|
||||
closeAllMenus();
|
||||
}}
|
||||
onClose={closeAllMenus}
|
||||
/>
|
||||
)}
|
||||
{/* Permission mode chip — only for Catty Agent */}
|
||||
{permissionMode && onPermissionModeChange && (
|
||||
<>
|
||||
@@ -1543,7 +1537,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Permission mode"
|
||||
className="fixed z-[1000] w-[250px] max-w-[calc(100vw-16px)] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
|
||||
className="fixed z-[1000] w-[200px] max-w-[calc(100vw-16px)] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
|
||||
style={{ left: menuPos.left, bottom: menuPos.bottom }}
|
||||
>
|
||||
{([
|
||||
@@ -1561,16 +1555,16 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
onPermissionModeChange(mode);
|
||||
closeAllMenus();
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer"
|
||||
className="w-full flex items-center gap-2 px-2.5 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer"
|
||||
>
|
||||
{permissionMode === mode
|
||||
? <Check size={11} className="text-primary shrink-0" />
|
||||
: <span className="w-[11px] shrink-0" />
|
||||
}
|
||||
<Icon size={12} className={`${color} shrink-0`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-foreground/85">{label}</div>
|
||||
<div className="text-[10px] text-muted-foreground/40 leading-tight">{desc}</div>
|
||||
<div className="flex-1 min-w-0 flex items-baseline gap-1.5">
|
||||
<span className="text-foreground/85 shrink-0">{label}</span>
|
||||
<span className="text-[11px] text-muted-foreground/45 truncate">{desc}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
@@ -1654,6 +1648,9 @@ function chatInputPropsAreEqual(prev: ChatInputProps, next: ChatInputProps): boo
|
||||
&& prev.permissionMode === next.permissionMode
|
||||
&& prev.onPermissionModeChange === next.onPermissionModeChange
|
||||
&& prev.providerSwitcher === next.providerSwitcher
|
||||
&& prev.pickerScope === next.pickerScope
|
||||
&& prev.thinkingLevel === next.thinkingLevel
|
||||
&& prev.onThinkingLevelChange === next.onThinkingLevelChange
|
||||
&& prev.parked === next.parked
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { ComposerModelPicker } from './ComposerModelPicker';
|
||||
|
||||
test('Catty picker keeps a single model list and hides other providers until the submenu opens', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ComposerModelPicker
|
||||
providers={[
|
||||
{
|
||||
id: 'p1',
|
||||
providerId: 'deepseek',
|
||||
name: 'DeepSeek',
|
||||
defaultModel: 'deepseek-v4-pro',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
providerId: 'openai',
|
||||
name: 'OpenAI',
|
||||
defaultModel: 'gpt-5.5',
|
||||
enabled: true,
|
||||
},
|
||||
]}
|
||||
selectedProviderId="p1"
|
||||
selectedModelId="deepseek-v4-pro"
|
||||
prefs={{ recent: [], pinned: [] }}
|
||||
onSelectProviderModel={() => {}}
|
||||
onTogglePinned={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(html, /DeepSeek/);
|
||||
assert.match(html, /placeholder="ai\.chat\.searchModels"/);
|
||||
assert.match(html, /deepseek-v4-pro/);
|
||||
assert.match(html, /aria-label="ai\.chat\.selectProvider"/);
|
||||
assert.doesNotMatch(html, /OpenAI/);
|
||||
assert.doesNotMatch(html, /w-\[128px\]/);
|
||||
});
|
||||
|
||||
test('external agent picker lists presets without a provider column', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ComposerModelPicker
|
||||
modelPresets={[
|
||||
{ id: 'gpt-5.5', name: 'GPT-5.5' },
|
||||
{ id: 'gpt-5.4', name: 'GPT-5.4' },
|
||||
]}
|
||||
selectedModelId="gpt-5.5"
|
||||
prefs={{ recent: [{ modelId: 'gpt-5.5' }], pinned: [] }}
|
||||
onSelectModel={() => {}}
|
||||
onTogglePinned={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(html, /GPT-5\.5/);
|
||||
assert.match(html, /GPT-5\.4/);
|
||||
assert.match(html, /ai\.chat\.recent/);
|
||||
assert.doesNotMatch(html, /ai\.chat\.providers/);
|
||||
});
|
||||
|
||||
test('custom model action is only offered in Catty provider-switcher mode', () => {
|
||||
const source = readFileSync(new URL('./ComposerModelPicker.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /const showCustom = Boolean\(\s*hasProviders/s);
|
||||
assert.match(source, /resolveComposerEnterModelId/);
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
import { Check, ChevronLeft, ChevronRight, Loader2, Pin, Search, Star } from 'lucide-react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import {
|
||||
filterComposerModels,
|
||||
resolveComposerEnterModelId,
|
||||
resolvePinnedAndRecentModels,
|
||||
type ComposerModelPrefEntry,
|
||||
type ComposerModelPrefs,
|
||||
type ComposerPickerModel,
|
||||
} from '../../infrastructure/ai/composerPicker';
|
||||
import type { AgentModelPreset, ProviderConfig } from '../../infrastructure/ai/types';
|
||||
import { ProviderIconBadge } from '../settings/tabs/ai/ProviderIconBadge';
|
||||
import { useProviderModelCatalog } from './useProviderModelCatalog';
|
||||
|
||||
export const COMPOSER_PROVIDER_PICKER_WIDTH = 260;
|
||||
export const COMPOSER_MODEL_PICKER_WIDTH = 260;
|
||||
|
||||
export interface ComposerModelPickerProps {
|
||||
providers?: ProviderConfig[];
|
||||
selectedProviderId?: string;
|
||||
selectedModelId?: string;
|
||||
modelPresets?: AgentModelPreset[];
|
||||
prefs: ComposerModelPrefs;
|
||||
onSelectProviderModel?: (providerId: string, modelId: string, contextWindow?: number) => void;
|
||||
onSelectModel?: (modelId: string) => void;
|
||||
onTogglePinned: (entry: ComposerModelPrefEntry) => void;
|
||||
}
|
||||
|
||||
const rowClassName =
|
||||
'flex h-8 w-full items-center gap-2 px-2.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer';
|
||||
|
||||
const SectionLabel: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<div className="px-2.5 pt-1.5 pb-0.5 text-[10px] tracking-wide text-muted-foreground/45">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const ModelRow: React.FC<{
|
||||
model: ComposerPickerModel;
|
||||
selected: boolean;
|
||||
pinned: boolean;
|
||||
onSelect: () => void;
|
||||
onTogglePinned: () => void;
|
||||
pinLabel: string;
|
||||
unpinLabel: string;
|
||||
}> = ({ model, selected, pinned, onSelect, onTogglePinned, pinLabel, unpinLabel }) => (
|
||||
<div className="group/row relative">
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
onClick={onSelect}
|
||||
className={rowClassName}
|
||||
>
|
||||
{selected
|
||||
? <Check size={11} className="text-primary shrink-0" />
|
||||
: <span className="w-[11px] shrink-0" />}
|
||||
<span className="min-w-0 flex-1 truncate text-foreground/88">{model.name}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={pinned ? unpinLabel : pinLabel}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onTogglePinned();
|
||||
}}
|
||||
className={`absolute right-1.5 top-1/2 -translate-y-1/2 rounded p-0.5 transition-opacity ${
|
||||
pinned
|
||||
? 'text-amber-400/90 opacity-100'
|
||||
: 'text-muted-foreground/45 opacity-0 group-hover/row:opacity-100 hover:text-foreground/70'
|
||||
}`}
|
||||
>
|
||||
<Star size={11} fill={pinned ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ComposerModelPicker: React.FC<ComposerModelPickerProps> = ({
|
||||
providers = [],
|
||||
selectedProviderId,
|
||||
selectedModelId,
|
||||
modelPresets = [],
|
||||
prefs,
|
||||
onSelectProviderModel,
|
||||
onSelectModel,
|
||||
onTogglePinned,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const hasProviders = providers.length > 0;
|
||||
const [previewProviderId, setPreviewProviderId] = useState(
|
||||
selectedProviderId || providers[0]?.id || '',
|
||||
);
|
||||
const [query, setQuery] = useState('');
|
||||
const [view, setView] = useState<'models' | 'providers'>('models');
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProviderId) setPreviewProviderId(selectedProviderId);
|
||||
}, [selectedProviderId]);
|
||||
|
||||
const previewProvider = hasProviders
|
||||
? providers.find((provider) => provider.id === previewProviderId) ?? providers[0]
|
||||
: undefined;
|
||||
const catalog = useProviderModelCatalog(previewProvider, hasProviders);
|
||||
|
||||
const models = useMemo<ComposerPickerModel[]>(() => {
|
||||
if (hasProviders) return catalog.models;
|
||||
return modelPresets.map((preset) => ({
|
||||
id: preset.id,
|
||||
name: preset.name,
|
||||
description: preset.description,
|
||||
}));
|
||||
}, [catalog.models, hasProviders, modelPresets]);
|
||||
|
||||
const filtered = useMemo(() => filterComposerModels(models, query), [models, query]);
|
||||
const grouped = useMemo(
|
||||
() => resolvePinnedAndRecentModels({
|
||||
models: filtered,
|
||||
prefs,
|
||||
providerId: previewProvider?.id,
|
||||
allowMissing: Boolean(previewProvider?.id) && !query.trim(),
|
||||
}),
|
||||
[filtered, prefs, previewProvider?.id, query],
|
||||
);
|
||||
const pinnedKeys = useMemo(
|
||||
() => new Set(
|
||||
prefs.pinned
|
||||
.filter((entry) => !previewProvider || !entry.providerId || entry.providerId === previewProvider.id)
|
||||
.map((entry) => entry.modelId),
|
||||
),
|
||||
[prefs.pinned, previewProvider],
|
||||
);
|
||||
|
||||
const trimmedQuery = query.trim();
|
||||
const showCustom = Boolean(
|
||||
hasProviders
|
||||
&& trimmedQuery
|
||||
&& !models.some((model) => model.id.toLowerCase() === trimmedQuery.toLowerCase()),
|
||||
);
|
||||
|
||||
const selectModel = (modelId: string) => {
|
||||
const contextWindow = models.find((model) => model.id === modelId)?.contextWindow;
|
||||
if (hasProviders && previewProvider) {
|
||||
onSelectProviderModel?.(previewProvider.id, modelId, contextWindow);
|
||||
return;
|
||||
}
|
||||
onSelectModel?.(modelId);
|
||||
};
|
||||
|
||||
const prefEntryFor = (modelId: string): ComposerModelPrefEntry => (
|
||||
previewProvider ? { providerId: previewProvider.id, modelId } : { modelId }
|
||||
);
|
||||
|
||||
if (hasProviders && view === 'providers') {
|
||||
return (
|
||||
<div className="w-[260px] max-w-[calc(100vw-16px)] py-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView('models')}
|
||||
className={rowClassName}
|
||||
>
|
||||
<ChevronLeft size={12} className="text-muted-foreground/60 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground/70">
|
||||
{t('ai.chat.providers')}
|
||||
</span>
|
||||
</button>
|
||||
<div className="mx-2 my-1 border-t border-border/40" />
|
||||
{providers.map((provider) => {
|
||||
const isBound = provider.id === selectedProviderId;
|
||||
return (
|
||||
<button
|
||||
key={provider.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isBound}
|
||||
onClick={() => {
|
||||
setPreviewProviderId(provider.id);
|
||||
setQuery('');
|
||||
setView('models');
|
||||
}}
|
||||
className={rowClassName}
|
||||
>
|
||||
<ProviderIconBadge provider={provider} size="xs" />
|
||||
<span className="min-w-0 flex-1 truncate text-foreground/88">{provider.name}</span>
|
||||
{isBound && <Check size={11} className="text-primary shrink-0" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-[260px] max-w-[calc(100vw-16px)] py-1">
|
||||
{hasProviders && previewProvider && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('ai.chat.selectProvider')}
|
||||
onClick={() => setView('providers')}
|
||||
className={rowClassName}
|
||||
>
|
||||
<ProviderIconBadge provider={previewProvider} size="xs" />
|
||||
<span className="min-w-0 flex-1 truncate text-foreground/88">{previewProvider.name}</span>
|
||||
<ChevronRight size={12} className="text-muted-foreground/50 shrink-0" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="px-2 pb-1">
|
||||
<div className="flex h-7 items-center gap-1.5 rounded-md bg-muted/40 px-2">
|
||||
<Search size={11} className="text-muted-foreground/50 shrink-0" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && trimmedQuery) {
|
||||
event.preventDefault();
|
||||
const nextId = resolveComposerEnterModelId({
|
||||
query: trimmedQuery,
|
||||
models,
|
||||
grouped,
|
||||
filtered,
|
||||
showCustom,
|
||||
});
|
||||
if (nextId) selectModel(nextId);
|
||||
}
|
||||
}}
|
||||
placeholder={t('ai.chat.searchModels')}
|
||||
className="h-full w-full bg-transparent text-[12px] text-foreground/88 outline-none placeholder:text-muted-foreground/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[280px] overflow-y-auto">
|
||||
{catalog.loading && (
|
||||
<div className="flex h-8 items-center gap-1.5 px-2.5 text-[11px] text-muted-foreground/55">
|
||||
<Loader2 size={11} className="animate-spin" />
|
||||
{t('ai.chat.loadingModels')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCustom && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectModel(trimmedQuery)}
|
||||
className={rowClassName}
|
||||
>
|
||||
<Pin size={11} className="text-muted-foreground/55 shrink-0" />
|
||||
<span className="min-w-0 truncate text-foreground/85">
|
||||
{t('ai.chat.useCustomModel').replace('{id}', trimmedQuery)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{grouped.pinned.length > 0 && (
|
||||
<>
|
||||
<SectionLabel>{t('ai.chat.pinned')}</SectionLabel>
|
||||
{grouped.pinned.map((model) => (
|
||||
<ModelRow
|
||||
key={`pin-${model.id}`}
|
||||
model={model}
|
||||
selected={model.id === selectedModelId && (!hasProviders || previewProvider?.id === selectedProviderId)}
|
||||
pinned
|
||||
onSelect={() => selectModel(model.id)}
|
||||
onTogglePinned={() => onTogglePinned(prefEntryFor(model.id))}
|
||||
pinLabel={t('ai.chat.pinModel')}
|
||||
unpinLabel={t('ai.chat.unpinModel')}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{grouped.recent.length > 0 && (
|
||||
<>
|
||||
<SectionLabel>{t('ai.chat.recent')}</SectionLabel>
|
||||
{grouped.recent.map((model) => (
|
||||
<ModelRow
|
||||
key={`recent-${model.id}`}
|
||||
model={model}
|
||||
selected={model.id === selectedModelId && (!hasProviders || previewProvider?.id === selectedProviderId)}
|
||||
pinned={pinnedKeys.has(model.id)}
|
||||
onSelect={() => selectModel(model.id)}
|
||||
onTogglePinned={() => onTogglePinned(prefEntryFor(model.id))}
|
||||
pinLabel={t('ai.chat.pinModel')}
|
||||
unpinLabel={t('ai.chat.unpinModel')}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(grouped.pinned.length > 0 || grouped.recent.length > 0) && grouped.rest.length > 0 && (
|
||||
<SectionLabel>{t('ai.chat.models')}</SectionLabel>
|
||||
)}
|
||||
|
||||
{grouped.rest.map((model) => (
|
||||
<ModelRow
|
||||
key={model.id}
|
||||
model={model}
|
||||
selected={model.id === selectedModelId && (!hasProviders || previewProvider?.id === selectedProviderId)}
|
||||
pinned={pinnedKeys.has(model.id)}
|
||||
onSelect={() => selectModel(model.id)}
|
||||
onTogglePinned={() => onTogglePinned(prefEntryFor(model.id))}
|
||||
pinLabel={t('ai.chat.pinModel')}
|
||||
unpinLabel={t('ai.chat.unpinModel')}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!catalog.loading && filtered.length === 0 && !showCustom && (
|
||||
<div className="px-2.5 py-2 text-[11px] text-muted-foreground/50">
|
||||
{catalog.error || t('ai.chat.noMatchingModels')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ComposerModelPicker);
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Brain, Check, ChevronDown } from 'lucide-react';
|
||||
import React, { useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { formatComposerThinkingLabel } from '../../infrastructure/ai/composerPicker';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
|
||||
export interface ComposerThinkingChipProps {
|
||||
levels: readonly string[];
|
||||
selectedLevel?: string;
|
||||
disabled?: boolean;
|
||||
open: boolean;
|
||||
menuPos: { left: number; bottom: number } | null;
|
||||
onToggle: (rect: DOMRect | undefined) => void;
|
||||
onSelect: (level: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const chipClassName =
|
||||
'inline-flex h-6 items-center gap-1 rounded-full px-1.5 text-[10.5px] text-foreground/72';
|
||||
|
||||
export const ComposerThinkingChip: React.FC<ComposerThinkingChipProps> = ({
|
||||
levels,
|
||||
selectedLevel,
|
||||
disabled = false,
|
||||
open,
|
||||
menuPos,
|
||||
onToggle,
|
||||
onSelect,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
const formatLevel = (level: string) => (
|
||||
level === 'off' ? t('ai.chat.thinkingOff') : formatComposerThinkingLabel(level)
|
||||
);
|
||||
const label = selectedLevel
|
||||
? formatLevel(selectedLevel)
|
||||
: t('ai.chat.thinkingLevel');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
ref={btnRef}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onToggle(btnRef.current?.getBoundingClientRect())}
|
||||
className={`${chipClassName} shrink-0 ${
|
||||
disabled
|
||||
? 'opacity-60'
|
||||
: 'cursor-pointer hover:bg-muted/24 transition-colors'
|
||||
}`}
|
||||
aria-label={t('ai.chat.thinkingLevel')}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<Brain size={11} className="text-violet-400/75" />
|
||||
<span className="truncate max-w-[56px]">{label}</span>
|
||||
{!disabled && <ChevronDown size={9} className="text-muted-foreground/50" />}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.thinkingLevel')}</TooltipContent>
|
||||
</Tooltip>
|
||||
{open && menuPos && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[999]" onClick={onClose} />
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label={t('ai.chat.thinkingLevel')}
|
||||
className="fixed z-[1000] min-w-[148px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
|
||||
style={{ left: menuPos.left, bottom: menuPos.bottom }}
|
||||
>
|
||||
{levels.map((level) => {
|
||||
const isSelected = selectedLevel === level;
|
||||
return (
|
||||
<button
|
||||
key={level}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
onClick={() => {
|
||||
if (!isSelected) onSelect(level);
|
||||
else onClose();
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-2.5 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer"
|
||||
>
|
||||
{isSelected
|
||||
? <Check size={11} className="text-primary shrink-0" />
|
||||
: <span className="w-[11px] shrink-0" />}
|
||||
<Brain size={12} className="text-violet-400/70 shrink-0" />
|
||||
<span className="text-foreground/85">{formatLevel(level)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ComposerThinkingChip);
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
fetchProviderModelCatalog,
|
||||
providerModelCacheKey,
|
||||
readCachedProviderModelCatalog,
|
||||
seedProviderModelCatalog,
|
||||
} from '../../infrastructure/ai/cattyProviderModels';
|
||||
import type { ComposerPickerModel } from '../../infrastructure/ai/composerPicker';
|
||||
import type { ProviderConfig } from '../../infrastructure/ai/types';
|
||||
import { getFetchBridge } from '../settings/tabs/ai/types';
|
||||
|
||||
export interface ProviderModelCatalog {
|
||||
models: ComposerPickerModel[];
|
||||
fetched: boolean;
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function useProviderModelCatalog(
|
||||
provider: ProviderConfig | undefined,
|
||||
enabled: boolean,
|
||||
): ProviderModelCatalog {
|
||||
const cacheKey = provider && enabled ? providerModelCacheKey(provider) : '';
|
||||
const providerRef = useRef(provider);
|
||||
providerRef.current = provider;
|
||||
const seed = useMemo(
|
||||
() => {
|
||||
if (!cacheKey) return { models: [], fetched: false };
|
||||
const current = providerRef.current;
|
||||
return current ? seedProviderModelCatalog(current) : { models: [], fetched: false };
|
||||
},
|
||||
[cacheKey],
|
||||
);
|
||||
const [catalog, setCatalog] = useState<Omit<ProviderModelCatalog, 'loading'>>(() => {
|
||||
const current = providerRef.current;
|
||||
const hit = current && enabled ? readCachedProviderModelCatalog(current) : null;
|
||||
return hit ? { models: hit, fetched: true } : seed;
|
||||
});
|
||||
const [loading, setLoading] = useState(() => {
|
||||
const current = providerRef.current;
|
||||
return Boolean(enabled && current && !readCachedProviderModelCatalog(current));
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const current = providerRef.current;
|
||||
if (!enabled || !current) {
|
||||
setCatalog({ models: [], fetched: false });
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const hit = readCachedProviderModelCatalog(current);
|
||||
if (hit) {
|
||||
setCatalog((prev) => (
|
||||
prev.fetched && prev.models === hit ? prev : { models: hit, fetched: true }
|
||||
));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setCatalog(seedProviderModelCatalog(current));
|
||||
setLoading(true);
|
||||
void fetchProviderModelCatalog(current, getFetchBridge()).then((next) => {
|
||||
if (cancelled) return;
|
||||
setCatalog(next);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [cacheKey, enabled]);
|
||||
|
||||
return {
|
||||
models: catalog.models.length > 0 ? catalog.models : seed.models,
|
||||
fetched: catalog.fetched,
|
||||
loading,
|
||||
error: catalog.error,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { useI18n } from '../../../application/i18n/I18nProvider';
|
||||
import type { AppLockSystemUnlockStatus } from '../../../application/state/useAppLockState';
|
||||
import {
|
||||
APP_LOCK_TIMEOUT_OPTIONS_MINUTES,
|
||||
type AppLockSettings,
|
||||
type AppLockSettingsChangeError,
|
||||
type AppLockTimeoutMinutes,
|
||||
} from '../../../domain/appLock';
|
||||
import { Button } from '../../ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../../ui/dialog';
|
||||
import { Input } from '../../ui/input';
|
||||
import { Label } from '../../ui/label';
|
||||
import { Select, SettingCard, SettingHint, SettingRow, SectionHeader, Toggle } from '../settings-ui';
|
||||
|
||||
type AppLockDialogMode = 'setup' | 'change' | 'disable' | null;
|
||||
|
||||
interface AppLockSettingsSectionProps {
|
||||
appLockSettings: AppLockSettings;
|
||||
setAppLockTimeoutMinutes: (timeoutMinutes: AppLockTimeoutMinutes) => void;
|
||||
requestAppLockDisable: (
|
||||
currentPassword: string,
|
||||
) => Promise<AppLockSettings | { ok: false; error: AppLockSettingsChangeError }>;
|
||||
requestAppLockPasswordChange: (input: {
|
||||
currentPassword?: string;
|
||||
nextPassword: string;
|
||||
}) => Promise<AppLockSettings | { ok: false; error: AppLockSettingsChangeError }>;
|
||||
appLockSystemUnlockStatus?: AppLockSystemUnlockStatus;
|
||||
setAppLockSystemUnlockEnabled?: (input: {
|
||||
enabled: boolean;
|
||||
currentPassword?: string;
|
||||
autoPromptEnabled?: boolean;
|
||||
}) => Promise<
|
||||
AppLockSettings
|
||||
| { ok: false; error: 'empty-current' | 'incorrect' | 'locked' | 'unsupported' | 'unavailable' | 'cancelled' | 'failed' }
|
||||
>;
|
||||
}
|
||||
|
||||
export const AppLockSettingsSection: React.FC<AppLockSettingsSectionProps> = ({
|
||||
appLockSettings,
|
||||
setAppLockTimeoutMinutes,
|
||||
requestAppLockDisable,
|
||||
requestAppLockPasswordChange,
|
||||
appLockSystemUnlockStatus,
|
||||
setAppLockSystemUnlockEnabled,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [dialogMode, setDialogMode] = useState<AppLockDialogMode>(null);
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [disablePassword, setDisablePassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isDisabling, setIsDisabling] = useState(false);
|
||||
const [isSavingPassword, setIsSavingPassword] = useState(false);
|
||||
const [isSavingSystemUnlock, setIsSavingSystemUnlock] = useState(false);
|
||||
|
||||
const hasPassword = Boolean(appLockSettings.passwordVerifier);
|
||||
const isDialogBusy = isDisabling || isSavingPassword;
|
||||
const showSystemUnlock = Boolean(
|
||||
hasPassword
|
||||
&& appLockSystemUnlockStatus?.supported
|
||||
&& appLockSystemUnlockStatus.label
|
||||
&& (appLockSystemUnlockStatus.available || appLockSettings.systemUnlockEnabled),
|
||||
);
|
||||
const timeoutOptions = APP_LOCK_TIMEOUT_OPTIONS_MINUTES.map((minutes) => ({
|
||||
value: String(minutes),
|
||||
label: t(`settings.appLock.timeout.${minutes}`),
|
||||
}));
|
||||
|
||||
const mapChangeError = useCallback((
|
||||
changeError: AppLockSettingsChangeError | 'locked' | 'unsupported' | 'unavailable' | 'failed',
|
||||
): string => {
|
||||
switch (changeError) {
|
||||
case 'empty-current':
|
||||
return t('settings.appLock.validation.currentRequired');
|
||||
case 'empty-next':
|
||||
return t('settings.appLock.validation.newRequired');
|
||||
case 'incorrect':
|
||||
return t('settings.appLock.validation.incorrect');
|
||||
case 'locked':
|
||||
return t('settings.appLock.systemUnlock.locked');
|
||||
case 'unsupported':
|
||||
case 'unavailable':
|
||||
case 'failed':
|
||||
return t('settings.appLock.systemUnlock.unavailable');
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const resetDialog = useCallback(() => {
|
||||
setDialogMode(null);
|
||||
setCurrentPassword('');
|
||||
setDisablePassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const openDialog = useCallback((mode: Exclude<AppLockDialogMode, null>) => {
|
||||
setError(null);
|
||||
setDialogMode(mode);
|
||||
}, []);
|
||||
|
||||
const handleTimeoutChange = useCallback((value: string) => {
|
||||
const timeoutMinutes = Number(value) as AppLockTimeoutMinutes;
|
||||
if (!APP_LOCK_TIMEOUT_OPTIONS_MINUTES.includes(timeoutMinutes)) return;
|
||||
setAppLockTimeoutMinutes(timeoutMinutes);
|
||||
}, [setAppLockTimeoutMinutes]);
|
||||
|
||||
const handleDisable = useCallback(async () => {
|
||||
setError(null);
|
||||
setIsDisabling(true);
|
||||
try {
|
||||
const result = await requestAppLockDisable(disablePassword);
|
||||
if ('ok' in result && result.ok === false) {
|
||||
setError(mapChangeError(result.error));
|
||||
return;
|
||||
}
|
||||
resetDialog();
|
||||
} finally {
|
||||
setIsDisabling(false);
|
||||
}
|
||||
}, [disablePassword, mapChangeError, requestAppLockDisable, resetDialog]);
|
||||
|
||||
const handleSavePassword = useCallback(async () => {
|
||||
setError(null);
|
||||
if (!newPassword.trim()) {
|
||||
setError(t('settings.appLock.validation.newRequired'));
|
||||
return;
|
||||
}
|
||||
if (!confirmPassword.trim()) {
|
||||
setError(t('settings.appLock.validation.confirmRequired'));
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError(t('settings.appLock.validation.mismatch'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingPassword(true);
|
||||
try {
|
||||
const result = await requestAppLockPasswordChange({
|
||||
currentPassword: hasPassword ? currentPassword : undefined,
|
||||
nextPassword: newPassword,
|
||||
});
|
||||
if ('ok' in result && result.ok === false) {
|
||||
setError(mapChangeError(result.error));
|
||||
return;
|
||||
}
|
||||
resetDialog();
|
||||
} finally {
|
||||
setIsSavingPassword(false);
|
||||
}
|
||||
}, [
|
||||
confirmPassword,
|
||||
currentPassword,
|
||||
hasPassword,
|
||||
mapChangeError,
|
||||
newPassword,
|
||||
requestAppLockPasswordChange,
|
||||
resetDialog,
|
||||
t,
|
||||
]);
|
||||
|
||||
const handleSystemUnlockChange = useCallback(async (enabled: boolean) => {
|
||||
if (!setAppLockSystemUnlockEnabled || !appLockSystemUnlockStatus?.label) return;
|
||||
setError(null);
|
||||
setIsSavingSystemUnlock(true);
|
||||
try {
|
||||
const result = await setAppLockSystemUnlockEnabled({
|
||||
enabled,
|
||||
autoPromptEnabled: enabled && appLockSettings.systemUnlockEnabled
|
||||
? appLockSettings.systemUnlockAutoPromptEnabled
|
||||
: false,
|
||||
});
|
||||
if ('ok' in result && result.ok === false) {
|
||||
if (result.error === 'cancelled') return;
|
||||
setError(mapChangeError(result.error));
|
||||
}
|
||||
} finally {
|
||||
setIsSavingSystemUnlock(false);
|
||||
}
|
||||
}, [
|
||||
appLockSettings.systemUnlockAutoPromptEnabled,
|
||||
appLockSettings.systemUnlockEnabled,
|
||||
appLockSystemUnlockStatus?.label,
|
||||
mapChangeError,
|
||||
setAppLockSystemUnlockEnabled,
|
||||
]);
|
||||
|
||||
const handleAutoPromptChange = useCallback(async (autoPromptEnabled: boolean) => {
|
||||
if (!setAppLockSystemUnlockEnabled || !appLockSystemUnlockStatus?.label) return;
|
||||
if (!appLockSettings.systemUnlockEnabled) return;
|
||||
setError(null);
|
||||
setIsSavingSystemUnlock(true);
|
||||
try {
|
||||
const result = await setAppLockSystemUnlockEnabled({ enabled: true, autoPromptEnabled });
|
||||
if ('ok' in result && result.ok === false) {
|
||||
if (result.error === 'cancelled') return;
|
||||
setError(mapChangeError(result.error));
|
||||
}
|
||||
} finally {
|
||||
setIsSavingSystemUnlock(false);
|
||||
}
|
||||
}, [
|
||||
appLockSettings.systemUnlockEnabled,
|
||||
appLockSystemUnlockStatus?.label,
|
||||
mapChangeError,
|
||||
setAppLockSystemUnlockEnabled,
|
||||
]);
|
||||
|
||||
const dialogTitle = dialogMode === 'disable'
|
||||
? t('settings.appLock.disableTitle')
|
||||
: dialogMode === 'change'
|
||||
? t('settings.appLock.changePasswordTitle')
|
||||
: t('settings.appLock.setupPasswordTitle');
|
||||
const dialogDescription = dialogMode === 'disable'
|
||||
? t('settings.appLock.disableDescription')
|
||||
: dialogMode === 'change'
|
||||
? t('settings.appLock.changePasswordDescription')
|
||||
: t('settings.appLock.setupPasswordDescription');
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title={t('settings.appLock.title')} anchorId="system-app-lock" />
|
||||
<SettingCard divided>
|
||||
{!hasPassword ? (
|
||||
<SettingRow
|
||||
label={t('settings.appLock.setupTitle')}
|
||||
description={t('settings.appLock.setupDescription')}
|
||||
>
|
||||
<Button type="button" size="sm" onClick={() => openDialog('setup')}>
|
||||
{t('settings.appLock.savePassword')}
|
||||
</Button>
|
||||
</SettingRow>
|
||||
) : (
|
||||
<>
|
||||
<SettingRow
|
||||
label={t('settings.appLock.manageTitle')}
|
||||
description={appLockSettings.enabled
|
||||
? t('settings.appLock.enabledStatus')
|
||||
: t('settings.appLock.disabledStatus')}
|
||||
>
|
||||
<span className={appLockSettings.enabled
|
||||
? 'text-xs font-medium text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-xs font-medium text-muted-foreground'}
|
||||
>
|
||||
{t(appLockSettings.enabled ? 'common.enabled' : 'common.disabled')}
|
||||
</span>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t('settings.appLock.timeout')}
|
||||
description={t('settings.appLock.timeoutDesc')}
|
||||
>
|
||||
<Select
|
||||
value={String(appLockSettings.timeoutMinutes)}
|
||||
options={timeoutOptions}
|
||||
onChange={handleTimeoutChange}
|
||||
className="w-36"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{showSystemUnlock && appLockSystemUnlockStatus?.label && (
|
||||
<>
|
||||
<SettingRow
|
||||
label={t('settings.appLock.systemUnlock.label').replace('{label}', appLockSystemUnlockStatus.label)}
|
||||
description={appLockSystemUnlockStatus.available
|
||||
? t('settings.appLock.systemUnlock.desc').replace('{label}', appLockSystemUnlockStatus.label)
|
||||
: t('settings.appLock.systemUnlock.unavailableDesc').replace('{label}', appLockSystemUnlockStatus.label)}
|
||||
>
|
||||
<Toggle
|
||||
checked={appLockSettings.systemUnlockEnabled}
|
||||
disabled={
|
||||
isSavingSystemUnlock
|
||||
|| (!appLockSystemUnlockStatus.available && !appLockSettings.systemUnlockEnabled)
|
||||
}
|
||||
ariaLabel={t('settings.appLock.systemUnlock.label').replace('{label}', appLockSystemUnlockStatus.label)}
|
||||
onChange={(enabled) => void handleSystemUnlockChange(enabled)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t('settings.appLock.systemUnlock.autoPrompt.label').replace('{label}', appLockSystemUnlockStatus.label)}
|
||||
description={t('settings.appLock.systemUnlock.autoPrompt.desc').replace('{label}', appLockSystemUnlockStatus.label)}
|
||||
>
|
||||
<Toggle
|
||||
checked={appLockSettings.systemUnlockAutoPromptEnabled}
|
||||
disabled={
|
||||
isSavingSystemUnlock
|
||||
|| !appLockSettings.systemUnlockEnabled
|
||||
|| !appLockSystemUnlockStatus.available
|
||||
}
|
||||
ariaLabel={t('settings.appLock.systemUnlock.autoPrompt.label').replace('{label}', appLockSystemUnlockStatus.label)}
|
||||
onChange={(enabled) => void handleAutoPromptChange(enabled)}
|
||||
/>
|
||||
</SettingRow>
|
||||
</>
|
||||
)}
|
||||
|
||||
<SettingRow
|
||||
label={t('settings.appLock.changePasswordTitle')}
|
||||
description={t('settings.appLock.changePasswordDescription')}
|
||||
>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => openDialog('change')}>
|
||||
{t('settings.appLock.replacePassword')}
|
||||
</Button>
|
||||
</SettingRow>
|
||||
|
||||
{appLockSettings.enabled && (
|
||||
<SettingRow
|
||||
label={t('settings.appLock.disableTitle')}
|
||||
description={t('settings.appLock.disableDescription')}
|
||||
>
|
||||
<Button type="button" size="sm" variant="destructive" onClick={() => openDialog('disable')}>
|
||||
{t('settings.appLock.disable')}
|
||||
</Button>
|
||||
</SettingRow>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SettingCard>
|
||||
<SettingHint>{t('settings.appLock.localOnlyHint')}</SettingHint>
|
||||
{error && dialogMode === null && (
|
||||
<p className="mt-2 text-xs text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
open={dialogMode !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !isDialogBusy) resetDialog();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[440px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
<DialogDescription>{dialogDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (dialogMode === 'disable') {
|
||||
void handleDisable();
|
||||
} else {
|
||||
void handleSavePassword();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{dialogMode === 'disable' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="app-lock-disable-password">{t('settings.appLock.currentPassword')}</Label>
|
||||
<Input
|
||||
id="app-lock-disable-password"
|
||||
type="password"
|
||||
value={disablePassword}
|
||||
autoComplete="current-password"
|
||||
placeholder={t('settings.appLock.currentPasswordForDisablePlaceholder')}
|
||||
disabled={isDialogBusy}
|
||||
onChange={(event) => {
|
||||
setDisablePassword(event.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dialogMode === 'change' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="app-lock-current-password">{t('settings.appLock.currentPassword')}</Label>
|
||||
<Input
|
||||
id="app-lock-current-password"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
autoComplete="current-password"
|
||||
placeholder={t('settings.appLock.currentPasswordForChangePlaceholder')}
|
||||
disabled={isDialogBusy}
|
||||
onChange={(event) => {
|
||||
setCurrentPassword(event.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dialogMode !== 'disable' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="app-lock-new-password">{t('settings.appLock.newPassword')}</Label>
|
||||
<Input
|
||||
id="app-lock-new-password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
autoComplete="new-password"
|
||||
placeholder={t('settings.appLock.newPasswordPlaceholder')}
|
||||
disabled={isDialogBusy}
|
||||
onChange={(event) => {
|
||||
setNewPassword(event.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="app-lock-confirm-password">{t('settings.appLock.confirmPassword')}</Label>
|
||||
<Input
|
||||
id="app-lock-confirm-password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
autoComplete="new-password"
|
||||
placeholder={t('settings.appLock.confirmPasswordPlaceholder')}
|
||||
disabled={isDialogBusy}
|
||||
onChange={(event) => {
|
||||
setConfirmPassword(event.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" disabled={isDialogBusy} onClick={resetDialog}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant={dialogMode === 'disable' ? 'destructive' : 'default'}
|
||||
disabled={isDialogBusy}
|
||||
>
|
||||
{dialogMode === 'disable'
|
||||
? (isDisabling ? t('settings.appLock.disabling') : t('settings.appLock.disable'))
|
||||
: (isSavingPassword ? t('settings.appLock.savingPassword') : t('settings.appLock.savePassword'))}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { Button } from "../../ui/button";
|
||||
import { SectionHeader, Select, SettingsAnchor, SettingsTabContent, SettingRow, Toggle } from "../settings-ui";
|
||||
import { isAppLockOverlayActive } from '../../../infrastructure/appLockOverlayDom';
|
||||
|
||||
export default function SettingsShortcutsTab(props: {
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
@@ -65,6 +66,8 @@ export default function SettingsShortcutsTab(props: {
|
||||
const specialSuffix = getSpecialSuffix(recordingBindingId);
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Skip while app lock overlay is up so password keys never bind (Codex P2).
|
||||
if (isAppLockOverlayActive()) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const readAppLockSectionSource = () => (
|
||||
readFileSync(new URL("./AppLockSettingsSection.tsx", import.meta.url), "utf8")
|
||||
);
|
||||
|
||||
test("disabling app lock does not trigger a second renderer-side unlock request", () => {
|
||||
const source = readAppLockSectionSource();
|
||||
|
||||
assert.doesNotMatch(source, /unlockApp\?\.\(/);
|
||||
assert.doesNotMatch(source, /unlockApp\?:/);
|
||||
});
|
||||
|
||||
test("app lock setup starts with password setup instead of a misleading enable toggle", () => {
|
||||
const source = readAppLockSectionSource();
|
||||
|
||||
assert.match(source, /!hasPassword \? \(/);
|
||||
assert.match(source, /settings\.appLock\.setupTitle/);
|
||||
assert.match(source, /settings\.appLock\.setupDescription/);
|
||||
assert.match(source, /settings\.appLock\.manageTitle/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/hasAppLockPassword \? t\("settings\.appLock\.enableDesc"\) : t\("settings\.appLock\.enableAfterPassword"\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("app lock section exposes settings-search anchors", () => {
|
||||
const source = readAppLockSectionSource();
|
||||
|
||||
assert.match(source, /anchorId="system-app-lock"/);
|
||||
});
|
||||
|
||||
test("app lock disable handler uses its modal current password field", () => {
|
||||
const source = readAppLockSectionSource();
|
||||
const handlerStart = source.indexOf("const handleDisable = useCallback");
|
||||
const handlerEnd = source.indexOf("const handleSavePassword", handlerStart);
|
||||
const handlerSource = source.slice(handlerStart, handlerEnd);
|
||||
|
||||
assert.match(handlerSource, /requestAppLockDisable\(disablePassword\)/);
|
||||
assert.match(handlerSource, /disablePassword,/);
|
||||
assert.doesNotMatch(source, /handleAppLockEnabledChange/);
|
||||
});
|
||||
|
||||
test("app lock page uses settings rows and moves password forms into a dialog", () => {
|
||||
const source = readAppLockSectionSource();
|
||||
const dialogStart = source.indexOf('<Dialog');
|
||||
const firstPasswordInput = source.indexOf('<Input');
|
||||
|
||||
assert.match(source, /<SettingCard divided>/);
|
||||
assert.match(source, /<SettingRow/);
|
||||
assert.match(source, /<DialogContent/);
|
||||
assert.ok(firstPasswordInput > dialogStart, "password inputs should only render inside the dialog");
|
||||
assert.doesNotMatch(source.slice(0, dialogStart), /<Input/);
|
||||
assert.doesNotMatch(source, /settings\.appLock\.enableDesc/);
|
||||
assert.match(source, /settings\.appLock\.disableTitle/);
|
||||
assert.match(source, /settings\.appLock\.disableDescription/);
|
||||
assert.match(source, /settings\.appLock\.disable/);
|
||||
assert.match(source, /settings\.appLock\.changePasswordTitle/);
|
||||
assert.match(source, /settings\.appLock\.currentPasswordForDisablePlaceholder/);
|
||||
assert.match(source, /settings\.appLock\.currentPasswordForChangePlaceholder/);
|
||||
});
|
||||
|
||||
test("app lock system unlock setting uses bridge-provided platform label", () => {
|
||||
const source = readAppLockSectionSource();
|
||||
|
||||
assert.match(source, /showSystemUnlock/);
|
||||
assert.match(source, /appLockSystemUnlockStatus\.label/);
|
||||
assert.match(source, /settings\.appLock\.systemUnlock\.label/);
|
||||
assert.doesNotMatch(source, /navigator\.platform/);
|
||||
});
|
||||
|
||||
test("app lock system unlock enablement does not require current password in settings", () => {
|
||||
const source = readAppLockSectionSource();
|
||||
const handlerStart = source.indexOf("const handleSystemUnlockChange = useCallback");
|
||||
const handlerEnd = source.indexOf("const handleAutoPromptChange", handlerStart);
|
||||
const handlerSource = source.slice(handlerStart, handlerEnd);
|
||||
|
||||
assert.match(handlerSource, /setAppLockSystemUnlockEnabled\(\{/);
|
||||
assert.doesNotMatch(handlerSource, /appLockSystemUnlockPassword/);
|
||||
assert.doesNotMatch(handlerSource, /currentPassword:/);
|
||||
|
||||
const systemUnlockStart = source.indexOf("{showSystemUnlock");
|
||||
const systemUnlockEnd = source.indexOf("settings.appLock.changePasswordTitle", systemUnlockStart);
|
||||
const systemUnlockSection = source.slice(systemUnlockStart, systemUnlockEnd);
|
||||
assert.doesNotMatch(systemUnlockSection, /settings\.appLock\.currentPassword/);
|
||||
});
|
||||
|
||||
test("app lock system unlock setting hides when unavailable unless already enabled", () => {
|
||||
const source = readAppLockSectionSource();
|
||||
|
||||
assert.match(source, /appLockSystemUnlockStatus\.available \|\| appLockSettings\.systemUnlockEnabled/);
|
||||
// Already-enabled toggle stays clickable when unavailable so the user can disable.
|
||||
assert.match(
|
||||
source,
|
||||
/disabled=\{\s*isSavingSystemUnlock\s*\|\|\s*\(!appLockSystemUnlockStatus\.available && !appLockSettings\.systemUnlockEnabled\)\s*\}/,
|
||||
);
|
||||
});
|
||||
|
||||
test("app lock system unlock exposes auto prompt as a child option", () => {
|
||||
const source = readAppLockSectionSource();
|
||||
|
||||
assert.match(source, /settings\.appLock\.systemUnlock\.autoPrompt\.label/);
|
||||
assert.match(source, /settings\.appLock\.systemUnlock\.autoPrompt\.desc/);
|
||||
assert.match(source, /appLockSettings\.systemUnlockAutoPromptEnabled/);
|
||||
assert.match(source, /appLockSettings\.systemUnlockEnabled/);
|
||||
});
|
||||
|
||||
test("app lock system unlock handles native verification failures", () => {
|
||||
const source = readAppLockSectionSource();
|
||||
|
||||
assert.match(source, /'cancelled' \| 'failed'/);
|
||||
assert.match(source, /result\.error === 'cancelled'/);
|
||||
assert.match(source, /case 'failed':\s*return t\('settings\.appLock\.systemUnlock\.unavailable'\)/);
|
||||
});
|
||||
|
||||
test("app lock disable explains that turning it off removes the saved password", () => {
|
||||
const englishLocale = readFileSync(new URL("../../../application/i18n/locales/en/core.ts", import.meta.url), "utf8");
|
||||
|
||||
assert.match(englishLocale, /saved password will be removed/);
|
||||
assert.match(englishLocale, /requires creating a new one/);
|
||||
});
|
||||
@@ -4,6 +4,8 @@
|
||||
import { ChevronDown, ChevronRight, Download, ExternalLink, FolderOpen, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import type { AppLockSystemUnlockStatus } from "../../../application/state/useAppLockState";
|
||||
import type { AppLockSettings, AppLockSettingsChangeError, AppLockTimeoutMinutes } from "../../../domain/appLock";
|
||||
import { getCredentialProtectionAvailability } from "../../../infrastructure/services/credentialProtection";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import type { UpdateState } from '../../../application/state/useUpdateCheck';
|
||||
@@ -13,6 +15,8 @@ import { Button } from "../../ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";
|
||||
import { Toggle, Select, SettingRow, SectionHeader, SettingCard, SettingHint, SettingsAnchor, SettingsTabContent } from "../settings-ui";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { isAppLockOverlayActive } from '../../../infrastructure/appLockOverlayDom';
|
||||
import { AppLockSettingsSection } from './AppLockSettingsSection';
|
||||
|
||||
interface CrashLogFile {
|
||||
fileName: string;
|
||||
@@ -77,6 +81,19 @@ function formatLastChecked(
|
||||
}
|
||||
|
||||
interface SettingsSystemTabProps {
|
||||
appLockSettings: AppLockSettings;
|
||||
setAppLockTimeoutMinutes: (timeoutMinutes: AppLockTimeoutMinutes) => void;
|
||||
requestAppLockDisable: (currentPassword: string) => Promise<AppLockSettings | { ok: false; error: AppLockSettingsChangeError }>;
|
||||
requestAppLockPasswordChange: (input: {
|
||||
currentPassword?: string;
|
||||
nextPassword: string;
|
||||
}) => Promise<AppLockSettings | { ok: false; error: AppLockSettingsChangeError }>;
|
||||
appLockSystemUnlockStatus?: AppLockSystemUnlockStatus;
|
||||
setAppLockSystemUnlockEnabled?: (input: {
|
||||
enabled: boolean;
|
||||
currentPassword?: string;
|
||||
autoPromptEnabled?: boolean;
|
||||
}) => Promise<AppLockSettings | { ok: false; error: 'empty-current' | 'incorrect' | 'locked' | 'unsupported' | 'unavailable' | 'cancelled' | 'failed' }>;
|
||||
sessionLogsEnabled: boolean;
|
||||
setSessionLogsEnabled: (enabled: boolean) => void;
|
||||
sessionLogsDir: string;
|
||||
@@ -120,6 +137,12 @@ interface SettingsSystemTabProps {
|
||||
}
|
||||
|
||||
const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
appLockSettings,
|
||||
setAppLockTimeoutMinutes,
|
||||
requestAppLockDisable,
|
||||
requestAppLockPasswordChange,
|
||||
appLockSystemUnlockStatus,
|
||||
setAppLockSystemUnlockEnabled,
|
||||
sessionLogsEnabled,
|
||||
setSessionLogsEnabled,
|
||||
sessionLogsDir,
|
||||
@@ -403,6 +426,7 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
if (!isRecordingHotkey) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (isAppLockOverlayActive()) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -441,7 +465,6 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
{ value: "raw", label: t("settings.sessionLogs.formatRaw") },
|
||||
{ value: "html", label: t("settings.sessionLogs.formatHtml") },
|
||||
];
|
||||
|
||||
return (
|
||||
<SettingsTabContent value="system">
|
||||
<SectionHeader title={t('settings.update.title')} anchorId="system-update" />
|
||||
@@ -643,6 +666,15 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
{t("settings.system.networkProxy.hint")}
|
||||
</SettingHint>
|
||||
|
||||
<AppLockSettingsSection
|
||||
appLockSettings={appLockSettings}
|
||||
setAppLockTimeoutMinutes={setAppLockTimeoutMinutes}
|
||||
requestAppLockDisable={requestAppLockDisable}
|
||||
requestAppLockPasswordChange={requestAppLockPasswordChange}
|
||||
appLockSystemUnlockStatus={appLockSystemUnlockStatus}
|
||||
setAppLockSystemUnlockEnabled={setAppLockSystemUnlockEnabled}
|
||||
/>
|
||||
|
||||
<SectionHeader title={t("settings.system.credentials.title")} />
|
||||
<SettingsAnchor anchorId="system-credentials">
|
||||
<SettingCard className="space-y-3 py-4">
|
||||
|
||||
@@ -31,3 +31,10 @@ test("terminal behavior settings expose disabled auto-close", () => {
|
||||
/settings\.terminal\.behavior\.autoCloseOnExit[\s\S]*?role="switch" aria-checked="false"/,
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal behavior settings expose OSC desktop notification mode", () => {
|
||||
const markup = renderSettings(true);
|
||||
assert.match(markup, /settings-anchor-terminal-osc-notifications/);
|
||||
assert.match(markup, /settings\.terminal\.behavior\.oscNotifications/);
|
||||
assert.match(markup, /settings\.terminal\.behavior\.oscNotifications\.always/);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { DEFAULT_TERMINAL_WORD_SEPARATORS } from "../../../domain/models";
|
||||
import type { DynamicTabTitleMode, LinkModifier, MiddleClickBehavior, RightClickBehavior, TerminalSettings } from "../../../domain/models";
|
||||
import type { DynamicTabTitleMode, LinkModifier, MiddleClickBehavior, OscNotificationMode, RightClickBehavior, TerminalSettings } from "../../../domain/models";
|
||||
import { Input } from "../../ui/input";
|
||||
import { Label } from "../../ui/label";
|
||||
import { SectionHeader, Select, SettingsAnchor, SettingRow, Toggle } from "../settings-ui";
|
||||
@@ -204,6 +204,23 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
anchorId="terminal-osc-notifications"
|
||||
label={t("settings.terminal.behavior.oscNotifications")}
|
||||
description={t("settings.terminal.behavior.oscNotifications.desc")}
|
||||
>
|
||||
<Select
|
||||
value={terminalSettings.oscNotifications ?? "always"}
|
||||
options={[
|
||||
{ value: "always", label: t("settings.terminal.behavior.oscNotifications.always") },
|
||||
{ value: "unfocused", label: t("settings.terminal.behavior.oscNotifications.unfocused") },
|
||||
{ value: "off", label: t("settings.terminal.behavior.oscNotifications.off") },
|
||||
]}
|
||||
onChange={(v) => updateTerminalSetting("oscNotifications", v as OscNotificationMode)}
|
||||
className="w-40"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
anchorId="terminal-osc52-clipboard"
|
||||
label={t("settings.terminal.behavior.osc52Clipboard")}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Pencil, Upload, RotateCcw, X, RefreshCw } from "lucide-react";
|
||||
import type { ProviderConfig, ProviderAdvancedParams, ProviderStyle } from "../../../../infrastructure/ai/types";
|
||||
import { PROVIDER_PRESETS, resolveProviderStyle } from "../../../../infrastructure/ai/types";
|
||||
import type { ProviderConfig, ProviderAdvancedParams, OpenAIApiFormat, ProviderStyle } from "../../../../infrastructure/ai/types";
|
||||
import { PROVIDER_PRESETS, resolveOpenAIApi, resolveProviderStyle } from "../../../../infrastructure/ai/types";
|
||||
import { normalizeOllamaSdkBaseURL } from "../../../../infrastructure/ai/ollamaCompatBaseUrl";
|
||||
import { sanitizeContextWindow } from "../../../../infrastructure/ai/contextCompaction";
|
||||
import {
|
||||
probeProviderConnection,
|
||||
@@ -53,6 +54,10 @@ async function compressIconFileToDataUrl(file: File): Promise<string> {
|
||||
}
|
||||
|
||||
const STYLE_OPTIONS: ReadonlyArray<ProviderStyle> = ["anthropic", "openai", "google"];
|
||||
const OPENAI_API_OPTIONS: ReadonlyArray<OpenAIApiFormat> = ["chat", "responses"];
|
||||
|
||||
/** Same box as the h-8 fields above. Transparent border keeps primary aligned with outline. */
|
||||
const PROVIDER_ACTION_CLASS = "box-border h-8 px-3 gap-1.5 text-sm font-medium leading-none";
|
||||
|
||||
export const ProviderConfigForm: React.FC<{
|
||||
provider: ProviderConfig;
|
||||
@@ -72,6 +77,7 @@ export const ProviderConfigForm: React.FC<{
|
||||
skipTLSVerify: provider.skipTLSVerify ?? false,
|
||||
advancedParams: provider.advancedParams ?? {},
|
||||
style: provider.style ?? "",
|
||||
openaiApi: resolveOpenAIApi(provider),
|
||||
iconId: provider.iconId ?? "",
|
||||
iconDataUrl: provider.iconDataUrl ?? "",
|
||||
});
|
||||
@@ -91,6 +97,9 @@ export const ProviderConfigForm: React.FC<{
|
||||
|
||||
const preset = PROVIDER_PRESETS[provider.providerId];
|
||||
const resolvedStyle: ProviderStyle = form.style || resolveProviderStyle({ providerId: provider.providerId });
|
||||
const resolvedBaseURL = provider.providerId === "ollama"
|
||||
? normalizeOllamaSdkBaseURL(form.baseURL || preset?.defaultBaseURL || "")
|
||||
: (form.baseURL || preset?.defaultBaseURL || "");
|
||||
const modelMetadataSourceKey = useMemo(() => JSON.stringify({
|
||||
providerId: provider.providerId,
|
||||
baseURL: form.baseURL || preset?.defaultBaseURL || "",
|
||||
@@ -220,7 +229,7 @@ export const ProviderConfigForm: React.FC<{
|
||||
}, []);
|
||||
|
||||
const handleTestConnection = useCallback(async () => {
|
||||
const baseURL = form.baseURL || preset?.defaultBaseURL || "";
|
||||
const baseURL = resolvedBaseURL;
|
||||
const inputCheck = validateProviderProbeInputs({
|
||||
baseURL,
|
||||
apiKey: form.apiKey,
|
||||
@@ -301,7 +310,7 @@ export const ProviderConfigForm: React.FC<{
|
||||
} finally {
|
||||
if (probeRequestIdRef.current === requestId) setIsTesting(false);
|
||||
}
|
||||
}, [form.apiKey, form.baseURL, form.skipTLSVerify, preset?.defaultBaseURL, preset?.modelsEndpoint, provider.providerId, resolvedStyle, t]);
|
||||
}, [form.apiKey, form.skipTLSVerify, preset?.modelsEndpoint, provider.providerId, resolvedBaseURL, resolvedStyle, t]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const cleanedParams: ProviderAdvancedParams = {};
|
||||
@@ -329,13 +338,16 @@ export const ProviderConfigForm: React.FC<{
|
||||
|
||||
const updates: Partial<ProviderConfig> = {
|
||||
name: trimmedName || defaultName,
|
||||
baseURL: form.baseURL || undefined,
|
||||
baseURL: provider.providerId === "ollama"
|
||||
? resolvedBaseURL
|
||||
: (form.baseURL || undefined),
|
||||
defaultModel: form.defaultModel || undefined,
|
||||
contextWindow: manualContextWindow,
|
||||
modelContextWindows: Object.keys(form.modelContextWindows).length > 0 ? form.modelContextWindows : undefined,
|
||||
skipTLSVerify: form.skipTLSVerify || undefined,
|
||||
advancedParams: Object.keys(cleanedParams).length > 0 ? cleanedParams : undefined,
|
||||
style: form.style || undefined,
|
||||
openaiApi: resolvedStyle === "openai" && form.openaiApi === "responses" ? "responses" : undefined,
|
||||
iconId: form.iconId || undefined,
|
||||
iconDataUrl: form.iconDataUrl || undefined,
|
||||
};
|
||||
@@ -348,7 +360,7 @@ export const ProviderConfigForm: React.FC<{
|
||||
}
|
||||
|
||||
onSave(updates);
|
||||
}, [form, onSave, provider.providerId, t]);
|
||||
}, [form, onSave, provider.providerId, resolvedBaseURL, resolvedStyle, t]);
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-3 border-t border-border/40 pt-3">
|
||||
@@ -475,6 +487,34 @@ export const ProviderConfigForm: React.FC<{
|
||||
<p className="text-[11px] text-muted-foreground/70">{t('ai.providers.style.help')}</p>
|
||||
</div>
|
||||
|
||||
{resolvedStyle === "openai" && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.openaiApi')}</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{OPENAI_API_OPTIONS.map((format) => {
|
||||
const isSelected = form.openaiApi === format;
|
||||
return (
|
||||
<button
|
||||
key={format}
|
||||
type="button"
|
||||
onClick={() => setForm((prev) => ({ ...prev, openaiApi: format }))}
|
||||
className={cn(
|
||||
"h-7 px-2.5 rounded-md text-xs border transition-colors",
|
||||
isSelected
|
||||
? "border-primary/70 bg-primary/15 text-foreground"
|
||||
: "border-border/50 bg-background text-muted-foreground hover:text-foreground hover:bg-muted/40",
|
||||
)}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
{t(`ai.providers.openaiApi.${format}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground/70">{t('ai.providers.openaiApi.help')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.apiKey')}</label>
|
||||
@@ -512,6 +552,9 @@ export const ProviderConfigForm: React.FC<{
|
||||
{resolvedStyle === "anthropic" ? (
|
||||
<p className="text-[11px] text-muted-foreground/70">{t('ai.providers.baseUrl.anthropicHelp')}</p>
|
||||
) : null}
|
||||
{provider.providerId === "ollama" ? (
|
||||
<p className="text-[11px] text-muted-foreground/70">{t('ai.providers.baseUrl.ollamaHelp')}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Default Model */}
|
||||
@@ -526,7 +569,7 @@ export const ProviderConfigForm: React.FC<{
|
||||
modelContextWindows: mergeModelContextWindow(prev.modelContextWindows, model.id, model.contextWindow) ?? prev.modelContextWindows,
|
||||
}));
|
||||
}}
|
||||
baseURL={form.baseURL || preset?.defaultBaseURL || ""}
|
||||
baseURL={resolvedBaseURL}
|
||||
modelsEndpoint={preset?.modelsEndpoint}
|
||||
presetModels={preset?.defaultModels}
|
||||
apiKey={form.apiKey}
|
||||
@@ -662,20 +705,26 @@ export const ProviderConfigForm: React.FC<{
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-2 pt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="default" size="sm" onClick={() => void handleSave()}>
|
||||
<Check size={14} className="mr-1.5" />
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className={cn(PROVIDER_ACTION_CLASS, "border border-transparent")}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
<Check size={14} className="size-3.5 shrink-0" />
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={PROVIDER_ACTION_CLASS}
|
||||
onClick={() => void handleTestConnection()}
|
||||
disabled={isTesting || isDecrypting}
|
||||
>
|
||||
<RefreshCw size={14} className={cn("mr-1.5", isTesting && "animate-spin")} />
|
||||
<RefreshCw size={14} className={cn("size-3.5 shrink-0", isTesting && "animate-spin")} />
|
||||
{isTesting ? t('ai.providers.test.testing') : t('ai.providers.test')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onCancel}>
|
||||
<Button variant="ghost" size="sm" className={PROVIDER_ACTION_CLASS} onClick={onCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
AIProviderId,
|
||||
ExternalAgentConfig,
|
||||
ProviderAdvancedParams,
|
||||
OpenAIApiFormat,
|
||||
ProviderStyle,
|
||||
} from "../../../../infrastructure/ai/types";
|
||||
|
||||
@@ -124,6 +125,7 @@ export interface ProviderFormState {
|
||||
skipTLSVerify: boolean;
|
||||
advancedParams: ProviderAdvancedParams;
|
||||
style: ProviderStyle | ""; // "" means inherit-from-providerId
|
||||
openaiApi: OpenAIApiFormat;
|
||||
iconId: string; // "" means no built-in pick (fall back to providerId)
|
||||
iconDataUrl: string; // "" means no upload override
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export const SftpColumnMenuItems: React.FC<SftpColumnMenuItemsProps> = ({
|
||||
<ContextMenuCheckboxItem checked disabled>
|
||||
{t('sftp.columns.name')}
|
||||
</ContextMenuCheckboxItem>
|
||||
{(['modified', 'size', 'type'] as const).map((field) => (
|
||||
{(['modified', 'size', 'type', 'owner'] as const).map((field) => (
|
||||
<ContextMenuCheckboxItem
|
||||
key={field}
|
||||
checked={visibleColumns[field]}
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface SftpPaneCallbacks {
|
||||
onOpenFileWith?: (entry: SftpFileEntry, fullPath?: string) => void; // Always show opener dialog
|
||||
onDownloadFile?: (entry: SftpFileEntry, fullPath?: string) => void; // Download to local filesystem
|
||||
onDownloadFiles?: (entries: SftpFileEntry[]) => void; // Batch download — picks one target directory for remote panes
|
||||
onExtractArchive?: (entry: SftpFileEntry, fullPath?: string) => void | Promise<void>;
|
||||
// External file upload (supports folders via DataTransfer)
|
||||
onUploadExternalFiles?: (dataTransfer: DataTransfer, targetPath?: string) => Promise<void>;
|
||||
// External file upload from <input type="file" multiple> picker (FileList).
|
||||
|
||||
@@ -141,6 +141,11 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
|
||||
{isSymlinkToDirectory ? 'link → folder' : entry.type === 'directory' ? 'folder' : entry.type === 'symlink' ? 'link' : entry.name.split('.').pop()?.toLowerCase() || 'file'}
|
||||
</span>
|
||||
)}
|
||||
{visibleColumns.owner && (
|
||||
<span className={cn("text-xs truncate text-right", isSelectionVisible ? "text-accent-foreground/85" : "text-muted-foreground")}>
|
||||
{isParentDir ? '' : (entry.owner || '--')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -155,9 +160,11 @@ const areEqual = (prev: SftpFileRowProps, next: SftpFileRowProps): boolean => {
|
||||
if (prev.columnWidths.modified !== next.columnWidths.modified) return false;
|
||||
if (prev.columnWidths.size !== next.columnWidths.size) return false;
|
||||
if (prev.columnWidths.type !== next.columnWidths.type) return false;
|
||||
if (prev.columnWidths.owner !== next.columnWidths.owner) return false;
|
||||
if (prev.visibleColumns.modified !== next.visibleColumns.modified) return false;
|
||||
if (prev.visibleColumns.size !== next.visibleColumns.size) return false;
|
||||
if (prev.visibleColumns.type !== next.visibleColumns.type) return false;
|
||||
if (prev.visibleColumns.owner !== next.visibleColumns.owner) return false;
|
||||
// Compare callbacks - important for ".." entry which has static properties
|
||||
if (prev.onOpen !== next.onOpen) return false;
|
||||
if (prev.onSelect !== next.onSelect) return false;
|
||||
@@ -170,7 +177,8 @@ const areEqual = (prev: SftpFileRowProps, next: SftpFileRowProps): boolean => {
|
||||
prevEntry.lastModified === nextEntry.lastModified &&
|
||||
prevEntry.linkTarget === nextEntry.linkTarget &&
|
||||
prevEntry.sizeFormatted === nextEntry.sizeFormatted &&
|
||||
prevEntry.lastModifiedFormatted === nextEntry.lastModifiedFormatted
|
||||
prevEntry.lastModifiedFormatted === nextEntry.lastModifiedFormatted &&
|
||||
prevEntry.owner === nextEntry.owner
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AppWindow, ArrowDown, ArrowRight, ArrowUp, ChevronDown, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2, Unplug, Upload } from "lucide-react";
|
||||
import { AppWindow, Archive, ArrowDown, ArrowRight, ArrowUp, ChevronDown, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2, Unplug, Upload } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
ContextMenu,
|
||||
@@ -17,6 +17,7 @@ import { sftpListOrderStore } from "./hooks/useSftpListOrderStore";
|
||||
import type { UseSftpPaneSortingResult } from "../../application/state/sftp/useSftpPaneSorting";
|
||||
import { buildSftpColumnTemplate, isNavigableDirectory, isSftpColumnMenuKey } from "./utils";
|
||||
import { isKnownBinaryFile } from "../../lib/sftpFileUtils";
|
||||
import { isExtractableArchive } from "../../domain/sftpArchive";
|
||||
import { SftpFileRow } from "./SftpFileRow";
|
||||
import { SftpColumnMenuItems } from "./SftpColumnMenuItems";
|
||||
import { getSftpVirtualListScrollTop } from "../../domain/sftpVirtualList";
|
||||
@@ -65,6 +66,7 @@ interface SftpPaneFileListProps {
|
||||
onEditFile?: (entry: SftpFileEntry) => void;
|
||||
onDownloadFile?: (entry: SftpFileEntry) => void;
|
||||
onDownloadFiles?: (entries: SftpFileEntry[]) => void;
|
||||
onExtractArchive?: (entry: SftpFileEntry) => void;
|
||||
onEditPermissions?: (entry: SftpFileEntry) => void;
|
||||
onUploadExternalFileList?: (fileList: FileList, targetPath?: string) => Promise<void> | void;
|
||||
onUploadExternalFolder?: (targetPath?: string) => Promise<void> | void;
|
||||
@@ -153,6 +155,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
||||
onEditFile,
|
||||
onDownloadFile,
|
||||
onDownloadFiles,
|
||||
onExtractArchive,
|
||||
onEditPermissions,
|
||||
onUploadExternalFileList,
|
||||
onUploadExternalFolder,
|
||||
@@ -347,6 +350,12 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
||||
{t("sftp.context.download")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{!isNavigableDirectory(entry) && onExtractArchive && isExtractableArchive(entry.name) && (
|
||||
<ContextMenuItem onClick={() => onExtractArchive(entry)}>
|
||||
<Archive size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.extract")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
@@ -472,6 +481,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
||||
onMoveEntriesToPath,
|
||||
onDownloadFile,
|
||||
onDownloadFiles,
|
||||
onExtractArchive,
|
||||
onDragEnd,
|
||||
onEditFile,
|
||||
onEditPermissions,
|
||||
@@ -597,7 +607,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
||||
)}
|
||||
{visibleColumns.type && (
|
||||
<div
|
||||
className="flex min-w-0 items-center gap-1 cursor-pointer hover:text-foreground justify-end overflow-hidden"
|
||||
className="flex min-w-0 items-center gap-1 cursor-pointer hover:text-foreground relative pr-2 justify-end overflow-hidden"
|
||||
onClick={() => handleSort("type")}
|
||||
>
|
||||
{sortField === "type" && (
|
||||
@@ -606,6 +616,23 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate whitespace-nowrap">{t("sftp.columns.kind")}</span>
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
|
||||
onMouseDown={(e) => handleResizeStart("type", e)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{visibleColumns.owner && (
|
||||
<div
|
||||
className="flex min-w-0 items-center gap-1 cursor-pointer hover:text-foreground justify-end overflow-hidden"
|
||||
onClick={() => handleSort("owner")}
|
||||
>
|
||||
{sortField === "owner" && (
|
||||
<span className="shrink-0 text-primary">
|
||||
{sortOrder === "asc" ? "↑" : "↓"}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate whitespace-nowrap">{t("sftp.columns.owner")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
test("selecting an inline SFTP bookmark closes its popover", async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost",
|
||||
});
|
||||
const window = dom.window;
|
||||
const previousGlobals = new Map<string, PropertyDescriptor | undefined>();
|
||||
const installGlobal = (key: string, value: unknown) => {
|
||||
previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value,
|
||||
});
|
||||
};
|
||||
|
||||
class ResizeObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
installGlobal("window", window);
|
||||
installGlobal("document", window.document);
|
||||
installGlobal("navigator", window.navigator);
|
||||
installGlobal("HTMLElement", window.HTMLElement);
|
||||
installGlobal("HTMLInputElement", window.HTMLInputElement);
|
||||
installGlobal("HTMLTextAreaElement", window.HTMLTextAreaElement);
|
||||
installGlobal("Element", window.Element);
|
||||
installGlobal("SVGElement", window.SVGElement);
|
||||
installGlobal("Node", window.Node);
|
||||
installGlobal("NodeFilter", window.NodeFilter);
|
||||
installGlobal("MutationObserver", window.MutationObserver);
|
||||
installGlobal("CustomEvent", window.CustomEvent);
|
||||
installGlobal("Event", window.Event);
|
||||
installGlobal("StorageEvent", window.StorageEvent);
|
||||
installGlobal("localStorage", window.localStorage);
|
||||
installGlobal("sessionStorage", window.sessionStorage);
|
||||
installGlobal("getComputedStyle", window.getComputedStyle.bind(window));
|
||||
installGlobal("requestAnimationFrame", window.requestAnimationFrame.bind(window));
|
||||
installGlobal("cancelAnimationFrame", window.cancelAnimationFrame.bind(window));
|
||||
installGlobal("ResizeObserver", ResizeObserverStub);
|
||||
installGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
|
||||
const { default: React, act } = await import("react");
|
||||
const { createRoot } = await import("react-dom/client");
|
||||
const { SftpPaneToolbar } = await import("./SftpPaneToolbar.tsx");
|
||||
const rootNode = window.document.getElementById("root");
|
||||
assert.ok(rootNode);
|
||||
const root = createRoot(rootNode);
|
||||
const navigatedPaths: string[] = [];
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(SftpPaneToolbar, {
|
||||
t: (key) => ({
|
||||
"sftp.bookmark.list": "Bookmarked paths",
|
||||
"sftp.bookmark.remove": "Remove bookmark",
|
||||
"sftp.viewMode.switchToTree": "Switch to tree view",
|
||||
}[key] ?? key),
|
||||
pane: {
|
||||
id: "pane-1",
|
||||
connection: {
|
||||
id: "conn-1",
|
||||
hostId: "host-1",
|
||||
name: "Example",
|
||||
currentPath: "/home/app",
|
||||
homeDir: "/home/app",
|
||||
isLocal: false,
|
||||
},
|
||||
files: [],
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
error: null,
|
||||
connectionLogs: [],
|
||||
selectedFiles: new Set(),
|
||||
filter: "",
|
||||
filenameEncoding: "auto",
|
||||
showHiddenFiles: false,
|
||||
transferMutationToken: 0,
|
||||
},
|
||||
onNavigateTo: () => {},
|
||||
onSetFilter: () => {},
|
||||
onSetFilenameEncoding: () => {},
|
||||
onRefresh: () => {},
|
||||
showFilterBar: false,
|
||||
setShowFilterBar: () => {},
|
||||
filterInputRef: { current: null },
|
||||
isEditingPath: false,
|
||||
editingPathValue: "",
|
||||
setEditingPathValue: () => {},
|
||||
setShowPathSuggestions: () => {},
|
||||
showPathSuggestions: false,
|
||||
setPathSuggestionIndex: () => {},
|
||||
pathSuggestions: [],
|
||||
pathSuggestionIndex: -1,
|
||||
pathInputRef: { current: null },
|
||||
pathDropdownRef: { current: null },
|
||||
handlePathBlur: () => {},
|
||||
handlePathKeyDown: () => {},
|
||||
handlePathDoubleClick: () => {},
|
||||
handlePathSubmit: () => {},
|
||||
getNextUntitledName: () => "untitled",
|
||||
setNewFileName: () => {},
|
||||
setFileNameError: () => {},
|
||||
setShowNewFileDialog: () => {},
|
||||
setShowNewFolderDialog: () => {},
|
||||
setNewFolderName: () => {},
|
||||
bookmarks: [{ id: "bm-1", path: "/srv/www", label: "Web root" }],
|
||||
isCurrentPathBookmarked: false,
|
||||
onToggleBookmark: () => {},
|
||||
onAddGlobalBookmark: () => {},
|
||||
isCurrentPathGlobalBookmarked: false,
|
||||
onNavigateToBookmark: (path) => navigatedPaths.push(path),
|
||||
onDeleteBookmark: () => {},
|
||||
showHiddenFiles: false,
|
||||
onToggleShowHiddenFiles: () => {},
|
||||
viewMode: "list",
|
||||
onSetViewMode: () => {},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const trigger = window.document.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Bookmarked paths"]',
|
||||
);
|
||||
assert.ok(trigger);
|
||||
await act(async () => trigger.click());
|
||||
|
||||
const bookmark = Array.from(window.document.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Web root"),
|
||||
);
|
||||
assert.ok(bookmark, "bookmark path should be visible after opening the popover");
|
||||
await act(async () => bookmark.click());
|
||||
|
||||
assert.deepEqual(navigatedPaths, ["/srv/www"]);
|
||||
assert.equal(
|
||||
Array.from(window.document.querySelectorAll("button")).some((button) =>
|
||||
button.textContent?.includes("Web root"),
|
||||
),
|
||||
false,
|
||||
"bookmark popover should close after selecting a path",
|
||||
);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
for (const [key, descriptor] of previousGlobals) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else delete (globalThis as Record<string, unknown>)[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
SftpPaneToolbar,
|
||||
} from "./SftpPaneToolbar.tsx";
|
||||
import type { SftpPane } from "../../application/state/sftp/types.ts";
|
||||
import { Popover } from "../ui/popover.tsx";
|
||||
import { TooltipProvider } from "../ui/tooltip.tsx";
|
||||
|
||||
const toolbarSource = fs.readFileSync(
|
||||
@@ -495,14 +496,18 @@ test("bookmark list renders saved paths as selectable rows", () => {
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(SftpBookmarkList, {
|
||||
bookmarks: [{ id: "bm-1", path: "/srv/www", label: "Web root" }],
|
||||
onNavigateToBookmark: () => {},
|
||||
onDeleteBookmark: () => {},
|
||||
t: (key: string) => ({
|
||||
"sftp.bookmark.remove": "Remove bookmark",
|
||||
}[key] ?? key),
|
||||
}),
|
||||
React.createElement(
|
||||
Popover,
|
||||
null,
|
||||
React.createElement(SftpBookmarkList, {
|
||||
bookmarks: [{ id: "bm-1", path: "/srv/www", label: "Web root" }],
|
||||
onNavigateToBookmark: () => {},
|
||||
onDeleteBookmark: () => {},
|
||||
t: (key: string) => ({
|
||||
"sftp.bookmark.remove": "Remove bookmark",
|
||||
}[key] ?? key),
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -251,14 +251,16 @@ export const SftpBookmarkList: React.FC<SftpBookmarkListProps> = ({
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 text-left text-xs truncate font-mono"
|
||||
onClick={() => onNavigateToBookmark(bm.path)}
|
||||
>
|
||||
{bm.label}
|
||||
<span className="ml-1.5 text-muted-foreground text-[10px]">{bm.path}</span>
|
||||
</button>
|
||||
<PopoverClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 text-left text-xs truncate font-mono"
|
||||
onClick={() => onNavigateToBookmark(bm.path)}
|
||||
>
|
||||
{bm.label}
|
||||
<span className="ml-1.5 text-muted-foreground text-[10px]">{bm.path}</span>
|
||||
</button>
|
||||
</PopoverClose>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{bm.path}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -121,6 +121,11 @@ export const TreeNode = React.memo<TreeNodeProps>(({
|
||||
{isParentEntry ? '' : (isDir ? t('sftp.kind.folder') : (entry.name.split('.').pop()?.toUpperCase() ?? '--'))}
|
||||
</span>
|
||||
)}
|
||||
{visibleColumns.owner && (
|
||||
<span className="min-w-0 text-right text-muted-foreground text-xs truncate">
|
||||
{isParentEntry ? '' : (entry.owner || '--')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -44,6 +44,7 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
onOpenFileWith,
|
||||
onEditFile,
|
||||
onDownloadFile,
|
||||
onExtractArchive,
|
||||
onEditPermissions,
|
||||
draggedFiles,
|
||||
openNewFolderDialog,
|
||||
@@ -197,6 +198,8 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
onEditFileRef.current = onEditFile;
|
||||
const onDownloadFileRef = useRef(onDownloadFile);
|
||||
onDownloadFileRef.current = onDownloadFile;
|
||||
const onExtractArchiveRef = useRef(onExtractArchive);
|
||||
onExtractArchiveRef.current = onExtractArchive;
|
||||
const onEditPermissionsRef = useRef(onEditPermissions);
|
||||
onEditPermissionsRef.current = onEditPermissions;
|
||||
const openRenameDialogRef = useRef(openRenameDialog);
|
||||
@@ -917,6 +920,7 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
onOpenFileWithRef,
|
||||
onEditFileRef,
|
||||
onDownloadFileRef,
|
||||
onExtractArchiveRef,
|
||||
onEditPermissionsRef,
|
||||
openDeleteConfirmRef,
|
||||
openRenameDialogRef,
|
||||
@@ -996,7 +1000,7 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
)}
|
||||
{visibleColumns.type && (
|
||||
<div
|
||||
className="flex items-center justify-end gap-1 cursor-pointer hover:text-foreground min-w-0 overflow-hidden"
|
||||
className="flex items-center justify-end gap-1 cursor-pointer hover:text-foreground relative pr-2 min-w-0 overflow-hidden"
|
||||
onClick={() => handleSort('type')}
|
||||
>
|
||||
{sortField === 'type' && (
|
||||
@@ -1005,6 +1009,23 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate whitespace-nowrap">{t('sftp.columns.kind')}</span>
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
|
||||
onMouseDown={(e) => handleResizeStart('type', e)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{visibleColumns.owner && (
|
||||
<div
|
||||
className="flex items-center justify-end gap-1 cursor-pointer hover:text-foreground min-w-0 overflow-hidden"
|
||||
onClick={() => handleSort('owner')}
|
||||
>
|
||||
{sortField === 'owner' && (
|
||||
<span className="shrink-0 text-primary">
|
||||
{sortOrder === 'asc' ? '↑' : '↓'}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate whitespace-nowrap">{t('sftp.columns.owner')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface SftpPaneTreeViewProps {
|
||||
onOpenFileWith?: (entry: SftpFileEntry, fullPath?: string) => void;
|
||||
onEditFile?: (entry: SftpFileEntry, fullPath?: string) => void;
|
||||
onDownloadFile?: (entry: SftpFileEntry, fullPath?: string) => void;
|
||||
onExtractArchive?: (entry: SftpFileEntry, fullPath?: string) => void | Promise<void>;
|
||||
onEditPermissions?: (entry: SftpFileEntry, fullPath?: string) => void;
|
||||
draggedFiles: (SftpTransferSource & { side: 'left' | 'right' })[] | null;
|
||||
openNewFolderDialog: (targetPath: string) => void;
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
useSftpWritableHosts,
|
||||
} from "./SftpContext";
|
||||
import type { SftpPane } from "../../application/state/sftp/types";
|
||||
import { joinPath } from "../../application/state/sftp/utils";
|
||||
import { joinPath, getParentPath } from "../../application/state/sftp/utils";
|
||||
import type { Host } from "../../domain/models";
|
||||
import { useSftpPaneDialogs } from "./hooks/useSftpPaneDialogs";
|
||||
import { useSftpPaneDragAndSelect } from "./hooks/useSftpPaneDragAndSelect";
|
||||
@@ -328,6 +328,14 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
}
|
||||
}, [callbacks, pane.connection?.currentPath, requestTreeReload]);
|
||||
|
||||
const handleExtractArchive = useCallback((entry: Parameters<NonNullable<typeof callbacks.onExtractArchive>>[0], fullPath?: string) => {
|
||||
const archivePath = fullPath ?? joinPath(pane.connection?.currentPath ?? "", entry.name);
|
||||
void Promise.resolve(callbacks.onExtractArchive?.(entry, fullPath)).then(() => {
|
||||
const parentPath = getParentPath(archivePath);
|
||||
if (parentPath) requestNestedTreeReload([parentPath]);
|
||||
});
|
||||
}, [callbacks, pane.connection?.currentPath, requestNestedTreeReload]);
|
||||
|
||||
const handleMoveEntriesToPath = useCallback(async (sourcePaths: string[], targetPath: string) => {
|
||||
await callbacks.onMoveEntriesToPath(sourcePaths, targetPath);
|
||||
}, [callbacks]);
|
||||
@@ -602,6 +610,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
onOpenFileWith={callbacks.onOpenFileWith}
|
||||
onEditFile={callbacks.onEditFile}
|
||||
onDownloadFile={callbacks.onDownloadFile}
|
||||
onExtractArchive={callbacks.onExtractArchive ? handleExtractArchive : undefined}
|
||||
onEditPermissions={callbacks.onEditPermissions}
|
||||
draggedFiles={draggedFiles}
|
||||
openNewFolderDialog={openNewFolderDialogAtPath}
|
||||
@@ -653,6 +662,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
onEditFile={callbacks.onEditFile}
|
||||
onDownloadFile={callbacks.onDownloadFile}
|
||||
onDownloadFiles={callbacks.onDownloadFiles}
|
||||
onExtractArchive={callbacks.onExtractArchive ? handleExtractArchive : undefined}
|
||||
onEditPermissions={callbacks.onEditPermissions}
|
||||
onUploadExternalFileList={handleUploadExternalFileList}
|
||||
onUploadExternalFolder={handleUploadExternalFolder}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { filterHiddenFiles, isNavigableDirectory } from "../utils";
|
||||
import type { SftpFileEntry } from "../../../types";
|
||||
import { extractDropEntries, type DropEntry } from "../../../lib/sftpFileUtils";
|
||||
import { toast } from "../../ui/toast";
|
||||
import { isAppLockOverlayActive } from '../../../infrastructure/appLockOverlayDom';
|
||||
import {
|
||||
createDropEntriesFromClipboardFiles,
|
||||
getSftpClipboardSystemTextPaths,
|
||||
@@ -992,8 +993,12 @@ export const useSftpKeyboardShortcuts = ({
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
// Use capture phase to intercept before other handlers
|
||||
window.addEventListener("keydown", handleKeyDown, true);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (isAppLockOverlayActive()) return;
|
||||
handleKeyDown(event);
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown, true);
|
||||
return () => window.removeEventListener("keydown", onKeyDown, true);
|
||||
}, [handleKeyDown, isActive]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "../../../application/state/sftp/transferConcurrency";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { toast } from "../../ui/toast";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { getFileExtension, getLanguageId, FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
|
||||
import { isNavigableDirectory } from "../utils";
|
||||
import { reportSftpUploadResults } from "../reportSftpUploadResults";
|
||||
@@ -535,6 +536,52 @@ export const useSftpViewFileOps = ({
|
||||
[handleDownloadFileForSide],
|
||||
);
|
||||
|
||||
const handleExtractArchiveForSide = useCallback(
|
||||
async (side: "left" | "right", file: SftpFileEntry, fullPath?: string) => {
|
||||
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
|
||||
if (!pane.connection) return;
|
||||
|
||||
const resolvedPath = fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name);
|
||||
toast.info(t("sftp.extract.extracting", { fileName: file.name }), "SFTP");
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (pane.connection.isLocal) {
|
||||
if (!bridge?.extractLocalArchive) {
|
||||
throw new Error("Local extract unavailable");
|
||||
}
|
||||
await bridge.extractLocalArchive(resolvedPath);
|
||||
} else {
|
||||
const sftpId = getSftpIdForConnection?.(pane.connection.id);
|
||||
if (!sftpId || !bridge?.extractSftpArchive) {
|
||||
throw new Error("SFTP session not found");
|
||||
}
|
||||
await bridge.extractSftpArchive(sftpId, resolvedPath, pane.filenameEncoding);
|
||||
}
|
||||
await sftpRef.current.refresh(side);
|
||||
toast.success(t("sftp.extract.success", { fileName: file.name }), "SFTP");
|
||||
} catch (e) {
|
||||
logger.error("[SftpView] Failed to extract archive:", e);
|
||||
toast.error(
|
||||
t("sftp.extract.error", {
|
||||
fileName: file.name,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
}),
|
||||
"SFTP",
|
||||
);
|
||||
}
|
||||
},
|
||||
[getSftpIdForConnection, sftpRef, t],
|
||||
);
|
||||
|
||||
const onExtractArchiveLeft = useCallback(
|
||||
(file: SftpFileEntry, fullPath?: string) => handleExtractArchiveForSide("left", file, fullPath),
|
||||
[handleExtractArchiveForSide],
|
||||
);
|
||||
const onExtractArchiveRight = useCallback(
|
||||
(file: SftpFileEntry, fullPath?: string) => handleExtractArchiveForSide("right", file, fullPath),
|
||||
[handleExtractArchiveForSide],
|
||||
);
|
||||
|
||||
// Multi-file download. For local panes, each file auto-downloads as a blob
|
||||
// (no prompt). For remote panes, prompts for a target directory once and
|
||||
// streams all selected entries into it — avoids the per-file save dialog
|
||||
@@ -738,6 +785,8 @@ export const useSftpViewFileOps = ({
|
||||
onOpenFileWithRight,
|
||||
onDownloadFileLeft,
|
||||
onDownloadFileRight,
|
||||
onExtractArchiveLeft,
|
||||
onExtractArchiveRight,
|
||||
onDownloadFilesLeft,
|
||||
onDownloadFilesRight,
|
||||
onUploadExternalFilesLeft,
|
||||
|
||||
@@ -81,6 +81,8 @@ export interface UseSftpViewFileOpsResult {
|
||||
onOpenFileWithRight: (file: SftpFileEntry, fullPath?: string) => void;
|
||||
onDownloadFileLeft: (file: SftpFileEntry, fullPath?: string) => void;
|
||||
onDownloadFileRight: (file: SftpFileEntry, fullPath?: string) => void;
|
||||
onExtractArchiveLeft: (file: SftpFileEntry, fullPath?: string) => void | Promise<void>;
|
||||
onExtractArchiveRight: (file: SftpFileEntry, fullPath?: string) => void | Promise<void>;
|
||||
onDownloadFilesLeft: (files: SftpFileEntry[]) => void;
|
||||
onDownloadFilesRight: (files: SftpFileEntry[]) => void;
|
||||
onUploadExternalFilesLeft: (dataTransfer: DataTransfer, targetPath?: string) => void;
|
||||
|
||||
@@ -90,6 +90,7 @@ export const useSftpViewPaneCallbacks = ({
|
||||
lastModified: ms,
|
||||
lastModifiedFormatted: formatDate(ms),
|
||||
permissions: f.permissions,
|
||||
owner: f.owner,
|
||||
linkTarget: f.linkTarget as 'file' | 'directory' | null | undefined,
|
||||
hidden: f.hidden,
|
||||
};
|
||||
@@ -154,6 +155,7 @@ export const useSftpViewPaneCallbacks = ({
|
||||
onOpenFileWith: fileOps.onOpenFileWithLeft,
|
||||
onDownloadFile: fileOps.onDownloadFileLeft,
|
||||
onDownloadFiles: fileOps.onDownloadFilesLeft,
|
||||
onExtractArchive: fileOps.onExtractArchiveLeft,
|
||||
onUploadExternalFiles: fileOps.onUploadExternalFilesLeft,
|
||||
onUploadExternalFileList: fileOps.onUploadExternalFileListLeft,
|
||||
onUploadExternalFolder: fileOps.onUploadExternalFolderLeft,
|
||||
@@ -196,6 +198,7 @@ export const useSftpViewPaneCallbacks = ({
|
||||
onOpenFileWith: fileOps.onOpenFileWithRight,
|
||||
onDownloadFile: fileOps.onDownloadFileRight,
|
||||
onDownloadFiles: fileOps.onDownloadFilesRight,
|
||||
onExtractArchive: fileOps.onExtractArchiveRight,
|
||||
onUploadExternalFiles: fileOps.onUploadExternalFilesRight,
|
||||
onUploadExternalFileList: fileOps.onUploadExternalFileListRight,
|
||||
onUploadExternalFolder: fileOps.onUploadExternalFolderRight,
|
||||
|
||||
@@ -14,6 +14,7 @@ const widths: ColumnWidths = {
|
||||
modified: 28,
|
||||
size: 7,
|
||||
type: 9,
|
||||
owner: 10,
|
||||
};
|
||||
|
||||
test('normalizes missing and invalid SFTP column preferences to all columns', () => {
|
||||
@@ -23,8 +24,15 @@ test('normalizes missing and invalid SFTP column preferences to all columns', ()
|
||||
|
||||
test('keeps the name column visible while restoring optional column preferences', () => {
|
||||
assert.deepEqual(
|
||||
normalizeSftpColumnVisibility({ name: false, modified: false, size: true, type: false }),
|
||||
{ name: true, modified: false, size: true, type: false },
|
||||
normalizeSftpColumnVisibility({ name: false, modified: false, size: true, type: false, owner: false }),
|
||||
{ name: true, modified: false, size: true, type: false, owner: false },
|
||||
);
|
||||
});
|
||||
|
||||
test('treats a missing owner preference as visible', () => {
|
||||
assert.equal(
|
||||
normalizeSftpColumnVisibility({ name: true, modified: true, size: true, type: true }).owner,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -34,11 +42,24 @@ test('builds a grid containing only visible SFTP columns', () => {
|
||||
modified: false,
|
||||
size: true,
|
||||
type: false,
|
||||
owner: false,
|
||||
});
|
||||
|
||||
assert.equal(template, 'minmax(140px, 56fr) minmax(52px, 7fr)');
|
||||
});
|
||||
|
||||
test('includes the owner column when it is visible', () => {
|
||||
const template = buildSftpColumnTemplate(widths, {
|
||||
name: true,
|
||||
modified: false,
|
||||
size: false,
|
||||
type: false,
|
||||
owner: true,
|
||||
});
|
||||
|
||||
assert.equal(template, 'minmax(140px, 56fr) minmax(56px, 10fr)');
|
||||
});
|
||||
|
||||
test('can reduce the SFTP file list to only the name column', () => {
|
||||
assert.equal(
|
||||
buildSftpColumnTemplate(widths, {
|
||||
@@ -46,6 +67,7 @@ test('can reduce the SFTP file list to only the name column', () => {
|
||||
modified: false,
|
||||
size: false,
|
||||
type: false,
|
||||
owner: false,
|
||||
}),
|
||||
'minmax(140px, 56fr)',
|
||||
);
|
||||
|
||||
@@ -27,4 +27,9 @@ test('list and tree SFTP views share column visibility and keyboard-accessible m
|
||||
assert.match(treeNodeSource, /visibleColumns\.modified/);
|
||||
assert.match(treeNodeSource, /visibleColumns\.size/);
|
||||
assert.match(treeNodeSource, /visibleColumns\.type/);
|
||||
assert.match(treeNodeSource, /visibleColumns\.owner/);
|
||||
assert.match(columnMenuSource, /'modified', 'size', 'type', 'owner'/);
|
||||
|
||||
const paneCallbacksSource = readFileSync(new URL('./hooks/useSftpViewPaneCallbacks.ts', import.meta.url), 'utf8');
|
||||
assert.match(paneCallbacksSource, /owner: f\.owner/);
|
||||
});
|
||||
@@ -59,6 +59,18 @@ test('SFTP descending kind sorting keeps directories first when enabled', () =>
|
||||
assert.deepEqual(sorted.map(({ name }) => name), ['src', 'archive.zip', 'BUILD']);
|
||||
});
|
||||
|
||||
test('SFTP owner sorting compares usernames and keeps directories first', () => {
|
||||
const ownerEntries = [
|
||||
{ ...entry('www.log', 'file', 100), owner: 'www-data' },
|
||||
{ ...entry('root.txt', 'file', 100), owner: 'root' },
|
||||
{ ...entry('home', 'directory', 100), owner: 'alice' },
|
||||
];
|
||||
|
||||
const sorted = sortSftpEntries(ownerEntries, 'owner', 'asc', true);
|
||||
|
||||
assert.deepEqual(sorted.map(({ name }) => name), ['home', 'root.txt', 'www.log']);
|
||||
});
|
||||
|
||||
test('SFTP kind sorting can mix files and directories when folders first is disabled', () => {
|
||||
const kindEntries = [
|
||||
entry('BUILD', 'file', 100),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useMemo } from 'react';
|
||||
import { AppWindow, ArrowRight, ArrowUp, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderInput, FolderPlus, Pencil, RefreshCw, Shield, Trash2, Upload } from 'lucide-react';
|
||||
import { AppWindow, Archive, ArrowRight, ArrowUp, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderInput, FolderPlus, Pencil, RefreshCw, Shield, Trash2, Upload } from 'lucide-react';
|
||||
import { ContextMenuContent, ContextMenuItem, ContextMenuSeparator } from '../ui/context-menu';
|
||||
import { getParentPath } from '../../application/state/sftp/utils';
|
||||
import { isKnownBinaryFile } from '../../lib/sftpFileUtils';
|
||||
import { isExtractableArchive } from '../../domain/sftpArchive';
|
||||
import { isNavigableDirectory } from './utils';
|
||||
import { getSftpTreeUploadFilesTargetPath, getSftpUploadFilesLabelKey, getSftpUploadFolderLabelKey } from './sftpUploadMenu';
|
||||
|
||||
@@ -15,7 +16,7 @@ export function useSftpPaneTreeContextMenu(props: SftpPaneTreeContextMenuProps)
|
||||
executeMoveAction, triggerUploadPicker, onUploadExternalFolder, uploadEnabled, folderUploadEnabled,
|
||||
setMoveTargetPaths, setMoveToPath, setMoveToError, setMoveToSuggestions, setMoveToSuggestionIndex,
|
||||
setIsMoving, setShowMoveToDialog, tRef, onCopyToOtherPaneRef, onNavigateToRef, onOpenFileWithSystemDefaultRef, onOpenFileWithRef,
|
||||
onEditFileRef, onDownloadFileRef, onEditPermissionsRef, openDeleteConfirmRef, openRenameDialogRef,
|
||||
onEditFileRef, onDownloadFileRef, onExtractArchiveRef, onEditPermissionsRef, openDeleteConfirmRef, openRenameDialogRef,
|
||||
openNewFolderDialogRef, openNewFileDialogRef,
|
||||
} = props;
|
||||
|
||||
@@ -82,6 +83,11 @@ export function useSftpPaneTreeContextMenu(props: SftpPaneTreeContextMenuProps)
|
||||
<Download size={14} className="mr-2" />{tRef.current('sftp.context.download')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{!isDir && onExtractArchiveRef.current && isExtractableArchive(entry.name) && (
|
||||
<ContextMenuItem onClick={() => onExtractArchiveRef.current?.(entry, entryPath)}>
|
||||
<Archive size={14} className="mr-2" />{tRef.current('sftp.context.extract')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={handleCopyToOtherPane}>
|
||||
<Copy size={14} className="mr-2" />{tRef.current('sftp.context.copyToOtherPane')}
|
||||
@@ -175,6 +181,7 @@ export function useSftpPaneTreeContextMenu(props: SftpPaneTreeContextMenuProps)
|
||||
onUploadExternalFolder,
|
||||
onCopyToOtherPaneRef,
|
||||
onDownloadFileRef,
|
||||
onExtractArchiveRef,
|
||||
onEditFileRef,
|
||||
onEditPermissionsRef,
|
||||
onNavigateToRef,
|
||||
|
||||
@@ -265,6 +265,7 @@ export const buildSftpColumnTemplate = (
|
||||
if (visibleColumns.modified) columns.push(`minmax(0, ${columnWidths.modified}fr)`);
|
||||
if (visibleColumns.size) columns.push(`minmax(52px, ${columnWidths.size}fr)`);
|
||||
if (visibleColumns.type) columns.push(`minmax(64px, ${columnWidths.type}fr)`);
|
||||
if (visibleColumns.owner) columns.push(`minmax(56px, ${columnWidths.owner}fr)`);
|
||||
return columns.join(' ');
|
||||
};
|
||||
|
||||
@@ -306,6 +307,9 @@ export const sortSftpEntries = (
|
||||
cmp = extA.localeCompare(extB);
|
||||
break;
|
||||
}
|
||||
case 'owner':
|
||||
cmp = (a.owner || '').localeCompare(b.owner || '');
|
||||
break;
|
||||
}
|
||||
return sortOrder === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import { TerminalTheme } from '../../domain/models';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { CustomThemeEditor } from './CustomThemeEditor';
|
||||
import { Button } from '../ui/button';
|
||||
import { isAppLockOverlayActive } from '../../infrastructure/appLockOverlayDom';
|
||||
|
||||
|
||||
interface CustomThemeModalProps {
|
||||
@@ -132,6 +133,7 @@ export const CustomThemeModal: React.FC<CustomThemeModalProps> = ({
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (isAppLockOverlayActive()) return;
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
onCancel();
|
||||
|
||||
Loaded 100 of 251 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user