mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-01 03:12:33 +00:00
Compare commits
365
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d87a95725d | ||
|
|
1bbb0e1f65 | ||
|
|
f65a9ffc2d | ||
|
|
05e9447998 | ||
|
|
fbacff7983 | ||
|
|
1b188c8aad | ||
|
|
eaacd01055 | ||
|
|
2ab2321226 | ||
|
|
95e1a147d8 | ||
|
|
a1f5181e58 | ||
|
|
971f702c32 | ||
|
|
5dbdf0965a | ||
|
|
b24d6dd196 | ||
|
|
b9ba7349c1 | ||
|
|
a40c0616d7 | ||
|
|
71e5c280fd | ||
|
|
b92084c477 | ||
|
|
ae669daf19 | ||
|
|
d049b85df4 | ||
|
|
a151240017 | ||
|
|
b09ae31830 | ||
|
|
0e74c310d2 | ||
|
|
359c2b7690 | ||
|
|
0fd7bed991 | ||
|
|
f23caa5026 | ||
|
|
dd4cd0a587 | ||
|
|
54e32dbcae | ||
|
|
8fc811d3e0 | ||
|
|
1a12dfa173 | ||
|
|
97d167eef7 | ||
|
|
b852f267f8 | ||
|
|
af1b624747 | ||
|
|
ff5c062bf1 | ||
|
|
539594845f | ||
|
|
3e2fa04b08 | ||
|
|
5718566ddb | ||
|
|
e345e59219 | ||
|
|
88b0a908a9 | ||
|
|
06e8be3df9 | ||
|
|
e8f51ea63e | ||
|
|
15c8b44818 | ||
|
|
8df32d5663 | ||
|
|
00ad6198b8 | ||
|
|
0ac9ca4245 | ||
|
|
fff99e46f8 | ||
|
|
73936c7792 | ||
|
|
8be8411a1f | ||
|
|
1d633431c1 | ||
|
|
21a0903b32 | ||
|
|
918d0d81f0 | ||
|
|
dd45af8fe3 | ||
|
|
e171ee1fe1 | ||
|
|
ada3d13ca3 | ||
|
|
6c5251c936 | ||
|
|
eee2191c8a | ||
|
|
2e51ff8b54 | ||
|
|
4525ea1c03 | ||
|
|
066e658afd | ||
|
|
88387b9c2b | ||
|
|
95ee17f3cc | ||
|
|
4ff20453f2 | ||
|
|
155401943b | ||
|
|
e543266b02 | ||
|
|
13b1bf506e | ||
|
|
f6050e1e44 | ||
|
|
e7b58a8831 | ||
|
|
992a303818 | ||
|
|
a3ab956999 | ||
|
|
21880ef1ea | ||
|
|
308df815d8 | ||
|
|
c138e1fc74 | ||
|
|
600544fd7f | ||
|
|
7e379ea5f5 | ||
|
|
ac3eb88956 | ||
|
|
140d78ae9e | ||
|
|
b7de0bb441 | ||
|
|
b11eaa172f | ||
|
|
809cbfdf79 | ||
|
|
d5198f45ca | ||
|
|
a233d3fb91 | ||
|
|
401d9d7a91 | ||
|
|
82e2f7287d | ||
|
|
dd30ff0e20 | ||
|
|
bb655caaa1 | ||
|
|
00f91c7f07 | ||
|
|
f5c5bcd17c | ||
|
|
dc6d1d9b60 | ||
|
|
3000837f4e | ||
|
|
4709ff2939 | ||
|
|
6583f422cd | ||
|
|
e6ed93a4ac | ||
|
|
e4c1ab2a8f | ||
|
|
a9e27f9688 | ||
|
|
29c90f1974 | ||
|
|
c4354d9451 | ||
|
|
0bf7e187e6 | ||
|
|
08b9027ef1 | ||
|
|
76fd52e85a | ||
|
|
9dc4336c4a | ||
|
|
bb71d28846 | ||
|
|
61b9cf2584 | ||
|
|
acfc581719 | ||
|
|
486a8d20e1 | ||
|
|
7a268b7726 | ||
|
|
54fbcc5531 | ||
|
|
6bc131cc69 | ||
|
|
913789992d | ||
|
|
ff25cb1a71 | ||
|
|
497afbbe82 | ||
|
|
9b0c07e614 | ||
|
|
3bf2242ac4 | ||
|
|
8988ebe903 | ||
|
|
53dd8af4c0 | ||
|
|
2c4657016a | ||
|
|
2568109538 | ||
|
|
49fc6e3fba | ||
|
|
c32646b570 | ||
|
|
7c45ce8c19 | ||
|
|
fd3b648a87 | ||
|
|
75e46b5584 | ||
|
|
8191f0b67b | ||
|
|
f034754455 | ||
|
|
623aa97e72 | ||
|
|
2897a18f1e | ||
|
|
cf01441dae | ||
|
|
95dd8b62c4 | ||
|
|
ab3eae409a | ||
|
|
79aafcdb1a | ||
|
|
d2a80e04d4 | ||
|
|
02addeaea9 | ||
|
|
e6b5b76efb | ||
|
|
0184d3c34f | ||
|
|
32afe5bf4b | ||
|
|
1f337d28ed | ||
|
|
3632b42e8e | ||
|
|
ea2c2ad301 | ||
|
|
988783c286 | ||
|
|
33de6ee388 | ||
|
|
d98cd37be6 | ||
|
|
ca344ad0a8 | ||
|
|
e7dcd22a7a | ||
|
|
d752897aa1 | ||
|
|
be6909c5ea | ||
|
|
f3e67fc0aa | ||
|
|
e1f915a767 | ||
|
|
e70f60f270 | ||
|
|
5db74fbb2d | ||
|
|
29c9610315 | ||
|
|
afc02116a8 | ||
|
|
66310a1172 | ||
|
|
399657247d | ||
|
|
ad1c5c9a3a | ||
|
|
d5bd69ff90 | ||
|
|
60ea41523a | ||
|
|
78d50b78da | ||
|
|
7a43157ae1 | ||
|
|
31e75e114e | ||
|
|
a6180c7db1 | ||
|
|
78d6662ce8 | ||
|
|
9f72eb6469 | ||
|
|
e882ec01f7 | ||
|
|
91c611c9f3 | ||
|
|
6f470b3142 | ||
|
|
d8d14253d6 | ||
|
|
14f4b31097 | ||
|
|
5245ebbaa0 | ||
|
|
e9f2a7c883 | ||
|
|
d7d711e1e3 | ||
|
|
9c63b40ddf | ||
|
|
a8e5b4992c | ||
|
|
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
@@ -71,11 +71,13 @@ on:
|
||||
- "scripts/linux/**"
|
||||
- "scripts/mosh-extra-resources.cjs"
|
||||
- "scripts/nodePtyConptyPatch.cjs"
|
||||
- "scripts/patch-xterm-macos-column-selection.cjs"
|
||||
- "scripts/patch-xterm-webgl-atlas.cjs"
|
||||
- "scripts/rebuildPatchedNodePty.cjs"
|
||||
- "scripts/resolve-et-bin-release.cjs"
|
||||
- "scripts/resolve-mosh-bin-release.cjs"
|
||||
- "scripts/verify-linux-*.sh"
|
||||
- "scripts/xterm-macos-column-selection.live.test.cjs"
|
||||
- "skills/**"
|
||||
|
||||
# A newer run for the same main push or PR cancels older in-progress work.
|
||||
@@ -220,6 +222,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:
|
||||
@@ -237,6 +245,10 @@ jobs:
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Test macOS Option column selection
|
||||
if: matrix.name == 'macos'
|
||||
run: npm run test:xterm-macos-selection
|
||||
|
||||
- name: Compile ConPTY test helpers
|
||||
if: matrix.name == 'windows'
|
||||
shell: pwsh
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
!README.ja-JP.md
|
||||
!README.md
|
||||
!README.zh-CN.md
|
||||
!README.zh-TW.md
|
||||
!electron-builder.config.cjs
|
||||
!eslint.config.js
|
||||
!flake.lock
|
||||
@@ -115,6 +116,7 @@ coverage
|
||||
!/build/icons
|
||||
!/build/installer.nsh
|
||||
/electron/native/**/build
|
||||
/electron/bridges/windowsHelloHelper/build/
|
||||
/release
|
||||
/out
|
||||
/public/monaco
|
||||
|
||||
Vendored
+2
-1
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"dbaeumer.vscode-eslint"
|
||||
"dbaeumer.vscode-eslint",
|
||||
"bradlc.vscode-tailwindcss"
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+3
-3
@@ -24,7 +24,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest">
|
||||
<img src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=最新版をダウンロード&color=success" alt="最新版をダウンロード">
|
||||
<img src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=%E6%9C%80%E6%96%B0%E7%89%88%E3%82%92%E3%83%80%E3%82%A6%E3%83%B3%E3%83%AD%E3%83%BC%E3%83%89&color=success" alt="最新版をダウンロード">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="./README.md">English</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.ja-JP.md">日本語</a>
|
||||
<a href="./README.md">English</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja-JP.md">日本語</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -295,7 +295,7 @@ npm run pack:linux # Linux (AppImage + DEB + RPM)
|
||||
4. ブランチにプッシュ (`git push origin feature/amazing-feature`)
|
||||
5. Pull Request をオープン
|
||||
|
||||
アーキテクチャの概要とコーディング規約については [agents.md](agents.md) を参照してください。
|
||||
アーキテクチャの概要とコーディング規約については [AGENTS.md](AGENTS.md) を参照してください。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="./README.md">English</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.ja-JP.md">日本語</a>
|
||||
<a href="./README.md">English</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja-JP.md">日本語</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -296,7 +296,7 @@ Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
See [agents.md](agents.md) for architecture overview and coding conventions.
|
||||
See [AGENTS.md](AGENTS.md) for architecture overview and coding conventions.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+3
-3
@@ -24,7 +24,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest">
|
||||
<img src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=下载最新版&color=success" alt="下载最新版">
|
||||
<img src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=%E4%B8%8B%E8%BD%BD%E6%9C%80%E6%96%B0%E7%89%88&color=success" alt="下载最新版">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="./README.md">English</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.ja-JP.md">日本語</a>
|
||||
<a href="./README.md">English</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja-JP.md">日本語</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -294,7 +294,7 @@ npm run pack:linux # Linux (AppImage + DEB + RPM)
|
||||
4. 推送到分支 (`git push origin feature/amazing-feature`)
|
||||
5. 打开一个 Pull Request
|
||||
|
||||
查看 [agents.md](agents.md) 了解架构概述和编码规范。
|
||||
查看 [AGENTS.md](AGENTS.md) 了解架构概述和编码规范。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+334
@@ -0,0 +1,334 @@
|
||||
<p align="center">
|
||||
<img src="public/icon.png" alt="Netcatty" width="128" height="128">
|
||||
</p>
|
||||
|
||||
<h1 align="center">Netcatty</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>🔥 AI 驅動的 SSH 用戶端、SFTP 瀏覽器與終端機管理工具 🚀</strong><br/>
|
||||
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
以 Electron、React 與 xterm.js 打造,介面精緻、功能完整的 SSH 工作區。<br/>
|
||||
🔥 內建 AI Agent · 分割終端 · Vault 檢視 · SFTP 工作流 · 自訂主題 —— 全部整合在一起。
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=Release"></a>
|
||||
|
||||
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge&logo=electron"></a>
|
||||
|
||||
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-green?style=for-the-badge"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest">
|
||||
<img src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=%E4%B8%8B%E8%BC%89%E6%9C%80%E6%96%B0%E7%89%88&color=success" alt="下載最新版">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://ko-fi.com/binaricat">
|
||||
<img src="https://cdn.ko-fi.com/cdn/kofi3.png?v=2" width="150" alt="Support on Ko-fi">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="./README.md">English</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja-JP.md">日本語</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<img width="3142" height="1764" alt="Screenshot 2026-07-02 at 22 51 24" src="https://github.com/user-attachments/assets/3116165d-623a-4d3a-a28a-914befb9b72d" />
|
||||
|
||||
---
|
||||
|
||||
<a name="catty-agent"></a>
|
||||
# 🔥 Catty Agent —— 你的 IT 維運 AI 夥伴
|
||||
|
||||
> 🚀 **用 AI 加速你每天的 IT 維運工作。** Catty Agent 是內建的 AI 助理,它了解你的伺服器、會執行指令,並且透過自然對話完成複雜的多主機作業。
|
||||
|
||||
### 🔥 Catty Agent 能做什麼?
|
||||
|
||||
- 🚀 **用自然語言管理伺服器** —— 直接說出需求,不必再背指令
|
||||
- 🔥 **即時伺服器診斷** —— 透過對話檢查狀態、查看日誌、監控資源
|
||||
- 🚀 **多主機協同作業** —— 同時在多台伺服器上協調任務
|
||||
- 🔥 **智慧的上下文感知** —— 理解你的伺服器環境,給出對症下藥的回應
|
||||
- 🚀 **一句話完成複雜操作** —— 建立叢集、部署服務,交代一聲就好
|
||||
|
||||
### 🎬 AI 實戰示範
|
||||
|
||||
#### 🔥 單一主機 —— 智慧伺服器診斷
|
||||
|
||||
請 Catty Agent 檢查伺服器健康狀況,它會自動執行合適的指令、分析輸出,並在幾秒內給你一份清楚的總結。
|
||||
|
||||
<img width="1510" height="870" alt="ai-single" src="https://github.com/user-attachments/assets/d3f34e53-8476-4fab-8634-394b931d1ff1" />
|
||||
|
||||
#### 🚀 多主機 —— 建立 Docker Swarm 叢集
|
||||
|
||||
看 Catty Agent 在一次對話裡,跨兩台伺服器編排出一個 Docker Swarm 叢集。初始化、權杖交換、節點加入都由它處理 —— 你只要說出你想要的結果。
|
||||
|
||||
<img width="1515" height="870" alt="ai-muti" src="https://github.com/user-attachments/assets/ca166fc5-65d9-4d7b-951a-962c2ef230d8" />
|
||||
|
||||
---
|
||||
|
||||
# 目錄 <!-- omit in toc -->
|
||||
|
||||
- [🔥 Catty Agent —— AI 夥伴](#catty-agent)
|
||||
- [Netcatty 是什麼](#netcatty-是什麼)
|
||||
- [為什麼選 Netcatty](#為什麼選-netcatty)
|
||||
- [功能特色](#功能特色)
|
||||
- [介面截圖](#介面截圖)
|
||||
- [主視窗](#主視窗)
|
||||
- [Vault 檢視](#vault-檢視)
|
||||
- [分割終端](#分割終端)
|
||||
- [支援的發行版](#支援的發行版)
|
||||
- [快速開始](#快速開始)
|
||||
- [建置與打包](#建置與打包)
|
||||
- [技術堆疊](#技術堆疊)
|
||||
- [參與貢獻](#參與貢獻)
|
||||
- [貢獻者](#貢獻者)
|
||||
- [Star 紀錄](#star-紀錄)
|
||||
- [授權條款](#授權條款)
|
||||
|
||||
---
|
||||
|
||||
<a name="netcatty-是什麼"></a>
|
||||
# Netcatty 是什麼
|
||||
|
||||
**Netcatty** 是一套現代化的 SSH 用戶端與終端機管理工具,支援 macOS、Windows 與 Linux,專為需要有效率管理多台遠端伺服器的開發者、系統管理員與 DevOps 工程師而設計。
|
||||
|
||||
- **Netcatty 是** PuTTY、Termius、SecureCRT 與 macOS Terminal.app 在 SSH 連線上的替代方案
|
||||
- **Netcatty 是** 一套功能完整的 SFTP 用戶端,具備雙窗格檔案瀏覽器
|
||||
- **Netcatty 是** 一個終端機工作區,支援分割窗格、分頁與工作階段管理
|
||||
- **Netcatty 支援** SSH、本機終端、Telnet、Mosh 與序列埠(Serial)連線(視環境而定)
|
||||
- **Netcatty 不是** Shell 的替代品 —— 它是透過 SSH/Telnet/Mosh 或本機/序列埠工作階段連上 Shell
|
||||
|
||||
---
|
||||
|
||||
<a name="為什麼選-netcatty"></a>
|
||||
# 為什麼選 Netcatty
|
||||
|
||||
如果你經常同時照顧一整批伺服器,Netcatty 是為了速度與順暢而設計的:
|
||||
|
||||
- **以工作區為核心** —— 分割窗格 + 分頁 + 工作階段還原,適合長時間掛著的工作流程
|
||||
- **Vault 整理** —— 網格/清單/樹狀檢視,搭配快速搜尋與順手的拖曳操作
|
||||
- **認真做的 SFTP** —— 內建編輯器 + 拖放 + 流暢的檔案操作
|
||||
|
||||
---
|
||||
|
||||
<a name="功能特色"></a>
|
||||
# 功能特色
|
||||
|
||||
### 🗂️ Vault
|
||||
- **多種檢視** —— 網格/清單/樹狀
|
||||
- **快速搜尋** —— 迅速找到主機與群組
|
||||
|
||||
### 🖥️ 終端機工作區
|
||||
- **分割窗格** —— 水平與垂直分割,方便多工
|
||||
- **工作階段管理** —— 多條連線並排處理
|
||||
- **內嵌圖片** —— 直接顯示遠端程式輸出的 Kitty 圖形、SIXEL 與 iTerm 內嵌圖片
|
||||
|
||||
### 📁 SFTP + 內建編輯器
|
||||
- **檔案工作流** —— 拖放上傳/下載
|
||||
- **就地編輯** —— 內建編輯器,快速修改檔案
|
||||
|
||||
### 🎨 個人化
|
||||
- **自訂主題** —— 依你的喜好調整外觀
|
||||
- **關鍵字高亮** —— 自訂終端輸出的高亮規則
|
||||
|
||||
---
|
||||
|
||||
<a name="介面截圖"></a>
|
||||
# 介面截圖
|
||||
|
||||
<a name="主視窗"></a>
|
||||
## 主視窗
|
||||
|
||||
主視窗是為了長時間的 SSH 工作流程而設計:工作階段、導覽與常用工具都集中在同一個地方。
|
||||
|
||||
<img width="1531" height="875" alt="black-grid" src="https://github.com/user-attachments/assets/004b80f6-5bbb-4f14-b8cd-33a0a5913b8c" />
|
||||
|
||||
<img width="1550" height="876" alt="light" src="https://github.com/user-attachments/assets/2b59a999-a25e-4217-944c-9aef0a09f272" />
|
||||
|
||||
<a name="vault-檢視"></a>
|
||||
## Vault 檢視
|
||||
|
||||
用最適合當下情境的方式整理與瀏覽主機:網格看全局、清單快速掃視、樹狀理層級。
|
||||
|
||||
<img width="1554" height="882" alt="list" src="https://github.com/user-attachments/assets/03249f15-b5f8-4770-a3c3-d5001636ea00" />
|
||||
|
||||
<img width="1561" height="878" alt="tree" src="https://github.com/user-attachments/assets/739f7b66-3898-43d0-8dd3-b9b97fd8e8de" />
|
||||
|
||||
<a name="分割終端"></a>
|
||||
## 分割終端
|
||||
|
||||
分割窗格讓你同時盯著多台伺服器或多個服務(部署 + 日誌 + 監控指標),不用一直切視窗。
|
||||
|
||||
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
|
||||
|
||||
---
|
||||
|
||||
<a name="支援的發行版"></a>
|
||||
# 支援的發行版
|
||||
|
||||
Netcatty 會自動辨識已連線主機的作業系統,並顯示對應圖示:
|
||||
|
||||
<p align="center">
|
||||
<img src="public/distro/ubuntu.svg" width="48" alt="Ubuntu" title="Ubuntu">
|
||||
<img src="public/distro/debian.svg" width="48" alt="Debian" title="Debian">
|
||||
<img src="public/distro/centos.svg" width="48" alt="CentOS" title="CentOS">
|
||||
<img src="public/distro/fedora.svg" width="48" alt="Fedora" title="Fedora">
|
||||
<img src="public/distro/arch.svg" width="48" alt="Arch Linux" title="Arch Linux">
|
||||
<img src="public/distro/alpine.svg" width="48" alt="Alpine" title="Alpine">
|
||||
<img src="public/distro/amazon.svg" width="48" alt="Amazon Linux" title="Amazon Linux">
|
||||
<img src="public/distro/redhat.svg" width="48" alt="Red Hat" title="Red Hat">
|
||||
<img src="public/distro/rocky.svg" width="48" alt="Rocky Linux" title="Rocky Linux">
|
||||
<img src="public/distro/opensuse.svg" width="48" alt="openSUSE" title="openSUSE">
|
||||
<img src="public/distro/oracle.svg" width="48" alt="Oracle Linux" title="Oracle Linux">
|
||||
<img src="public/distro/kali.svg" width="48" alt="Kali Linux" title="Kali Linux">
|
||||
<img src="public/distro/almalinux.svg" width="48" alt="AlmaLinux" title="AlmaLinux">
|
||||
</p>
|
||||
|
||||
<a name="快速開始"></a>
|
||||
# 快速開始
|
||||
|
||||
### 下載
|
||||
|
||||
到 [GitHub Releases](https://github.com/binaricat/Netcatty/releases/latest) 下載適合你平台的最新版本。
|
||||
|
||||
| 作業系統 | 支援情況 |
|
||||
| :--- | :--- |
|
||||
| **macOS** | Universal (x64 / arm64) |
|
||||
| **Windows** | x64 / arm64 |
|
||||
| **Linux** | x64 / arm64 |
|
||||
|
||||
也可以到 [GitHub Releases](https://github.com/binaricat/Netcatty/releases) 瀏覽所有版本。
|
||||
|
||||
### 程式碼簽章與隱私
|
||||
|
||||
Netcatty 正在申請 SignPath Foundation 的開源專案計畫。通過之後,適用的 Windows
|
||||
發行檔會採用 **Free code signing provided by SignPath.io, certificate by SignPath Foundation**。
|
||||
詳情請見[程式碼簽章政策](CODE_SIGNING_POLICY.md)與[隱私權政策](PRIVACY.md)。在申請與介接完成前,
|
||||
Windows 發行檔可能仍未簽章。
|
||||
|
||||
> **Windows 免安裝版的資料存放:** 先結束 Netcatty,然後在 `Netcatty.exe`(zip 版)或免安裝版啟動檔旁邊建立一個名為 `data` 的資料夾。下次啟動時,Netcatty 就會把設定檔存在那裡。已儲存的密碼與私密金鑰仍受建立它們的那個 Windows 使用者帳戶保護,所以把資料夾搬到另一台電腦或另一個 Windows 帳戶後,這些資訊必須重新輸入。
|
||||
|
||||
> **在 Windows 用 Netcatty 開啟資料夾:** 安裝版會在檔案總管的資料夾右鍵選單與資料夾空白處右鍵選單加入 **Open in Netcatty**,點了就會在該資料夾開啟本機終端。Windows 11 要先選 **顯示其他選項**。這個選單可以在 **設定 → 系統 → Windows 檔案總管** 隨時關閉或重新開啟。ZIP 版與免安裝版預設不會加入這個選單。
|
||||
|
||||
> **macOS 使用者請注意:** 目前的發行版本應該都已完成程式碼簽章與公證。如果 Gatekeeper 仍然跳出警告,請確認你下載的是 GitHub Releases 上最新的官方建置版本。
|
||||
|
||||
### Nix / NixOS
|
||||
|
||||
Netcatty 提供了一個 flake,替 Nix 與 NixOS 使用者包裝官方的 Linux AppImage 發行版:
|
||||
|
||||
```bash
|
||||
nix run github:binaricat/Netcatty
|
||||
```
|
||||
|
||||
若要以宣告式方式安裝,把 Netcatty flake 加為 input,並在你的 NixOS 或 Home Manager 套件清單中使用 `inputs.netcatty.packages.${pkgs.system}.default`。
|
||||
|
||||
### 事前需求
|
||||
- Node.js 18+ 與 npm
|
||||
- macOS、Windows 10+ 或 Linux
|
||||
|
||||
### 開發
|
||||
|
||||
```bash
|
||||
# 複製儲存庫
|
||||
git clone https://github.com/binaricat/Netcatty.git
|
||||
cd Netcatty
|
||||
|
||||
# 安裝相依套件
|
||||
npm install
|
||||
|
||||
# 啟動開發模式(Vite + Electron)
|
||||
npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<a name="建置與打包"></a>
|
||||
# 建置與打包
|
||||
|
||||
```bash
|
||||
# 建置正式版
|
||||
npm run build
|
||||
|
||||
# 為目前平台打包
|
||||
npm run pack
|
||||
|
||||
# 為特定平台打包
|
||||
npm run pack:mac # macOS (DMG + ZIP)
|
||||
npm run pack:win # Windows (NSIS 安裝程式)
|
||||
npm run pack:linux # Linux (AppImage + DEB + RPM)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<a name="技術堆疊"></a>
|
||||
# 技術堆疊
|
||||
|
||||
| 分類 | 技術 |
|
||||
|------|------|
|
||||
| 框架 | Electron 40 |
|
||||
| 前端 | React 19, TypeScript |
|
||||
| 建置工具 | Vite 7 |
|
||||
| 終端機 | xterm.js 5 |
|
||||
| 樣式 | Tailwind CSS 4 |
|
||||
| SSH/SFTP | ssh2, ssh2-sftp-client |
|
||||
| PTY | node-pty |
|
||||
| 圖示 | Lucide React |
|
||||
|
||||
---
|
||||
|
||||
<a name="參與貢獻"></a>
|
||||
# 參與貢獻
|
||||
|
||||
歡迎參與貢獻!請隨時送出 Pull Request。
|
||||
|
||||
1. Fork 本儲存庫
|
||||
2. 建立你的功能分支(`git checkout -b feature/amazing-feature`)
|
||||
3. 提交你的變更(`git commit -m 'Add some amazing feature'`)
|
||||
4. 推送到該分支(`git push origin feature/amazing-feature`)
|
||||
5. 開一個 Pull Request
|
||||
|
||||
架構概觀與程式碼慣例請見 [AGENTS.md](AGENTS.md)。
|
||||
|
||||
---
|
||||
|
||||
<a name="貢獻者"></a>
|
||||
# 貢獻者
|
||||
|
||||
感謝每一位參與貢獻的人!
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" />
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
<a name="授權條款"></a>
|
||||
# 授權條款
|
||||
|
||||
本專案採用 **GPL-3.0 授權** —— 詳情請見 [LICENSE](LICENSE) 檔案。
|
||||
|
||||
---
|
||||
|
||||
<a name="star-紀錄"></a>
|
||||
# Star 紀錄
|
||||
|
||||
<a href="https://www.star-history.com/#binaricat/Netcatty&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/star-history/star-history-dark.svg" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="docs/assets/star-history/star-history-light.svg" />
|
||||
<img alt="Star History Chart" src="docs/assets/star-history/star-history-light.svg" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
由 <a href="https://ko-fi.com/binaricat">binaricat</a> 用 ❤️ 打造
|
||||
</p>
|
||||
@@ -1,7 +1,13 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { executeHotkeyActionImpl, getLogHostVisualSnapshot, handleGlobalHotkeyKeyDownImpl } from './app/AppHandlers.ts';
|
||||
import {
|
||||
executeHotkeyActionImpl,
|
||||
getLogHostVisualSnapshot,
|
||||
handleEscapeKeyDownImpl,
|
||||
handleGlobalHotkeyKeyDownImpl,
|
||||
markForwardedNativeShortcutEvent,
|
||||
} from './app/AppHandlers.ts';
|
||||
import { matchesKeyBinding } from '../domain/models.ts';
|
||||
import { DEFAULT_KEY_BINDINGS } from '../domain/models/keyBindings.ts';
|
||||
|
||||
@@ -30,6 +36,14 @@ class FakeHTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
class FakeMonacoHTMLElement extends FakeHTMLElement {
|
||||
tagName = 'TEXTAREA';
|
||||
|
||||
closest(selector: string): FakeMonacoHTMLElement | null {
|
||||
return selector.includes('monaco') ? this : null;
|
||||
}
|
||||
}
|
||||
|
||||
const previousHTMLElement = globalThis.HTMLElement;
|
||||
globalThis.HTMLElement = FakeHTMLElement as unknown as typeof HTMLElement;
|
||||
|
||||
@@ -111,6 +125,81 @@ test('global hotkey handler routes quick switch through focused search inputs',
|
||||
assert.deepEqual(handledActions, ['quickSwitch']);
|
||||
});
|
||||
|
||||
test('global hotkey handler magnifies panes from focused form inputs', () => {
|
||||
const target = new FakeInputHTMLElement();
|
||||
const handledActions: string[] = [];
|
||||
const event = {
|
||||
key: 'm',
|
||||
code: 'KeyM',
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
altKey: true,
|
||||
shiftKey: false,
|
||||
target,
|
||||
composedPath: () => [target],
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
} as unknown as KeyboardEvent;
|
||||
|
||||
handleGlobalHotkeyKeyDownImpl(
|
||||
() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: 'Ctrl + W',
|
||||
executeHotkeyAction: (action: string) => {
|
||||
handledActions.push(action);
|
||||
},
|
||||
hotkeyScheme: 'pc',
|
||||
keyBindings: DEFAULT_KEY_BINDINGS,
|
||||
matchesKeyBinding,
|
||||
}),
|
||||
event,
|
||||
);
|
||||
|
||||
assert.deepEqual(handledActions, ['togglePaneZoom']);
|
||||
});
|
||||
|
||||
test('forwarded native shortcut can run a reassigned global action from Monaco', () => {
|
||||
const target = new FakeMonacoHTMLElement();
|
||||
const handledActions: string[] = [];
|
||||
let prevented = false;
|
||||
const event = markForwardedNativeShortcutEvent({
|
||||
key: 'w',
|
||||
code: 'KeyW',
|
||||
ctrlKey: false,
|
||||
metaKey: true,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
target,
|
||||
composedPath: () => [target],
|
||||
preventDefault: () => {
|
||||
prevented = true;
|
||||
},
|
||||
stopPropagation: () => {},
|
||||
} as unknown as KeyboardEvent);
|
||||
const keyBindings = DEFAULT_KEY_BINDINGS.map((binding) => {
|
||||
if (binding.action === 'closeTab') return { ...binding, mac: 'Disabled' };
|
||||
if (binding.action === 'newTab') return { ...binding, mac: '⌘ + W' };
|
||||
return binding;
|
||||
});
|
||||
|
||||
handleGlobalHotkeyKeyDownImpl(
|
||||
() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: 'Disabled',
|
||||
executeHotkeyAction: (action: string) => {
|
||||
handledActions.push(action);
|
||||
},
|
||||
hotkeyScheme: 'mac',
|
||||
keyBindings,
|
||||
matchesKeyBinding,
|
||||
}),
|
||||
event,
|
||||
);
|
||||
|
||||
assert.deepEqual(handledActions, ['newTab']);
|
||||
assert.equal(prevented, true);
|
||||
});
|
||||
|
||||
test('quick switch hotkey toggles the quick switcher open state', () => {
|
||||
let isQuickSwitcherOpen = false;
|
||||
const setIsQuickSwitcherOpen = (next: boolean) => {
|
||||
@@ -170,6 +259,148 @@ test('quick switch hotkey toggles the quick switcher open state', () => {
|
||||
assert.equal(isQuickSwitcherOpen, false);
|
||||
});
|
||||
|
||||
test('pane zoom hotkey delegates to the active in-app magnification surface', () => {
|
||||
let toggles = 0;
|
||||
const noop = () => {};
|
||||
const controller = {
|
||||
getState: () => 'focusable' as const,
|
||||
focus: () => false,
|
||||
restore: () => false,
|
||||
toggle: () => {
|
||||
toggles += 1;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
executeHotkeyActionImpl(() => ({
|
||||
IS_DEV: false,
|
||||
MOVE_FOCUS_DEBOUNCE_MS: 0,
|
||||
activeTabStore: { getActiveTabId: () => 'workspace-1' },
|
||||
addConnectionLogRef: { current: noop },
|
||||
closeSession: noop,
|
||||
closeTabInFlightRef: { current: false },
|
||||
closeWorkspace: noop,
|
||||
collectSessionIds: () => [],
|
||||
confirmIfBusyLocalTerminal: async () => true,
|
||||
createLocalTerminalWithCurrentShell: noop,
|
||||
editorTabs: [],
|
||||
fromEditorTabId: () => null,
|
||||
handleOpenSettingsRef: { current: noop },
|
||||
handleRequestCloseEditorTabRef: { current: noop },
|
||||
isEditorTabId: () => false,
|
||||
isQuickSwitcherOpen: false,
|
||||
lastMoveFocusTimeRef: { current: 0 },
|
||||
moveFocusInWorkspace: noop,
|
||||
orderedTabs: [],
|
||||
resolveCloseIntent: () => ({ kind: 'noop' }),
|
||||
resolveSnippetsShortcutIntent: () => ({ kind: 'noop' }),
|
||||
sessions: [],
|
||||
setActiveTabId: noop,
|
||||
setAddToWorkspaceDialog: noop,
|
||||
setIsQuickSwitcherOpen: noop,
|
||||
setNavigateToSection: noop,
|
||||
settings: { showSftpTab: true, shellOnlyTabNumberShortcuts: false },
|
||||
sftpPaneMagnificationRef: { current: null },
|
||||
splitSessionWithCurrentShell: noop,
|
||||
systemInfoRef: { current: { username: 'user', hostname: 'host' } },
|
||||
terminalPaneMagnificationRef: { current: controller },
|
||||
toEditorTabId: (id: string) => `editor:${id}`,
|
||||
toggleBroadcast: noop,
|
||||
toggleScriptsSidePanelRef: { current: noop },
|
||||
toggleSidePanelRef: { current: noop },
|
||||
toggleWorkspaceViewMode: noop,
|
||||
workspaces: [],
|
||||
}), 'togglePaneZoom', {} as KeyboardEvent);
|
||||
|
||||
assert.equal(toggles, 1);
|
||||
});
|
||||
|
||||
test('move-focus shortcut cannot send input behind a magnified pane', () => {
|
||||
let moveCalls = 0;
|
||||
executeHotkeyActionImpl(() => ({
|
||||
IS_DEV: false,
|
||||
MOVE_FOCUS_DEBOUNCE_MS: 0,
|
||||
activeTabStore: { getActiveTabId: () => 'workspace-1' },
|
||||
editorTabs: [],
|
||||
lastMoveFocusTimeRef: { current: 0 },
|
||||
moveFocusInWorkspace: () => {
|
||||
moveCalls += 1;
|
||||
return true;
|
||||
},
|
||||
orderedTabs: [],
|
||||
settings: { showSftpTab: true, shellOnlyTabNumberShortcuts: false },
|
||||
sftpPaneMagnificationRef: { current: null },
|
||||
terminalPaneMagnificationRef: {
|
||||
current: {
|
||||
getState: () => 'focused' as const,
|
||||
focus: () => false,
|
||||
restore: () => true,
|
||||
toggle: () => true,
|
||||
},
|
||||
},
|
||||
toEditorTabId: (id: string) => `editor:${id}`,
|
||||
workspaces: [{ id: 'workspace-1', title: 'Workspace' }],
|
||||
}), 'moveFocus', {
|
||||
key: 'ArrowRight',
|
||||
} as KeyboardEvent);
|
||||
|
||||
assert.equal(moveCalls, 0);
|
||||
});
|
||||
|
||||
test('Escape restores magnification after transient dialogs are closed', () => {
|
||||
let restores = 0;
|
||||
let prevented = false;
|
||||
let stopped = false;
|
||||
const event = {
|
||||
key: 'Escape',
|
||||
defaultPrevented: false,
|
||||
preventDefault: () => { prevented = true; },
|
||||
stopPropagation: () => { stopped = true; },
|
||||
} as unknown as KeyboardEvent;
|
||||
|
||||
handleEscapeKeyDownImpl(() => ({
|
||||
isQuickSwitcherOpen: false,
|
||||
setIsQuickSwitcherOpen: () => {},
|
||||
sftpPaneMagnificationRef: { current: null },
|
||||
terminalPaneMagnificationRef: {
|
||||
current: {
|
||||
getState: () => 'focused',
|
||||
focus: () => false,
|
||||
restore: () => {
|
||||
restores += 1;
|
||||
return true;
|
||||
},
|
||||
toggle: () => false,
|
||||
},
|
||||
},
|
||||
}), event);
|
||||
|
||||
assert.equal(restores, 1);
|
||||
assert.equal(prevented, true);
|
||||
assert.equal(stopped, true);
|
||||
});
|
||||
|
||||
test('consumed Escape does not restore magnification', () => {
|
||||
let restores = 0;
|
||||
handleEscapeKeyDownImpl(() => ({
|
||||
isQuickSwitcherOpen: false,
|
||||
setIsQuickSwitcherOpen: () => {},
|
||||
terminalPaneMagnificationRef: {
|
||||
current: {
|
||||
getState: () => 'focused',
|
||||
focus: () => false,
|
||||
restore: () => {
|
||||
restores += 1;
|
||||
return true;
|
||||
},
|
||||
toggle: () => false,
|
||||
},
|
||||
},
|
||||
}), { key: 'Escape', defaultPrevented: true } as KeyboardEvent);
|
||||
|
||||
assert.equal(restores, 0);
|
||||
});
|
||||
|
||||
test('close tab hotkey routes native plugin view tabs through their owner', () => {
|
||||
let closedTabId = '';
|
||||
const pluginTabId = 'plugin-view:com.example.view:com.example.view.panel';
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Host, HostProtocol, TerminalSession } from '../../types';
|
||||
import type { PassphraseRequest } from '../../components/PassphraseModal';
|
||||
import type { TerminalPopupPayload } from '../../domain/systemManager/types';
|
||||
import { getEffectiveHostDistro, classifyDistroId, shouldProbeSessionCwd } from '../../domain/host';
|
||||
import { getAvailablePaneMagnificationController } from '../../domain/paneMagnification';
|
||||
import { sanitizeHostIconFields } from '../../domain/hostIcon';
|
||||
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
|
||||
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
|
||||
@@ -13,6 +14,12 @@ import { captureInheritedCwd } from '../state/inheritedCwd';
|
||||
|
||||
type AppContextGetter = () => Record<string, any>;
|
||||
const TERMINAL_PASSTHROUGH_ACTIONS = getTerminalPassthroughActions();
|
||||
const forwardedNativeShortcutEvents = new WeakSet<KeyboardEvent>();
|
||||
|
||||
export function markForwardedNativeShortcutEvent(event: KeyboardEvent): KeyboardEvent {
|
||||
forwardedNativeShortcutEvents.add(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
async function deliverKeyboardInteractiveResponse(
|
||||
ctx: Record<string, any>,
|
||||
@@ -259,8 +266,18 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
|
||||
const quickSwitchBinding = keyBindings.find((binding) => binding.action === 'quickSwitch');
|
||||
const quickSwitchKeyStr = quickSwitchBinding ? (isMac ? quickSwitchBinding.mac : quickSwitchBinding.pc) : null;
|
||||
const isQuickSwitchHotkey = quickSwitchKeyStr ? matchesKeyBinding(e, quickSwitchKeyStr, isMac) : false;
|
||||
const paneZoomBinding = keyBindings.find((binding) => binding.action === 'togglePaneZoom');
|
||||
const paneZoomKeyStr = paneZoomBinding ? (isMac ? paneZoomBinding.mac : paneZoomBinding.pc) : null;
|
||||
const isPaneZoomHotkey = paneZoomKeyStr ? matchesKeyBinding(e, paneZoomKeyStr, isMac) : false;
|
||||
|
||||
if ((isFormElement || isMonacoElement) && !isXtermInput && e.key !== 'Escape' && !isQuickSwitchHotkey) {
|
||||
if (
|
||||
(isFormElement || isMonacoElement)
|
||||
&& !isXtermInput
|
||||
&& e.key !== 'Escape'
|
||||
&& !isQuickSwitchHotkey
|
||||
&& !isPaneZoomHotkey
|
||||
&& !forwardedNativeShortcutEvents.has(e)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -315,10 +332,24 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
|
||||
}
|
||||
|
||||
export function handleEscapeKeyDownImpl(getCtx: AppContextGetter, e: KeyboardEvent) {
|
||||
const { isQuickSwitcherOpen, setIsQuickSwitcherOpen } = getCtx();
|
||||
const {
|
||||
isQuickSwitcherOpen,
|
||||
setIsQuickSwitcherOpen,
|
||||
sftpPaneMagnificationRef,
|
||||
terminalPaneMagnificationRef,
|
||||
} = getCtx();
|
||||
{
|
||||
if (e.key === 'Escape' && isQuickSwitcherOpen) {
|
||||
if (e.key !== 'Escape' || e.defaultPrevented) return;
|
||||
if (isQuickSwitcherOpen) {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
return;
|
||||
}
|
||||
if (getAvailablePaneMagnificationController([
|
||||
sftpPaneMagnificationRef?.current,
|
||||
terminalPaneMagnificationRef?.current,
|
||||
])?.restore()) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -671,7 +702,7 @@ export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: st
|
||||
}
|
||||
|
||||
export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string, e: KeyboardEvent) {
|
||||
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, closePluginViewTab, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isPluginViewTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, workspaces } = getCtx();
|
||||
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, closePluginViewTab, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isPluginViewTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, sftpPaneMagnificationRef, splitSessionWithCurrentShell, systemInfoRef, terminalPaneMagnificationRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces } = getCtx();
|
||||
{
|
||||
const shortcutTabs = buildNumberShortcutTabTargets({
|
||||
showSftpTab: settings.showSftpTab ?? true,
|
||||
@@ -907,15 +938,18 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
break;
|
||||
}
|
||||
case 'togglePaneZoom': {
|
||||
// Toggle workspace between split and focus (zoom) mode
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
const activeWs = workspaces.find(w => w.id === currentId);
|
||||
if (activeWs) {
|
||||
toggleWorkspaceViewMode(activeWs.id);
|
||||
}
|
||||
getAvailablePaneMagnificationController([
|
||||
sftpPaneMagnificationRef?.current,
|
||||
terminalPaneMagnificationRef?.current,
|
||||
])?.toggle();
|
||||
break;
|
||||
}
|
||||
case 'moveFocus': {
|
||||
const magnificationController = getAvailablePaneMagnificationController([
|
||||
sftpPaneMagnificationRef?.current,
|
||||
terminalPaneMagnificationRef?.current,
|
||||
]);
|
||||
if (magnificationController?.getState() === 'focused') break;
|
||||
// Debounce to prevent double-triggering when focus switches between terminals
|
||||
const now = Date.now();
|
||||
if (now - lastMoveFocusTimeRef.current < MOVE_FOCUS_DEBOUNCE_MS) {
|
||||
|
||||
@@ -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\)/);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const source = readFileSync(new URL('./AppSideEffects.tsx', import.meta.url), 'utf8');
|
||||
|
||||
test('terminal Escape restoration runs before xterm can stop propagation', () => {
|
||||
const effectStart = source.indexOf("const onCaptureKeyDown = (e: KeyboardEvent) => {");
|
||||
const effectSource = source.slice(effectStart, effectStart + 900);
|
||||
|
||||
assert.notEqual(effectStart, -1);
|
||||
assert.match(effectSource, /target\.closest\('\.xterm'\)/);
|
||||
assert.match(effectSource, /window\.addEventListener\('keydown', onCaptureKeyDown, true\)/);
|
||||
assert.match(effectSource, /window\.removeEventListener\('keydown', onCaptureKeyDown, true\)/);
|
||||
});
|
||||
@@ -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,
|
||||
@@ -50,9 +52,10 @@ import {
|
||||
} from '../../domain/terminalAppearance';
|
||||
import { selectConnectionLogForTerminalDataCapture } from '../../domain/connectionLog';
|
||||
import { collectSessionIds } from '../../domain/workspace';
|
||||
import type { PaneMagnificationController } from '../../domain/paneMagnification';
|
||||
import { resolveCloseIntent } from '../state/resolveCloseIntent';
|
||||
import { resolveSnippetsShortcutIntent } from '../state/resolveSnippetsShortcutIntent';
|
||||
import { resolveWindowCommandCloseIntent } from '../state/windowCommandClose';
|
||||
import { isPrimaryModifierWBinding, resolveWindowCommandCloseIntent } from '../state/windowCommandClose';
|
||||
import type { SyncPayload } from '../../domain/sync';
|
||||
import { applySyncPayload, buildLocalVaultPayloadAsync, hasMeaningfulSyncData } from '../syncPayload';
|
||||
import {
|
||||
@@ -87,7 +90,7 @@ import { isScriptSnippet } from '../../domain/snippetScript.ts';
|
||||
import { collectSnippetDeleteIds } from '../../domain/snippetSelection.ts';
|
||||
import { shouldOpenLocalTerminalOnStartup, resolveStartupLandingSetting } from '../../domain/startupLanding';
|
||||
import { useAppStartupEffects } from './useAppStartupEffects';
|
||||
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl } from './AppHandlers';
|
||||
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl, markForwardedNativeShortcutEvent } from './AppHandlers';
|
||||
|
||||
type OpenSessionInNewWindowPayload = {
|
||||
title?: string;
|
||||
@@ -101,6 +104,7 @@ const HOTKEY_DEBUG =
|
||||
|
||||
export function AppSideEffects() {
|
||||
const settings = useAppSettingsRuntime();
|
||||
const { locked: appLockLocked } = useAppLockChrome();
|
||||
const { t } = useI18n();
|
||||
const pluginViewTabs = usePluginViewTabs();
|
||||
|
||||
@@ -593,7 +597,7 @@ export function AppSideEffects() {
|
||||
const _handleTrayPanelConnect = useEffectEvent((hostId: string) => { return handleTrayPanelConnectImpl(() => ({ addConnectionLog, connectToHost, hostId, hosts, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef, t, toast }), hostId); });
|
||||
const _handleTrayPanelConnectRequest = useEffectEvent((hostId: string) => { return handleTrayPanelConnectRequestImpl(() => ({ connectNow: _handleTrayPanelConnect, hostId, isVaultInitialized, queueConnect: (queuedHostId: string) => setPendingTrayPanelConnectHostIds((prev) => [...prev, queuedHostId]) }), hostId); });
|
||||
const _handleGlobalHotkeyKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleGlobalHotkeyKeyDownImpl(() => ({ HOTKEY_DEBUG, closeTabKeyStr, e, executeHotkeyAction, hotkeyScheme, keyBindings, matchesKeyBinding }), e); });
|
||||
const _handleEscapeKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleEscapeKeyDownImpl(() => ({ e, isQuickSwitcherOpen, setIsQuickSwitcherOpen }), e); });
|
||||
const _handleEscapeKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleEscapeKeyDownImpl(() => ({ e, isQuickSwitcherOpen, setIsQuickSwitcherOpen, sftpPaneMagnificationRef, terminalPaneMagnificationRef }), e); });
|
||||
|
||||
// Vault hosts for tray / auto-start; terminalHosts (vault + ephemeral) only for
|
||||
// dedicated transfer resume so quick-connect rows do not break tray connect.
|
||||
@@ -620,6 +624,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 +647,7 @@ export function AppSideEffects() {
|
||||
_handleTrayJumpToSession(sessionId);
|
||||
});
|
||||
const unsubscribeToggle = bridge.onTrayTogglePortForward((ruleId, start) => {
|
||||
_handleTrayTogglePortForward(ruleId, start);
|
||||
_handleTrayTogglePortForwardMaybeDeferred(ruleId, start);
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -638,6 +656,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();
|
||||
@@ -898,6 +924,8 @@ export function AppSideEffects() {
|
||||
|
||||
const toggleScriptsSidePanelRef = useRef<(() => void) | null>(null);
|
||||
const toggleSidePanelRef = useRef<(() => void) | null>(null);
|
||||
const terminalPaneMagnificationRef = useRef<PaneMagnificationController | null>(null);
|
||||
const sftpPaneMagnificationRef = useRef<PaneMagnificationController | null>(null);
|
||||
const openNoteRequestIdRef = useRef(0);
|
||||
const [openNoteRequest, setOpenNoteRequest] = useState<{
|
||||
tabId: string;
|
||||
@@ -1040,8 +1068,10 @@ export function AppSideEffects() {
|
||||
showSftpTab: showSftpTabRef.current,
|
||||
shellOnlyTabNumberShortcuts: shellOnlyTabNumberShortcutsRef.current,
|
||||
},
|
||||
sftpPaneMagnificationRef,
|
||||
splitSessionWithCurrentShell,
|
||||
systemInfoRef,
|
||||
terminalPaneMagnificationRef,
|
||||
toEditorTabId,
|
||||
toggleBroadcast,
|
||||
toggleScriptsSidePanelRef,
|
||||
@@ -1066,10 +1096,6 @@ export function AppSideEffects() {
|
||||
const openDialogs = Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"][data-state="open"]'));
|
||||
const topmostOpenDialog = openDialogs[openDialogs.length - 1] ?? null;
|
||||
const topmostDialogClose = topmostOpenDialog?.querySelector<HTMLElement>('[data-dialog-close="true"]');
|
||||
if (topmostDialogClose) {
|
||||
topmostDialogClose.click();
|
||||
return;
|
||||
}
|
||||
|
||||
const intent = resolveWindowCommandCloseIntent({
|
||||
activeTabId: activeTabStore.getActiveTabId(),
|
||||
@@ -1078,8 +1104,29 @@ export function AppSideEffects() {
|
||||
workspaceIds: workspaces.map((workspace) => workspace.id),
|
||||
logViewIds: logViews.map((logView) => logView.id),
|
||||
pluginViewTabIds: pluginViewTabs.map((tab) => tab.id),
|
||||
hasOpenDialog: Boolean(topmostDialogClose),
|
||||
closeTabShortcutEnabled: isPrimaryModifierWBinding(closeTabKeyStr, matchesKeyBinding, true),
|
||||
});
|
||||
|
||||
if (intent.kind === 'forwardShortcut') {
|
||||
// The native macOS menu accelerator consumed the original key event.
|
||||
// Re-dispatch it so a freed Cmd+W can still be assigned to another action.
|
||||
const forwardedEvent = markForwardedNativeShortcutEvent(new KeyboardEvent('keydown', {
|
||||
key: 'w',
|
||||
code: 'KeyW',
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
(document.activeElement ?? window).dispatchEvent(forwardedEvent);
|
||||
return;
|
||||
}
|
||||
|
||||
if (intent.kind === 'closeDialog') {
|
||||
topmostDialogClose?.click();
|
||||
return;
|
||||
}
|
||||
|
||||
if (intent.kind === 'closeTab') {
|
||||
executeHotkeyAction('closeTab', new KeyboardEvent('keydown', { key: 'w', metaKey: true }));
|
||||
return;
|
||||
@@ -1091,23 +1138,27 @@ export function AppSideEffects() {
|
||||
}
|
||||
|
||||
await netcattyBridge.get()?.windowClose?.();
|
||||
}, [closeLogView, editorTabs, executeHotkeyAction, logViews, pluginViewTabs, sessions, workspaces]);
|
||||
}, [closeLogView, closeTabKeyStr, 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 +1166,26 @@ export function AppSideEffects() {
|
||||
|
||||
window.addEventListener('keydown', handleGlobalKeyDown, true);
|
||||
return () => window.removeEventListener('keydown', handleGlobalKeyDown, true);
|
||||
}, [hotkeyScheme, isHotkeyRecording]);
|
||||
}, [hotkeyScheme, isHotkeyRecording, appLockLocked]);
|
||||
|
||||
useEffect(() => {
|
||||
if (appLockLocked) return;
|
||||
const onCaptureKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
const target = e.target;
|
||||
if (!(target instanceof HTMLElement) || !target.closest('.xterm')) return;
|
||||
_handleEscapeKeyDown(e);
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
_handleEscapeKeyDown(e);
|
||||
};
|
||||
window.addEventListener('keydown', onCaptureKeyDown, true);
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, []);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onCaptureKeyDown, true);
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [appLockLocked]);
|
||||
|
||||
const handleDeleteHost = useCallback((hostId: string) => {
|
||||
const target = hosts.find(h => h.id === hostId);
|
||||
@@ -1390,7 +1452,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 +1511,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 +1528,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 +1566,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 +1583,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 +1603,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 +1620,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 +1660,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();
|
||||
@@ -1767,6 +1885,8 @@ export function AppSideEffects() {
|
||||
splitSessionWithCurrentShell,
|
||||
toggleScriptsSidePanelRef,
|
||||
toggleSidePanelRef,
|
||||
terminalPaneMagnificationRef,
|
||||
sftpPaneMagnificationRef,
|
||||
// Chrome glue
|
||||
handleEndSessionDrag,
|
||||
handleOpenQuickSwitcher,
|
||||
|
||||
@@ -29,12 +29,14 @@ 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';
|
||||
import { selectPluginThemeTokens } from '../state/pluginContributionEnvironment';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { resolveEffectiveTerminalHost } from '../../domain/terminalHostResolution';
|
||||
import { getAvailablePaneMagnificationController } from '../../domain/paneMagnification';
|
||||
import { pluginViewTabStore, usePluginViewTabs } from '../state/pluginViewTabStore';
|
||||
import { buildPluginSettingScopeCatalog } from '../state/usePluginSettingScopeCatalog';
|
||||
import { useWorkSurfaceHostEditor } from '../state/useWorkSurfaceHostEditor';
|
||||
@@ -275,7 +277,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
setWorkspaceFocusedSession, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
|
||||
sftpShowHiddenFiles, sftpUseCompressedUpload, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
|
||||
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById,
|
||||
toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource,
|
||||
toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, terminalPaneMagnificationRef, sftpPaneMagnificationRef, toggleWorkspaceViewMode, unmanageSource,
|
||||
readPersistedHosts, readPersistedManagedSources, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources,
|
||||
updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaces,
|
||||
VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper,
|
||||
@@ -301,6 +303,32 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
} = useSettingsChromeStore();
|
||||
const { setTheme, setWindowOpacity } = useSettingsChromeActions();
|
||||
|
||||
const paneMagnificationController = getAvailablePaneMagnificationController([
|
||||
sftpPaneMagnificationRef?.current,
|
||||
terminalPaneMagnificationRef?.current,
|
||||
]);
|
||||
const paneMagnificationState = paneMagnificationController?.getState() ?? 'unavailable';
|
||||
const handleMagnifyCurrentPane = () => {
|
||||
getAvailablePaneMagnificationController([
|
||||
sftpPaneMagnificationRef?.current,
|
||||
terminalPaneMagnificationRef?.current,
|
||||
])?.focus();
|
||||
};
|
||||
const handleRestoreMagnifiedPane = () => {
|
||||
getAvailablePaneMagnificationController([
|
||||
sftpPaneMagnificationRef?.current,
|
||||
terminalPaneMagnificationRef?.current,
|
||||
])?.restore();
|
||||
};
|
||||
|
||||
// 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 +513,8 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onOpenQuickSwitcher={handleOpenQuickSwitcher}
|
||||
onThemeChange={setTheme}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
onLockApp={handleLockApp}
|
||||
appLockEnabled={appLockEnabled}
|
||||
externalMcpEnabled={externalMcpToggle.enabled}
|
||||
onToggleExternalMcp={externalMcpToggle.setEnabled}
|
||||
showExternalMcpToggle={!isPeerSessionWindow}
|
||||
@@ -633,6 +663,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
editorWordWrap={editorWordWrap}
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
terminalSettings={terminalSettings}
|
||||
paneMagnificationRef={sftpPaneMagnificationRef}
|
||||
/>
|
||||
|
||||
<TerminalLayerMount
|
||||
@@ -727,6 +758,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
showHostTreeSidebar={showHostTreeSidebar}
|
||||
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
|
||||
toggleSidePanelRef={toggleSidePanelRef}
|
||||
paneMagnificationRef={terminalPaneMagnificationRef}
|
||||
onStartSessionRename={startSessionRename}
|
||||
onSubmitSessionRename={submitSessionRename}
|
||||
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
|
||||
@@ -878,6 +910,9 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
}}
|
||||
keyBindings={keyBindings}
|
||||
terminalSettings={terminalSettings}
|
||||
paneMagnificationState={paneMagnificationState}
|
||||
onMagnifyCurrentPane={handleMagnifyCurrentPane}
|
||||
onRestoreMagnifiedPane={handleRestoreMagnifiedPane}
|
||||
/>
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
|
||||
@@ -239,6 +239,8 @@ export function TerminalHost() {
|
||||
toggleBroadcast: sessionActions?.toggleBroadcast,
|
||||
toggleScriptsSidePanelRef: handlers.toggleScriptsSidePanelRef,
|
||||
toggleSidePanelRef: handlers.toggleSidePanelRef,
|
||||
terminalPaneMagnificationRef: handlers.terminalPaneMagnificationRef,
|
||||
sftpPaneMagnificationRef: handlers.sftpPaneMagnificationRef,
|
||||
toggleWorkspaceViewMode: sessionActions?.toggleWorkspaceViewMode,
|
||||
updateHostDistro: vaultActions?.updateHostDistro,
|
||||
updateSplitSizes: sessionActions?.updateSplitSizes,
|
||||
|
||||
@@ -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',
|
||||
@@ -516,6 +534,9 @@ export const enAiMessages: Messages = {
|
||||
'ai.safety.commandTimeout': 'Command Timeout',
|
||||
'ai.safety.commandTimeout.description': 'Maximum seconds a command can run before being terminated through Netcatty execution.',
|
||||
'ai.safety.commandTimeout.unit': 'sec',
|
||||
'ai.safety.responseIdleTimeout': 'Built-in AI Response Wait',
|
||||
'ai.safety.responseIdleTimeout.description': 'Cancel a built-in AI request after this many seconds without a new response. This setting does not control total response time or command execution.',
|
||||
'ai.safety.responseIdleTimeout.unit': 'sec',
|
||||
'ai.safety.maxIterations': 'Max Iterations',
|
||||
'ai.safety.maxIterations.description': 'Maximum number of AI tool-use loops to prevent runaway execution. External agents may have their own internal iteration limits that take precedence.',
|
||||
'ai.safety.blocklist': 'Command Blocklist',
|
||||
|
||||
@@ -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.',
|
||||
@@ -279,6 +358,12 @@ export const enCoreMessages: Messages = {
|
||||
'settings.vault.showHostTreeSidebarDesc': 'Display the host list sidebar and its top-bar toggle on terminal and editor tabs.',
|
||||
'settings.vault.autoImportSystemKnownHosts': 'Auto-import system known_hosts',
|
||||
'settings.vault.autoImportSystemKnownHostsDesc': 'When Vault loads, silently import entries from the OpenSSH known_hosts file. Turn this off to keep Netcatty known hosts separate; Scan System still imports on demand.',
|
||||
'settings.vault.notesFont': 'Code font',
|
||||
'settings.vault.notesFontDesc': 'Font family used for code blocks and inline code in notes',
|
||||
'settings.vault.notesFontSize': 'Note font size',
|
||||
'settings.vault.notesFontSizeDesc': 'Font size used for note body text',
|
||||
'settings.vault.notesCodeFontSize': 'Code font size',
|
||||
'settings.vault.notesCodeFontSizeDesc': 'Font size used for code blocks and inline code in notes',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': 'Update Available',
|
||||
@@ -564,6 +649,11 @@ Highlight the focused split pane:
|
||||
'settings.terminal.behavior.autoCloseOnExit': 'Auto-close terminal on exit',
|
||||
'settings.terminal.behavior.autoCloseOnExit.desc':
|
||||
'Allow terminal tabs and windows to close automatically after session exit. Turn this off to keep them open after every exit.',
|
||||
'settings.terminal.behavior.disconnectedNotice': 'When a connection disconnects',
|
||||
'settings.terminal.behavior.disconnectedNotice.desc':
|
||||
'Show a compact notice inside established terminals, or use the full disconnected dialog. Initial connection failures still use the dialog.',
|
||||
'settings.terminal.behavior.disconnectedNotice.terminal': 'Terminal notice',
|
||||
'settings.terminal.behavior.disconnectedNotice.dialog': 'Dialog',
|
||||
'settings.terminal.behavior.rightClick.menu': 'Show menu',
|
||||
'settings.terminal.behavior.rightClick.paste': 'Paste',
|
||||
'settings.terminal.behavior.rightClick.selectWord': 'Select word',
|
||||
@@ -610,6 +700,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.',
|
||||
|
||||
@@ -62,6 +62,9 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.composeBar.snippetClickHint': 'Click to insert · Shift+Click to send',
|
||||
'terminal.toolbar.focus': 'Focus',
|
||||
'terminal.toolbar.focusMode': 'Focus Mode',
|
||||
'terminal.paneMagnification.magnify': 'Magnify Current Pane',
|
||||
'terminal.paneMagnification.restore': 'Restore Pane Layout',
|
||||
'terminal.paneMagnification.hint': 'Magnified',
|
||||
'terminal.toolbar.detach': 'Detach to standalone tab',
|
||||
'terminal.toolbar.dragPane': 'Drag terminal pane',
|
||||
'terminal.toolbar.showActions': 'Show terminal actions',
|
||||
@@ -183,6 +186,7 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.progress.cancelling': 'Cancelling...',
|
||||
'terminal.progress.startOver': 'Start over',
|
||||
'terminal.progress.enterReconnectHint': 'Press Enter to reconnect',
|
||||
'terminal.progress.reconnecting': 'Reconnecting...',
|
||||
'terminal.progress.autoReconnectScheduled': 'Connection lost. Reconnecting in {seconds}s (attempt {attempt}).',
|
||||
'terminal.progress.autoReconnectAttempt': 'Auto reconnect attempt {attempt}...',
|
||||
'terminal.connection.dismissDisconnectedDialog': 'Dismiss disconnected notice',
|
||||
|
||||
@@ -34,6 +34,61 @@ export const enVaultMessages: Messages = {
|
||||
'notes.empty.desc': 'Create Markdown notes for runbooks, reminders, and server handoff details.',
|
||||
'notes.empty.group': 'No notes in this notebook.',
|
||||
'notes.search.noResults': 'No notes found.',
|
||||
'notes.toolbar.modeLive': 'Live Preview',
|
||||
'notes.toolbar.modeLiveTitle': 'Live Preview Mode (WYSIWYG)',
|
||||
'notes.toolbar.modeSource': 'Source Mode',
|
||||
'notes.toolbar.modeSourceTitle': 'Source Mode (Markdown code)',
|
||||
'notes.toolbar.modePreview': 'Reading Mode',
|
||||
'notes.toolbar.modePreviewTitle': 'Reading Mode (Read-only)',
|
||||
'notes.toolbar.undo': 'Undo (Ctrl+Z)',
|
||||
'notes.toolbar.redo': 'Redo (Ctrl+Y)',
|
||||
'notes.toolbar.typography': 'Code font and typography settings',
|
||||
'notes.toolbar.customCodeFont': 'Custom Code Font',
|
||||
'notes.toolbar.fontsAvailable': '{count} fonts available',
|
||||
'notes.toolbar.searchFont': 'Search fonts...',
|
||||
'notes.toolbar.noFontsFound': 'No matching monospace fonts found',
|
||||
'notes.toolbar.defaultFont': 'Default Monospace (Default)',
|
||||
'notes.toolbar.bodyFontSize': 'Body Font Size',
|
||||
'notes.toolbar.codeFontSize': 'Code Font Size',
|
||||
'notes.toolbar.headingLevel': 'Heading Level',
|
||||
'notes.toolbar.h1': 'Heading 1',
|
||||
'notes.toolbar.h2': 'Heading 2',
|
||||
'notes.toolbar.h3': 'Heading 3',
|
||||
'notes.toolbar.h4': 'Heading 4',
|
||||
'notes.toolbar.bold': 'Bold (Ctrl+B)',
|
||||
'notes.toolbar.italic': 'Italic (Ctrl+I)',
|
||||
'notes.toolbar.strikethrough': 'Strikethrough',
|
||||
'notes.toolbar.underline': 'Underline',
|
||||
'notes.toolbar.inlineCode': 'Inline Code',
|
||||
'notes.toolbar.bulletList': 'Bullet List',
|
||||
'notes.toolbar.orderedList': 'Numbered List',
|
||||
'notes.toolbar.taskList': 'Task List',
|
||||
'notes.toolbar.quote': 'Quote',
|
||||
'notes.toolbar.codeBlock': 'Code Block',
|
||||
'notes.toolbar.mathFormula': 'Math Formula (LaTeX / Math)',
|
||||
'notes.toolbar.table': 'Insert Table',
|
||||
'notes.toolbar.divider': 'Divider',
|
||||
'notes.toolbar.link': 'Insert Link',
|
||||
'notes.toolbar.image': 'Insert Image',
|
||||
'notes.action.pin': 'Pin Note',
|
||||
'notes.action.unpin': 'Unpin Note',
|
||||
'notes.tag.remove': 'Remove tag',
|
||||
'notes.tag.placeholder': 'Tag name...',
|
||||
'notes.tag.add': 'Add tag',
|
||||
'notes.outline.title': 'Table of Contents',
|
||||
'notes.outline.titleWithCount': 'Table of Contents ({count})',
|
||||
'notes.outline.empty': 'No outline headings',
|
||||
'notes.outline.emptyHint': 'Use # and ## in Markdown to create headings',
|
||||
'notes.export.share': 'Export & Share',
|
||||
'notes.export.currentNote': 'Current Note',
|
||||
'notes.export.exportMarkdown': 'Export as Markdown (.md)',
|
||||
'notes.export.exportHtml': 'Export as HTML (.html)',
|
||||
'notes.export.copyMarkdown': 'Copy Markdown Source',
|
||||
'notes.export.allNotes': 'All Notes',
|
||||
'notes.export.exportAllZip': 'Export All as ZIP Archive',
|
||||
'notes.export.toast.markdownSuccess': 'Markdown file exported',
|
||||
'notes.export.toast.htmlSuccess': 'HTML file exported',
|
||||
'notes.export.toast.zipSuccess': 'All notes exported as ZIP backup',
|
||||
|
||||
// Vault import
|
||||
'vault.import.title': 'Add data to your vault',
|
||||
@@ -208,6 +263,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 +285,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',
|
||||
@@ -391,6 +451,13 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.tabs.copyCurrentPath': 'Copy and go to current path',
|
||||
'sftp.conflict.title': 'File Conflict',
|
||||
'sftp.conflict.desc': 'A file with the same name already exists at the destination',
|
||||
'sftp.conflict.folderTitle': 'Folder Conflict',
|
||||
'sftp.conflict.folderDesc': 'Choose how to continue.',
|
||||
'sftp.conflict.folderFileDesc': 'A file with the same name already exists at the destination. A folder cannot be merged with a file.',
|
||||
'sftp.conflict.folderSymlinkDesc': 'A symbolic link with the same name already exists at the destination. A folder cannot be merged with a symbolic link.',
|
||||
'sftp.conflict.folderUnknownDesc': 'An item with the same name exists at the destination, but its type could not be confirmed. Replace is unavailable to protect destination data.',
|
||||
'sftp.conflict.folderMergeHint': 'Merge: keeps destination-only content.',
|
||||
'sftp.conflict.folderReplaceWarning': 'Replace: deletes destination-only content and cannot be undone.',
|
||||
'sftp.conflict.alreadyExistsSuffix': 'already exists',
|
||||
'sftp.conflict.existingFile': 'Existing file',
|
||||
'sftp.conflict.newFile': 'New file',
|
||||
@@ -489,7 +556,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',
|
||||
@@ -516,6 +534,9 @@ export const esAiMessages: Messages = {
|
||||
'ai.safety.commandTimeout': 'Tiempo de espera de comandos',
|
||||
'ai.safety.commandTimeout.description': 'Máximo de segundos que un comando puede ejecutarse antes de ser terminado mediante la ejecución de Netcatty.',
|
||||
'ai.safety.commandTimeout.unit': 'seg',
|
||||
'ai.safety.responseIdleTimeout': 'Espera de respuesta de la IA integrada',
|
||||
'ai.safety.responseIdleTimeout.description': 'Cancela una solicitud de la IA integrada tras este número de segundos sin una nueva respuesta. No controla la duración total ni la ejecución de comandos.',
|
||||
'ai.safety.responseIdleTimeout.unit': 'seg',
|
||||
'ai.safety.maxIterations': 'Máximo de iteraciones',
|
||||
'ai.safety.maxIterations.description': 'Máximo de bucles de uso de herramientas de IA para evitar una ejecución descontrolada. Los agentes externos pueden tener sus propios límites de iteración internos que tienen prioridad.',
|
||||
'ai.safety.blocklist': 'Lista de bloqueo de comandos',
|
||||
@@ -635,4 +656,4 @@ export const esAiMessages: Messages = {
|
||||
'zmodem.overwrite.skip': 'Omitir',
|
||||
'zmodem.overwrite.cancel': 'Cancelar',
|
||||
'settings.shortcuts.resetToDefault': 'Restablecer al predeterminado',
|
||||
};
|
||||
};
|
||||
@@ -279,6 +279,12 @@ export const esCoreMessages: Messages = {
|
||||
'settings.vault.showHostTreeSidebarDesc': 'Muestra la barra lateral de lista de hosts y su alternador en la barra superior en las pestañas de terminal y editor.',
|
||||
'settings.vault.autoImportSystemKnownHosts': 'Importar automáticamente known_hosts del sistema',
|
||||
'settings.vault.autoImportSystemKnownHostsDesc': 'Cuando se carga el Vault, importa en silencio las entradas del archivo known_hosts de OpenSSH. Desactívalo para mantener los hosts conocidos de Netcatty separados; Escanear sistema sigue importando a pedido.',
|
||||
'settings.vault.notesFont': 'Fuente de código',
|
||||
'settings.vault.notesFontDesc': 'Familia tipográfica para bloques de código y código en línea en notas',
|
||||
'settings.vault.notesFontSize': 'Tamaño de fuente de notas',
|
||||
'settings.vault.notesFontSizeDesc': 'Tamaño de fuente para el cuerpo de texto de las notas',
|
||||
'settings.vault.notesCodeFontSize': 'Tamaño de fuente de código',
|
||||
'settings.vault.notesCodeFontSizeDesc': 'Tamaño de fuente para bloques de código y código en línea en notas',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': 'Actualización disponible',
|
||||
@@ -564,6 +570,11 @@ Resalta el panel dividido enfocado:
|
||||
'settings.terminal.behavior.autoCloseOnExit': 'Cerrar la terminal automáticamente al salir',
|
||||
'settings.terminal.behavior.autoCloseOnExit.desc':
|
||||
'Permite que las pestañas y ventanas de terminal se cierren automáticamente después de que la sesión termina. Desactívalo para mantenerlas abiertas después de cada salida.',
|
||||
'settings.terminal.behavior.disconnectedNotice': 'Cuando se desconecta una conexión',
|
||||
'settings.terminal.behavior.disconnectedNotice.desc':
|
||||
'Muestra un aviso compacto en terminales ya conectadas o usa el diálogo completo. Los fallos de conexión iniciales siguen usando el diálogo.',
|
||||
'settings.terminal.behavior.disconnectedNotice.terminal': 'Aviso en terminal',
|
||||
'settings.terminal.behavior.disconnectedNotice.dialog': 'Diálogo',
|
||||
'settings.terminal.behavior.rightClick.menu': 'Mostrar menú',
|
||||
'settings.terminal.behavior.rightClick.paste': 'Pegar',
|
||||
'settings.terminal.behavior.rightClick.selectWord': 'Seleccionar palabra',
|
||||
@@ -610,6 +621,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.',
|
||||
@@ -1093,4 +1110,4 @@ Resalta el panel dividido enfocado:
|
||||
'plugins.authentication.responseFailedWithMessage': 'No se pudo enviar la respuesta de autenticación: {message}. Inténtalo de nuevo o cancela la solicitud.',
|
||||
'plugins.authentication.continue': 'Continuar',
|
||||
|
||||
};
|
||||
};
|
||||
@@ -62,6 +62,9 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.composeBar.snippetClickHint': 'Clic para insertar · Shift+Clic para enviar',
|
||||
'terminal.toolbar.focus': 'Enfocar',
|
||||
'terminal.toolbar.focusMode': 'Modo de Enfoque',
|
||||
'terminal.paneMagnification.magnify': 'Ampliar panel actual',
|
||||
'terminal.paneMagnification.restore': 'Restaurar diseño de paneles',
|
||||
'terminal.paneMagnification.hint': 'Ampliado',
|
||||
'terminal.toolbar.detach': 'Desacoplar a pestaña independiente',
|
||||
'terminal.toolbar.dragPane': 'Arrastrar panel de terminal',
|
||||
'terminal.toolbar.showActions': 'Mostrar acciones de la terminal',
|
||||
@@ -183,6 +186,7 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.progress.cancelling': 'Cancelando...',
|
||||
'terminal.progress.startOver': 'Empezar de nuevo',
|
||||
'terminal.progress.enterReconnectHint': 'Presiona Enter para reconectar',
|
||||
'terminal.progress.reconnecting': 'Reconectando...',
|
||||
'terminal.progress.autoReconnectScheduled': 'Conexión perdida. Reconectando en {seconds}s (intento {attempt}).',
|
||||
'terminal.progress.autoReconnectAttempt': 'Intento de reconexión automática {attempt}...',
|
||||
'terminal.connection.dismissDisconnectedDialog': 'Descartar aviso de desconexión',
|
||||
@@ -840,4 +844,4 @@ export const esTerminalMessages: Messages = {
|
||||
'sftp.editor.saveAndClose': 'Guardar y cerrar',
|
||||
'sftp.editor.quitBlockedByDirty': 'Editores sin guardar: guarda o descarta antes de salir',
|
||||
|
||||
};
|
||||
};
|
||||
@@ -34,6 +34,61 @@ export const esVaultMessages: Messages = {
|
||||
'notes.empty.desc': 'Crea notas Markdown para runbooks, recordatorios y detalles de traspaso de servidores.',
|
||||
'notes.empty.group': 'No hay notas en este cuaderno.',
|
||||
'notes.search.noResults': 'No se encontraron notas.',
|
||||
'notes.toolbar.modeLive': 'Vista en vivo',
|
||||
'notes.toolbar.modeLiveTitle': 'Modo vista en vivo (WYSIWYG)',
|
||||
'notes.toolbar.modeSource': 'Modo fuente',
|
||||
'notes.toolbar.modeSourceTitle': 'Modo fuente (código Markdown)',
|
||||
'notes.toolbar.modePreview': 'Modo lectura',
|
||||
'notes.toolbar.modePreviewTitle': 'Modo lectura (solo lectura)',
|
||||
'notes.toolbar.undo': 'Deshacer (Ctrl+Z)',
|
||||
'notes.toolbar.redo': 'Rehacer (Ctrl+Y)',
|
||||
'notes.toolbar.typography': 'Fuente de código y ajustes de tipografía',
|
||||
'notes.toolbar.customCodeFont': 'Fuente de código personalizada',
|
||||
'notes.toolbar.fontsAvailable': '{count} fuentes disponibles',
|
||||
'notes.toolbar.searchFont': 'Buscar fuentes...',
|
||||
'notes.toolbar.noFontsFound': 'No se encontraron fuentes monoespaciadas coincidentes',
|
||||
'notes.toolbar.defaultFont': 'Monoespaciado predeterminado (Default)',
|
||||
'notes.toolbar.bodyFontSize': 'Tamaño de texto',
|
||||
'notes.toolbar.codeFontSize': 'Tamaño de código',
|
||||
'notes.toolbar.headingLevel': 'Nivel de encabezado',
|
||||
'notes.toolbar.h1': 'Encabezado 1',
|
||||
'notes.toolbar.h2': 'Encabezado 2',
|
||||
'notes.toolbar.h3': 'Encabezado 3',
|
||||
'notes.toolbar.h4': 'Encabezado 4',
|
||||
'notes.toolbar.bold': 'Negrita (Ctrl+B)',
|
||||
'notes.toolbar.italic': 'Cursiva (Ctrl+I)',
|
||||
'notes.toolbar.strikethrough': 'Tachado',
|
||||
'notes.toolbar.underline': 'Subrayado',
|
||||
'notes.toolbar.inlineCode': 'Código en línea',
|
||||
'notes.toolbar.bulletList': 'Lista con viñetas',
|
||||
'notes.toolbar.orderedList': 'Lista numerada',
|
||||
'notes.toolbar.taskList': 'Lista de tareas',
|
||||
'notes.toolbar.quote': 'Cita',
|
||||
'notes.toolbar.codeBlock': 'Bloque de código',
|
||||
'notes.toolbar.mathFormula': 'Fórmula matemática (LaTeX / Math)',
|
||||
'notes.toolbar.table': 'Insertar tabla',
|
||||
'notes.toolbar.divider': 'Línea divisoria',
|
||||
'notes.toolbar.link': 'Insertar enlace',
|
||||
'notes.toolbar.image': 'Insertar imagen',
|
||||
'notes.action.pin': 'Fijar nota',
|
||||
'notes.action.unpin': 'Desfijar nota',
|
||||
'notes.tag.remove': 'Eliminar etiqueta',
|
||||
'notes.tag.placeholder': 'Nombre de etiqueta...',
|
||||
'notes.tag.add': 'Añadir etiqueta',
|
||||
'notes.outline.title': 'Índice de contenido',
|
||||
'notes.outline.titleWithCount': 'Índice de contenido ({count})',
|
||||
'notes.outline.empty': 'Sin encabezados de esquema',
|
||||
'notes.outline.emptyHint': 'Use # y ## en Markdown para crear encabezados',
|
||||
'notes.export.share': 'Exportar y compartir',
|
||||
'notes.export.currentNote': 'Nota actual',
|
||||
'notes.export.exportMarkdown': 'Exportar como Markdown (.md)',
|
||||
'notes.export.exportHtml': 'Exportar como HTML (.html)',
|
||||
'notes.export.copyMarkdown': 'Copiar código Markdown',
|
||||
'notes.export.allNotes': 'Todas las notas',
|
||||
'notes.export.exportAllZip': 'Exportar todo como archivo ZIP',
|
||||
'notes.export.toast.markdownSuccess': 'Archivo Markdown exportado',
|
||||
'notes.export.toast.htmlSuccess': 'Archivo HTML exportado',
|
||||
'notes.export.toast.zipSuccess': 'Todas las notas exportadas como copia de seguridad ZIP',
|
||||
|
||||
// Vault import
|
||||
'vault.import.title': 'Agrega datos a tu Vault',
|
||||
@@ -208,6 +263,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 +285,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',
|
||||
@@ -391,6 +451,13 @@ export const esVaultMessages: Messages = {
|
||||
'sftp.tabs.copyCurrentPath': 'Copiar e ir a la ruta actual',
|
||||
'sftp.conflict.title': 'Conflicto de archivos',
|
||||
'sftp.conflict.desc': 'Ya existe un archivo con el mismo nombre en el destino',
|
||||
'sftp.conflict.folderTitle': 'Conflicto de carpetas',
|
||||
'sftp.conflict.folderDesc': 'Elige cómo continuar.',
|
||||
'sftp.conflict.folderFileDesc': 'Ya existe un archivo con el mismo nombre en el destino. Una carpeta no se puede combinar con un archivo.',
|
||||
'sftp.conflict.folderSymlinkDesc': 'Ya existe un enlace simbólico con el mismo nombre en el destino. Una carpeta no se puede combinar con un enlace simbólico.',
|
||||
'sftp.conflict.folderUnknownDesc': 'Ya existe un elemento con el mismo nombre en el destino, pero no se pudo confirmar su tipo. Reemplazar no está disponible para proteger los datos del destino.',
|
||||
'sftp.conflict.folderMergeHint': 'Combinar: conserva el contenido que solo existe en el destino.',
|
||||
'sftp.conflict.folderReplaceWarning': 'Reemplazar: elimina el contenido exclusivo del destino y no se puede deshacer.',
|
||||
'sftp.conflict.alreadyExistsSuffix': 'ya existe',
|
||||
'sftp.conflict.existingFile': 'Archivo existente',
|
||||
'sftp.conflict.newFile': 'Archivo nuevo',
|
||||
@@ -489,7 +556,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.',
|
||||
@@ -980,4 +1047,4 @@ export const esVaultMessages: Messages = {
|
||||
'vault.import.plugins.moreItems': '{count} elementos más están incluidos en esta importación.',
|
||||
'vault.import.plugins.moreDiagnostics': '{count} diagnósticos más se omitieron de esta vista previa.',
|
||||
|
||||
};
|
||||
};
|
||||
@@ -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': 'Без названия',
|
||||
@@ -468,6 +486,9 @@ export const ruAiMessages: Messages = {
|
||||
'ai.safety.commandTimeout': 'Тайм-аут команды',
|
||||
'ai.safety.commandTimeout.description': 'Максимальное число секунд, которое команда может выполняться до принудительного завершения. Применяется как к встроенным, так и к внешним агентам.',
|
||||
'ai.safety.commandTimeout.unit': 'с',
|
||||
'ai.safety.responseIdleTimeout': 'Ожидание ответа встроенного ИИ',
|
||||
'ai.safety.responseIdleTimeout.description': 'Отменяет запрос встроенного ИИ после указанного числа секунд без нового ответа. Не управляет общей длительностью ответа и выполнением команд.',
|
||||
'ai.safety.responseIdleTimeout.unit': 'с',
|
||||
'ai.safety.maxIterations': 'Макс. число итераций',
|
||||
'ai.safety.maxIterations.description': 'Максимальное число циклов использования инструментов AI, чтобы предотвратить бесконтрольное выполнение. У внешних агентов могут быть собственные внутренние лимиты итераций, имеющие приоритет.',
|
||||
'ai.safety.blocklist': 'Чёрный список команд',
|
||||
|
||||
@@ -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.',
|
||||
@@ -263,6 +342,12 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.vault.showHostTreeSidebarDesc': 'Показывать список хостов и кнопку в верхней панели для вкладок терминала и редактора.',
|
||||
'settings.vault.autoImportSystemKnownHosts': 'Автоимпорт системного known_hosts',
|
||||
'settings.vault.autoImportSystemKnownHostsDesc': 'При открытии хранилища тихо импортировать записи из OpenSSH known_hosts. Отключите, чтобы держать известные хосты Netcatty отдельно; кнопка «Сканировать систему» по-прежнему импортирует вручную.',
|
||||
'settings.vault.notesFont': 'Шрифт кода',
|
||||
'settings.vault.notesFontDesc': 'Шрифт для блоков кода и встроенного кода в заметках',
|
||||
'settings.vault.notesFontSize': 'Размер шрифта заметок',
|
||||
'settings.vault.notesFontSizeDesc': 'Размер шрифта для основного текста заметок',
|
||||
'settings.vault.notesCodeFontSize': 'Размер шрифта кода',
|
||||
'settings.vault.notesCodeFontSizeDesc': 'Размер шрифта для блоков кода и встроенного кода в заметках',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': 'Доступно обновление',
|
||||
@@ -521,6 +606,11 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.behavior.autoCloseOnExit': 'Автоматически закрывать терминал при выходе',
|
||||
'settings.terminal.behavior.autoCloseOnExit.desc':
|
||||
'Разрешить автоматическое закрытие вкладок и окон терминала после завершения сеанса. Отключите, чтобы сохранять их после любого выхода.',
|
||||
'settings.terminal.behavior.disconnectedNotice': 'При разрыве соединения',
|
||||
'settings.terminal.behavior.disconnectedNotice.desc':
|
||||
'Показывать компактное уведомление внутри уже подключённого терминала или полный диалог. Ошибки первого подключения по-прежнему показываются в диалоге.',
|
||||
'settings.terminal.behavior.disconnectedNotice.terminal': 'В терминале',
|
||||
'settings.terminal.behavior.disconnectedNotice.dialog': 'Диалог',
|
||||
'settings.terminal.behavior.rightClick.menu': 'Показать меню',
|
||||
'settings.terminal.behavior.rightClick.paste': 'Вставить',
|
||||
'settings.terminal.behavior.rightClick.selectWord': 'Выбрать слово',
|
||||
@@ -567,6 +657,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.',
|
||||
|
||||
@@ -83,6 +83,9 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.composeBar.snippetClickHint': 'Клик — вставить · Shift+клик — отправить',
|
||||
'terminal.toolbar.focus': 'Фокус',
|
||||
'terminal.toolbar.focusMode': 'Режим фокуса',
|
||||
'terminal.paneMagnification.magnify': 'Увеличить текущую панель',
|
||||
'terminal.paneMagnification.restore': 'Восстановить раскладку панелей',
|
||||
'terminal.paneMagnification.hint': 'Увеличено',
|
||||
'terminal.toolbar.detach': 'Открепить в отдельную вкладку',
|
||||
'terminal.toolbar.dragPane': 'Перетащить панель терминала',
|
||||
'terminal.toolbar.showActions': 'Показать действия терминала',
|
||||
@@ -204,6 +207,9 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.progress.cancelling': 'Отмена...',
|
||||
'terminal.progress.startOver': 'Начать заново',
|
||||
'terminal.progress.enterReconnectHint': 'Нажмите Enter, чтобы подключиться снова',
|
||||
'terminal.progress.reconnecting': 'Повторное подключение...',
|
||||
'terminal.progress.autoReconnectScheduled': 'Соединение потеряно. Повторное подключение через {seconds} с (попытка {attempt}).',
|
||||
'terminal.progress.autoReconnectAttempt': 'Попытка автоматического подключения {attempt}...',
|
||||
'terminal.connection.dismissDisconnectedDialog': 'Закрыть уведомление об отключении',
|
||||
'terminal.connection.chainOf': 'Цепочка {current} из {total}',
|
||||
'terminal.connection.showLogs': 'Показать журналы',
|
||||
|
||||
@@ -34,6 +34,61 @@ export const ruVaultMessages: Messages = {
|
||||
'notes.empty.desc': 'Создавайте Markdown-заметки для инструкций, напоминаний и передачи серверов.',
|
||||
'notes.empty.group': 'В этом блокноте пока нет заметок.',
|
||||
'notes.search.noResults': 'Заметки не найдены.',
|
||||
'notes.toolbar.modeLive': 'Живой просмотр',
|
||||
'notes.toolbar.modeLiveTitle': 'Режим живого просмотра (WYSIWYG)',
|
||||
'notes.toolbar.modeSource': 'Исходный код',
|
||||
'notes.toolbar.modeSourceTitle': 'Режим исходного кода (Markdown)',
|
||||
'notes.toolbar.modePreview': 'Чтение',
|
||||
'notes.toolbar.modePreviewTitle': 'Режим чтения (только для чтения)',
|
||||
'notes.toolbar.undo': 'Отменить (Ctrl+Z)',
|
||||
'notes.toolbar.redo': 'Повторить (Ctrl+Y)',
|
||||
'notes.toolbar.typography': 'Шрифт кода и параметры оформления',
|
||||
'notes.toolbar.customCodeFont': 'Пользовательский шрифт кода',
|
||||
'notes.toolbar.fontsAvailable': '{count} доступных шрифтов',
|
||||
'notes.toolbar.searchFont': 'Поиск шрифтов...',
|
||||
'notes.toolbar.noFontsFound': 'Совпадающие моноширинные шрифты не найдены',
|
||||
'notes.toolbar.defaultFont': 'Моноширинный по умолчанию (Default)',
|
||||
'notes.toolbar.bodyFontSize': 'Размер текста',
|
||||
'notes.toolbar.codeFontSize': 'Размер кода',
|
||||
'notes.toolbar.headingLevel': 'Уровень заголовка',
|
||||
'notes.toolbar.h1': 'Заголовок 1',
|
||||
'notes.toolbar.h2': 'Заголовок 2',
|
||||
'notes.toolbar.h3': 'Заголовок 3',
|
||||
'notes.toolbar.h4': 'Заголовок 4',
|
||||
'notes.toolbar.bold': 'Жирный (Ctrl+B)',
|
||||
'notes.toolbar.italic': 'Курсив (Ctrl+I)',
|
||||
'notes.toolbar.strikethrough': 'Зачёркнутый',
|
||||
'notes.toolbar.underline': 'Подчёркнутый',
|
||||
'notes.toolbar.inlineCode': 'Встроенный код',
|
||||
'notes.toolbar.bulletList': 'Маркированный список',
|
||||
'notes.toolbar.orderedList': 'Нумерованный список',
|
||||
'notes.toolbar.taskList': 'Список задач',
|
||||
'notes.toolbar.quote': 'Цитата',
|
||||
'notes.toolbar.codeBlock': 'Блок кода',
|
||||
'notes.toolbar.mathFormula': 'Математическая формула (LaTeX / Math)',
|
||||
'notes.toolbar.table': 'Вставить таблицу',
|
||||
'notes.toolbar.divider': 'Разделитель',
|
||||
'notes.toolbar.link': 'Вставить ссылку',
|
||||
'notes.toolbar.image': 'Вставить изображение',
|
||||
'notes.action.pin': 'Закрепить заметку',
|
||||
'notes.action.unpin': 'Открепить заметку',
|
||||
'notes.tag.remove': 'Удалить тег',
|
||||
'notes.tag.placeholder': 'Имя тега...',
|
||||
'notes.tag.add': 'Добавить тег',
|
||||
'notes.outline.title': 'Оглавление',
|
||||
'notes.outline.titleWithCount': 'Оглавление ({count})',
|
||||
'notes.outline.empty': 'Нет заголовков',
|
||||
'notes.outline.emptyHint': 'Используйте # и ## в Markdown для создания заголовков',
|
||||
'notes.export.share': 'Экспорт и общий доступ',
|
||||
'notes.export.currentNote': 'Текущая заметка',
|
||||
'notes.export.exportMarkdown': 'Экспорт в Markdown (.md)',
|
||||
'notes.export.exportHtml': 'Экспорт в HTML (.html)',
|
||||
'notes.export.copyMarkdown': 'Копировать исходный Markdown',
|
||||
'notes.export.allNotes': 'Все заметки',
|
||||
'notes.export.exportAllZip': 'Экспортировать все в ZIP-архив',
|
||||
'notes.export.toast.markdownSuccess': 'Markdown-файл экспортирован',
|
||||
'notes.export.toast.htmlSuccess': 'HTML-файл экспортирован',
|
||||
'notes.export.toast.zipSuccess': 'Все заметки экспортированы в архив ZIP',
|
||||
|
||||
// Vault hosts header/actions
|
||||
'vault.hosts.search.placeholder': 'Найти хост или ssh user@hostname / ssh -p 2222 user@hostname...',
|
||||
@@ -245,6 +300,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 +322,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': 'Копировать текущий путь',
|
||||
@@ -427,6 +487,13 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.tabs.copyCurrentPath': 'Копировать и перейти к текущему пути',
|
||||
'sftp.conflict.title': 'Конфликт файлов',
|
||||
'sftp.conflict.desc': 'В месте назначения уже существует файл с таким именем',
|
||||
'sftp.conflict.folderTitle': 'Конфликт папок',
|
||||
'sftp.conflict.folderDesc': 'Выберите действие.',
|
||||
'sftp.conflict.folderFileDesc': 'В месте назначения уже существует файл с таким именем. Папку нельзя объединить с файлом.',
|
||||
'sftp.conflict.folderSymlinkDesc': 'В месте назначения уже существует символическая ссылка с таким именем. Папку нельзя объединить с символической ссылкой.',
|
||||
'sftp.conflict.folderUnknownDesc': 'В месте назначения уже существует элемент с таким именем, но его тип не удалось определить. Замена недоступна для защиты данных в месте назначения.',
|
||||
'sftp.conflict.folderMergeHint': 'Объединить: сохранить содержимое, которое есть только в папке назначения.',
|
||||
'sftp.conflict.folderReplaceWarning': 'Заменить: удалить содержимое, которое есть только в папке назначения. Это нельзя отменить.',
|
||||
'sftp.conflict.alreadyExistsSuffix': 'уже существует',
|
||||
'sftp.conflict.existingFile': 'Существующий файл',
|
||||
'sftp.conflict.newFile': 'Новый файл',
|
||||
@@ -525,7 +592,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",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { enVaultMessages } from './en/vault.ts';
|
||||
import { esVaultMessages } from './es/vault.ts';
|
||||
import { ruVaultMessages } from './ru/vault.ts';
|
||||
import { zhCNVaultMessages } from './zh-CN/vault.ts';
|
||||
import { zhTWVaultMessages } from './zh-TW/vault.ts';
|
||||
|
||||
const FOLDER_CONFLICT_KEYS = [
|
||||
'sftp.conflict.folderTitle',
|
||||
'sftp.conflict.folderDesc',
|
||||
'sftp.conflict.folderFileDesc',
|
||||
'sftp.conflict.folderSymlinkDesc',
|
||||
'sftp.conflict.folderUnknownDesc',
|
||||
'sftp.conflict.folderMergeHint',
|
||||
'sftp.conflict.folderReplaceWarning',
|
||||
] as const;
|
||||
|
||||
test('folder conflict safety copy exists in every supported locale', () => {
|
||||
const locales = {
|
||||
en: enVaultMessages,
|
||||
es: esVaultMessages,
|
||||
ru: ruVaultMessages,
|
||||
'zh-CN': zhCNVaultMessages,
|
||||
'zh-TW': zhTWVaultMessages,
|
||||
};
|
||||
|
||||
for (const [locale, messages] of Object.entries(locales)) {
|
||||
for (const key of FOLDER_CONFLICT_KEYS) {
|
||||
assert.equal(typeof messages[key], 'string', `${locale} is missing ${key}`);
|
||||
assert.notEqual(messages[key]?.trim(), '', `${locale} has an empty ${key}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { enTerminalMessages } from "./en/terminal";
|
||||
import { esTerminalMessages } from "./es/terminal";
|
||||
import { ruTerminalMessages } from "./ru/terminal";
|
||||
import { zhCNTerminalMessages } from "./zh-CN/terminal";
|
||||
import { zhTWTerminalMessages } from "./zh-TW/terminal";
|
||||
|
||||
test("terminal reconnect notices are localized in every bundled language", () => {
|
||||
const messages = [
|
||||
enTerminalMessages,
|
||||
esTerminalMessages,
|
||||
ruTerminalMessages,
|
||||
zhCNTerminalMessages,
|
||||
zhTWTerminalMessages,
|
||||
];
|
||||
const keys = [
|
||||
"terminal.progress.enterReconnectHint",
|
||||
"terminal.progress.reconnecting",
|
||||
"terminal.progress.autoReconnectScheduled",
|
||||
"terminal.progress.autoReconnectAttempt",
|
||||
];
|
||||
|
||||
for (const locale of messages) {
|
||||
for (const key of keys) {
|
||||
assert.equal(typeof locale[key], "string", `missing ${key}`);
|
||||
assert.notEqual(locale[key]?.trim(), "", `empty ${key}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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': '无标题',
|
||||
@@ -516,6 +534,9 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.safety.commandTimeout': '命令超时',
|
||||
'ai.safety.commandTimeout.description': '通过 Netcatty 执行命令时允许运行的最长秒数,超时将被终止。',
|
||||
'ai.safety.commandTimeout.unit': '秒',
|
||||
'ai.safety.responseIdleTimeout': '内置 AI 响应等待时间',
|
||||
'ai.safety.responseIdleTimeout.description': '内置 AI 请求连续这么多秒没有收到新响应时会被取消。这个设置不控制整段响应总时长,也不影响命令超时。',
|
||||
'ai.safety.responseIdleTimeout.unit': '秒',
|
||||
'ai.safety.maxIterations': '最大迭代次数',
|
||||
'ai.safety.maxIterations.description': '防止 AI 失控执行的最大工具调用循环次数。外部 Agent 可能有自己的内部迭代限制,以其为准。',
|
||||
'ai.safety.blocklist': '命令黑名单',
|
||||
|
||||
@@ -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 配置无关。',
|
||||
@@ -276,6 +355,12 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.vault.showHostTreeSidebarDesc': '在终端和编辑器标签页显示主机列表侧栏及顶部开关。',
|
||||
'settings.vault.autoImportSystemKnownHosts': '自动导入系统 known_hosts',
|
||||
'settings.vault.autoImportSystemKnownHostsDesc': '打开主机库时静默导入 OpenSSH known_hosts 中的记录。关闭后,Netcatty 已知主机与系统信任库保持独立;仍可通过「扫描系统」手动导入。',
|
||||
'settings.vault.notesFont': '代码字体',
|
||||
'settings.vault.notesFontDesc': '自定义笔记中代码块与行内代码使用的字体',
|
||||
'settings.vault.notesFontSize': '笔记字号',
|
||||
'settings.vault.notesFontSizeDesc': '自定义笔记正文字体大小',
|
||||
'settings.vault.notesCodeFontSize': '代码字号',
|
||||
'settings.vault.notesCodeFontSizeDesc': '自定义笔记中代码块与行内代码的字体大小',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': '发现新版本',
|
||||
@@ -813,6 +898,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 +919,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': '复制当前路径',
|
||||
|
||||
@@ -30,6 +30,7 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'terminal.toolbar.timestampsEnable': '显示时间戳',
|
||||
'terminal.toolbar.timestampsDisable': '隐藏时间戳',
|
||||
'terminal.progress.enterReconnectHint': '按 Enter 重新连接',
|
||||
'terminal.progress.reconnecting': '正在重新连接...',
|
||||
'terminal.progress.autoReconnectScheduled': '连接已断开,将在 {seconds} 秒后自动重连(第 {attempt} 次)。',
|
||||
'terminal.progress.autoReconnectAttempt': '正在自动重连(第 {attempt} 次)...',
|
||||
'terminal.connection.protocol.et': 'EternalTerminal',
|
||||
@@ -131,7 +132,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': '为文件夹和文件夹空白处添加右键项,可直接在该目录打开本地终端。开关立即生效,无需重装。',
|
||||
@@ -293,6 +294,10 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'即使 tmux、vim 等全屏应用接管了鼠标,也显示右键菜单。关闭时右键会交给应用处理(Shift+右键仍可打开菜单)。',
|
||||
'settings.terminal.behavior.autoCloseOnExit': '退出后自动关闭终端',
|
||||
'settings.terminal.behavior.autoCloseOnExit.desc': '允许终端标签页和窗口在会话退出后自动关闭。关闭此项后,无论退出结果如何都会保留。',
|
||||
'settings.terminal.behavior.disconnectedNotice': '连接断开时',
|
||||
'settings.terminal.behavior.disconnectedNotice.desc': '已连接的会话断开后,可在终端内显示简短提示,或继续使用完整弹窗。首次连接失败仍使用弹窗。',
|
||||
'settings.terminal.behavior.disconnectedNotice.terminal': '终端内提示',
|
||||
'settings.terminal.behavior.disconnectedNotice.dialog': '弹窗提示',
|
||||
'settings.terminal.behavior.rightClick.menu': '显示菜单',
|
||||
'settings.terminal.behavior.rightClick.paste': '粘贴',
|
||||
'settings.terminal.behavior.rightClick.selectWord': '选择单词',
|
||||
@@ -338,6 +343,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 转义序列访问本地剪贴板。',
|
||||
|
||||
@@ -34,6 +34,61 @@ export const zhCNVaultMessages: Messages = {
|
||||
'notes.empty.desc': '可以记录运维手册、临时备忘、服务器交接信息。',
|
||||
'notes.empty.group': '这个笔记本里还没有笔记。',
|
||||
'notes.search.noResults': '没有找到笔记。',
|
||||
'notes.toolbar.modeLive': '实时预览',
|
||||
'notes.toolbar.modeLiveTitle': '实时预览模式 (WYSIWYG)',
|
||||
'notes.toolbar.modeSource': '源码模式',
|
||||
'notes.toolbar.modeSourceTitle': '源码模式 (Markdown 代码)',
|
||||
'notes.toolbar.modePreview': '阅读模式',
|
||||
'notes.toolbar.modePreviewTitle': '阅读模式 (只读排版)',
|
||||
'notes.toolbar.undo': '撤销 (Ctrl+Z)',
|
||||
'notes.toolbar.redo': '重做 (Ctrl+Y)',
|
||||
'notes.toolbar.typography': '代码字体与排版设置',
|
||||
'notes.toolbar.customCodeFont': '自定义代码字体',
|
||||
'notes.toolbar.fontsAvailable': '共 {count} 款可用字体',
|
||||
'notes.toolbar.searchFont': '搜索字体...',
|
||||
'notes.toolbar.noFontsFound': '未找到匹配的等宽字体',
|
||||
'notes.toolbar.defaultFont': '默认等宽字体 (Default)',
|
||||
'notes.toolbar.bodyFontSize': '正文字号',
|
||||
'notes.toolbar.codeFontSize': '代码字号',
|
||||
'notes.toolbar.headingLevel': '标题层级',
|
||||
'notes.toolbar.h1': '一级标题',
|
||||
'notes.toolbar.h2': '二级标题',
|
||||
'notes.toolbar.h3': '三级标题',
|
||||
'notes.toolbar.h4': '四级标题',
|
||||
'notes.toolbar.bold': '加粗 (Ctrl+B)',
|
||||
'notes.toolbar.italic': '斜体 (Ctrl+I)',
|
||||
'notes.toolbar.strikethrough': '删除线',
|
||||
'notes.toolbar.underline': '下划线',
|
||||
'notes.toolbar.inlineCode': '行内代码',
|
||||
'notes.toolbar.bulletList': '无序列表',
|
||||
'notes.toolbar.orderedList': '有序列表',
|
||||
'notes.toolbar.taskList': '待办任务清单',
|
||||
'notes.toolbar.quote': '引用块',
|
||||
'notes.toolbar.codeBlock': '代码块',
|
||||
'notes.toolbar.mathFormula': '数学公式块 (LaTeX / Math)',
|
||||
'notes.toolbar.table': '插入表格',
|
||||
'notes.toolbar.divider': '分割线',
|
||||
'notes.toolbar.link': '插入超链接',
|
||||
'notes.toolbar.image': '插入图片',
|
||||
'notes.action.pin': '置顶笔记',
|
||||
'notes.action.unpin': '取消置顶',
|
||||
'notes.tag.remove': '移除标签',
|
||||
'notes.tag.placeholder': '标签名...',
|
||||
'notes.tag.add': '添加标签',
|
||||
'notes.outline.title': '大纲目录',
|
||||
'notes.outline.titleWithCount': '大纲目录 ({count})',
|
||||
'notes.outline.empty': '暂无标题大纲',
|
||||
'notes.outline.emptyHint': '在 Markdown 中使用 #、## 编写各级标题',
|
||||
'notes.export.share': '导出与分享',
|
||||
'notes.export.currentNote': '当前笔记',
|
||||
'notes.export.exportMarkdown': '导出为 Markdown (.md)',
|
||||
'notes.export.exportHtml': '导出为 HTML (.html)',
|
||||
'notes.export.copyMarkdown': '复制 Markdown 源码',
|
||||
'notes.export.allNotes': '全部笔记',
|
||||
'notes.export.exportAllZip': '导出全部为 ZIP 压缩包',
|
||||
'notes.export.toast.markdownSuccess': '已导出 Markdown 文件',
|
||||
'notes.export.toast.htmlSuccess': '已导出 HTML 文件',
|
||||
'notes.export.toast.zipSuccess': '已导出全部笔记备份 ZIP',
|
||||
|
||||
// Select Host panel
|
||||
'selectHost.title': '选择主机',
|
||||
@@ -409,6 +464,9 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.composeBar.snippetClickHint': '单击插入 · Shift+单击直接发送',
|
||||
'terminal.toolbar.focus': '聚焦',
|
||||
'terminal.toolbar.focusMode': '聚焦模式',
|
||||
'terminal.paneMagnification.magnify': '放大当前面板',
|
||||
'terminal.paneMagnification.restore': '还原面板布局',
|
||||
'terminal.paneMagnification.hint': '已放大',
|
||||
'terminal.toolbar.detach': '移出到独立标签',
|
||||
'terminal.toolbar.encoding': '终端编码',
|
||||
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
||||
@@ -908,6 +966,13 @@ export const zhCNVaultMessages: Messages = {
|
||||
'sftp.tabs.copyCurrentPath': '复制并跳转到当前路径',
|
||||
'sftp.conflict.title': '文件冲突',
|
||||
'sftp.conflict.desc': '目标位置已存在同名文件',
|
||||
'sftp.conflict.folderTitle': '文件夹冲突',
|
||||
'sftp.conflict.folderDesc': '请选择处理方式。',
|
||||
'sftp.conflict.folderFileDesc': '目标位置已存在同名文件,文件夹无法与文件合并。',
|
||||
'sftp.conflict.folderSymlinkDesc': '目标位置已存在同名符号链接,文件夹无法与符号链接合并。',
|
||||
'sftp.conflict.folderUnknownDesc': '目标位置已存在同名项目,但无法确认其类型。为保护目标数据,替换操作不可用。',
|
||||
'sftp.conflict.folderMergeHint': '合并:保留目标中独有的内容。',
|
||||
'sftp.conflict.folderReplaceWarning': '替换:删除目标中独有的内容,且无法恢复。',
|
||||
'sftp.conflict.alreadyExistsSuffix': '已存在',
|
||||
'sftp.conflict.existingFile': '已有文件',
|
||||
'sftp.conflict.newFile': '新文件',
|
||||
|
||||
@@ -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',
|
||||
@@ -85,10 +90,10 @@ export const zhTWAiMessages: Messages = {
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
'ai.codex.title': 'Codex CLI',
|
||||
'ai.codex.description': '介接 OpenAI Codex。可以在這裡登入 ChatGPT,也可以在設定裡啟用相容 OpenAI 的 API Key 和自訂介面地址。',
|
||||
'ai.codex.description': '介接 OpenAI Codex。可以在這裡登入 ChatGPT,也可以在設定裡啟用相容 OpenAI 的 API Key 和自訂介面位址。',
|
||||
'ai.codex.appServer.title': '使用 Codex App Server',
|
||||
'ai.codex.appServer.experimental': '實驗性',
|
||||
'ai.codex.appServer.description': '使用持久化 Codex 協定,提供原生審批、沙箱控制、動態模型和對話中提問。預設仍使用 SDK。',
|
||||
'ai.codex.appServer.description': '使用持久化 Codex 協定,提供原生核准、沙箱控制、動態模型和對話中提問。預設仍使用 SDK。',
|
||||
'ai.codex.appServer.checking': '正在檢查 App Server 支援…',
|
||||
'ai.codex.appServer.available': '目前 Codex CLI 支援 App Server。',
|
||||
'ai.codex.appServer.modelCatalogWarning': 'Codex 動態模型目錄暫時無法使用,目前將使用內建模型清單。',
|
||||
@@ -123,7 +128,7 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.codex.notFoundHint': '在 PATH 中未找到 codex。請安裝或在下方指定執行檔路徑。',
|
||||
'ai.codex.customPathPlaceholder': '例如 /usr/local/bin/codex',
|
||||
'ai.codex.check': '檢查',
|
||||
'ai.codex.resetPath': '重置',
|
||||
'ai.codex.resetPath': '重設',
|
||||
'ai.codex.openLogin': '開啟登入',
|
||||
'ai.codex.logout': '登出',
|
||||
'ai.codex.connectChatGPT': '連線 ChatGPT',
|
||||
@@ -149,7 +154,7 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
|
||||
'ai.claude.envVars.hint': '每行一個 KEY=VALUE,傳給 Claude agent。明文存在本機——API key/憑證建議用上面的「設定目錄」(claude 登入),不要放這裡。',
|
||||
'ai.claude.check': '檢查',
|
||||
'ai.claude.resetPath': '重置',
|
||||
'ai.claude.resetPath': '重設',
|
||||
|
||||
// AI GitHub Copilot CLI
|
||||
'ai.copilot.title': 'GitHub Copilot CLI',
|
||||
@@ -161,7 +166,7 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.copilot.notFoundHint': '在 PATH 中未找到 copilot。請安裝或在下方指定執行檔路徑。',
|
||||
'ai.copilot.customPathPlaceholder': '例如 /usr/local/bin/copilot',
|
||||
'ai.copilot.check': '檢查',
|
||||
'ai.copilot.resetPath': '重置',
|
||||
'ai.copilot.resetPath': '重設',
|
||||
|
||||
// AI Cursor SDK
|
||||
'ai.cursor.title': 'Cursor',
|
||||
@@ -210,7 +215,7 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.codebuddy.notFoundHint': '在 PATH 中未找到 codebuddy。請安裝或在下方指定執行檔路徑。',
|
||||
'ai.codebuddy.customPathPlaceholder': '例如 /usr/local/bin/codebuddy',
|
||||
'ai.codebuddy.check': '檢查',
|
||||
'ai.codebuddy.resetPath': '重置',
|
||||
'ai.codebuddy.resetPath': '重設',
|
||||
'ai.codebuddy.configSection': '認證與設定(可選)',
|
||||
'ai.codebuddy.internetEnv': '網路環境',
|
||||
'ai.codebuddy.internetEnv.default': '預設(海外)',
|
||||
@@ -266,7 +271,7 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.opencode.notFoundHint': '在 PATH 中未找到 opencode。請安裝或在下方指定執行檔路徑。',
|
||||
'ai.opencode.customPathPlaceholder': '例如 /usr/local/bin/opencode',
|
||||
'ai.opencode.check': '檢查',
|
||||
'ai.opencode.resetPath': '重置',
|
||||
'ai.opencode.resetPath': '重設',
|
||||
|
||||
// AI Grok Build(應用內託管 Agent,與 External MCP「新增到 Grok」不同)
|
||||
'ai.grok.title': 'Grok Build',
|
||||
@@ -278,7 +283,7 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.grok.notFoundHint': '在 PATH 中未找到 grok。請安裝 Grok Build CLI 或在下方指定執行檔路徑。',
|
||||
'ai.grok.customPathPlaceholder': '例如 /usr/local/bin/grok',
|
||||
'ai.grok.check': '檢查',
|
||||
'ai.grok.resetPath': '重置',
|
||||
'ai.grok.resetPath': '重設',
|
||||
'ai.grok.runtime.acp.title': '使用 Grok ACP(agent stdio)',
|
||||
'ai.grok.runtime.acp.default': '預設',
|
||||
'ai.grok.runtime.acp.description':
|
||||
@@ -322,7 +327,7 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.externalMcp.security.description': '僅監聽 127.0.0.1,使用輪替 token,寫入操作重用 AI 權限模式,關閉時刪除 discovery。',
|
||||
'ai.externalMcp.permissionMode': '目前權限模式:{mode}',
|
||||
'ai.externalMcp.permissionMode.label': '寫入權限模式',
|
||||
'ai.externalMcp.permissionMode.hint': '與「設定 → AI → 安全」為同一項。自動:Netcatty 寫入操作不再彈本應用審批;確認:每次詢問。外部用戶端(Codex / Claude / Grok)仍可能有自己的工具審批介面。',
|
||||
'ai.externalMcp.permissionMode.hint': '與「設定 → AI → 安全」為同一項。自動:Netcatty 寫入操作不再彈本應用核准;確認:每次詢問。外部用戶端(Codex / Claude / Grok)仍可能有自己的工具核准介面。',
|
||||
'ai.externalMcp.permissionMode.unknown': '未知',
|
||||
'ai.externalMcp.discovery': 'Discovery',
|
||||
'ai.externalMcp.launcher': 'Launcher',
|
||||
@@ -381,7 +386,7 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.quickMessages.title': '快捷訊息',
|
||||
'ai.quickMessages.description': '建立常用提示詞,在 AI 聊天框輸入 / 或點選快捷按鈕即可插入到輸入框。與使用者 Skills 不同,快捷訊息會直接填入訊息內容。',
|
||||
'ai.quickMessages.add': '新增快捷訊息',
|
||||
'ai.quickMessages.createTitle': '新建快捷訊息',
|
||||
'ai.quickMessages.createTitle': '新增快捷訊息',
|
||||
'ai.quickMessages.editTitle': '編輯快捷訊息',
|
||||
'ai.quickMessages.name': '名稱',
|
||||
'ai.quickMessages.name.placeholder': '例如:檢查磁碟空間',
|
||||
@@ -403,9 +408,9 @@ export const zhTWAiMessages: Messages = {
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': '尚未設定 AI 供應商。請前往 **設定 → AI → 供應商** 新增並啟用一個供應商。',
|
||||
'ai.chat.toolDenied': '操作已被使用者拒絕。',
|
||||
'ai.chat.toolApproved': '已批准',
|
||||
'ai.chat.toolApproved': '已核准',
|
||||
'ai.chat.toolApprovalHint': 'Enter 允許 · Esc 拒絕',
|
||||
'ai.chat.approve': '批准',
|
||||
'ai.chat.approve': '核准',
|
||||
'ai.chat.approveOnce': '允許',
|
||||
'ai.chat.alwaysAllow': '始終',
|
||||
'ai.chat.slashStopDesc': '停止目前 AI 回合並取消進行中的工具',
|
||||
@@ -422,7 +427,7 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.approvalReason': '原因',
|
||||
'ai.chat.approvalInvocation': '呼叫詳情',
|
||||
'ai.chat.permissionRequired': '需要權限',
|
||||
'ai.chat.permissionDescription': 'AI Agent 希望執行一個需要你批准的工具呼叫。',
|
||||
'ai.chat.permissionDescription': 'AI Agent 希望執行一個需要你核准的工具呼叫。',
|
||||
'ai.chat.commandBlocked': '此指令已被安全策略攔截,無法執行。',
|
||||
'ai.chat.recommendAllow': '允許',
|
||||
'ai.chat.recommendConfirm': '確認',
|
||||
@@ -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': '無標題',
|
||||
@@ -466,7 +484,7 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.loadMoreSessions': '載入更多工作階段(還有 {n} 條)',
|
||||
'ai.chat.noSessions': '沒有歷史工作階段',
|
||||
'ai.chat.retryHint': '你可以重新發送訊息來重試。',
|
||||
'ai.chat.approvalTimeout': '工具審批已超時(5 分鐘)。你可以重新發送訊息來重試。',
|
||||
'ai.chat.approvalTimeout': '工具核准已超時(5 分鐘)。你可以重新發送訊息來重試。',
|
||||
'ai.chat.menuHosts': '主機',
|
||||
'ai.chat.menuContext': '上下文',
|
||||
'ai.chat.menuFiles': '檔案',
|
||||
@@ -488,7 +506,7 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chatShortcuts.selectionAction.description': '在終端裡選取文字後顯示 AI 快捷按鈕。',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Codex 主程序處理器尚未載入。請完全重啟 Netcatty 或重啟 Electron 開發程序,然後重試。',
|
||||
'ai.codex.bridgeError': 'Codex 主程序處理器尚未載入。請完全重新啟動 Netcatty 或重新啟動 Electron 開發程序,然後重試。',
|
||||
|
||||
// AI Web Search
|
||||
'ai.webSearch.title': '網路搜尋',
|
||||
@@ -499,7 +517,7 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.webSearch.apiKey': 'API 金鑰',
|
||||
'ai.webSearch.apiKey.description': '所選搜尋供應商的 API 金鑰。',
|
||||
'ai.webSearch.apiKey.placeholder': '輸入 API 金鑰...',
|
||||
'ai.webSearch.apiHost': 'API 地址',
|
||||
'ai.webSearch.apiHost': 'API 位址',
|
||||
'ai.webSearch.apiHost.description': '自訂 API 端點。除非使用代理,否則保持預設值。',
|
||||
'ai.webSearch.apiHost.searxngDescription': 'SearXNG 例項的 URL(必填)。',
|
||||
'ai.webSearch.maxResults': '最大結果數',
|
||||
@@ -508,13 +526,16 @@ export const zhTWAiMessages: Messages = {
|
||||
// AI Safety Settings
|
||||
'ai.safety.title': '安全',
|
||||
'ai.safety.permissionMode': '權限模式',
|
||||
'ai.safety.permissionMode.description': '控制 AI 透過 Netcatty 存取終端工作階段的方式。觀察者模式會阻止經由 Netcatty 的寫操作;外部 Agent CLI 可能仍有自己的本機工具和審批流程。',
|
||||
'ai.safety.permissionMode.description': '控制 AI 透過 Netcatty 存取終端工作階段的方式。觀察者模式會阻止經由 Netcatty 的寫操作;外部 Agent CLI 可能仍有自己的本機工具和核准流程。',
|
||||
'ai.safety.permissionMode.observer': '觀察者 - 只讀,禁止操作',
|
||||
'ai.safety.permissionMode.confirm': '確認 - 操作前詢問',
|
||||
'ai.safety.permissionMode.auto': '自動 - 自由執行',
|
||||
'ai.safety.commandTimeout': '指令超時',
|
||||
'ai.safety.commandTimeout.description': '透過 Netcatty 執行指令時允許執行的最長秒數,超時將被終止。',
|
||||
'ai.safety.commandTimeout.unit': '秒',
|
||||
'ai.safety.responseIdleTimeout': '內建 AI 回應等待時間',
|
||||
'ai.safety.responseIdleTimeout.description': '內建 AI 請求連續這麼多秒沒有收到新回應時會被取消。此設定不控制整段回應總時長,也不影響指令超時。',
|
||||
'ai.safety.responseIdleTimeout.unit': '秒',
|
||||
'ai.safety.maxIterations': '最大迭代次數',
|
||||
'ai.safety.maxIterations.description': '防止 AI 失控執行的最大工具呼叫迴圈次數。外部 Agent 可能有自己的內部迭代限制,以其為準。',
|
||||
'ai.safety.blocklist': '指令黑名單',
|
||||
@@ -525,7 +546,7 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.safety.grants.title': '許可記憶表',
|
||||
'ai.safety.grants.heading': 'Confirm 模式放行規則',
|
||||
'ai.safety.grants.description': 'Confirm 模式會先詢問再執行;儲存為規則後,相符的同類操作會自動放行。規則對所有終端節點/工作階段生效,也可手動編輯。',
|
||||
'ai.safety.grants.empty': '尚無規則。可在審批時選擇「一律許可」,或手動新增。',
|
||||
'ai.safety.grants.empty': '尚無規則。可在核准時選擇「一律許可」,或手動新增。',
|
||||
'ai.safety.grants.capability': '能力',
|
||||
'ai.safety.grants.sessionPattern': '節點/工作階段模式',
|
||||
'ai.safety.grants.commandPattern': '指令模式(可選)',
|
||||
@@ -560,7 +581,7 @@ export const zhTWAiMessages: Messages = {
|
||||
'terminal.layer.hostTree.newHostInGroup': '在此群組中新增主機',
|
||||
'terminal.layer.hostTree.editHost': '編輯主機',
|
||||
'terminal.layer.hostTree.hostSavedNextConnection': '主機已更新,連線設定將在下次連線時生效。',
|
||||
'terminal.layer.hostTree.newGroup': '新建分組',
|
||||
'terminal.layer.hostTree.newGroup': '新增群組',
|
||||
'terminal.layer.hostTree.localShell': '本機 Shell',
|
||||
'terminal.layer.hostTree.tagsEmpty': '暫無標籤',
|
||||
'terminal.layer.hostTree.clearTags': '清除篩選',
|
||||
@@ -571,11 +592,11 @@ export const zhTWAiMessages: Messages = {
|
||||
'terminal.layer.hostTree.details.user': '使用者',
|
||||
'terminal.layer.hostTree.details.port': '埠',
|
||||
'terminal.layer.hostTree.details.protocol': '協定',
|
||||
'terminal.layer.hostTree.details.group': '分組',
|
||||
'terminal.layer.hostTree.details.group': '群組',
|
||||
'terminal.layer.hostTree.details.tags': '標籤',
|
||||
'terminal.layer.hostTree.details.lastConnected': '最近連線',
|
||||
'topTabs.openQuickSwitcher': '開啟快速切換',
|
||||
'topTabs.moreTabs': '更多標籤頁',
|
||||
'topTabs.moreTabs': '更多分頁',
|
||||
'topTabs.aiAssistant': 'AI 助手',
|
||||
'topTabs.newLocalTerminal': '新增本機終端',
|
||||
'topTabs.controlPanel': '快捷控制',
|
||||
@@ -633,5 +654,5 @@ export const zhTWAiMessages: Messages = {
|
||||
'zmodem.overwrite.overwrite': '覆蓋',
|
||||
'zmodem.overwrite.skip': '跳過',
|
||||
'zmodem.overwrite.cancel': '取消',
|
||||
'settings.shortcuts.resetToDefault': '重置為預設',
|
||||
'settings.shortcuts.resetToDefault': '重設為預設值',
|
||||
};
|
||||
@@ -5,7 +5,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'common.save': '儲存',
|
||||
'common.cancel': '取消',
|
||||
'common.close': '關閉',
|
||||
'common.reset': '重置',
|
||||
'common.reset': '重設',
|
||||
'common.zoomIn': '放大',
|
||||
'common.zoomOut': '縮小',
|
||||
'common.settings': '設定',
|
||||
@@ -41,7 +41,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sort.za': 'Z-a',
|
||||
'sort.newest': '從新到舊',
|
||||
'sort.oldest': '從舊到新',
|
||||
'sort.group': '按分組',
|
||||
'sort.group': '依群組',
|
||||
'field.label': 'Label',
|
||||
'field.type': '型別',
|
||||
'auth.keyType': '型別 {type}',
|
||||
@@ -49,29 +49,58 @@ export const zhTWCoreMessages: Messages = {
|
||||
|
||||
// Dialogs / prompts
|
||||
'confirm.deleteHost': '刪除主機 "{name}"?',
|
||||
'confirm.deleteIdentity': '刪除身份 "{name}"?',
|
||||
'confirm.deleteIdentity': '刪除身分 "{name}"?',
|
||||
'confirm.removeProvider': '移除供應商 "{name}"?',
|
||||
'confirm.closeBusyTerminal.title': '確認關閉',
|
||||
'confirm.closeBusyTerminal.message': '程序 "{command}" 仍在執行,關閉後會被終止。',
|
||||
'confirm.closeBusyTerminal.messageWithMore': '程序 "{command}" 及其他 {count} 個正在執行的程序將被終止。',
|
||||
'confirm.closeBusyTerminal.cancel': '取消',
|
||||
'confirm.closeBusyTerminal.close': '關閉',
|
||||
'dialog.createWorkspace.title': '建立工作區',
|
||||
'dialog.renameWorkspace.title': '重新命名工作區',
|
||||
'dialog.renameSession.title': '重新命名工作階段',
|
||||
'field.name': '名稱',
|
||||
'field.selectHosts': '選擇主機',
|
||||
'placeholder.workspaceName': '工作區名稱',
|
||||
'placeholder.sessionName': '工作階段名稱',
|
||||
'placeholder.searchHosts': '搜尋主機...',
|
||||
'toast.settingsUnavailable': '目前平台無法開啟設定視窗。',
|
||||
'credentials.protectionUnavailable.title': '憑證保護不可用',
|
||||
'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': '應用程式',
|
||||
'settings.tab.appearance': '外觀',
|
||||
'settings.tab.terminal': '終端',
|
||||
'settings.tab.shortcuts': '快捷鍵',
|
||||
'settings.tab.shortcuts': '快速鍵',
|
||||
'settings.tab.ai': 'AI',
|
||||
'settings.tab.syncCloud': '同步與雲',
|
||||
'settings.tab.system': '系統',
|
||||
@@ -115,6 +144,59 @@ export const zhTWCoreMessages: 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 設定無關。',
|
||||
@@ -155,7 +237,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.update.download': '下載更新',
|
||||
'settings.update.downloading': '正在下載... {percent}%',
|
||||
'settings.update.readyToInstall': '更新已下載,準備安裝。',
|
||||
'settings.update.restartNow': '重啟並更新',
|
||||
'settings.update.restartNow': '重新啟動並更新',
|
||||
'settings.update.error': '檢查更新失敗。',
|
||||
'settings.update.downloadError': '下載失敗。',
|
||||
'settings.update.manualDownload': '前往 GitHub 下載',
|
||||
@@ -174,12 +256,12 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.sessionRestore.startupLandingDesc': '當沒有可恢復的工作階段時,選擇啟動後進入主機主頁還是直接開啟本機終端。',
|
||||
'settings.sessionRestore.startupLanding.vault': '主頁(主機)',
|
||||
'settings.sessionRestore.startupLanding.localTerminal': '本機終端',
|
||||
'settings.sessionRestore.restorePreviousSession': '恢復上次的標籤頁和工作區',
|
||||
'settings.sessionRestore.restorePreviousSessionDesc': '啟動時重新開啟上次的終端標籤頁和工作區佈局。',
|
||||
'settings.sessionRestore.restorePreviousSession': '恢復上次的分頁和工作區',
|
||||
'settings.sessionRestore.restorePreviousSessionDesc': '啟動時重新開啟上次的終端分頁和工作區佈局。',
|
||||
'settings.sessionRestore.restoreTerminalCwd': '恢復終端工作目錄',
|
||||
'settings.sessionRestore.restoreTerminalCwdDesc': '重新連線恢復的終端時,嘗試回到上次記錄的目錄;失敗時不會阻塞連線。',
|
||||
'terminal.restore.placeholder.title': '已恢復的工作階段',
|
||||
'terminal.restore.placeholder.desc': '此終端目前已斷開。準備好後可以重新連線,為這個標籤頁啟動新的 shell。',
|
||||
'terminal.restore.placeholder.desc': '此終端目前已斷開。準備好後可以重新連線,為這個分頁啟動新的 shell。',
|
||||
'terminal.restore.placeholder.reconnect': '重新連線',
|
||||
'terminal.restore.cwdLog': '正在恢復工作目錄:{cwd}',
|
||||
|
||||
@@ -218,16 +300,16 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.sshDebugLogs.hint': '開啟後,新發起的 SSH 連線會寫入診斷資訊,方便排查堡壘機、認證和異常斷開問題。',
|
||||
|
||||
// Settings > Global Hotkey (Quake Mode)
|
||||
'settings.globalHotkey.title': '全域快捷鍵',
|
||||
'settings.globalHotkey.title': '全域快速鍵',
|
||||
'settings.globalHotkey.toggleWindow': '切換視窗',
|
||||
'settings.globalHotkey.toggleWindowDesc': '按下組合鍵以設定顯示/隱藏視窗的全域快捷鍵。',
|
||||
'settings.globalHotkey.toggleWindowDesc': '按下組合鍵以設定顯示/隱藏視窗的全域快速鍵。',
|
||||
'settings.globalHotkey.notSet': '未設定',
|
||||
'settings.globalHotkey.reset': '恢復預設',
|
||||
'settings.globalHotkey.closeToTray': '關閉時最小化到系統匣',
|
||||
'settings.globalHotkey.closeToTrayDesc': '啟用後,關閉視窗將最小化到系統匣而不是結束程式。',
|
||||
'settings.globalHotkey.enabled': '啟用全域快捷鍵',
|
||||
'settings.globalHotkey.enabledDesc': '註冊系統級鍵盤快捷鍵。停用後將取消所有全域快捷鍵註冊。',
|
||||
'settings.globalHotkey.hint': '全域快捷鍵在系統範圍內工作,可快速顯示或隱藏視窗(下拉式終端風格)。',
|
||||
'settings.globalHotkey.enabled': '啟用全域快速鍵',
|
||||
'settings.globalHotkey.enabledDesc': '註冊系統級鍵盤快速鍵。停用後將取消所有全域快速鍵註冊。',
|
||||
'settings.globalHotkey.hint': '全域快速鍵在系統範圍內工作,可快速顯示或隱藏視窗(下拉式終端風格)。',
|
||||
|
||||
// Tray Panel
|
||||
'tray.openMainWindow': '開啟主視窗',
|
||||
@@ -254,7 +336,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
// Settings > Application
|
||||
'settings.application.checkUpdates': '檢查更新',
|
||||
'settings.application.reportProblem': '反饋問題',
|
||||
'settings.application.reportProblem.subtitle': '生成預填的 GitHub issue',
|
||||
'settings.application.reportProblem.subtitle': '產生預填的 GitHub issue',
|
||||
'settings.application.community': '社群',
|
||||
'settings.application.community.subtitle': 'GitHub Discussions',
|
||||
'settings.application.github': 'GitHub',
|
||||
@@ -267,15 +349,21 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.vault.showRecentHosts': '顯示最近連線的主機',
|
||||
'settings.vault.showRecentHostsDesc': '在主機列表頂部顯示最近連線過的主機',
|
||||
'settings.vault.selectBeforeConnect': '先選取再連線',
|
||||
'settings.vault.selectBeforeConnectDesc': '單擊選取主機(網格模式顯示強調色邊框),再次單擊連線;分組同理。預設仍為單擊直接連線。',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': '根目錄只顯示未分組主機',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': '開啟後,主機庫根目錄的主機列表只顯示沒有分組的主機,已分組主機請從左側分組進入檢視。',
|
||||
'settings.vault.showSftpTab': '顯示 SFTP 標籤頁',
|
||||
'settings.vault.showSftpTabDesc': '在頂部標籤欄顯示獨立的 SFTP 檢視。關閉後可改用工作階段內左側的 SFTP 側欄。',
|
||||
'settings.vault.selectBeforeConnectDesc': '點一下選取主機(網格模式會顯示強調色邊框),再點一下才連線;群組同理。預設仍是點一下直接連線。',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': '根目錄只顯示未歸類主機',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': '開啟後,主機庫根目錄的主機列表只顯示沒有群組的主機,已歸類的主機請從左側群組進入檢視。',
|
||||
'settings.vault.showSftpTab': '顯示 SFTP 分頁',
|
||||
'settings.vault.showSftpTabDesc': '在頂部分頁列顯示獨立的 SFTP 檢視。關閉後可改用工作階段內左側的 SFTP 側欄。',
|
||||
'settings.vault.showHostTreeSidebar': '顯示主機列表側欄',
|
||||
'settings.vault.showHostTreeSidebarDesc': '在終端和編輯器標籤頁顯示主機列表側欄及頂部開關。',
|
||||
'settings.vault.showHostTreeSidebarDesc': '在終端和編輯器分頁顯示主機列表側欄及頂部開關。',
|
||||
'settings.vault.autoImportSystemKnownHosts': '自動匯入系統 known_hosts',
|
||||
'settings.vault.autoImportSystemKnownHostsDesc': '開啟主機庫時靜默匯入 OpenSSH known_hosts 中的記錄。關閉後,Netcatty 已知主機與系統信任庫保持獨立;仍可透過「掃描系統」手動匯入。',
|
||||
'settings.vault.notesFont': '程式碼字型',
|
||||
'settings.vault.notesFontDesc': '自訂筆記中程式碼區塊與行內程式碼使用的字型',
|
||||
'settings.vault.notesFontSize': '筆記字級',
|
||||
'settings.vault.notesFontSizeDesc': '自訂筆記內文字型大小',
|
||||
'settings.vault.notesCodeFontSize': '程式碼字級',
|
||||
'settings.vault.notesCodeFontSizeDesc': '自訂筆記中程式碼區塊與行內程式碼的文字大小',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': '發現新版本',
|
||||
@@ -288,11 +376,11 @@ export const zhTWCoreMessages: Messages = {
|
||||
'update.viewInSettings': '在設定中檢視',
|
||||
'update.readyToInstall.title': '更新已就緒',
|
||||
'update.readyToInstall.message': '版本 {version} 已下載完成,準備安裝。',
|
||||
'update.restartNow': '立即重啟',
|
||||
'update.restartNow': '立即重新啟動',
|
||||
'update.downloadFailed.title': '更新失敗',
|
||||
'update.downloadFailed.message': '下載更新失敗,可前往 GitHub 手動下載。',
|
||||
'update.needsSave.title': '有未儲存內容',
|
||||
'update.needsSave.message': '請先儲存已開啟的編輯器,然後再次點選「立即重啟」以安裝更新。',
|
||||
'update.needsSave.message': '請先儲存已開啟的編輯器,然後再次點選「立即重新啟動」以安裝更新。',
|
||||
'update.openReleases': '開啟 Releases',
|
||||
'update.remindLater': '稍後提醒',
|
||||
'update.skipVersion': '跳過此版本',
|
||||
@@ -331,11 +419,11 @@ export const zhTWCoreMessages: Messages = {
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
部分列 / 窗格還支援 \`[data-selected="true"]\`、\`[data-focused="true"]\` 等狀態鉤子。
|
||||
部分列 / 窗格還支援 \`[data-selected="true"]\`、\`[data-focused="true"]\` 等狀態掛鉤。
|
||||
|
||||
由於 Tailwind 工具類優先順序較高,覆蓋樣式時通常需要使用 \`!important\`。
|
||||
|
||||
### 頂部標籤列
|
||||
### 頂部分頁列
|
||||
|
||||
- \`top-tabs\`
|
||||
- \`top-tabs-toolbar-actions\` — 右側選單和工具列
|
||||
@@ -406,7 +494,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
隱藏頂部標籤列裡的主機列表開關:
|
||||
隱藏頂部分頁列裡的主機列表開關:
|
||||
|
||||
\`\`\`css
|
||||
[data-section="top-tabs-host-tree-toggle"] {
|
||||
@@ -435,7 +523,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
\`\`\`
|
||||
`,
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* 範例 — 由於 Tailwind 優先順序較高,需要使用 !important */\n\n/* 隱藏頂部標籤欄裡的主機列表開關 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隱藏開啟快速切換器的加號按鈕 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* SFTP / 操作側欄邊框(關閉側欄後不會殘留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整個操作側欄背景,而不只是頂部標籤 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改選取的 SFTP 檔案行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分割分割線 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 標示目前聚焦的分割 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 設定 → 終端 → 工作區聚焦指示 → 聚焦窗格顯示邊框 */',
|
||||
'/* 範例 — 由於 Tailwind 優先順序較高,需要使用 !important */\n\n/* 隱藏頂部分頁列裡的主機列表開關 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隱藏開啟快速切換器的加號按鈕 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* SFTP / 操作側欄邊框(關閉側欄後不會殘留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整個操作側欄背景,而不只是頂部分頁 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改選取的 SFTP 檔案行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分割分割線 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 標示目前聚焦的分割 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 設定 → 終端 → 工作區聚焦指示 → 聚焦窗格顯示邊框 */',
|
||||
'settings.appearance.language': '語言',
|
||||
'settings.appearance.language.desc': '選擇介面語言',
|
||||
'settings.appearance.uiFont': '介面字型',
|
||||
@@ -443,7 +531,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.appearance.windowOpacity': '視窗透明度',
|
||||
'settings.appearance.windowOpacity.desc': '調節整個應用程式視窗的透明度,方便疊在其他內容上方。較低時終端文字也會變淡;部分 Linux 桌面環境可能不支援。',
|
||||
'settings.appearance.appIcon': '應用程式圖示',
|
||||
'settings.appearance.appIcon.desc': '選擇 Dock/工作列圖示風格,切換應用程式時更容易找到 Netcatty。執行時生效,安裝包內的圖示不會改變。',
|
||||
'settings.appearance.appIcon.desc': '選擇 Dock/工作列圖示風格,切換應用程式時更容易找到 Netcatty。執行時生效,安裝檔內的圖示不會改變。',
|
||||
'settings.appearance.appIcon.original': '原始',
|
||||
'settings.appearance.appIcon.bright': '明亮',
|
||||
'settings.appearance.appIcon.dark': '深色',
|
||||
@@ -460,8 +548,8 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.appearance.appIcon.whiteViolet': '白底 · 紫羅蘭貓',
|
||||
'settings.appearance.appIcon.rainbow': '彩虹貓',
|
||||
// Context menus / common actions
|
||||
'action.newHost': '新建主機',
|
||||
'action.newSubfolder': '新建資料夾',
|
||||
'action.newHost': '新增主機',
|
||||
'action.newSubfolder': '新增資料夾',
|
||||
'action.copyPublicKey': '複製公鑰',
|
||||
'action.keyExport': '匯出金鑰',
|
||||
'action.edit': '編輯',
|
||||
@@ -504,7 +592,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sync.autoSync.keptLocalTitle': '已保留本機資料',
|
||||
'sync.autoSync.keptLocalMessage': '保留了空的本機主機庫,未套用雲端資料。',
|
||||
'sync.autoSync.emptyVaultConflict.title': '偵測到空主機庫',
|
||||
'sync.autoSync.emptyVaultConflict.description': '本機主機庫為空,但云端有資料。這通常發生在應用程式更新或儲存重置之後。請選擇如何處理:',
|
||||
'sync.autoSync.emptyVaultConflict.description': '本機主機庫為空,但雲端有資料。這通常發生在應用程式更新或儲存重設之後。請選擇如何處理:',
|
||||
'sync.autoSync.emptyVaultConflict.cloudLabel': '雲端',
|
||||
'sync.autoSync.emptyVaultConflict.restore': '從雲端恢復',
|
||||
'sync.autoSync.emptyVaultConflict.restoreDesc': '推薦 — 從雲端備份恢復主機、金鑰和程式碼片段',
|
||||
@@ -527,14 +615,14 @@ export const zhTWCoreMessages: Messages = {
|
||||
|
||||
'sync.entityType.hosts': '主機',
|
||||
'sync.entityType.keys': '金鑰',
|
||||
'sync.entityType.identities': '身份',
|
||||
'sync.entityType.identities': '身分',
|
||||
'sync.entityType.proxyProfiles': '代理設定',
|
||||
'sync.entityType.snippets': '程式碼片段',
|
||||
'sync.entityType.customGroups': '分組',
|
||||
'sync.entityType.customGroups': '群組',
|
||||
'sync.entityType.snippetPackages': '片段包',
|
||||
'sync.entityType.knownHosts': '主機金鑰記錄',
|
||||
'sync.entityType.portForwardingRules': '埠轉發規則',
|
||||
'sync.entityType.groupConfigs': '分組設定',
|
||||
'sync.entityType.groupConfigs': '群組設定',
|
||||
|
||||
'sync.credentialsUnavailable': '目前裝置無法解密部分已儲存憑證。請先在本機重新輸入憑證後再同步。',
|
||||
'time.never': '從未',
|
||||
@@ -559,58 +647,58 @@ export const zhTWCoreMessages: Messages = {
|
||||
'proxyProfiles.empty.desc': '建立可重複使用的 HTTP、SOCKS5 或指令代理,然後在主機詳情裡選擇。',
|
||||
'proxyProfiles.usage': '已關聯 {count} 處',
|
||||
'proxyProfiles.copyName': '{name} 副本',
|
||||
'proxyProfiles.panel.newTitle': '新建代理',
|
||||
'proxyProfiles.panel.newTitle': '新增代理',
|
||||
'proxyProfiles.field.name': '代理名稱',
|
||||
'proxyProfiles.error.required': '名稱和代理詳情不能為空。',
|
||||
'proxyProfiles.error.port': '埠必須在 1 到 65535 之間。',
|
||||
'proxyProfiles.viewMode': '代理顯示方式',
|
||||
'proxyProfiles.delete.title': '刪除代理?',
|
||||
'proxyProfiles.delete.desc': '刪除 "{name}" 會同時從 {count} 個主機或分組設定中解除關聯。',
|
||||
'proxyProfiles.delete.desc': '刪除 "{name}" 會同時從 {count} 個主機或群組設定中解除關聯。',
|
||||
|
||||
'vault.groups.title': '分組',
|
||||
'vault.groups.title': '群組',
|
||||
'vault.groups.total': '共 {count} 個',
|
||||
'vault.groups.hostsCount': '{count} 台主機',
|
||||
'vault.groups.newSubgroup': '新建子分組',
|
||||
'vault.groups.rename': '重新命名分組',
|
||||
'vault.groups.unnamed': '未命名分組',
|
||||
'vault.groups.delete': '刪除分組',
|
||||
'vault.groups.createSubfolder': '建立子分組',
|
||||
'vault.groups.createRoot': '建立根分組',
|
||||
'vault.groups.createDialog.desc': '建立新的分組用於組織主機。',
|
||||
'vault.groups.renameDialogTitle': '重新命名分組',
|
||||
'vault.groups.renameDialog.desc': '重新命名已有分組。',
|
||||
'vault.groups.deleteDialogTitle': '刪除分組',
|
||||
'vault.groups.deleteDialog.desc': '這將永久刪除該分組並將所有主機移動到根級別。',
|
||||
'vault.groups.deleteDialog.managedDesc': '這是一個託管的 SSH config 分組。刪除後將同時刪除所有主機並斷開與原始檔的連線。',
|
||||
'vault.groups.deleteDialog.deleteHosts': '同時刪除該分組下的所有主機',
|
||||
'vault.groups.deleteDialog.bulkTitle': '刪除所選分組',
|
||||
'vault.groups.deleteDialog.bulkDesc': '這將永久刪除所選分組,並把其中的主機移動到根層級。',
|
||||
'vault.groups.deleteDialog.bulkDeleteHosts': '同時刪除所選分組下的所有主機',
|
||||
'vault.groups.selectedCount': '已選擇 {count} 個分組',
|
||||
'vault.groups.newSubgroup': '新增子群組',
|
||||
'vault.groups.rename': '重新命名群組',
|
||||
'vault.groups.unnamed': '未命名群組',
|
||||
'vault.groups.delete': '刪除群組',
|
||||
'vault.groups.createSubfolder': '建立子群組',
|
||||
'vault.groups.createRoot': '建立根群組',
|
||||
'vault.groups.createDialog.desc': '建立新的群組用於組織主機。',
|
||||
'vault.groups.renameDialogTitle': '重新命名群組',
|
||||
'vault.groups.renameDialog.desc': '重新命名已有群組。',
|
||||
'vault.groups.deleteDialogTitle': '刪除群組',
|
||||
'vault.groups.deleteDialog.desc': '這將永久刪除該群組並將所有主機移動到根層級。',
|
||||
'vault.groups.deleteDialog.managedDesc': '這是一個託管的 SSH config 群組。刪除後將同時刪除所有主機並斷開與原始檔的連線。',
|
||||
'vault.groups.deleteDialog.deleteHosts': '同時刪除該群組下的所有主機',
|
||||
'vault.groups.deleteDialog.bulkTitle': '刪除所選群組',
|
||||
'vault.groups.deleteDialog.bulkDesc': '這將永久刪除所選群組,並把其中的主機移動到根層級。',
|
||||
'vault.groups.deleteDialog.bulkDeleteHosts': '同時刪除所選群組下的所有主機',
|
||||
'vault.groups.selectedCount': '已選擇 {count} 個群組',
|
||||
'vault.groups.deleteMultiple.success': '已刪除 {count} 個所選項目',
|
||||
'vault.groups.ungrouped': '未分組',
|
||||
'vault.groups.field.name': '分組名稱',
|
||||
'vault.groups.ungrouped': '未群組',
|
||||
'vault.groups.field.name': '群組名稱',
|
||||
'vault.groups.placeholder.example': '例如:Production',
|
||||
'vault.groups.parentLabel': '父級',
|
||||
'vault.groups.pathLabel': '路徑',
|
||||
'vault.groups.settings': '分組設定',
|
||||
'vault.groups.details': '分組詳情',
|
||||
'vault.groups.settings': '群組設定',
|
||||
'vault.groups.details': '群組詳情',
|
||||
'vault.groups.details.general': '常規',
|
||||
'vault.groups.details.ssh': 'SSH',
|
||||
'vault.groups.details.telnet': 'Telnet',
|
||||
'vault.groups.details.advanced': '高階',
|
||||
'vault.groups.details.appearance': '外觀',
|
||||
'vault.groups.details.mosh': 'Mosh',
|
||||
'vault.groups.details.parentGroup': '父分組',
|
||||
'vault.groups.details.parentGroup': '父群組',
|
||||
'vault.groups.details.none': '無',
|
||||
'vault.groups.details.inherited': '繼承自分組',
|
||||
'vault.groups.details.inherited': '繼承自群組',
|
||||
'vault.groups.details.addProtocol': '新增協定',
|
||||
'vault.groups.details.removeProtocol': '移除協定',
|
||||
'vault.groups.details.fontFamily': '字型',
|
||||
'vault.groups.details.fontSize': '字號',
|
||||
'vault.groups.errors.required': '分組名稱不能為空。',
|
||||
'vault.groups.errors.invalidChars': "分組名稱不能包含 '/' 或 '\\\\'.",
|
||||
'vault.groups.errors.duplicatePath': '該位置已存在同名分組。',
|
||||
'vault.groups.details.fontSize': '字型大小',
|
||||
'vault.groups.errors.required': '群組名稱不能為空。',
|
||||
'vault.groups.errors.invalidChars': "群組名稱不能包含 '/' 或 '\\\\'.",
|
||||
'vault.groups.errors.duplicatePath': '該位置已存在同名群組。',
|
||||
|
||||
'vault.deleteConfirm.title': '刪除「{name}」?',
|
||||
'vault.deleteConfirm.desc': '此操作不可復原。',
|
||||
@@ -619,7 +707,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'vault.deleteConfirm.portForwardingDesc': '這會刪除此埠轉發規則。',
|
||||
|
||||
'vault.managedSource.unmanage': '取消託管',
|
||||
'vault.managedSource.unmanageSuccess': '已取消託管分組',
|
||||
'vault.managedSource.unmanageSuccess': '已取消託管群組',
|
||||
|
||||
'vault.hosts.header.entries': '{count} 條',
|
||||
'vault.hosts.header.live': '{count} 個線上',
|
||||
@@ -632,8 +720,8 @@ export const zhTWCoreMessages: Messages = {
|
||||
'vault.view.tree': '樹形',
|
||||
'vault.tree.expandAll': '展開全部',
|
||||
'vault.tree.collapseAll': '摺疊全部',
|
||||
'vault.hosts.newHost': '新建主機',
|
||||
'vault.hosts.newGroup': '新建分組',
|
||||
'vault.hosts.newHost': '新增主機',
|
||||
'vault.hosts.newGroup': '新增群組',
|
||||
'vault.hosts.import': '匯入',
|
||||
'vault.hosts.export': '匯出',
|
||||
'vault.hosts.export.toast.success': '已匯出 {count} 個主機到 CSV',
|
||||
@@ -645,12 +733,12 @@ export const zhTWCoreMessages: Messages = {
|
||||
'vault.hosts.recentlyConnected': '最近連線',
|
||||
'vault.hosts.pinToTop': '置頂',
|
||||
'vault.hosts.unpin': '取消置頂',
|
||||
'vault.hosts.copyCredentials': '複製賬密資訊',
|
||||
'vault.hosts.copyCredentials.toast.success': '賬密資訊已複製到剪貼簿',
|
||||
'vault.hosts.copyCredentials': '複製帳密資訊',
|
||||
'vault.hosts.copyCredentials.toast.success': '帳密資訊已複製到剪貼簿',
|
||||
'vault.hosts.copyCredentials.toast.noPassword': '該主機未儲存密碼',
|
||||
'vault.hosts.multiSelect': '多選',
|
||||
'vault.hosts.selected': '已選擇 {count} 項',
|
||||
'vault.hosts.selectedSummary': '已選擇 {hosts} 個主機和 {groups} 個分組',
|
||||
'vault.hosts.selectedSummary': '已選擇 {hosts} 個主機和 {groups} 個群組',
|
||||
'vault.hosts.selectAll': '全選',
|
||||
'vault.hosts.deselectAll': '取消全選',
|
||||
'vault.hosts.deleteSelected': '刪除 ({count})',
|
||||
@@ -668,16 +756,16 @@ export const zhTWCoreMessages: Messages = {
|
||||
|
||||
// Vault import
|
||||
'vault.import.title': '新增資料到你的 Vault',
|
||||
'vault.import.desc': '從常見工具遷移連線資訊。選擇一種格式開始匯入。',
|
||||
'vault.import.desc': '從常見工具移轉連線資訊。選擇一種格式開始匯入。',
|
||||
'vault.import.chooseFormat': '選擇檔案格式',
|
||||
'vault.import.destination.title': '匯入的主機要放到哪裡?',
|
||||
'vault.import.destination.preserve': '保留原始分組',
|
||||
'vault.import.destination.existing': '選擇現有分組',
|
||||
'vault.import.destination.new': '新增分組',
|
||||
'vault.import.destination.noGroups': '暫無可選分組',
|
||||
'vault.import.destination.newPlaceholder': '輸入新分組名稱',
|
||||
'vault.import.destination.preserve': '保留原始群組',
|
||||
'vault.import.destination.existing': '選擇現有群組',
|
||||
'vault.import.destination.new': '新增群組',
|
||||
'vault.import.destination.noGroups': '暫無可選群組',
|
||||
'vault.import.destination.newPlaceholder': '輸入新群組名稱',
|
||||
'vault.import.destination.settings': '匯入位置',
|
||||
'vault.import.destination.settingsHint': '設定主機要放到哪個分組',
|
||||
'vault.import.destination.settingsHint': '設定主機要放到哪個群組',
|
||||
'vault.import.destination.done': '完成',
|
||||
'vault.import.securecrt.directoryHint': '請選擇 SecureCRT 的 Sessions 資料夾。',
|
||||
'vault.import.securecrt.chooseSource': '選擇 SecureCRT 匯入來源。',
|
||||
@@ -725,11 +813,11 @@ export const zhTWCoreMessages: Messages = {
|
||||
'vault.import.sshConfig.importOnlyDesc': '一次性匯入,修改不會同步回檔案。',
|
||||
'vault.import.sshConfig.managed': '託管同步',
|
||||
'vault.import.sshConfig.managedDesc': '保持同步,修改會自動儲存回檔案。',
|
||||
'vault.import.sshConfig.managedDestinationHint': '託管同步會使用獨立的託管分組;上方目標分組只用於一次性匯入。',
|
||||
'vault.import.sshConfig.managedDestinationHint': '託管同步會使用獨立的託管群組;上方目標群組只用於一次性匯入。',
|
||||
'vault.import.sshConfig.managedGroup': 'ssh config',
|
||||
'vault.import.sshConfig.managedSuccess': '已匯入 {count} 個主機,檔案已託管。',
|
||||
'vault.import.sshConfig.alreadyManaged': '該檔案已被託管。',
|
||||
'vault.import.sshConfig.alreadyManagedDesc': '該檔案已在分組 "{group}" 下託管。如需重新匯入,請先移除現有的託管源。',
|
||||
'vault.import.sshConfig.alreadyManagedDesc': '該檔案已在群組 "{group}" 下託管。如需重新匯入,請先移除現有的託管源。',
|
||||
'vault.import.sshConfig.noFilePath': '無法託管此檔案。',
|
||||
'vault.import.sshConfig.noFilePathDesc': '無法確定檔案路徑。託管同步需要存取檔案系統。',
|
||||
|
||||
@@ -755,7 +843,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'pf.title': '埠轉發規則',
|
||||
'pf.rulesCount': '{count} 條規則',
|
||||
'pf.wizard.editTitle': '編輯埠轉發規則',
|
||||
'pf.wizard.newTitle': '新建埠轉發規則',
|
||||
'pf.wizard.newTitle': '新增埠轉發規則',
|
||||
'pf.wizard.saveChanges': '儲存修改',
|
||||
'pf.wizard.done': '完成',
|
||||
'pf.wizard.continue': '繼續',
|
||||
@@ -773,24 +861,24 @@ export const zhTWCoreMessages: Messages = {
|
||||
'pf.type.remote.desc': '遠端轉發在遠端開啟埠,並將連線轉發到本機(目前)主機。',
|
||||
'pf.type.dynamic.desc': '動態轉發將 Netcatty 作為 SOCKS 代理使用。',
|
||||
'pf.wizard.type.title': '選擇埠轉發型別:',
|
||||
'pf.wizard.localConfig.title': '設定本機埠與繫結地址:',
|
||||
'pf.wizard.localConfig.title': '設定本機埠與繫結位址:',
|
||||
'pf.wizard.localConfig.desc': '該埠會在本機(目前裝置)開啟,並接收流量。',
|
||||
'pf.wizard.localConfig.localPort': '本機埠 *',
|
||||
'pf.wizard.bindAddress': '繫結地址',
|
||||
'pf.wizard.bindAddress': '繫結位址',
|
||||
'pf.wizard.remoteHost.title': '選擇遠端主機:',
|
||||
'pf.wizard.remoteHost.desc': '選擇要開啟埠的遠端主機。該埠的流量將轉發到目標地址。',
|
||||
'pf.wizard.remoteConfig.title': '設定埠與繫結地址:',
|
||||
'pf.wizard.remoteConfig.desc': '將從所選主機的指定埠與網路卡地址轉發流量。',
|
||||
'pf.wizard.remoteHost.desc': '選擇要開啟埠的遠端主機。該埠的流量將轉發到目標位址。',
|
||||
'pf.wizard.remoteConfig.title': '設定埠與繫結位址:',
|
||||
'pf.wizard.remoteConfig.desc': '將從所選主機的指定埠與網路卡位址轉發流量。',
|
||||
'pf.wizard.remoteConfig.remotePort': '遠端埠 *',
|
||||
'pf.wizard.destination.title': '設定目標地址:',
|
||||
'pf.wizard.destination.desc.local': '輸入你希望透過 tunnel 存取的遠端目標地址。',
|
||||
'pf.wizard.destination.desc.remote': '要轉發流量到的目標地址與埠。',
|
||||
'pf.wizard.destination.address': '目標地址 *',
|
||||
'pf.wizard.destination.title': '設定目標位址:',
|
||||
'pf.wizard.destination.desc.local': '輸入你希望透過 tunnel 存取的遠端目標位址。',
|
||||
'pf.wizard.destination.desc.remote': '要轉發流量到的目標位址與埠。',
|
||||
'pf.wizard.destination.address': '目標位址 *',
|
||||
'pf.wizard.destination.addressPlaceholder': '例如:127.0.0.1 或 192.168.1.100',
|
||||
'pf.wizard.destination.port': '目標埠 *',
|
||||
'pf.wizard.sshServer.title': '選擇 SSH server:',
|
||||
'pf.wizard.sshServer.desc.dynamic': '選擇作為 SOCKS proxy 的 SSH server。',
|
||||
'pf.wizard.sshServer.desc.default': '選擇用於將流量 tunnel 到目標地址的 SSH server。',
|
||||
'pf.wizard.sshServer.desc.default': '選擇用於將流量 tunnel 到目標位址的 SSH server。',
|
||||
'pf.wizard.label.title': '設定 Label:',
|
||||
'pf.wizard.label.placeholder.dynamic': '例如:SOCKS Proxy',
|
||||
'pf.wizard.label.placeholder.default': '例如:MySQL Production',
|
||||
@@ -798,8 +886,8 @@ export const zhTWCoreMessages: Messages = {
|
||||
'pf.wizard.placeholders.portExample': '例如:{port}',
|
||||
|
||||
// SFTP
|
||||
'sftp.newFolder': '新建資料夾',
|
||||
'sftp.newFile': '新建檔案',
|
||||
'sftp.newFolder': '新增資料夾',
|
||||
'sftp.newFile': '新增檔案',
|
||||
'sftp.filter': '篩選',
|
||||
'sftp.filter.placeholder': '按檔名篩選...',
|
||||
'sftp.bookmark.add': '收藏此路徑',
|
||||
@@ -813,6 +901,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': '空目錄',
|
||||
@@ -822,7 +911,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sftp.upload': '上傳',
|
||||
'sftp.uploadFiles': '上傳檔案',
|
||||
'sftp.uploadFolder': '上傳資料夾',
|
||||
'sftp.dragDropToUpload': '拖拽檔案到這裡上傳',
|
||||
'sftp.dragDropToUpload': '拖曳檔案到這裡上傳',
|
||||
'sftp.retry': '重試',
|
||||
'sftp.context.open': '開啟',
|
||||
'sftp.context.navigateTo': '跳轉到這裡',
|
||||
@@ -833,6 +922,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': '複製目前路徑',
|
||||
@@ -856,7 +949,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sftp.context.uploadFolderHere': '上傳資料夾到這裡...',
|
||||
'sftp.context.downloadSelected': '下載選取項({count})',
|
||||
'sftp.context.deleteSelected': '刪除選取項({count})',
|
||||
'sftp.dropFilesHere': '拖拽檔案到這裡',
|
||||
'sftp.dropFilesHere': '拖曳檔案到這裡',
|
||||
'sftp.itemsCount': '{count} 個專案',
|
||||
'sftp.selectedCount': '已選 {count} 個',
|
||||
'sftp.path.doubleClickToEdit': '雙擊編輯路徑',
|
||||
@@ -894,7 +987,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sftp.transferCenter.phase.uploading': '正在上傳',
|
||||
'sftp.transferCenter.phase.transferring': '傳輸中',
|
||||
'sftp.transferCenter.phase.extracting': '正在解壓',
|
||||
'sftp.transferCenter.phase.verifying': '正在校驗',
|
||||
'sftp.transferCenter.phase.verifying': '正在驗證',
|
||||
'sftp.transferCenter.bucket.active': '進行中',
|
||||
'sftp.transferCenter.bucket.all': '全部',
|
||||
'sftp.transferCenter.bucket.queued': '排隊中',
|
||||
@@ -925,7 +1018,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sftp.transferCenter.clear': '清除記錄',
|
||||
'sftp.transferCenter.applyAll': '套用到全部',
|
||||
'sftp.transfers.resizeNameColumn': '調整檔名列寬',
|
||||
'sftp.transfers.dragToResize': '拖拽調整高度',
|
||||
'sftp.transfers.dragToResize': '拖曳調整高度',
|
||||
'sftp.goUp': '上一級',
|
||||
'sftp.goToTerminalCwd': '定位到終端目前目錄',
|
||||
'sftp.locatePathInTerminal': '定位路徑到終端',
|
||||
@@ -941,7 +1034,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sftp.folderName.placeholder': '輸入資料夾名稱',
|
||||
'sftp.fileName': '檔名稱',
|
||||
'sftp.fileName.placeholder': '輸入檔名稱',
|
||||
'sftp.prompt.newFolderName': '新建資料夾名稱?',
|
||||
'sftp.prompt.newFolderName': '新增資料夾名稱?',
|
||||
'sftp.rename.title': '重新命名',
|
||||
'sftp.rename.newName': '新名稱',
|
||||
'sftp.rename.placeholder': '輸入新名稱',
|
||||
@@ -975,7 +1068,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sftp.picker.connected.connecting': '連線中',
|
||||
'sftp.picker.noMatch': '沒有相符的主機',
|
||||
'sftp.permissions.title': '編輯權限',
|
||||
'sftp.permissions.owner': '所有者',
|
||||
'sftp.permissions.owner': '擁有者',
|
||||
'sftp.permissions.group': '群組',
|
||||
'sftp.permissions.others': '其他',
|
||||
'sftp.permissions.octal': '八進位制',
|
||||
@@ -984,7 +1077,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sftp.permissions.failed': '權限更新失敗',
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': '搜尋主機或標籤頁',
|
||||
'qs.search.placeholder': '搜尋主機或分頁',
|
||||
'qs.jumpTo': '跳轉到',
|
||||
'qs.localTerminal': '本機終端',
|
||||
'qs.localShells': '本機 Shell',
|
||||
|
||||
@@ -18,21 +18,21 @@ export const zhTWScriptsMessages = {
|
||||
'scripts.editor.modalSubtitle': '在更大的工作區中編輯指令碼的中繼資料與程式碼。',
|
||||
'scripts.editor.lineCount': '{count} 行',
|
||||
'scripts.editor.resize': '調整編輯器高度',
|
||||
'scripts.targets.hint': '所選分組會動態比對,之後加入分組的主機也會自動生效。',
|
||||
'scripts.targets.hint': '所選群組會動態比對,之後加入群組的主機也會自動生效。',
|
||||
'scripts.targets.connectOrderHint': '連線指令碼的執行順序請在主機詳情 → 自動化 中調整。',
|
||||
'scripts.targets.currentHostMismatch': '此指令碼未分配給目前主機。',
|
||||
'hostDetails.automation.groupScripts': '分組繼承指令碼',
|
||||
'hostDetails.automation.groupScriptsHint': '這些指令碼會動態跟隨主機所在分組,並在主機專屬佇列之前執行。',
|
||||
'hostDetails.automation.groupScripts': '群組繼承指令碼',
|
||||
'hostDetails.automation.groupScriptsHint': '這些指令碼會動態跟隨主機所在群組,並在主機專屬佇列之前執行。',
|
||||
'scripts.actions.runNowHint': '在所選目標主機上執行;勾選「對所有主機生效」時在所有可連線的主機上執行。',
|
||||
'scripts.actions.runParallel': '在所有標籤頁並行執行',
|
||||
'scripts.actions.runSequential': '在所有標籤頁依序執行',
|
||||
'scripts.actions.runOnAllTabs': '在所有標籤頁執行',
|
||||
'scripts.actions.skippedConnectingSessions': '有 {count} 個標籤仍在連線中,已跳過',
|
||||
'scripts.actions.skippedSensitiveSessions': '有 {count} 個標籤處於密碼/敏感輸入,已跳過',
|
||||
'scripts.actions.runParallel': '在所有分頁並行執行',
|
||||
'scripts.actions.runSequential': '在所有分頁依序執行',
|
||||
'scripts.actions.runOnAllTabs': '在所有分頁執行',
|
||||
'scripts.actions.skippedConnectingSessions': '有 {count} 個分頁仍在連線中,已跳過',
|
||||
'scripts.actions.skippedSensitiveSessions': '有 {count} 個分頁處於密碼/敏感輸入,已跳過',
|
||||
'scripts.actions.noRunnableHosts': '沒有與指令碼目標相符的可連線主機',
|
||||
'scripts.sidePanel.library': '指令碼庫',
|
||||
'scripts.sidePanel.running': '執行中',
|
||||
'scripts.sidePanel.newScript': '新建指令碼',
|
||||
'scripts.sidePanel.newScript': '新增指令碼',
|
||||
'scripts.running.empty': '目前工作階段沒有正在執行的指令碼。',
|
||||
'scripts.running.unnamed': '未命名指令碼',
|
||||
'scripts.running.status.running': '執行中',
|
||||
@@ -49,7 +49,7 @@ export const zhTWScriptsMessages = {
|
||||
'scripts.recording.unavailableHint': '此入口不支援錄製,請用終端右側的指令碼側欄',
|
||||
'scripts.recording.activeHint': '正在錄製目前終端。在終端中正常輸入指令;點「停止錄製」或終端工具列的 REC 結束並儲存。',
|
||||
'scripts.recording.started': '已開始錄製,請在終端中操作',
|
||||
'scripts.recording.noSession': '請先連線終端(單一標籤或工作區皆可)',
|
||||
'scripts.recording.noSession': '請先連線終端(單一分頁或工作區皆可)',
|
||||
'scripts.recording.alreadyActive': '已有其他終端正在錄製',
|
||||
'scripts.recording.stop': '停止錄製',
|
||||
'scripts.recording.pause': '暫停錄製',
|
||||
@@ -62,7 +62,7 @@ export const zhTWScriptsMessages = {
|
||||
'scripts.recording.saveAndEdit': '儲存並編輯',
|
||||
'scripts.recording.helpTitle': '如何錄製指令碼',
|
||||
'scripts.recording.helpIntro': '錄製會把你在終端裡的操作自動轉成可重複執行的自動化指令碼,適合部署、巡檢等重複性任務。',
|
||||
'scripts.recording.helpStep1': '連線主機。單一終端標籤或工作區裡的終端都可以,開啟右側的指令碼側欄即可。',
|
||||
'scripts.recording.helpStep1': '連線主機。單一終端分頁或工作區裡的終端都可以,開啟右側的指令碼側欄即可。',
|
||||
'scripts.recording.helpStep2': '點選「開始錄製」。終端工具列出現紅色 REC 表示已開始。',
|
||||
'scripts.recording.helpStep3': '在終端裡像平常一樣輸入指令,按 Enter 執行。每次按 Enter 都會記為一步。',
|
||||
'scripts.recording.helpStep4': '全部操作完成後,點選「停止錄製」,或點終端工具列 REC 旁的停止按鈕。',
|
||||
@@ -106,7 +106,7 @@ export const zhTWScriptsMessages = {
|
||||
'scripts.observer.blocked': 'Observer 模式禁止執行會寫入終端的指令碼。',
|
||||
'vault.section.scripts': '指令碼',
|
||||
'vault.nav.scripts': '指令碼',
|
||||
'snippets.action.newScript': '新建自動化指令碼',
|
||||
'snippets.action.newScript': '新增自動化指令碼',
|
||||
'hostDetails.section.automation': '自動化',
|
||||
'hostDetails.automation.loginScript': '登入指令碼',
|
||||
'hostDetails.automation.loginScriptPlaceholder': '選擇指令碼',
|
||||
|
||||
@@ -52,10 +52,10 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.overview.cpuCores': 'CPU 核心',
|
||||
'systemManager.overview.disks': '磁碟',
|
||||
'systemManager.overview.interfaces': '網路介面',
|
||||
'systemManager.overview.topProcesses': '記憶體占用最高程序',
|
||||
'systemManager.overview.topProcesses': '記憶體佔用最高程序',
|
||||
'systemManager.overview.noData': '暫無資料',
|
||||
'systemManager.overview.noDisks': '暫無磁碟資料',
|
||||
'systemManager.overview.noInterfaces': '暫無網卡資料',
|
||||
'systemManager.overview.noInterfaces': '暫無網路卡資料',
|
||||
'systemManager.overview.noTopProcesses': '暫無程序資料',
|
||||
|
||||
'systemManager.processes.search': '搜尋程序…',
|
||||
@@ -97,9 +97,9 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.common.loadingDetails': '正在載入詳情…',
|
||||
'systemManager.common.loadingStats': '正在載入效能資料…',
|
||||
|
||||
'systemManager.tmux.new': '新建',
|
||||
'systemManager.tmux.new': '新增',
|
||||
'systemManager.tmux.search': '搜尋工作階段…',
|
||||
'systemManager.tmux.newSessionTitle': '新建 tmux 工作階段',
|
||||
'systemManager.tmux.newSessionTitle': '新增 tmux 工作階段',
|
||||
'systemManager.tmux.newSessionDesc': '為工作階段命名,並可選在啟動時執行指令碼。',
|
||||
'systemManager.tmux.newSessionTabCustom': '自訂指令',
|
||||
'systemManager.tmux.newSessionTabSnippet': '從程式碼片段',
|
||||
@@ -127,7 +127,7 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.loadingDetails': '正在載入詳情…',
|
||||
'systemManager.tmux.clients': '已附加用戶端',
|
||||
'systemManager.tmux.windowList': '視窗',
|
||||
'systemManager.tmux.newWindow': '新建視窗',
|
||||
'systemManager.tmux.newWindow': '新增視窗',
|
||||
'systemManager.tmux.newWindowPlaceholder': '視窗名稱(可選)',
|
||||
'systemManager.tmux.noWindows': '沒有視窗',
|
||||
'systemManager.tmux.unavailable': '此主機未偵測到 tmux',
|
||||
|
||||
@@ -14,7 +14,7 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'terminal.clipboardImageUpload.noImage': '剪貼簿中沒有圖片',
|
||||
'terminal.clipboardImageUpload.failed': '無法上傳剪貼簿圖片',
|
||||
'terminal.toolbar.configureOsc7': '設定目錄追蹤',
|
||||
'terminal.toolbar.detach': '移出到獨立標籤',
|
||||
'terminal.toolbar.detach': '移出到獨立分頁',
|
||||
'terminal.toolbar.dragPane': '拖動終端窗格',
|
||||
'terminal.toolbar.showActions': '顯示終端操作',
|
||||
'terminal.menu.detach': '從工作區移出',
|
||||
@@ -30,6 +30,7 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'terminal.toolbar.timestampsEnable': '顯示時間戳',
|
||||
'terminal.toolbar.timestampsDisable': '隱藏時間戳',
|
||||
'terminal.progress.enterReconnectHint': '按 Enter 重新連線',
|
||||
'terminal.progress.reconnecting': '正在重新連線...',
|
||||
'terminal.progress.autoReconnectScheduled': '連線已斷開,將在 {seconds} 秒後自動重連(第 {attempt} 次)。',
|
||||
'terminal.progress.autoReconnectAttempt': '正在自動重連(第 {attempt} 次)...',
|
||||
'terminal.connection.protocol.et': 'EternalTerminal',
|
||||
@@ -86,7 +87,7 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'sftp.preview.title': '圖片預覽',
|
||||
'sftp.preview.zoomIn': '放大',
|
||||
'sftp.preview.zoomOut': '縮小',
|
||||
'sftp.preview.resetZoom': '重置縮放',
|
||||
'sftp.preview.resetZoom': '重設縮放',
|
||||
'sftp.preview.fitToWindow': '適應視窗',
|
||||
|
||||
// Settings > SFTP File Associations
|
||||
@@ -131,7 +132,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': '為資料夾與資料夾空白處加入右鍵項目,可直接在該目錄開啟本機終端機。開關立即生效,無需重裝。',
|
||||
@@ -157,7 +158,7 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.sftp.followTerminalCwd.enableDesc': '開啟側欄 SFTP 時預設啟用追隨模式,終端執行 cd 後檔案瀏覽器會自動跳轉',
|
||||
|
||||
'settings.sftp.defaultViewMode': '預設檢視模式',
|
||||
'settings.sftp.defaultViewMode.desc': '選擇開啟新 SFTP 標籤頁時的預設檢視模式。每個主機的偏好設定會覆蓋此全域設定。',
|
||||
'settings.sftp.defaultViewMode.desc': '選擇開啟新 SFTP 分頁時的預設檢視模式。每個主機的偏好設定會覆蓋此全域設定。',
|
||||
'settings.sftp.defaultViewMode.list': '列表檢視',
|
||||
'settings.sftp.defaultViewMode.listDesc': '以平面列表顯示目前目錄的檔案',
|
||||
'settings.sftp.defaultViewMode.tree': '樹形檢視',
|
||||
@@ -204,7 +205,7 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.sftp.compressedUpload': '資料夾壓縮傳輸',
|
||||
'settings.sftp.compressedUpload.desc': '上傳前壓縮資料夾,可大幅減少傳輸時間。',
|
||||
'settings.sftp.compressedUpload.enable': '啟用資料夾壓縮',
|
||||
'settings.sftp.compressedUpload.enableDesc': '自動使用 tar 壓縮資料夾後再傳輸。需要伺服器支援 tar 指令,不支援時自動回退到普通傳輸。',
|
||||
'settings.sftp.compressedUpload.enableDesc': '自動使用 tar 壓縮資料夾後再傳輸。需要伺服器支援 tar 指令,不支援時自動改用一般傳輸。',
|
||||
|
||||
'settings.sftp.skipUnchanged.enable': '跳過未改檔案',
|
||||
'settings.sftp.skipUnchanged.enableDesc': '當目標檔案的大小與修改時間已與來源一致時跳過傳輸(類似 rsync)。替換到暫存路徑時自動停用。',
|
||||
@@ -292,7 +293,11 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.behavior.rightClick.fullscreenMenu.desc':
|
||||
'即使 tmux、vim 等全螢幕應用接管了滑鼠,也顯示右鍵選單。關閉時右鍵會交給應用處理(Shift+右鍵仍可開啟選單)。',
|
||||
'settings.terminal.behavior.autoCloseOnExit': '結束後自動關閉終端',
|
||||
'settings.terminal.behavior.autoCloseOnExit.desc': '允許終端標籤頁和視窗在工作階段結束後自動關閉。關閉此項後,無論結束結果如何都會保留。',
|
||||
'settings.terminal.behavior.autoCloseOnExit.desc': '允許終端分頁和視窗在工作階段結束後自動關閉。關閉此項後,無論結束結果如何都會保留。',
|
||||
'settings.terminal.behavior.disconnectedNotice': '連線中斷時',
|
||||
'settings.terminal.behavior.disconnectedNotice.desc': '已連線的工作階段中斷後,可在終端內顯示簡短提示,或繼續使用完整對話框。首次連線失敗仍使用對話框。',
|
||||
'settings.terminal.behavior.disconnectedNotice.terminal': '終端內提示',
|
||||
'settings.terminal.behavior.disconnectedNotice.dialog': '對話框提示',
|
||||
'settings.terminal.behavior.rightClick.menu': '顯示選單',
|
||||
'settings.terminal.behavior.rightClick.paste': '貼上',
|
||||
'settings.terminal.behavior.rightClick.selectWord': '選擇單字',
|
||||
@@ -319,7 +324,7 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'當剪貼簿包含圖片時,在遠端工作階段中貼上會透過 SFTP 將圖片上傳到遠端目前目錄的 .netcatty-paste-images/ 資料夾,並在終端輸入遠端路徑,而不是貼上文字。',
|
||||
'settings.terminal.behavior.shiftEnterNewline': 'Shift+Enter 傳送文字',
|
||||
'settings.terminal.behavior.shiftEnterNewline.desc':
|
||||
'在終端中按 Shift+Enter 時傳送設定的文字,而不是普通 Enter。',
|
||||
'在終端中按 Shift+Enter 時傳送設定的文字,而不是一般 Enter。',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': '傳送內容',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'使用 \\n 表示換行,\\t 表示 Tab,\\\\ 表示反斜線。',
|
||||
@@ -332,12 +337,18 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.behavior.forcePromptNewLine': '提示字元另起一行',
|
||||
'settings.terminal.behavior.forcePromptNewLine.desc':
|
||||
'當指令輸出的最後一行未以換行符結束時,將識別到的 shell 提示字元移動到下一行顯示。',
|
||||
'settings.terminal.behavior.dynamicTabTitle': '動態標籤頁標題',
|
||||
'settings.terminal.behavior.dynamicTabTitle': '動態分頁標題',
|
||||
'settings.terminal.behavior.dynamicTabTitle.desc':
|
||||
'選擇標籤頁什麼時候跟隨 Shell 報告的視窗標題,並更新編程 CLI 圖示。關閉時也會停止即時圖示識別。',
|
||||
'選擇分頁什麼時候跟隨 Shell 報告的視窗標題,並更新編程 CLI 圖示。關閉時也會停止即時圖示識別。',
|
||||
'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 轉義序列存取本機剪貼簿。',
|
||||
@@ -371,7 +382,7 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.startupCommandDelay.label': '啟動指令延遲(毫秒)',
|
||||
'settings.terminal.startupCommandDelay.desc': '連線建立後等待多久再發送啟動指令;啟動指令為多行時,行與行之間也使用該間隔。慢連線可調大。',
|
||||
'settings.terminal.keywordHighlight.title': '關鍵字標示',
|
||||
'settings.terminal.keywordHighlight.resetColors': '重置為預設顏色',
|
||||
'settings.terminal.keywordHighlight.resetColors': '重設為預設顏色',
|
||||
'settings.terminal.keywordHighlight.resetDefaults': '把內建規則恢復為預設',
|
||||
'settings.terminal.keywordHighlight.resetBuiltIn': '恢復內建標籤與正規表示式',
|
||||
'settings.terminal.keywordHighlight.addCustom': '新增自訂規則',
|
||||
@@ -406,20 +417,20 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.localShell.startDir.isFile': '路徑是檔案,不是目錄',
|
||||
'settings.terminal.section.connection': '連線',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled': 'SSH 斷線後自動重連',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled.desc': '開啟後,已連上的 SSH 工作階段意外斷開時會每 5 秒嘗試重新連線,直到關閉標籤頁或連線恢復。',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled.desc': '開啟後,已連上的 SSH 工作階段意外斷開時會每 5 秒嘗試重新連線,直到關閉分頁或連線恢復。',
|
||||
'settings.terminal.connection.keepaliveInterval': '工作階段保持間隔',
|
||||
'settings.terminal.connection.keepaliveInterval.desc': '向伺服器傳送 SSH 保活資料包的頻率(秒)。設為 0 表示全域停用——單個主機可在自己的設定裡覆蓋此值。',
|
||||
'settings.terminal.connection.keepaliveCountMax': '最大無響應保活次數',
|
||||
'settings.terminal.connection.keepaliveCountMax.desc': '判定連線死亡前允許的無響應保活次數。值越大對短暫網路抖動和響應慢的 SSH 服務越寬容。',
|
||||
'settings.terminal.connection.x11Display': 'X11 顯示地址',
|
||||
'settings.terminal.connection.x11Display.desc': '可選的本機 X11 顯示地址。留空則使用系統預設值。',
|
||||
'settings.terminal.connection.keepaliveCountMax': '最大無回應保活次數',
|
||||
'settings.terminal.connection.keepaliveCountMax.desc': '判定連線死亡前允許的無回應保活次數。值越大對短暫網路抖動和回應慢的 SSH 服務越寬容。',
|
||||
'settings.terminal.connection.x11Display': 'X11 顯示位址',
|
||||
'settings.terminal.connection.x11Display.desc': '可選的本機 X11 顯示位址。留空則使用系統預設值。',
|
||||
'settings.terminal.connection.x11Display.placeholder': '自動(:0 或 DISPLAY)',
|
||||
'settings.terminal.section.serverStats': '伺服器狀態(Linux)',
|
||||
'settings.terminal.hostInfoBar.show': '顯示主機資訊列',
|
||||
'settings.terminal.hostInfoBar.show.desc': '在終端上方顯示主機地址和伺服器資訊。隱藏後仍可使用操作按鈕。',
|
||||
'settings.terminal.hostInfoBar.show.desc': '在終端上方顯示主機位址和伺服器資訊。隱藏後仍可使用操作按鈕。',
|
||||
'settings.terminal.hostInfoBar.titleMode': '主機資訊標題',
|
||||
'settings.terminal.hostInfoBar.titleMode.desc': '選擇資訊列顯示連線地址(user@host)還是主機名稱(Vault 標籤)。',
|
||||
'settings.terminal.hostInfoBar.titleMode.address': '連線地址',
|
||||
'settings.terminal.hostInfoBar.titleMode.desc': '選擇資訊列顯示連線位址(user@host)還是主機名稱(Vault 標籤)。',
|
||||
'settings.terminal.hostInfoBar.titleMode.address': '連線位址',
|
||||
'settings.terminal.hostInfoBar.titleMode.label': '主機名稱',
|
||||
'settings.terminal.serverStats.show': '顯示伺服器狀態',
|
||||
'settings.terminal.serverStats.show.desc': '在終端狀態列顯示 CPU、記憶體和磁碟使用情況(僅限 Linux 伺服器)。',
|
||||
@@ -441,18 +452,18 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.rendering.renderer': '渲染器',
|
||||
'settings.terminal.rendering.renderer.desc': '選擇終端渲染技術。自動模式會在低記憶體裝置上使用 DOM 渲染。更改將在新終端工作階段中生效。',
|
||||
'settings.terminal.rendering.auto': '自動',
|
||||
'settings.terminal.rendering.hibernateHiddenTabs': '隱藏標籤頁休眠',
|
||||
'settings.terminal.rendering.hibernateHiddenTabs.desc': '標籤頁離開視野後釋放終端渲染器以節省記憶體,SSH 連線保持不斷。檔案傳輸期間不會休眠。',
|
||||
'settings.terminal.rendering.hibernateHiddenTabs': '隱藏分頁休眠',
|
||||
'settings.terminal.rendering.hibernateHiddenTabs.desc': '分頁離開視野後釋放終端渲染器以節省記憶體,SSH 連線保持不斷。檔案傳輸期間不會休眠。',
|
||||
'settings.terminal.rendering.hibernateHiddenTabsDelay': '休眠延遲',
|
||||
'settings.terminal.rendering.hibernateHiddenTabsDelay.desc': '標籤頁離開視野多久後釋放渲染器(5–600 秒)。',
|
||||
'settings.terminal.rendering.hibernateSkipAltScreen': '全屏 TUI 跳過休眠',
|
||||
'settings.terminal.rendering.hibernateHiddenTabsDelay.desc': '分頁離開視野多久後釋放渲染器(5–600 秒)。',
|
||||
'settings.terminal.rendering.hibernateSkipAltScreen': '全螢幕 TUI 跳過休眠',
|
||||
'settings.terminal.rendering.hibernateSkipAltScreen.desc': 'vim、htop 或 Agent TUI 佔用 alternate screen 時保持終端渲染器,避免慢恢復。',
|
||||
'settings.terminal.rendering.hibernateKeepRendererCount': '保留隱藏渲染器數量',
|
||||
'settings.terminal.rendering.hibernateKeepRendererCount.desc': '多少個不可見標籤頁僅暫停 WebGL、不銷燬 xterm,超出後才完整休眠。',
|
||||
'settings.terminal.rendering.hibernateKeepRendererCount.desc': '多少個不可見分頁僅暫停 WebGL、不銷毀 xterm,超出後才完整休眠。',
|
||||
'settings.terminal.rendering.hibernateReplayChunkBytes': '喚醒分片大小',
|
||||
'settings.terminal.rendering.hibernateReplayChunkBytes.desc': '恢復休眠終端時每幀回放的 ANSI 位元組數(4–64 KB)。',
|
||||
'settings.terminal.rendering.hibernatePreferWasmSerialize': '優先 WASM 序列化',
|
||||
'settings.terminal.rendering.hibernatePreferWasmSerialize.desc': '在可用時使用 WASM 終端序列化,否則回退到 JS SerializeAddon。',
|
||||
'settings.terminal.rendering.hibernatePreferWasmSerialize.desc': '在可用時使用 WASM 終端序列化,否則改用 JS SerializeAddon。',
|
||||
|
||||
// Settings > Terminal > Inline Images
|
||||
'settings.terminal.section.inlineImages': '內嵌圖片',
|
||||
@@ -472,12 +483,12 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.inlineImages.sequenceLimit.desc': '開始解碼前允許接受的單條圖片跳脫序列大小上限(1–64 MB)。',
|
||||
'settings.terminal.inlineImages.unit.mb': 'MB',
|
||||
'settings.terminal.inlineImages.unit.megapixels': 'MP',
|
||||
'settings.terminal.inlineImages.hibernateNote': '已繪製內嵌圖片的標籤頁會保留渲染器而不進入完整休眠,因為休眠快照只能還原文字。',
|
||||
'settings.terminal.inlineImages.hibernateNote': '已繪製內嵌圖片的分頁會保留渲染器而不進入完整休眠,因為休眠快照只能還原文字。',
|
||||
|
||||
// Settings > Terminal > Workspace Focus Indicator
|
||||
'settings.terminal.section.workspaceFocus': '工作區焦點提示',
|
||||
'settings.terminal.workspaceFocus.style': '焦點提示樣式',
|
||||
'settings.terminal.workspaceFocus.style.desc': '在分割檢視中如何標識目前聚焦的窗格。',
|
||||
'settings.terminal.workspaceFocus.style.desc': '在分割檢視中如何標示目前聚焦的窗格。',
|
||||
'settings.terminal.workspaceFocus.dim': '淡化未聚焦窗格',
|
||||
'settings.terminal.workspaceFocus.border': '為聚焦窗格顯示邊框',
|
||||
|
||||
@@ -503,43 +514,43 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.passwordPromptAssist.picker': '憑證選擇列表',
|
||||
|
||||
// Settings > Shortcuts
|
||||
'settings.shortcuts.section.scheme': '快捷鍵方案',
|
||||
'settings.shortcuts.scheme.label': '鍵盤快捷鍵',
|
||||
'settings.shortcuts.scheme.desc': '選擇快捷鍵使用的鍵盤佈局',
|
||||
'settings.shortcuts.section.scheme': '快速鍵方案',
|
||||
'settings.shortcuts.scheme.label': '鍵盤快速鍵',
|
||||
'settings.shortcuts.scheme.desc': '選擇快速鍵使用的鍵盤佈局',
|
||||
'settings.shortcuts.scheme.disabled': '停用',
|
||||
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
|
||||
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
|
||||
'settings.shortcuts.disableTerminalFontZoom.label': '停用終端縮放',
|
||||
'settings.shortcuts.disableTerminalFontZoom.desc': '關閉終端文字縮放快捷操作,包括 Cmd/Ctrl 加滾輪。',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': '標籤快捷鍵跳過固定標籤',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': '開啟後,Cmd/Ctrl+[1...9] 和 Ctrl+Tab 僅在終端、工作區、編輯器等可關閉標籤頁之間切換,不包括固定的 Vault 和 SFTP 標籤頁。',
|
||||
'settings.shortcuts.showTabNumberBadges.label': '顯示標籤頁序號角標',
|
||||
'settings.shortcuts.showTabNumberBadges.desc': '按住 Cmd/Ctrl 時,在對應 Cmd/Ctrl+[1...9] 的標籤頁上顯示 1-9,方便準確切換。',
|
||||
'settings.shortcuts.section.custom': '自訂快捷鍵',
|
||||
'settings.shortcuts.resetAll': '全部重置',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': '分頁快速鍵跳過固定分頁',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': '開啟後,Cmd/Ctrl+[1...9] 和 Ctrl+Tab 僅在終端、工作區、編輯器等可關閉分頁之間切換,不包括固定的 Vault 和 SFTP 分頁。',
|
||||
'settings.shortcuts.showTabNumberBadges.label': '顯示分頁序號角標',
|
||||
'settings.shortcuts.showTabNumberBadges.desc': '按住 Cmd/Ctrl 時,在對應 Cmd/Ctrl+[1...9] 的分頁上顯示 1-9,方便準確切換。',
|
||||
'settings.shortcuts.section.custom': '自訂快速鍵',
|
||||
'settings.shortcuts.resetAll': '全部重設',
|
||||
'settings.shortcuts.recording': '請按鍵...',
|
||||
'settings.shortcuts.none': '無',
|
||||
'settings.shortcuts.setDisabled': '設為停用',
|
||||
'settings.shortcuts.category.tabs': '標籤頁',
|
||||
'settings.shortcuts.category.tabs': '分頁',
|
||||
'settings.shortcuts.category.terminal': '終端',
|
||||
'settings.shortcuts.category.navigation': '導航',
|
||||
'settings.shortcuts.category.app': '應用程式',
|
||||
'settings.shortcuts.category.sftp': 'SFTP',
|
||||
'settings.shortcuts.binding.switch-tab-1-9': '切換到標籤頁 [1...9]',
|
||||
'settings.shortcuts.binding.next-tab': '下一個標籤頁',
|
||||
'settings.shortcuts.binding.prev-tab': '上一個標籤頁',
|
||||
'settings.shortcuts.binding.close-tab': '關閉標籤頁',
|
||||
'settings.shortcuts.binding.switch-tab-1-9': '切換到分頁 [1...9]',
|
||||
'settings.shortcuts.binding.next-tab': '下一個分頁',
|
||||
'settings.shortcuts.binding.prev-tab': '上一個分頁',
|
||||
'settings.shortcuts.binding.close-tab': '關閉分頁',
|
||||
'settings.shortcuts.binding.close-session': '關閉工作階段窗格',
|
||||
'settings.shortcuts.binding.new-tab': '新建本機標籤頁',
|
||||
'settings.shortcuts.binding.new-tab': '新增本機分頁',
|
||||
'settings.shortcuts.binding.copy': '從終端複製',
|
||||
'settings.shortcuts.binding.paste': '貼上到終端',
|
||||
'settings.shortcuts.binding.paste-selection': '將選區貼上到終端',
|
||||
'settings.shortcuts.binding.select-all': '全選終端內容',
|
||||
'settings.shortcuts.binding.clear-buffer': '清空終端緩衝區',
|
||||
'settings.shortcuts.binding.search-terminal': '開啟終端搜尋',
|
||||
'settings.shortcuts.binding.increase-terminal-font-size': '增大終端字號',
|
||||
'settings.shortcuts.binding.decrease-terminal-font-size': '減小終端字號',
|
||||
'settings.shortcuts.binding.reset-terminal-font-size': '重置終端字號',
|
||||
'settings.shortcuts.binding.increase-terminal-font-size': '增大終端字型大小',
|
||||
'settings.shortcuts.binding.decrease-terminal-font-size': '減小終端字型大小',
|
||||
'settings.shortcuts.binding.reset-terminal-font-size': '重設終端字型大小',
|
||||
'settings.shortcuts.binding.move-focus': '在分割間移動焦點',
|
||||
'settings.shortcuts.binding.split-horizontal': '水平分割',
|
||||
'settings.shortcuts.binding.split-vertical': '垂直分割',
|
||||
@@ -551,7 +562,7 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.shortcuts.binding.port-forwarding': '開啟埠轉發',
|
||||
'settings.shortcuts.binding.command-palette': '開啟指令面板',
|
||||
'settings.shortcuts.binding.quick-switch': '快速切換',
|
||||
'settings.shortcuts.binding.new-workspace': '新建工作區',
|
||||
'settings.shortcuts.binding.new-workspace': '新增工作區',
|
||||
'settings.shortcuts.binding.snippets': '開啟程式碼片段',
|
||||
'settings.shortcuts.binding.broadcast': '切換廣播模式',
|
||||
'settings.shortcuts.binding.toggle-side-panel': '切換側邊欄',
|
||||
@@ -562,7 +573,7 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.shortcuts.binding.sftp-rename': '重新命名檔案',
|
||||
'settings.shortcuts.binding.sftp-delete': '刪除檔案',
|
||||
'settings.shortcuts.binding.sftp-refresh': '重新整理',
|
||||
'settings.shortcuts.binding.sftp-new-folder': '新建資料夾',
|
||||
'settings.shortcuts.binding.sftp-new-folder': '新增資料夾',
|
||||
'settings.shortcuts.binding.sftp-open': '開啟檔案 / 進入目錄',
|
||||
'settings.shortcuts.binding.sftp-go-parent': '轉到上級目錄',
|
||||
'settings.shortcuts.binding.sftp-navigate-to': '轉到選取的目錄',
|
||||
@@ -576,18 +587,18 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'hostDetails.proxyPanel.credentials': '憑證',
|
||||
'hostDetails.proxyPanel.usernamePlaceholder': '使用者名稱',
|
||||
'hostDetails.proxyPanel.passwordPlaceholder': '密碼',
|
||||
'hostDetails.proxyPanel.identities': '身份',
|
||||
'hostDetails.proxyPanel.keychainIdentity': '鑰匙串身份',
|
||||
'hostDetails.proxyPanel.identities': '身分',
|
||||
'hostDetails.proxyPanel.keychainIdentity': '鑰匙串身分',
|
||||
'hostDetails.proxyPanel.manualCredentials': '手動憑證',
|
||||
'hostDetails.proxyPanel.remove': '移除代理',
|
||||
'hostDetails.proxyPanel.savedProxy': '已儲存代理',
|
||||
'hostDetails.proxyPanel.selectSaved': '選擇已儲存代理',
|
||||
'hostDetails.proxyPanel.customProxy': '自訂代理',
|
||||
'hostDetails.proxyPanel.missing': '缺失',
|
||||
'hostDetails.proxyPanel.missing': '缺少',
|
||||
'hostDetails.proxyPanel.missingSaved': '儲存的代理不存在',
|
||||
'hostDetails.proxyPanel.missingIdentity': '鑰匙串身份不存在',
|
||||
'hostDetails.proxyPanel.incompleteIdentity': '代理身份需要使用者名稱和密碼',
|
||||
'hostDetails.proxyPanel.unreadableIdentity': '此裝置無法讀取代理身份密碼',
|
||||
'hostDetails.proxyPanel.missingIdentity': '鑰匙串身分不存在',
|
||||
'hostDetails.proxyPanel.incompleteIdentity': '代理身分需要使用者名稱和密碼',
|
||||
'hostDetails.proxyPanel.unreadableIdentity': '此裝置無法讀取代理身分密碼',
|
||||
'hostDetails.proxyPanel.error.required': '代理主機和埠,或 ProxyCommand 不能為空。',
|
||||
'hostDetails.envVars.title': '環境變數',
|
||||
'hostDetails.envVars.desc': '為 {host} 設定環境變數。',
|
||||
@@ -602,41 +613,41 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'hostDetails.chain.target': '目標',
|
||||
'hostDetails.chain.availableHosts': '可用主機',
|
||||
'hostDetails.chain.clear': '清空',
|
||||
'hostDetails.group.title': '新建分組',
|
||||
'hostDetails.group.title': '新增群組',
|
||||
'hostDetails.group.general': '常規',
|
||||
'hostDetails.group.namePlaceholder': '分組名稱',
|
||||
'hostDetails.group.parentPlaceholder': '父分組',
|
||||
'hostDetails.group.namePlaceholder': '群組名稱',
|
||||
'hostDetails.group.parentPlaceholder': '父群組',
|
||||
'hostDetails.group.cloudSync': '雲同步',
|
||||
'hostDetails.group.addProtocol': '新增協定',
|
||||
|
||||
// Keychain
|
||||
'keychain.filter.key': '金鑰',
|
||||
'keychain.filter.certificate': '憑證',
|
||||
'keychain.action.generateKey': '生成金鑰',
|
||||
'keychain.action.generateKey': '產生金鑰',
|
||||
'keychain.action.importKey': '匯入金鑰',
|
||||
'keychain.action.newIdentity': '新建身份',
|
||||
'keychain.action.newIdentity': '新增身分',
|
||||
'keychain.action.importCertificate': '匯入憑證',
|
||||
'keychain.view.grid': '網格',
|
||||
'keychain.view.list': '列表',
|
||||
'keychain.section.keys': '金鑰',
|
||||
'keychain.section.identities': '身份',
|
||||
'keychain.section.identities': '身分',
|
||||
'keychain.count.items': '{count} 項',
|
||||
'keychain.empty.title': '設定金鑰',
|
||||
'keychain.empty.desc': '匯入或生成 SSH 金鑰用於安全認證。',
|
||||
'keychain.panel.generateKey': '生成金鑰',
|
||||
'keychain.panel.newKey': '新建金鑰',
|
||||
'keychain.empty.desc': '匯入或產生 SSH 金鑰用於安全認證。',
|
||||
'keychain.panel.generateKey': '產生金鑰',
|
||||
'keychain.panel.newKey': '新增金鑰',
|
||||
'keychain.panel.keyDetails': '金鑰詳情',
|
||||
'keychain.panel.editKey': '編輯金鑰',
|
||||
'keychain.panel.editIdentity': '編輯身份',
|
||||
'keychain.panel.newIdentity': '新建身份',
|
||||
'keychain.panel.editIdentity': '編輯身分',
|
||||
'keychain.panel.newIdentity': '新增身分',
|
||||
'keychain.panel.keyExport': '金鑰匯出',
|
||||
'keychain.validation.labelRequired': '請填寫金鑰的 Label',
|
||||
'keychain.validation.labelAndPrivateKeyRequired': 'Label 和私鑰為必填項',
|
||||
'keychain.validation.labelAndUsernameRequired': 'Label 和使用者名稱為必填項',
|
||||
'keychain.error.generationUnavailable': '無法生成金鑰:請確保應用程式執行在 Electron 環境',
|
||||
'keychain.error.generateKeyPairFailed': '生成金鑰對失敗',
|
||||
'keychain.error.generateKeyFailed': '生成金鑰失敗',
|
||||
'keychain.error.keyGenerationTitle': '金鑰生成',
|
||||
'keychain.error.generationUnavailable': '無法產生金鑰:請確保應用程式執行在 Electron 環境',
|
||||
'keychain.error.generateKeyPairFailed': '產生金鑰對失敗',
|
||||
'keychain.error.generateKeyFailed': '產生金鑰失敗',
|
||||
'keychain.error.keyGenerationTitle': '金鑰產生',
|
||||
'keychain.export.exportTo': '匯出到 *',
|
||||
'keychain.export.selectHost': '選擇主機',
|
||||
'keychain.export.location': '位置 ~ $1 *',
|
||||
@@ -677,8 +688,8 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'keychain.generate.labelPlaceholder': '金鑰 Label',
|
||||
'keychain.generate.passphrasePlaceholder': 'Passphrase(可選)',
|
||||
'keychain.generate.savePassphrase': '儲存 Passphrase',
|
||||
'keychain.generate.generate': '生成',
|
||||
'keychain.generate.generateSave': '生成並儲存',
|
||||
'keychain.generate.generate': '產生',
|
||||
'keychain.generate.generateSave': '產生並儲存',
|
||||
'keychain.import.dropHint': '將金鑰檔案拖到這裡',
|
||||
'keychain.import.importFromFile': '從檔案匯入',
|
||||
'keychain.import.saveKey': '儲存金鑰',
|
||||
@@ -694,7 +705,7 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'keychain.identity.selectCredential': '選擇{kind}',
|
||||
'keychain.identity.save': '儲存',
|
||||
'keychain.identity.update': '更新',
|
||||
'keychain.keyDialog.newTitle': '新建金鑰',
|
||||
'keychain.keyDialog.newTitle': '新增金鑰',
|
||||
'keychain.keyDialog.newDesc': '新增新的 SSH 金鑰',
|
||||
'keychain.keyDialog.editTitle': '編輯金鑰',
|
||||
'keychain.keyDialog.editDesc': '更新此 SSH 金鑰',
|
||||
@@ -706,12 +717,12 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'tabs.closePluginViewAria': '關閉 {title}',
|
||||
'tabs.logPrefix': '日誌:',
|
||||
'tabs.logLocal': '本機',
|
||||
'tabs.copyTab': '複製標籤頁',
|
||||
'tabs.copyTabToNewWindow': '複製標籤頁到新視窗',
|
||||
'tabs.copyTabToNewWindowFailed': '無法在新視窗開啟標籤頁',
|
||||
'tabs.closeOthers': '關閉其他標籤',
|
||||
'tabs.closeToRight': '關閉右側標籤',
|
||||
'tabs.closeAll': '關閉所有標籤',
|
||||
'tabs.copyTab': '複製分頁',
|
||||
'tabs.copyTabToNewWindow': '複製分頁到新視窗',
|
||||
'tabs.copyTabToNewWindowFailed': '無法在新視窗開啟分頁',
|
||||
'tabs.closeOthers': '關閉其他分頁',
|
||||
'tabs.closeToRight': '關閉右側分頁',
|
||||
'tabs.closeAll': '關閉所有分頁',
|
||||
'keychain.edit.labelRequired': 'Label *',
|
||||
'keychain.edit.keyLabelPlaceholder': '金鑰 Label',
|
||||
'keychain.edit.privateKeyRequired': '私鑰 *',
|
||||
@@ -724,11 +735,11 @@ export const zhTWTerminalMessages: Messages = {
|
||||
|
||||
// Snippets
|
||||
'snippets.searchPlaceholder': '搜尋程式碼片段...',
|
||||
'snippets.action.newSnippet': '新建程式碼片段',
|
||||
'snippets.action.newPackage': '新建程式碼包',
|
||||
'snippets.action.newSnippet': '新增程式碼片段',
|
||||
'snippets.action.newPackage': '新增程式碼包',
|
||||
'snippets.action.import': '匯入',
|
||||
'snippets.action.selectSnippets': '選擇程式碼片段',
|
||||
'snippets.panel.newTitle': '新建程式碼片段',
|
||||
'snippets.panel.newTitle': '新增程式碼片段',
|
||||
'snippets.panel.editTitle': '編輯程式碼片段',
|
||||
'snippets.field.description': '描述',
|
||||
'snippets.field.descriptionPlaceholder': '例如:check network load',
|
||||
@@ -742,8 +753,8 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'snippets.targets.title': '執行目標',
|
||||
'snippets.targets.add': '新增目標',
|
||||
'snippets.targets.selectHosts': '選擇主機',
|
||||
'snippets.targets.selectGroups': '選擇分組',
|
||||
'snippets.targets.noGroups': '未找到分組',
|
||||
'snippets.targets.selectGroups': '選擇群組',
|
||||
'snippets.targets.noGroups': '未找到群組',
|
||||
'snippets.history.title': 'Shell 歷史',
|
||||
'snippets.history.subtitle': '{count} 條指令',
|
||||
'snippets.history.emptyTitle': '暫無 Shell 歷史',
|
||||
@@ -760,7 +771,7 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'snippets.breadcrumb.separator': '›',
|
||||
'snippets.empty.title': '建立程式碼片段',
|
||||
'snippets.empty.desc': '將常用指令儲存為程式碼片段,一鍵重複使用。',
|
||||
'snippets.panel.newAutomationTitle': '新建自動化指令碼',
|
||||
'snippets.panel.newAutomationTitle': '新增自動化指令碼',
|
||||
'snippets.panel.editAutomationTitle': '編輯自動化指令碼',
|
||||
'snippets.panel.resizeWidth': '調整面板寬度',
|
||||
'snippets.targets.allHosts': '對所有主機生效',
|
||||
@@ -808,7 +819,7 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'snippets.import.conflict.hostBindingsNote': '程式碼片段的主機繫結不會被匯入或匯出。',
|
||||
'snippets.import.conflict.skip': '跳過重複項',
|
||||
'snippets.import.conflict.overwrite': '覆蓋重複項',
|
||||
'snippets.packageDialog.title': '新建程式碼包',
|
||||
'snippets.packageDialog.title': '新增程式碼包',
|
||||
'snippets.packageDialog.parent': '父級:{parent}',
|
||||
'snippets.packageDialog.root': '根目錄',
|
||||
'snippets.packageDialog.placeholder': '例如:ops/maintenance',
|
||||
@@ -828,13 +839,13 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'snippets.field.multiLineRunMode.lineDelay': '逐行傳送',
|
||||
'snippets.field.multiLineRunModeHint': '登入提示、網路設備巨集等情境可用逐行傳送。',
|
||||
// Snippet Shortkey
|
||||
'snippets.field.shortkey': '快捷鍵',
|
||||
'snippets.shortkey.placeholder': '點選設定快捷鍵',
|
||||
'snippets.shortkey.recording': '請按下快捷鍵組合...',
|
||||
'snippets.shortkey.hint': '在終端中按下此快捷鍵可快速傳送指令。',
|
||||
'snippets.shortkey.clear': '清除快捷鍵',
|
||||
'snippets.shortkey.error.systemConflict': '此快捷鍵與「{name}」衝突',
|
||||
'snippets.shortkey.error.snippetConflict': '此快捷鍵已被程式碼片段使用:{name}',
|
||||
'snippets.field.shortkey': '快速鍵',
|
||||
'snippets.shortkey.placeholder': '點選設定快速鍵',
|
||||
'snippets.shortkey.recording': '請按下快速鍵組合...',
|
||||
'snippets.shortkey.hint': '在終端中按下此快速鍵可快速傳送指令。',
|
||||
'snippets.shortkey.clear': '清除快速鍵',
|
||||
'snippets.shortkey.error.systemConflict': '此快速鍵與「{name}」衝突',
|
||||
'snippets.shortkey.error.snippetConflict': '此快速鍵已被程式碼片段使用:{name}',
|
||||
|
||||
'snippets.variables.dialogTitle': '填寫變數',
|
||||
'snippets.variables.dialogDesc': '執行「{label}」前請填寫以下變數。',
|
||||
@@ -854,12 +865,12 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'serial.modal.desc': '設定序列埠連線引數',
|
||||
'serial.field.port': '序列埠',
|
||||
'serial.field.selectPort': '選擇序列埠...',
|
||||
'serial.field.baudRate': '波特率',
|
||||
'serial.field.baudRate': '鮑率',
|
||||
'serial.field.dataBits': '資料位',
|
||||
'serial.field.stopBits': '停止位',
|
||||
'serial.field.stopBits15Warning': '1.5 停止位在 Windows 下可能不被所有裝置支援',
|
||||
'serial.field.stopBits': '停止位元',
|
||||
'serial.field.stopBits15Warning': '1.5 停止位元在 Windows 下可能不被所有裝置支援',
|
||||
'serial.field.parity': '同位位元',
|
||||
'serial.field.flowControl': '流控制',
|
||||
'serial.field.flowControl': '流量控制',
|
||||
'serial.noPorts': '未偵測到序列埠裝置。請連線裝置後重新整理。',
|
||||
'serial.field.customPort': '自訂序列埠路徑',
|
||||
'serial.field.customPortPlaceholder': '例如 /dev/ttys001 或 COM1',
|
||||
@@ -877,16 +888,16 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'serial.field.localEcho': '強制本機回顯',
|
||||
'serial.field.localEchoDesc': '本機回顯輸入字元(用於沒有遠端回顯的裝置)',
|
||||
'serial.field.lineMode': '行模式',
|
||||
'serial.field.lineModeDesc': '緩衝輸入,按回車後傳送(而不是逐字元傳送)',
|
||||
'serial.field.lineModeDesc': '緩衝輸入,按 Enter 鍵後傳送(而不是逐字元傳送)',
|
||||
'serial.field.backspaceBehavior': '退格鍵',
|
||||
'serial.field.backspaceBehaviorDesc': '如果網路裝置不回應預設退格鍵,請改用 Ctrl+H。',
|
||||
'serial.backspace.default': '預設(DEL,0x7F)',
|
||||
'serial.backspace.ctrlH': 'Ctrl+H(BS,0x08)',
|
||||
'serial.field.charset': '字元編碼',
|
||||
'serial.connectionError': '連線序列埠失敗',
|
||||
'serial.field.baudRatePlaceholder': '選擇或輸入波特率...',
|
||||
'serial.field.baudRateEmpty': '輸入自訂波特率',
|
||||
'serial.field.customBaudRate': '使用自訂波特率',
|
||||
'serial.field.baudRatePlaceholder': '選擇或輸入鮑率...',
|
||||
'serial.field.baudRateEmpty': '輸入自訂鮑率',
|
||||
'serial.field.customBaudRate': '使用自訂鮑率',
|
||||
'serial.field.saveConfig': '儲存設定',
|
||||
'serial.field.saveConfigDesc': '將此序列埠設定儲存到主機列表以便快速存取',
|
||||
'serial.field.configLabel': '設定名稱',
|
||||
@@ -896,11 +907,11 @@ export const zhTWTerminalMessages: Messages = {
|
||||
|
||||
// Keyboard Interactive Authentication (2FA/MFA)
|
||||
'keyboard.interactive.title': '需要驗證',
|
||||
'keyboard.interactive.desc': '伺服器需要額外的身份驗證。',
|
||||
'keyboard.interactive.descWithHost': '伺服器 {hostname} 需要額外的身份驗證。',
|
||||
'keyboard.interactive.response': '響應',
|
||||
'keyboard.interactive.desc': '伺服器需要額外的身分驗證。',
|
||||
'keyboard.interactive.descWithHost': '伺服器 {hostname} 需要額外的身分驗證。',
|
||||
'keyboard.interactive.response': '回應',
|
||||
'keyboard.interactive.enterCode': '輸入驗證碼',
|
||||
'keyboard.interactive.enterResponse': '輸入響應',
|
||||
'keyboard.interactive.enterResponse': '輸入回應',
|
||||
'keyboard.interactive.submit': '提交',
|
||||
'keyboard.interactive.verifying': '驗證中...',
|
||||
'keyboard.interactive.savePassword': '儲存密碼',
|
||||
|
||||
@@ -3,8 +3,8 @@ import type { Messages } from '../types';
|
||||
export const zhTWVaultMessages: Messages = {
|
||||
// Notes
|
||||
'notes.search.placeholder': '搜尋筆記、主機、標籤...',
|
||||
'notes.action.newNote': '新建筆記',
|
||||
'notes.action.newGroup': '新建資料夾',
|
||||
'notes.action.newNote': '新增筆記',
|
||||
'notes.action.newGroup': '新增資料夾',
|
||||
'notes.action.importMarkdown': '匯入 Markdown',
|
||||
'notes.action.exportNote': '匯出筆記',
|
||||
'notes.action.exportGroup': '匯出資料夾',
|
||||
@@ -21,7 +21,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'notes.codeBlock.copyFailed': '複製程式碼塊失敗。',
|
||||
'notes.group.prompt': '資料夾路徑',
|
||||
'notes.group.all': '筆記本',
|
||||
'notes.group.ungrouped': '未分組',
|
||||
'notes.group.ungrouped': '未群組',
|
||||
'notes.section.notebooks': '筆記本',
|
||||
'notes.section.notes': '筆記',
|
||||
'notes.mode.edit': '編輯',
|
||||
@@ -32,16 +32,71 @@ export const zhTWVaultMessages: Messages = {
|
||||
'notes.preview.empty': '暫無內容可預覽。',
|
||||
'notes.empty.title': '還沒有筆記',
|
||||
'notes.empty.desc': '可以記錄運維手冊、臨時備忘、伺服器交接資訊。',
|
||||
'notes.empty.group': '這個筆記本里還沒有筆記。',
|
||||
'notes.empty.group': '這個筆記本裡還沒有筆記。',
|
||||
'notes.search.noResults': '沒有找到筆記。',
|
||||
'notes.toolbar.modeLive': '即時預覽',
|
||||
'notes.toolbar.modeLiveTitle': '即時預覽模式 (WYSIWYG)',
|
||||
'notes.toolbar.modeSource': '原始碼模式',
|
||||
'notes.toolbar.modeSourceTitle': '原始碼模式 (Markdown 程式碼)',
|
||||
'notes.toolbar.modePreview': '閱讀模式',
|
||||
'notes.toolbar.modePreviewTitle': '閱讀模式 (唯讀排版)',
|
||||
'notes.toolbar.undo': '復原 (Ctrl+Z)',
|
||||
'notes.toolbar.redo': '重做 (Ctrl+Y)',
|
||||
'notes.toolbar.typography': '程式碼字型與排版設定',
|
||||
'notes.toolbar.customCodeFont': '自訂程式碼字型',
|
||||
'notes.toolbar.fontsAvailable': '共 {count} 款可用字型',
|
||||
'notes.toolbar.searchFont': '搜尋字型...',
|
||||
'notes.toolbar.noFontsFound': '未找到相符的等寬字型',
|
||||
'notes.toolbar.defaultFont': '預設等寬字型 (Default)',
|
||||
'notes.toolbar.bodyFontSize': '內文字級',
|
||||
'notes.toolbar.codeFontSize': '程式碼字級',
|
||||
'notes.toolbar.headingLevel': '標題層級',
|
||||
'notes.toolbar.h1': '一級標題',
|
||||
'notes.toolbar.h2': '二級標題',
|
||||
'notes.toolbar.h3': '三級標題',
|
||||
'notes.toolbar.h4': '四級標題',
|
||||
'notes.toolbar.bold': '粗體 (Ctrl+B)',
|
||||
'notes.toolbar.italic': '斜體 (Ctrl+I)',
|
||||
'notes.toolbar.strikethrough': '刪除線',
|
||||
'notes.toolbar.underline': '底線',
|
||||
'notes.toolbar.inlineCode': '行內程式碼',
|
||||
'notes.toolbar.bulletList': '無序列表',
|
||||
'notes.toolbar.orderedList': '有序列表',
|
||||
'notes.toolbar.taskList': '待辦任務清單',
|
||||
'notes.toolbar.quote': '引用區塊',
|
||||
'notes.toolbar.codeBlock': '程式碼區塊',
|
||||
'notes.toolbar.mathFormula': '數學公式區塊 (LaTeX / Math)',
|
||||
'notes.toolbar.table': '插入表格',
|
||||
'notes.toolbar.divider': '分隔線',
|
||||
'notes.toolbar.link': '插入超連結',
|
||||
'notes.toolbar.image': '插入圖片',
|
||||
'notes.action.pin': '置頂筆記',
|
||||
'notes.action.unpin': '取消置頂',
|
||||
'notes.tag.remove': '移除標籤',
|
||||
'notes.tag.placeholder': '標籤名...',
|
||||
'notes.tag.add': '新增標籤',
|
||||
'notes.outline.title': '大綱目錄',
|
||||
'notes.outline.titleWithCount': '大綱目錄 ({count})',
|
||||
'notes.outline.empty': '暫無標題大綱',
|
||||
'notes.outline.emptyHint': '在 Markdown 中使用 #、## 編寫各級標題',
|
||||
'notes.export.share': '匯出與分享',
|
||||
'notes.export.currentNote': '目前筆記',
|
||||
'notes.export.exportMarkdown': '匯出為 Markdown (.md)',
|
||||
'notes.export.exportHtml': '匯出為 HTML (.html)',
|
||||
'notes.export.copyMarkdown': '複製 Markdown 源碼',
|
||||
'notes.export.allNotes': '全部筆記',
|
||||
'notes.export.exportAllZip': '匯出全部為 ZIP 壓縮包',
|
||||
'notes.export.toast.markdownSuccess': '已匯出 Markdown 檔案',
|
||||
'notes.export.toast.htmlSuccess': '已匯出 HTML 檔案',
|
||||
'notes.export.toast.zipSuccess': '已匯出全部筆記備份 ZIP',
|
||||
|
||||
// Select Host panel
|
||||
'selectHost.title': '選擇主機',
|
||||
'selectHost.noHostsFound': '未找到主機',
|
||||
'selectHost.newHost': '新建主機',
|
||||
'selectHost.newHost': '新增主機',
|
||||
'selectHost.continue': '繼續',
|
||||
'selectHost.continueWithCount': '繼續(已選 {count} 個)',
|
||||
'selectHost.toggleGroup': '切換分組 {name} 下的全部主機',
|
||||
'selectHost.toggleGroup': '切換群組 {name} 下的全部主機',
|
||||
'selectHost.toggleHost': '切換 {name}',
|
||||
|
||||
// Quick Connect
|
||||
@@ -70,14 +125,14 @@ export const zhTWVaultMessages: Messages = {
|
||||
'protocolSelect.port': '埠:',
|
||||
// Host Details
|
||||
'hostDetails.title.details': '主機詳情',
|
||||
'hostDetails.title.new': '新建主機',
|
||||
'hostDetails.title.new': '新增主機',
|
||||
'hostDetails.saveAria': '儲存',
|
||||
'hostDetails.section.address': '地址',
|
||||
'hostDetails.section.address': '位址',
|
||||
'hostDetails.hostname.placeholder': 'IP 或 主機名',
|
||||
'hostDetails.section.general': '通用',
|
||||
'hostDetails.section.sftp': 'SFTP 設定',
|
||||
'hostDetails.sftp.fileProtocol': '檔案協定',
|
||||
'hostDetails.sftp.fileProtocol.desc': '自動優先 SFTP;當伺服器無 SFTP 子系統時回退到 SCP 模式(如部分 NAS)。僅支援 SCP 的主機可強制 SCP。',
|
||||
'hostDetails.sftp.fileProtocol.desc': '自動優先 SFTP;當伺服器無 SFTP 子系統時改用 SCP 模式(如部分 NAS)。僅支援 SCP 的主機可強制 SCP。',
|
||||
'hostDetails.sftp.fileProtocol.auto': '自動',
|
||||
'hostDetails.sftp.fileProtocol.sftp': '僅 SFTP',
|
||||
'hostDetails.sftp.fileProtocol.scp': 'SCP 模式',
|
||||
@@ -112,7 +167,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.icon.tab.type': '型別',
|
||||
'hostDetails.icon.mode.auto': '自動',
|
||||
'hostDetails.icon.mode.custom': '自訂',
|
||||
'hostDetails.icon.reset': '重置主機圖示',
|
||||
'hostDetails.icon.reset': '重設主機圖示',
|
||||
'hostDetails.icon.colorLabel': '圖示顏色',
|
||||
'hostDetails.icon.colorDefault': '預設',
|
||||
'hostDetails.icon.colorAuto': '自動',
|
||||
@@ -152,7 +207,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.icon.color.orange': '橙色',
|
||||
'hostDetails.icon.color.slate': '石板灰',
|
||||
'hostDetails.icon.color.violet': '紫羅蘭',
|
||||
'hostDetails.icon.color.pink': '粉色',
|
||||
'hostDetails.icon.color.pink': '粉紅色',
|
||||
'hostDetails.icon.color.rose': '玫瑰紅',
|
||||
'hostDetails.icon.color.lime': '青檸',
|
||||
'hostDetails.icon.color.teal': '藍綠色',
|
||||
@@ -215,8 +270,8 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.auth.mfaFirst': '優先互動式驗證',
|
||||
'hostDetails.auth.mfaFirst.desc': '適用於必須透過 keyboard-interactive 輸入密碼和 MFA 的伺服器。',
|
||||
'hostDetails.auth.credentialRequired': '儲存這種登入方式前,請先選擇金鑰或憑證。',
|
||||
'hostDetails.identity.suggestions': '身份',
|
||||
'hostDetails.identity.missing': '身份不存在',
|
||||
'hostDetails.identity.suggestions': '身分',
|
||||
'hostDetails.identity.missing': '身分不存在',
|
||||
'hostDetails.credential.keyCertificate': '金鑰 / 憑證 / 本機金鑰',
|
||||
'hostDetails.credential.key': '金鑰',
|
||||
'hostDetails.credential.certificate': '憑證',
|
||||
@@ -270,9 +325,9 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.algorithms.category.compress': '壓縮 (Compression)',
|
||||
'hostDetails.section.keepalive': '工作階段保活',
|
||||
'hostDetails.keepalive.override': '為此主機單獨設定',
|
||||
'hostDetails.keepalive.desc': '為該主機使用專屬的保活策略,而不是跟隨全域設定。適用於不響應 keepalive@openssh.com 請求的老舊路由器 / 交換機——將間隔設為 0 可對該主機徹底關閉保活。',
|
||||
'hostDetails.keepalive.desc': '為該主機使用專屬的保活策略,而不是跟隨全域設定。適用於不回應 keepalive@openssh.com 請求的老舊路由器 / 交換機——將間隔設為 0 可對該主機徹底關閉保活。',
|
||||
'hostDetails.keepalive.interval': '間隔(秒)',
|
||||
'hostDetails.keepalive.countMax': '最大無響應保活次數',
|
||||
'hostDetails.keepalive.countMax': '最大無回應保活次數',
|
||||
'hostDetails.keepalive.disabledHint': '間隔為 0 時該主機不傳送保活包,僅依賴 TCP 層超時偵測斷連。',
|
||||
'hostDetails.section.sshTimeouts': 'SSH 連線逾時',
|
||||
'hostDetails.sshTimeouts.tcpConnect': 'TCP 連線逾時(秒)',
|
||||
@@ -302,18 +357,18 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.telnetOn': 'Telnet on',
|
||||
'hostDetails.port': '埠',
|
||||
'hostDetails.telnet.credentials': '憑證',
|
||||
'hostDetails.telnet.identity.placeholder': '手動身份',
|
||||
'hostDetails.telnet.identity.placeholder': '手動身分',
|
||||
'hostDetails.telnet.username': 'Telnet 使用者名稱',
|
||||
'hostDetails.telnet.password': 'Telnet 密碼',
|
||||
'hostDetails.charset.placeholder': '字元集(例如 UTF-8)',
|
||||
'hostDetails.telnet.add': '新增 Telnet 協定',
|
||||
'hostDetails.telnet.setDefault': '預設用 Telnet 連線',
|
||||
'hostDetails.tags': '標籤',
|
||||
'hostDetails.group': '分組',
|
||||
'hostDetails.selectGroup': '選擇分組',
|
||||
'hostDetails.group': '群組',
|
||||
'hostDetails.selectGroup': '選擇群組',
|
||||
'hostDetails.addTag': '新增標籤...',
|
||||
'hostDetails.createTag': '建立標籤',
|
||||
'hostDetails.createGroup': '建立分組',
|
||||
'hostDetails.createGroup': '建立群組',
|
||||
'hostDetails.plugin.title': '外掛程式連線',
|
||||
'hostDetails.plugin.provider.placeholder': '選擇連線提供者',
|
||||
'hostDetails.plugin.provider.empty': '尚未安裝連線提供者',
|
||||
@@ -332,7 +387,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
|
||||
// Host form (legacy modal)
|
||||
'hostForm.title.edit': '編輯主機',
|
||||
'hostForm.title.new': '新建主機',
|
||||
'hostForm.title.new': '新增主機',
|
||||
'hostForm.desc.edit': '更新該主機的連線資訊',
|
||||
'hostForm.desc.new': '建立一個新的 SSH 主機條目',
|
||||
'hostForm.field.label': '名稱',
|
||||
@@ -343,7 +398,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostForm.field.username': '使用者名稱',
|
||||
'hostForm.field.osType': '作業系統型別',
|
||||
'hostForm.placeholder.selectOs': '選擇作業系統',
|
||||
'hostForm.field.group': '分組',
|
||||
'hostForm.field.group': '群組',
|
||||
'hostForm.placeholder.group': '例如:AWS、DigitalOcean',
|
||||
'hostForm.field.tags': '標籤',
|
||||
'hostForm.placeholder.addTag': '新增標籤…',
|
||||
@@ -395,21 +450,24 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.toolbar.broadcastEnable': '啟用廣播模式',
|
||||
'terminal.toolbar.broadcastDisable': '關閉廣播模式',
|
||||
'terminal.toolbar.composeBar': '撰寫欄',
|
||||
'terminal.composeBar.placeholder': '在此輸入指令,按回車傳送...',
|
||||
'terminal.composeBar.placeholder': '在此輸入指令,按 Enter 鍵傳送...',
|
||||
'terminal.composeBar.send': '傳送',
|
||||
'terminal.composeBar.close': '關閉撰寫欄',
|
||||
'terminal.composeBar.broadcasting': '正在廣播到所有工作階段',
|
||||
'terminal.composeBar.resize': '拖拽調整撰寫欄高度',
|
||||
'terminal.composeBar.resize': '拖曳調整撰寫欄高度',
|
||||
'terminal.composeBar.manageSnippets': '管理快捷程式碼片段',
|
||||
'terminal.composeBar.searchSnippets': '搜尋程式碼片段...',
|
||||
'terminal.composeBar.noPinnedSnippets': '點選 + 固定常用程式碼片段',
|
||||
'terminal.composeBar.noMatchingSnippets': '沒有相符的程式碼片段',
|
||||
'terminal.composeBar.pinnedCount': '已固定 {count} 個',
|
||||
'terminal.composeBar.unpinSnippet': '從快捷欄移除 {label}',
|
||||
'terminal.composeBar.snippetClickHint': '單擊插入 · Shift+單擊直接傳送',
|
||||
'terminal.composeBar.snippetClickHint': '點選插入 · Shift+點選直接傳送',
|
||||
'terminal.toolbar.focus': '聚焦',
|
||||
'terminal.toolbar.focusMode': '聚焦模式',
|
||||
'terminal.toolbar.detach': '移出到獨立標籤',
|
||||
'terminal.paneMagnification.magnify': '放大目前面板',
|
||||
'terminal.paneMagnification.restore': '還原面板配置',
|
||||
'terminal.paneMagnification.hint': '已放大',
|
||||
'terminal.toolbar.detach': '移出到獨立分頁',
|
||||
'terminal.toolbar.encoding': '終端編碼',
|
||||
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
||||
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
||||
@@ -423,12 +481,12 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.toolbar.hostHighlight.clearAll': '清除全部',
|
||||
'terminal.toolbar.hostHighlight.changeColor': '更改標示顏色',
|
||||
'terminal.toolbar.hostHighlight.selectColor': '選擇新規則的顏色',
|
||||
'terminal.statusbar.copyHostname.label': '複製主機地址',
|
||||
'terminal.statusbar.copyHostname.tooltip': '複製主機地址({hostname})',
|
||||
'terminal.statusbar.copyHostname.toast': '已複製主機地址:{hostname}',
|
||||
'terminal.statusbar.copyHostname.error': '複製主機地址失敗',
|
||||
'terminal.statusbar.copyHostname.label': '複製主機位址',
|
||||
'terminal.statusbar.copyHostname.tooltip': '複製主機位址({hostname})',
|
||||
'terminal.statusbar.copyHostname.toast': '已複製主機位址:{hostname}',
|
||||
'terminal.statusbar.copyHostname.error': '複製主機位址失敗',
|
||||
'terminal.statusbar.disconnect.label': '中斷連線',
|
||||
'terminal.statusbar.disconnect.tooltip': '中斷目前工作階段,但保留標籤頁',
|
||||
'terminal.statusbar.disconnect.tooltip': '中斷目前工作階段,但保留分頁',
|
||||
'terminal.statusbar.reconnect.label': '重新連線',
|
||||
'terminal.statusbar.reconnect.tooltip': '重新連線目前工作階段',
|
||||
'terminal.serverStats.cpu': 'CPU 使用率',
|
||||
@@ -554,9 +612,9 @@ export const zhTWVaultMessages: Messages = {
|
||||
// Custom Themes
|
||||
'terminal.customTheme.section': '自訂主題',
|
||||
'terminal.customTheme.yourThemes': '我的主題',
|
||||
'terminal.customTheme.new': '新建主題',
|
||||
'terminal.customTheme.new': '新增主題',
|
||||
'terminal.customTheme.newDesc': '克隆目前主題並自訂',
|
||||
'terminal.customTheme.newTitle': '新建自訂主題',
|
||||
'terminal.customTheme.newTitle': '新增自訂主題',
|
||||
'terminal.customTheme.editTitle': '編輯主題',
|
||||
'terminal.customTheme.import': '匯入 .itermcolors',
|
||||
'terminal.customTheme.importDesc': '從 iTerm2 配色方案檔案匯入',
|
||||
@@ -599,7 +657,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'cloudSync.gate.confirmPlaceholder': '再次輸入密碼',
|
||||
'cloudSync.gate.mismatch': '兩次輸入的密碼不一致',
|
||||
'cloudSync.gate.warning':
|
||||
'我已瞭解:如果忘記主金鑰,資料無法恢復,且沒有密碼重置功能。',
|
||||
'我已了解:如果忘記主金鑰,資料無法恢復,且沒有密碼重設功能。',
|
||||
'cloudSync.gate.enableVault': '啟用加密 Vault',
|
||||
'cloudSync.gate.enabledToast': '已啟用加密 Vault',
|
||||
'cloudSync.gate.setupFailed': '設定主金鑰失敗',
|
||||
@@ -619,7 +677,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'cloudSync.provider.comingSoon': '即將支援',
|
||||
'cloudSync.webdav.title': 'WebDAV 設定',
|
||||
'cloudSync.webdav.desc': '設定 WebDAV 端點用於加密同步。',
|
||||
'cloudSync.webdav.endpoint': '端點地址',
|
||||
'cloudSync.webdav.endpoint': '端點位址',
|
||||
'cloudSync.webdav.authType': '認證方式',
|
||||
'cloudSync.webdav.auth.basic': 'Basic',
|
||||
'cloudSync.webdav.auth.digest': 'Digest',
|
||||
@@ -634,7 +692,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'cloudSync.webdav.validation.token': '請輸入 Token。',
|
||||
'cloudSync.s3.title': 'S3 設定',
|
||||
'cloudSync.s3.desc': '連線到 S3 相容物件儲存以進行加密同步。',
|
||||
'cloudSync.s3.endpoint': '端點地址',
|
||||
'cloudSync.s3.endpoint': '端點位址',
|
||||
'cloudSync.s3.region': 'Region',
|
||||
'cloudSync.s3.bucket': 'Bucket',
|
||||
'cloudSync.s3.accessKeyId': 'Access Key ID',
|
||||
@@ -692,7 +750,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'cloudSync.convergent.active': 'CRDT v2 已啟用;每次上傳後都會驗證雲端寫入。',
|
||||
'cloudSync.convergent.paused': '此裝置已暫停 CRDT v2;雲端中繼資料仍會保留。',
|
||||
'cloudSync.convergent.enabled': '已啟用可收斂同步',
|
||||
'cloudSync.convergent.preview.title': '遷移預覽',
|
||||
'cloudSync.convergent.preview.title': '移轉預覽',
|
||||
'cloudSync.convergent.preview.entities': '實體',
|
||||
'cloudSync.convergent.preview.providers': '雲端服務',
|
||||
'cloudSync.convergent.preview.conflicts': '衝突',
|
||||
@@ -760,7 +818,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'cloudSync.revisionHistory.keys': '金鑰',
|
||||
'cloudSync.revisionHistory.snippets': '程式碼片段',
|
||||
'cloudSync.revisionHistory.notes': '筆記',
|
||||
'cloudSync.revisionHistory.identities': '身份',
|
||||
'cloudSync.revisionHistory.identities': '身分',
|
||||
'cloudSync.revisionHistory.restoreButton': '恢復此版本',
|
||||
'cloudSync.revisionHistory.restored': '已從選取的修訂恢復主機庫資料。',
|
||||
'cloudSync.revisionHistory.revisionNotFound': '修訂未找到或不包含主機庫資料。',
|
||||
@@ -809,15 +867,15 @@ export const zhTWVaultMessages: Messages = {
|
||||
'cloudSync.conflict.detailsCounts': '本機 {local} · 雲端 {cloud} · 衝突 {conflicts}',
|
||||
'cloudSync.conflict.entity.hosts': '主機',
|
||||
'cloudSync.conflict.entity.keys': '金鑰',
|
||||
'cloudSync.conflict.entity.identities': '身份',
|
||||
'cloudSync.conflict.entity.identities': '身分',
|
||||
'cloudSync.conflict.entity.proxyProfiles': '代理設定',
|
||||
'cloudSync.conflict.entity.snippets': '片段',
|
||||
'cloudSync.conflict.entity.notes': '筆記',
|
||||
'cloudSync.conflict.entity.noteGroups': '筆記分組',
|
||||
'cloudSync.conflict.entity.customGroups': '分組',
|
||||
'cloudSync.conflict.entity.noteGroups': '筆記群組',
|
||||
'cloudSync.conflict.entity.customGroups': '群組',
|
||||
'cloudSync.conflict.entity.snippetPackages': '片段包',
|
||||
'cloudSync.conflict.entity.portForwardingRules': '埠轉發',
|
||||
'cloudSync.conflict.entity.groupConfigs': '分組設定',
|
||||
'cloudSync.conflict.entity.groupConfigs': '群組設定',
|
||||
'cloudSync.conflict.entity.settings': '設定',
|
||||
'cloudSync.conflict.keepLocal': '覆蓋雲端(保留本機)',
|
||||
'cloudSync.conflict.useCloud': '下載雲端(覆蓋本機)',
|
||||
@@ -837,19 +895,19 @@ export const zhTWVaultMessages: Messages = {
|
||||
'cloudSync.resolve.uploaded': '已上傳本機資料',
|
||||
'cloudSync.resolve.failedTitle': '衝突處理失敗',
|
||||
'cloudSync.clearLocal.title': '清空本機資料',
|
||||
'cloudSync.clearLocal.desc': '重置本機版本和同步歷史。下次同步將從雲端下載。',
|
||||
'cloudSync.clearLocal.desc': '重設本機版本和同步歷史。下次同步將從雲端下載。',
|
||||
'cloudSync.clearLocal.button': '清空',
|
||||
'cloudSync.clearLocal.dialog.title': '清空本機 Vault 資料?',
|
||||
'cloudSync.clearLocal.dialog.desc': '這將重置本機版本為 0 並清除同步歷史。下次同步時會從雲端下載資料,替換本機資料。',
|
||||
'cloudSync.clearLocal.dialog.desc': '這將重設本機版本為 0 並清除同步歷史。下次同步時會從雲端下載資料,替換本機資料。',
|
||||
'cloudSync.clearLocal.dialog.cancel': '取消',
|
||||
'cloudSync.clearLocal.dialog.confirm': '確認清空',
|
||||
'cloudSync.clearLocal.toast.title': '本機資料已清空',
|
||||
'cloudSync.clearLocal.toast.desc': '本機版本已重置為 0。同步以從雲端下載資料。',
|
||||
'cloudSync.clearLocal.toast.desc': '本機版本已重設為 0。同步以從雲端下載資料。',
|
||||
|
||||
// Common (additional)
|
||||
'common.searchPlaceholder': '搜尋...',
|
||||
'common.import': '匯入',
|
||||
'common.generate': '生成',
|
||||
'common.generate': '產生',
|
||||
'common.delete': '刪除',
|
||||
'common.edit': '編輯',
|
||||
'sftp.context.openWithDefault': '系統預設程式開啟',
|
||||
@@ -874,9 +932,9 @@ export const zhTWVaultMessages: Messages = {
|
||||
'pf.form.labelPlaceholder': '規則標籤',
|
||||
'pf.form.intermediateHost': '中轉主機 *',
|
||||
'pf.form.createRule': '建立規則',
|
||||
'pf.form.openWizard': '打開向導',
|
||||
'pf.form.openWizard': '開啟精靈',
|
||||
'pf.form.openWizardTitle': '開啟埠轉發精靈',
|
||||
'pf.action.newForwarding': '新建轉發',
|
||||
'pf.action.newForwarding': '新增轉發',
|
||||
'pf.view.grid': '網格',
|
||||
'pf.view.list': '列表',
|
||||
'pf.rule.summary.local': '本機 {bindAddress}:{localPort} -> SSH 隧道 -> 遠端 {remoteHost}:{remotePort}',
|
||||
@@ -884,7 +942,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'pf.rule.summary.dynamic': 'SOCKS 監聽於 {bindAddress}:{localPort}',
|
||||
'pf.tooltip.relayHost': '中轉主機',
|
||||
'pf.tooltip.hostLabel': '主機',
|
||||
'pf.tooltip.hostAddress': '地址',
|
||||
'pf.tooltip.hostAddress': '位址',
|
||||
'pf.tooltip.noHost': '未設定中轉主機',
|
||||
'pf.tooltip.localDesc': '本機埠轉發:透過 SSH 隧道存取遠端服務',
|
||||
'pf.tooltip.remoteDesc': '遠端埠轉發:將本機服務公開給遠端主機',
|
||||
@@ -901,13 +959,20 @@ export const zhTWVaultMessages: Messages = {
|
||||
'sftp.pane.selectHost': '選擇主機',
|
||||
'sftp.pane.selectHostToStart': '先選擇一個主機',
|
||||
'sftp.pane.chooseFilesystem': '選擇要瀏覽的本機或遠端檔案系統',
|
||||
'sftp.tabs.addTab': '新建標籤頁',
|
||||
'sftp.tabs.closeTab': '關閉標籤頁',
|
||||
'sftp.tabs.newTab': '新標籤頁',
|
||||
'sftp.tabs.copyDefaultPath': '複製標籤頁(預設路徑)',
|
||||
'sftp.tabs.addTab': '新增分頁',
|
||||
'sftp.tabs.closeTab': '關閉分頁',
|
||||
'sftp.tabs.newTab': '新分頁',
|
||||
'sftp.tabs.copyDefaultPath': '複製分頁(預設路徑)',
|
||||
'sftp.tabs.copyCurrentPath': '複製並跳轉到目前路徑',
|
||||
'sftp.conflict.title': '檔案衝突',
|
||||
'sftp.conflict.desc': '目標位置已存在同名檔案',
|
||||
'sftp.conflict.folderTitle': '資料夾衝突',
|
||||
'sftp.conflict.folderDesc': '請選擇處理方式。',
|
||||
'sftp.conflict.folderFileDesc': '目標位置已存在同名檔案,資料夾無法與檔案合併。',
|
||||
'sftp.conflict.folderSymlinkDesc': '目標位置已存在同名符號連結,資料夾無法與符號連結合併。',
|
||||
'sftp.conflict.folderUnknownDesc': '目標位置已存在同名項目,但無法確認其類型。為保護目標資料,替換操作不可用。',
|
||||
'sftp.conflict.folderMergeHint': '合併:保留目標中獨有的內容。',
|
||||
'sftp.conflict.folderReplaceWarning': '替換:刪除目標中獨有的內容,且無法復原。',
|
||||
'sftp.conflict.alreadyExistsSuffix': '已存在',
|
||||
'sftp.conflict.existingFile': '已有檔案',
|
||||
'sftp.conflict.newFile': '新檔案',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -154,7 +154,8 @@ export const useAvailableFonts = (): TerminalFont[] => {
|
||||
|
||||
return useSyncExternalStore(
|
||||
fontStore.subscribe,
|
||||
fontStore.getAvailableFonts
|
||||
fontStore.getAvailableFonts,
|
||||
fontStore.getAvailableFonts,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -165,6 +166,7 @@ export const useInstalledFontFamilies = (): string[] | null => {
|
||||
return useSyncExternalStore(
|
||||
fontStore.subscribe,
|
||||
fontStore.getInstalledFontFamilies,
|
||||
fontStore.getInstalledFontFamilies,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -127,3 +127,13 @@ test("live and restart-resume directory replacement both call the shared promoti
|
||||
assert.match(liveSource, /promoteDirectoryReplacePaths\(\{/);
|
||||
assert.match(resumeSource, /promoteDirectoryReplacePaths\(\{/);
|
||||
});
|
||||
|
||||
test("live directory replacement bypasses the merge-only same-host copy shortcut", () => {
|
||||
const liveSource = fs.readFileSync(new URL("./useSftpTransfers.ts", import.meta.url), "utf8");
|
||||
const sameHostCopyGuard = liveSource.slice(
|
||||
liveSource.indexOf("if (\n task.isDirectory"),
|
||||
liveSource.indexOf("sameHostCopyDirectory!", liveSource.indexOf("if (\n task.isDirectory")),
|
||||
);
|
||||
|
||||
assert.match(sameHostCopyGuard, /!task\.replaceExistingTarget/);
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
type SftpFilterFocusListener = () => void;
|
||||
|
||||
const listenersByPaneId = new Map<string, Set<SftpFilterFocusListener>>();
|
||||
|
||||
export const sftpFilterFocusStore = {
|
||||
request(paneId: string): void {
|
||||
listenersByPaneId.get(paneId)?.forEach((listener) => listener());
|
||||
},
|
||||
|
||||
subscribe(paneId: string, listener: SftpFilterFocusListener): () => void {
|
||||
const listeners = listenersByPaneId.get(paneId) ?? new Set<SftpFilterFocusListener>();
|
||||
listeners.add(listener);
|
||||
listenersByPaneId.set(paneId, listeners);
|
||||
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (listeners.size === 0) {
|
||||
listenersByPaneId.delete(paneId);
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
@@ -845,7 +845,14 @@ export const useSftpTransfers = ({
|
||||
// Try same-host directory optimization first; falls back to recursive transfer
|
||||
// if remote cp is unavailable (e.g. Windows SSH servers).
|
||||
let dirHandledBySameHost = false;
|
||||
if (task.isDirectory && task.resumable === false && sameHost && encodingSafeForExec && sourceSftpId) {
|
||||
if (
|
||||
task.isDirectory
|
||||
&& !task.replaceExistingTarget
|
||||
&& task.resumable === false
|
||||
&& sameHost
|
||||
&& encodingSafeForExec
|
||||
&& sourceSftpId
|
||||
) {
|
||||
if (cancelledTasksRef.current.has(task.id)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
@@ -36,3 +36,32 @@ test('cleanup only removes the handler that created it', () => {
|
||||
assert.equal(registry.request('session-1'), true);
|
||||
assert.deepEqual(requests, ['current']);
|
||||
});
|
||||
|
||||
test('active reconnect state blocks duplicate requests and notifies subscribers', () => {
|
||||
const registry = createTerminalReconnectRegistry();
|
||||
const requests: string[] = [];
|
||||
let notifications = 0;
|
||||
const unsubscribe = registry.subscribe(() => { notifications += 1; });
|
||||
registry.register('session-1', () => requests.push('session-1'));
|
||||
|
||||
registry.setActive('session-1', true);
|
||||
assert.equal(registry.isActive('session-1'), true);
|
||||
assert.equal(registry.request('session-1'), false);
|
||||
assert.deepEqual(requests, []);
|
||||
|
||||
registry.setActive('session-1', false);
|
||||
assert.equal(registry.request('session-1'), true);
|
||||
assert.deepEqual(requests, ['session-1']);
|
||||
assert.equal(notifications, 2);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test('unregistering the current handler clears active reconnect state', () => {
|
||||
const registry = createTerminalReconnectRegistry();
|
||||
const unregister = registry.register('session-1', () => {});
|
||||
registry.setActive('session-1', true);
|
||||
|
||||
unregister();
|
||||
|
||||
assert.equal(registry.isActive('session-1'), false);
|
||||
});
|
||||
@@ -3,6 +3,29 @@ type TerminalReconnectHandler = () => void;
|
||||
export const createTerminalReconnectRegistry = () => {
|
||||
const handlers = new Map<string, TerminalReconnectHandler>();
|
||||
const pendingRequests = new Set<string>();
|
||||
const activeSessionIds = new Set<string>();
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const emit = () => {
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
|
||||
const setActive = (sessionId: string, active: boolean): void => {
|
||||
const changed = active
|
||||
? !activeSessionIds.has(sessionId)
|
||||
: activeSessionIds.has(sessionId);
|
||||
if (!changed) return;
|
||||
if (active) activeSessionIds.add(sessionId);
|
||||
else activeSessionIds.delete(sessionId);
|
||||
emit();
|
||||
};
|
||||
|
||||
const isActive = (sessionId: string): boolean => activeSessionIds.has(sessionId);
|
||||
|
||||
const subscribe = (listener: () => void): (() => void) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
};
|
||||
|
||||
const register = (sessionId: string, handler: TerminalReconnectHandler): (() => void) => {
|
||||
handlers.set(sessionId, handler);
|
||||
@@ -12,11 +35,13 @@ export const createTerminalReconnectRegistry = () => {
|
||||
return () => {
|
||||
if (handlers.get(sessionId) === handler) {
|
||||
handlers.delete(sessionId);
|
||||
setActive(sessionId, false);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const request = (sessionId: string): boolean => {
|
||||
if (isActive(sessionId)) return false;
|
||||
const handler = handlers.get(sessionId);
|
||||
if (!handler) {
|
||||
pendingRequests.add(sessionId);
|
||||
@@ -26,7 +51,7 @@ export const createTerminalReconnectRegistry = () => {
|
||||
return true;
|
||||
};
|
||||
|
||||
return { register, request };
|
||||
return { register, request, setActive, isActive, subscribe };
|
||||
};
|
||||
|
||||
export const terminalReconnectRegistry = createTerminalReconnectRegistry();
|
||||
@@ -103,6 +103,7 @@ export interface SendToCattyContext {
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
commandBlocklist?: string[];
|
||||
commandTimeout?: number;
|
||||
responseIdleTimeout?: number;
|
||||
terminalSessions: TerminalSessionInfo[];
|
||||
webSearchConfig?: WebSearchConfig | null;
|
||||
getExecutorContext?: () => ExecutorContext;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
STORAGE_KEY_AI_DEFAULT_AGENT,
|
||||
STORAGE_KEY_AI_COMMAND_BLOCKLIST,
|
||||
STORAGE_KEY_AI_COMMAND_TIMEOUT,
|
||||
STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT,
|
||||
STORAGE_KEY_AI_MAX_ITERATIONS,
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
||||
@@ -29,7 +30,9 @@ import type {
|
||||
import {
|
||||
DEFAULT_COMMAND_BLOCKLIST,
|
||||
DEFAULT_COMMAND_TIMEOUT_SECONDS,
|
||||
DEFAULT_RESPONSE_IDLE_TIMEOUT_SECONDS,
|
||||
normalizeCommandTimeoutSeconds,
|
||||
normalizeResponseIdleTimeoutSeconds,
|
||||
} from '../../infrastructure/ai/types';
|
||||
import { removeProviderReferences } from './aiProviderCleanup';
|
||||
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
||||
@@ -74,6 +77,12 @@ export function useAISettingsState() {
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? DEFAULT_COMMAND_TIMEOUT_SECONDS,
|
||||
)
|
||||
);
|
||||
const [responseIdleTimeout, setResponseIdleTimeoutRaw] = useState<number>(() =>
|
||||
normalizeResponseIdleTimeoutSeconds(
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT)
|
||||
?? DEFAULT_RESPONSE_IDLE_TIMEOUT_SECONDS,
|
||||
)
|
||||
);
|
||||
const [maxIterations, setMaxIterationsRaw] = useState<number>(() =>
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20
|
||||
);
|
||||
@@ -175,6 +184,12 @@ export function useAISettingsState() {
|
||||
getAIBridge()?.aiMcpSetCommandTimeout?.(normalizedValue);
|
||||
}, []);
|
||||
|
||||
const setResponseIdleTimeout = useCallback((value: number) => {
|
||||
const normalizedValue = normalizeResponseIdleTimeoutSeconds(value);
|
||||
setResponseIdleTimeoutRaw(normalizedValue);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT, normalizedValue);
|
||||
}, []);
|
||||
|
||||
const setMaxIterations = useCallback((value: number) => {
|
||||
setMaxIterationsRaw(value);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_MAX_ITERATIONS, value);
|
||||
@@ -249,6 +264,13 @@ export function useAISettingsState() {
|
||||
getAIBridge()?.aiMcpSetCommandTimeout?.(normalizedTimeout);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT: {
|
||||
const timeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT)
|
||||
?? DEFAULT_RESPONSE_IDLE_TIMEOUT_SECONDS;
|
||||
if (!Number.isFinite(timeout)) break;
|
||||
setResponseIdleTimeoutRaw(normalizeResponseIdleTimeoutSeconds(timeout));
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_MAX_ITERATIONS: {
|
||||
const iters = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20;
|
||||
if (!Number.isFinite(iters)) break;
|
||||
@@ -315,6 +337,8 @@ export function useAISettingsState() {
|
||||
setCommandBlocklist,
|
||||
commandTimeout,
|
||||
setCommandTimeout,
|
||||
responseIdleTimeout,
|
||||
setResponseIdleTimeout,
|
||||
maxIterations,
|
||||
setMaxIterations,
|
||||
webSearchConfig,
|
||||
@@ -346,6 +370,8 @@ export function useAISettingsState() {
|
||||
setCommandBlocklist,
|
||||
commandTimeout,
|
||||
setCommandTimeout,
|
||||
responseIdleTimeout,
|
||||
setResponseIdleTimeout,
|
||||
maxIterations,
|
||||
setMaxIterations,
|
||||
webSearchConfig,
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
import { resolveInheritedAIActiveSessionId } from '../../domain/aiWorkspaceScopeInherit.ts';
|
||||
import { resolveDisplayedPanelView } from '../../components/ai/aiPanelViewState.ts';
|
||||
import type { AISession } from '../../infrastructure/ai/types.ts';
|
||||
import { useAISessionsStore } from './aiSessionsStore.ts';
|
||||
import { useAIState } from './useAIState.ts';
|
||||
|
||||
const SCOPE_KEY = 'workspace:workspace-old';
|
||||
const RESTORED_SESSION: AISession = {
|
||||
id: 'chat-old',
|
||||
title: 'Restored chat',
|
||||
agentId: 'catty',
|
||||
scope: {
|
||||
type: 'terminal',
|
||||
targetId: 'terminal-a',
|
||||
hostIds: ['host-a'],
|
||||
},
|
||||
messages: [
|
||||
{ id: 'user-1', role: 'user', content: 'old question', timestamp: 1 },
|
||||
{ id: 'assistant-1', role: 'assistant', content: 'old answer', timestamp: 2 },
|
||||
],
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
};
|
||||
|
||||
function NewChatHarness() {
|
||||
const ai = useAIState();
|
||||
const state = useAISessionsStore();
|
||||
const visibleSessionIds = new Set(state.sessions.map((session) => session.id));
|
||||
const inheritedSessionId = resolveInheritedAIActiveSessionId({
|
||||
scopeType: 'workspace',
|
||||
scopeTargetId: 'workspace-old',
|
||||
activeSessionIdMap: state.activeSessionIdMap,
|
||||
memberTerminalIds: ['terminal-a', 'terminal-b'],
|
||||
preferredTerminalId: 'terminal-a',
|
||||
visibleSessionIds,
|
||||
});
|
||||
const view = resolveDisplayedPanelView(
|
||||
state.panelViewByScope[SCOPE_KEY],
|
||||
state.draftsByScope[SCOPE_KEY] != null,
|
||||
state.sessions as AISession[],
|
||||
inheritedSessionId,
|
||||
'workspace',
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-view={view.mode}
|
||||
data-session={view.mode === 'session' ? view.sessionId : ''}
|
||||
onClick={() => {
|
||||
ai.clearDraftForScope(SCOPE_KEY);
|
||||
ai.updateDraft(SCOPE_KEY, 'catty', () => ({
|
||||
text: '',
|
||||
agentId: 'catty',
|
||||
attachments: [],
|
||||
selectedUserSkillSlugs: [],
|
||||
updatedAt: Date.now(),
|
||||
}));
|
||||
ai.showDraftView(SCOPE_KEY);
|
||||
}}
|
||||
>
|
||||
New Chat
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
test('restored workspace enters a blank draft when New Chat is clicked', async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
|
||||
url: 'http://localhost',
|
||||
});
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: dom.window,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: dom.window.document,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: dom.window.localStorage,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'CustomEvent', {
|
||||
configurable: true,
|
||||
value: dom.window.CustomEvent,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'Event', {
|
||||
configurable: true,
|
||||
value: dom.window.Event,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
|
||||
dom.window.localStorage.setItem('netcatty_ai_sessions_v1', JSON.stringify([RESTORED_SESSION]));
|
||||
dom.window.localStorage.setItem('netcatty_ai_active_session_map_v1', JSON.stringify({
|
||||
'terminal:terminal-a': RESTORED_SESSION.id,
|
||||
[SCOPE_KEY]: RESTORED_SESSION.id,
|
||||
}));
|
||||
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<NewChatHarness />);
|
||||
});
|
||||
|
||||
const button = container.querySelector('button');
|
||||
assert.ok(button);
|
||||
assert.equal(button.dataset.view, 'session');
|
||||
assert.equal(button.dataset.session, RESTORED_SESSION.id);
|
||||
|
||||
await act(async () => {
|
||||
button.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
assert.equal(button.dataset.view, 'draft');
|
||||
assert.equal(button.dataset.session, '');
|
||||
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
dom.window.close();
|
||||
});
|
||||
+111
-29
@@ -11,11 +11,13 @@ import {
|
||||
STORAGE_KEY_AI_DEFAULT_AGENT,
|
||||
STORAGE_KEY_AI_COMMAND_BLOCKLIST,
|
||||
STORAGE_KEY_AI_COMMAND_TIMEOUT,
|
||||
STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT,
|
||||
STORAGE_KEY_AI_MAX_ITERATIONS,
|
||||
STORAGE_KEY_AI_SESSIONS,
|
||||
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';
|
||||
@@ -37,7 +39,9 @@ import type {
|
||||
import {
|
||||
DEFAULT_COMMAND_BLOCKLIST,
|
||||
DEFAULT_COMMAND_TIMEOUT_SECONDS,
|
||||
DEFAULT_RESPONSE_IDLE_TIMEOUT_SECONDS,
|
||||
normalizeCommandTimeoutSeconds,
|
||||
normalizeResponseIdleTimeoutSeconds,
|
||||
} from '../../infrastructure/ai/types';
|
||||
import {
|
||||
activateDraftView,
|
||||
@@ -79,6 +83,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[]>(() =>
|
||||
@@ -122,6 +148,12 @@ export function useAIState() {
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? DEFAULT_COMMAND_TIMEOUT_SECONDS,
|
||||
)
|
||||
);
|
||||
const [responseIdleTimeout, setResponseIdleTimeoutRaw] = useState<number>(() =>
|
||||
normalizeResponseIdleTimeoutSeconds(
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT)
|
||||
?? DEFAULT_RESPONSE_IDLE_TIMEOUT_SECONDS,
|
||||
)
|
||||
);
|
||||
const [maxIterations, setMaxIterationsRaw] = useState<number>(() =>
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20
|
||||
);
|
||||
@@ -174,6 +206,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 +305,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 +316,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 +369,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;
|
||||
});
|
||||
@@ -382,6 +438,12 @@ export function useAIState() {
|
||||
bridge?.aiMcpSetCommandTimeout?.(normalizedValue);
|
||||
}, []);
|
||||
|
||||
const setResponseIdleTimeout = useCallback((value: number) => {
|
||||
const normalizedValue = normalizeResponseIdleTimeoutSeconds(value);
|
||||
setResponseIdleTimeoutRaw(normalizedValue);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT, normalizedValue);
|
||||
}, []);
|
||||
|
||||
const setMaxIterations = useCallback((value: number) => {
|
||||
setMaxIterationsRaw(value);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_MAX_ITERATIONS, value);
|
||||
@@ -462,6 +524,16 @@ export function useAIState() {
|
||||
getAIBridge()?.aiMcpSetCommandTimeout?.(normalizedTimeout);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT: {
|
||||
const timeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT)
|
||||
?? DEFAULT_RESPONSE_IDLE_TIMEOUT_SECONDS;
|
||||
if (!Number.isFinite(timeout)) {
|
||||
console.warn('[useAIState] Cross-window sync: AI_RESPONSE_IDLE_TIMEOUT is not a finite number, skipping');
|
||||
break;
|
||||
}
|
||||
setResponseIdleTimeoutRaw(normalizeResponseIdleTimeoutSeconds(timeout));
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_MAX_ITERATIONS: {
|
||||
const iters = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20;
|
||||
if (!Number.isFinite(iters)) {
|
||||
@@ -493,6 +565,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) ?? {};
|
||||
@@ -876,34 +951,25 @@ export function useAIState() {
|
||||
}, []);
|
||||
|
||||
const showDraftView = useCallback((scopeKey: string) => {
|
||||
const currentPanelViewByScope = panelViewByScope;
|
||||
let nextActiveSessionIdMap: Record<string, string | null> | null = null;
|
||||
let nextPanelViewByScope: PanelViewByScope | null = null;
|
||||
let activeSessionMapChanged = false;
|
||||
let panelViewChanged = false;
|
||||
const currentActiveSessionIdMap = latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {};
|
||||
const next = activateDraftView(
|
||||
currentActiveSessionIdMap,
|
||||
panelViewByScope,
|
||||
scopeKey,
|
||||
);
|
||||
|
||||
setActiveSessionIdMapRaw((prevActiveSessionIdMap) => {
|
||||
const next = activateDraftView(
|
||||
prevActiveSessionIdMap,
|
||||
currentPanelViewByScope,
|
||||
scopeKey,
|
||||
);
|
||||
activeSessionMapChanged = next.activeSessionIdMap !== prevActiveSessionIdMap;
|
||||
panelViewChanged = next.panelViewByScope !== currentPanelViewByScope;
|
||||
nextActiveSessionIdMap = next.activeSessionIdMap;
|
||||
nextPanelViewByScope = next.panelViewByScope;
|
||||
return activeSessionMapChanged ? next.activeSessionIdMap : prevActiveSessionIdMap;
|
||||
});
|
||||
|
||||
if (activeSessionMapChanged && nextActiveSessionIdMap) {
|
||||
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
|
||||
if (next.activeSessionIdMap !== currentActiveSessionIdMap) {
|
||||
setLatestAIActiveSessionMapSnapshot(next.activeSessionIdMap);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next.activeSessionIdMap);
|
||||
setActiveSessionIdMapRaw(next.activeSessionIdMap);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
}
|
||||
|
||||
if (panelViewChanged && nextPanelViewByScope) {
|
||||
setLatestAIPanelViewByScopeSnapshot(nextPanelViewByScope);
|
||||
setPanelViewByScopeRaw(nextPanelViewByScope);
|
||||
if (next.panelViewByScope !== panelViewByScope) {
|
||||
setLatestAIPanelViewByScopeSnapshot(next.panelViewByScope);
|
||||
setPanelViewByScopeRaw(next.panelViewByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
||||
}
|
||||
}, [panelViewByScope]);
|
||||
@@ -1113,7 +1179,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) => {
|
||||
@@ -1180,12 +1254,16 @@ export function useAIState() {
|
||||
setCommandBlocklist,
|
||||
commandTimeout,
|
||||
setCommandTimeout,
|
||||
responseIdleTimeout,
|
||||
setResponseIdleTimeout,
|
||||
maxIterations,
|
||||
setMaxIterations,
|
||||
agentModelMap,
|
||||
setAgentModel,
|
||||
agentProviderMap,
|
||||
setAgentProvider,
|
||||
agentThinkingMap,
|
||||
setAgentThinking,
|
||||
webSearchConfig,
|
||||
setWebSearchConfig,
|
||||
quickMessages,
|
||||
@@ -1238,12 +1316,16 @@ export function useAIState() {
|
||||
setCommandBlocklist,
|
||||
commandTimeout,
|
||||
setCommandTimeout,
|
||||
responseIdleTimeout,
|
||||
setResponseIdleTimeout,
|
||||
maxIterations,
|
||||
setMaxIterations,
|
||||
agentModelMap,
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createStoredNumberSyncHandler } from "./useStoredNumber.ts";
|
||||
|
||||
test("stored number instances adopt same-window adapter changes", () => {
|
||||
let current = 14;
|
||||
let received = 14;
|
||||
const handler = createStoredNumberSyncHandler({
|
||||
storageKey: "note-size",
|
||||
readValue: () => current,
|
||||
onValue: (value) => { received = value; },
|
||||
});
|
||||
|
||||
current = 18;
|
||||
handler(new CustomEvent("netcatty:local-storage-adapter-changed", { detail: { key: "other" } }));
|
||||
assert.equal(received, 14);
|
||||
handler(new CustomEvent("netcatty:local-storage-adapter-changed", { detail: { key: "note-size" } }));
|
||||
assert.equal(received, 18);
|
||||
});
|
||||
@@ -1,5 +1,35 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
|
||||
localStorageAdapter,
|
||||
} from "../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
export const readStoredNumberValue = (
|
||||
storageKey: string,
|
||||
fallback: number,
|
||||
clamp?: { min: number; max: number },
|
||||
): number => {
|
||||
if (typeof globalThis.localStorage === "undefined") return fallback;
|
||||
const stored = localStorageAdapter.readNumber(storageKey);
|
||||
if (stored === null) return fallback;
|
||||
if (clamp) return Math.max(clamp.min, Math.min(clamp.max, stored));
|
||||
return stored;
|
||||
};
|
||||
|
||||
export const createStoredNumberSyncHandler = ({
|
||||
storageKey,
|
||||
readValue,
|
||||
onValue,
|
||||
}: {
|
||||
storageKey: string;
|
||||
readValue: () => number;
|
||||
onValue: (value: number) => void;
|
||||
}) => (event: Event) => {
|
||||
const key = event.type === "storage"
|
||||
? (event as StorageEvent).key
|
||||
: (event as CustomEvent<{ key?: string }>).detail?.key;
|
||||
if (key === storageKey) onValue(readValue());
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for reading a number from localStorage with lazy persistence.
|
||||
@@ -13,13 +43,32 @@ export const useStoredNumber = (
|
||||
fallback: number,
|
||||
clamp?: { min: number; max: number },
|
||||
) => {
|
||||
const [value, setValue] = useState<number>(() => {
|
||||
if (typeof globalThis.localStorage === "undefined") return fallback;
|
||||
const stored = localStorageAdapter.readNumber(storageKey);
|
||||
if (stored === null) return fallback;
|
||||
if (clamp) return Math.max(clamp.min, Math.min(clamp.max, stored));
|
||||
return stored;
|
||||
});
|
||||
const clampMin = clamp?.min;
|
||||
const clampMax = clamp?.max;
|
||||
const readValue = useCallback(
|
||||
() => readStoredNumberValue(
|
||||
storageKey,
|
||||
fallback,
|
||||
clampMin != null && clampMax != null ? { min: clampMin, max: clampMax } : undefined,
|
||||
),
|
||||
[clampMax, clampMin, fallback, storageKey],
|
||||
);
|
||||
const [value, setValue] = useState<number>(readValue);
|
||||
|
||||
useEffect(() => {
|
||||
const target = globalThis as typeof globalThis & {
|
||||
addEventListener?: (type: string, listener: EventListener) => void;
|
||||
removeEventListener?: (type: string, listener: EventListener) => void;
|
||||
};
|
||||
if (typeof target.addEventListener !== "function") return;
|
||||
const sync = createStoredNumberSyncHandler({ storageKey, readValue, onValue: setValue });
|
||||
target.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, sync);
|
||||
target.addEventListener("storage", sync);
|
||||
return () => {
|
||||
target.removeEventListener?.(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, sync);
|
||||
target.removeEventListener?.("storage", sync);
|
||||
};
|
||||
}, [readValue, storageKey]);
|
||||
|
||||
const persist = useCallback(
|
||||
(v: number) => {
|
||||
|
||||
@@ -104,3 +104,16 @@ test("stored string helpers read fallback and resolve updater-style toggles", (t
|
||||
"edit",
|
||||
);
|
||||
});
|
||||
|
||||
test("stored string helpers work with default validator when isAllowedValue is omitted", (t) => {
|
||||
const env = installLocalStorage();
|
||||
t.after(() => env.restore());
|
||||
|
||||
const storageKey = "netcatty:test-font";
|
||||
assert.equal(readStoredStringValue(storageKey, "default-font"), "default-font");
|
||||
assert.equal(readOptionalStoredStringValue(storageKey), null);
|
||||
|
||||
env.storage.setItem(storageKey, "Inter");
|
||||
assert.equal(readStoredStringValue(storageKey, "default-font"), "Inter");
|
||||
assert.equal(readOptionalStoredStringValue(storageKey), "Inter");
|
||||
});
|
||||
@@ -8,23 +8,27 @@ type StoredStringSetter<T extends string> = (nextValue: T | ((currentValue: T) =
|
||||
|
||||
const canUseLocalStorage = () => typeof globalThis.localStorage !== "undefined";
|
||||
|
||||
export const readStoredStringValue = <T extends string>(
|
||||
const defaultIsAllowedString = <T extends string>(value: string | null): value is T => typeof value === "string";
|
||||
|
||||
export const readStoredStringValue = <T extends string = string>(
|
||||
storageKey: string,
|
||||
fallback: T,
|
||||
isAllowedValue: (value: string | null) => value is T,
|
||||
isAllowedValue: (value: string | null) => value is T = defaultIsAllowedString,
|
||||
): T => {
|
||||
if (!canUseLocalStorage()) return fallback;
|
||||
const stored = localStorageAdapter.readString(storageKey);
|
||||
return isAllowedValue(stored) ? stored : fallback;
|
||||
const validator = isAllowedValue ?? defaultIsAllowedString;
|
||||
return validator(stored) ? stored : fallback;
|
||||
};
|
||||
|
||||
export const readOptionalStoredStringValue = <T extends string>(
|
||||
export const readOptionalStoredStringValue = <T extends string = string>(
|
||||
storageKey: string,
|
||||
isAllowedValue: (value: string | null) => value is T,
|
||||
isAllowedValue: (value: string | null) => value is T = defaultIsAllowedString,
|
||||
): T | null => {
|
||||
if (!canUseLocalStorage()) return null;
|
||||
const stored = localStorageAdapter.readString(storageKey);
|
||||
return isAllowedValue(stored) ? stored : null;
|
||||
const validator = isAllowedValue ?? defaultIsAllowedString;
|
||||
return validator(stored) ? stored : null;
|
||||
};
|
||||
|
||||
export const resolveStoredStringUpdate = <T extends string>(
|
||||
@@ -47,11 +51,12 @@ export const createStoredStringSyncHandlers = <T extends string>({
|
||||
}: {
|
||||
storageKey: string;
|
||||
fallback: T;
|
||||
isAllowedValue: (value: string | null) => value is T;
|
||||
isAllowedValue?: (value: string | null) => value is T;
|
||||
onValue: (value: T) => void;
|
||||
}) => {
|
||||
const validator = isAllowedValue ?? defaultIsAllowedString;
|
||||
const syncFromStorage = () => {
|
||||
onValue(readStoredStringValue(storageKey, fallback, isAllowedValue));
|
||||
onValue(readStoredStringValue(storageKey, fallback, validator));
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -64,15 +69,16 @@ export const createStoredStringSyncHandlers = <T extends string>({
|
||||
};
|
||||
};
|
||||
|
||||
export const useStoredString = <T extends string>(
|
||||
export const useStoredString = <T extends string = string>(
|
||||
storageKey: string,
|
||||
fallback: T,
|
||||
isAllowedValue: (value: string | null) => value is T,
|
||||
isAllowedValue?: (value: string | null) => value is T,
|
||||
) => {
|
||||
const validator = isAllowedValue ?? defaultIsAllowedString;
|
||||
const [value, setValue] = useState<T>(() => readStoredStringValue(
|
||||
storageKey,
|
||||
fallback,
|
||||
isAllowedValue,
|
||||
validator,
|
||||
));
|
||||
|
||||
const setAndPersist = useCallback<StoredStringSetter<T>>((nextValue) => {
|
||||
@@ -98,7 +104,7 @@ export const useStoredString = <T extends string>(
|
||||
} = createStoredStringSyncHandlers({
|
||||
storageKey,
|
||||
fallback,
|
||||
isAllowedValue,
|
||||
isAllowedValue: validator,
|
||||
onValue: setValue,
|
||||
});
|
||||
|
||||
@@ -108,7 +114,7 @@ export const useStoredString = <T extends string>(
|
||||
target.removeEventListener?.(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleAdapterChange);
|
||||
target.removeEventListener?.("storage", handleBrowserStorage);
|
||||
};
|
||||
}, [fallback, isAllowedValue, storageKey]);
|
||||
}, [fallback, storageKey, validator]);
|
||||
|
||||
return [value, setAndPersist] as const;
|
||||
};
|
||||
@@ -1,7 +1,16 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { resolveWindowCommandCloseIntent } from "./windowCommandClose.ts";
|
||||
import { matchesKeyBinding } from "../../domain/models.ts";
|
||||
import { isPrimaryModifierWBinding, resolveWindowCommandCloseIntent } from "./windowCommandClose.ts";
|
||||
|
||||
test("primary-modifier W only belongs to the matching close-tab binding", () => {
|
||||
assert.equal(isPrimaryModifierWBinding("⌘ + W", matchesKeyBinding, true), true);
|
||||
assert.equal(isPrimaryModifierWBinding("⌘ + E", matchesKeyBinding, true), false);
|
||||
assert.equal(isPrimaryModifierWBinding("Ctrl + W", matchesKeyBinding, false), true);
|
||||
assert.equal(isPrimaryModifierWBinding("Disabled", matchesKeyBinding, true), false);
|
||||
assert.equal(isPrimaryModifierWBinding(null, matchesKeyBinding, true), false);
|
||||
});
|
||||
|
||||
test("Cmd+W closes the active closable tab first", () => {
|
||||
assert.deepEqual(
|
||||
@@ -16,6 +25,92 @@ test("Cmd+W closes the active closable tab first", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("disabled close-tab binding forwards native Cmd+W to other custom shortcuts", () => {
|
||||
assert.deepEqual(
|
||||
resolveWindowCommandCloseIntent({
|
||||
activeTabId: "s1",
|
||||
editorTabIds: [],
|
||||
sessionIds: ["s1"],
|
||||
workspaceIds: [],
|
||||
logViewIds: [],
|
||||
closeTabShortcutEnabled: false,
|
||||
}),
|
||||
{ kind: "forwardShortcut" },
|
||||
);
|
||||
});
|
||||
|
||||
test("disabled close-tab binding forwards native Cmd+W while a dialog is open", () => {
|
||||
assert.deepEqual(
|
||||
resolveWindowCommandCloseIntent({
|
||||
activeTabId: "s1",
|
||||
editorTabIds: [],
|
||||
sessionIds: ["s1"],
|
||||
workspaceIds: [],
|
||||
logViewIds: [],
|
||||
closeTabShortcutEnabled: false,
|
||||
hasOpenDialog: true,
|
||||
}),
|
||||
{ kind: "forwardShortcut" },
|
||||
);
|
||||
});
|
||||
|
||||
test("enabled close-tab binding closes the topmost dialog before the active tab", () => {
|
||||
assert.deepEqual(
|
||||
resolveWindowCommandCloseIntent({
|
||||
activeTabId: "s1",
|
||||
editorTabIds: [],
|
||||
sessionIds: ["s1"],
|
||||
workspaceIds: [],
|
||||
logViewIds: [],
|
||||
closeTabShortcutEnabled: true,
|
||||
hasOpenDialog: true,
|
||||
}),
|
||||
{ kind: "closeDialog" },
|
||||
);
|
||||
});
|
||||
|
||||
test("disabled close-tab binding also forwards native Cmd+W on a log tab", () => {
|
||||
assert.deepEqual(
|
||||
resolveWindowCommandCloseIntent({
|
||||
activeTabId: "log-1",
|
||||
editorTabIds: [],
|
||||
sessionIds: [],
|
||||
workspaceIds: [],
|
||||
logViewIds: ["log-1"],
|
||||
closeTabShortcutEnabled: false,
|
||||
}),
|
||||
{ kind: "forwardShortcut" },
|
||||
);
|
||||
});
|
||||
|
||||
test("disabled close-tab binding forwards native Cmd+W instead of closing the window", () => {
|
||||
assert.deepEqual(
|
||||
resolveWindowCommandCloseIntent({
|
||||
activeTabId: "vault",
|
||||
editorTabIds: [],
|
||||
sessionIds: [],
|
||||
workspaceIds: [],
|
||||
logViewIds: [],
|
||||
closeTabShortcutEnabled: false,
|
||||
}),
|
||||
{ kind: "forwardShortcut" },
|
||||
);
|
||||
});
|
||||
|
||||
test("disabled close-tab binding forwards native Cmd+W when nothing is active", () => {
|
||||
assert.deepEqual(
|
||||
resolveWindowCommandCloseIntent({
|
||||
activeTabId: null,
|
||||
editorTabIds: [],
|
||||
sessionIds: [],
|
||||
workspaceIds: [],
|
||||
logViewIds: [],
|
||||
closeTabShortcutEnabled: false,
|
||||
}),
|
||||
{ kind: "forwardShortcut" },
|
||||
);
|
||||
});
|
||||
|
||||
test("Cmd+W on a log view closes the log view", () => {
|
||||
assert.deepEqual(
|
||||
resolveWindowCommandCloseIntent({
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
export type WindowCommandCloseIntent =
|
||||
| { kind: 'forwardShortcut' }
|
||||
| { kind: 'closeDialog' }
|
||||
| { kind: 'closeTab' }
|
||||
| { kind: 'closeLogView'; tabId: string }
|
||||
| { kind: 'closeWindow' };
|
||||
|
||||
type KeyBindingMatcher = (event: KeyboardEvent, binding: string, isMac: boolean) => boolean;
|
||||
|
||||
export function isPrimaryModifierWBinding(
|
||||
binding: string | null,
|
||||
matcher: KeyBindingMatcher,
|
||||
isMac: boolean,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
binding
|
||||
&& matcher({
|
||||
key: 'w',
|
||||
code: 'KeyW',
|
||||
metaKey: isMac,
|
||||
ctrlKey: !isMac,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
} as KeyboardEvent, binding, isMac),
|
||||
);
|
||||
}
|
||||
|
||||
interface ResolveWindowCommandCloseIntentInput {
|
||||
activeTabId: string | null;
|
||||
editorTabIds: string[];
|
||||
@@ -10,6 +32,8 @@ interface ResolveWindowCommandCloseIntentInput {
|
||||
workspaceIds: string[];
|
||||
logViewIds: string[];
|
||||
pluginViewTabIds?: string[];
|
||||
closeTabShortcutEnabled?: boolean;
|
||||
hasOpenDialog?: boolean;
|
||||
}
|
||||
|
||||
export function resolveWindowCommandCloseIntent({
|
||||
@@ -19,7 +43,17 @@ export function resolveWindowCommandCloseIntent({
|
||||
workspaceIds,
|
||||
logViewIds,
|
||||
pluginViewTabIds = [],
|
||||
closeTabShortcutEnabled = true,
|
||||
hasOpenDialog = false,
|
||||
}: ResolveWindowCommandCloseIntentInput): WindowCommandCloseIntent {
|
||||
if (!closeTabShortcutEnabled) {
|
||||
return { kind: 'forwardShortcut' };
|
||||
}
|
||||
|
||||
if (hasOpenDialog) {
|
||||
return { kind: 'closeDialog' };
|
||||
}
|
||||
|
||||
if (!activeTabId) {
|
||||
return { kind: 'closeWindow' };
|
||||
}
|
||||
|
||||
@@ -231,9 +231,11 @@ test("buildSyncPayload includes AI configuration settings", () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_DEFAULT_AGENT, "codex");
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_COMMAND_BLOCKLIST, JSON.stringify(["rm -rf"]));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_COMMAND_TIMEOUT, "120");
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT, "600");
|
||||
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");
|
||||
|
||||
@@ -251,9 +253,11 @@ test("buildSyncPayload includes AI configuration settings", () => {
|
||||
defaultAgentId: "codex",
|
||||
commandBlocklist: ["rm -rf"],
|
||||
commandTimeout: 120,
|
||||
responseIdleTimeout: 600,
|
||||
maxIterations: 10,
|
||||
agentModelMap: { codex: "gpt-test" },
|
||||
agentProviderMap: { catty: "openai-main" },
|
||||
agentThinkingMap: { catty: "high" },
|
||||
webSearchConfig: webSearchWithoutKey,
|
||||
showTerminalSelectionAction: false,
|
||||
});
|
||||
@@ -267,6 +271,14 @@ test("terminal selection AI preference is syncable for auto-sync detection", ()
|
||||
);
|
||||
});
|
||||
|
||||
test("AI response wait time is syncable for auto-sync detection", () => {
|
||||
assert.ok(
|
||||
(SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(
|
||||
storageKeys.STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal side panel auto-open settings are syncable for auto-sync detection", () => {
|
||||
assert.ok(
|
||||
(SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(
|
||||
@@ -280,6 +292,30 @@ test("terminal side panel auto-open settings are syncable for auto-sync detectio
|
||||
);
|
||||
});
|
||||
|
||||
test("note appearance settings survive sync and trigger auto-sync", async () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_FAMILY, "Menlo, monospace");
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_SIZE, "16");
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE, "14");
|
||||
|
||||
const payload = buildSyncPayload(vault([]));
|
||||
assert.equal(payload.settings?.noteFontFamily, "Menlo, monospace");
|
||||
assert.equal(payload.settings?.noteFontSize, 16);
|
||||
assert.equal(payload.settings?.noteCodeFontSize, 14);
|
||||
for (const key of [
|
||||
storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_FAMILY,
|
||||
storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_SIZE,
|
||||
storageKeys.STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE,
|
||||
]) {
|
||||
assert.ok((SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(key));
|
||||
}
|
||||
|
||||
localStorage.clear();
|
||||
await applySyncPayload(payload, { importVaultData: () => {} });
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_FAMILY), "Menlo, monospace");
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_SIZE), "16");
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE), "14");
|
||||
});
|
||||
|
||||
test("buildSyncPayload includes host tree sidebar visibility setting", () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, "false");
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN, "true");
|
||||
@@ -445,6 +481,7 @@ test("applySyncPayload restores AI configuration settings", async () => {
|
||||
defaultAgentId: "claude",
|
||||
commandBlocklist: ["shutdown"],
|
||||
commandTimeout: 30,
|
||||
responseIdleTimeout: 900,
|
||||
maxIterations: 5,
|
||||
agentModelMap: { claude: "claude-test" },
|
||||
agentProviderMap: { catty: "anthropic-main" },
|
||||
@@ -465,6 +502,7 @@ test("applySyncPayload restores AI configuration settings", async () => {
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_DEFAULT_AGENT), "claude");
|
||||
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_COMMAND_BLOCKLIST)!), ["shutdown"]);
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_COMMAND_TIMEOUT), "30");
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT), "900");
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_MAX_ITERATIONS), "5");
|
||||
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP)!), { claude: "claude-test" });
|
||||
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP)!), { catty: "anthropic-main" });
|
||||
@@ -1285,6 +1323,26 @@ test("terminal auto-close preference survives sync round-trip", async () => {
|
||||
assert.equal(restored.autoCloseOnExit, false);
|
||||
});
|
||||
|
||||
test("terminal disconnected notice preference survives sync round-trip", async () => {
|
||||
localStorage.setItem(
|
||||
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ disconnectedNoticeMode: "dialog" }),
|
||||
);
|
||||
|
||||
const payload = buildSyncPayload(vault());
|
||||
const termSettings = (payload.settings?.terminalSettings ?? {}) as Record<string, unknown>;
|
||||
assert.equal(termSettings.disconnectedNoticeMode, "dialog");
|
||||
|
||||
localStorage.setItem(
|
||||
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ disconnectedNoticeMode: "terminal" }),
|
||||
);
|
||||
await applySyncPayload(payload, { importVaultData: () => {} });
|
||||
|
||||
const restored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS)!);
|
||||
assert.equal(restored.disconnectedNoticeMode, "dialog");
|
||||
});
|
||||
|
||||
test("applySyncPayload restores the terminal host information bar preference", async () => {
|
||||
localStorage.setItem(
|
||||
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
||||
|
||||
@@ -99,13 +99,18 @@ import {
|
||||
STORAGE_KEY_AI_DEFAULT_AGENT,
|
||||
STORAGE_KEY_AI_COMMAND_BLOCKLIST,
|
||||
STORAGE_KEY_AI_COMMAND_TIMEOUT,
|
||||
STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT,
|
||||
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,
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
STORAGE_KEY_VAULT_NOTES_FONT_FAMILY,
|
||||
STORAGE_KEY_VAULT_NOTES_FONT_SIZE,
|
||||
STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE,
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
import { isTerminalSidePanelAutoOpenTab } from '../domain/terminalSidePanelAutoOpen';
|
||||
import { prepareRestoredPayloadConvergentWrites } from './convergentSyncReplica';
|
||||
@@ -249,8 +254,8 @@ const SYNCABLE_TERMINAL_KEYS = [
|
||||
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
||||
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
|
||||
'autoUploadClipboardImageOnPaste',
|
||||
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'dynamicTabTitleMode',
|
||||
'autoCloseOnExit',
|
||||
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'oscNotifications', 'dynamicTabTitleMode',
|
||||
'autoCloseOnExit', 'disconnectedNoticeMode',
|
||||
'showHostInfoBar', 'hostInfoBarTitleMode', 'showServerStats',
|
||||
'serverStatsRefreshInterval',
|
||||
'systemManagerProcessRefreshInterval', 'systemManagerTmuxRefreshInterval',
|
||||
@@ -273,6 +278,9 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_UI_FONT_FAMILY,
|
||||
STORAGE_KEY_UI_LANGUAGE,
|
||||
STORAGE_KEY_CUSTOM_CSS,
|
||||
STORAGE_KEY_VAULT_NOTES_FONT_FAMILY,
|
||||
STORAGE_KEY_VAULT_NOTES_FONT_SIZE,
|
||||
STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE,
|
||||
STORAGE_KEY_TERM_THEME,
|
||||
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
|
||||
STORAGE_KEY_TERM_THEME_DARK,
|
||||
@@ -310,9 +318,11 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_AI_DEFAULT_AGENT,
|
||||
STORAGE_KEY_AI_COMMAND_BLOCKLIST,
|
||||
STORAGE_KEY_AI_COMMAND_TIMEOUT,
|
||||
STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT,
|
||||
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,
|
||||
@@ -428,6 +438,12 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (lang) settings.uiLanguage = lang;
|
||||
const css = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_CSS);
|
||||
if (css != null) settings.customCSS = css;
|
||||
const noteFontFamily = localStorageAdapter.readString(STORAGE_KEY_VAULT_NOTES_FONT_FAMILY);
|
||||
if (noteFontFamily != null) settings.noteFontFamily = noteFontFamily;
|
||||
const noteFontSize = localStorageAdapter.readNumber(STORAGE_KEY_VAULT_NOTES_FONT_SIZE);
|
||||
if (noteFontSize != null && noteFontSize >= 10 && noteFontSize <= 32) settings.noteFontSize = noteFontSize;
|
||||
const noteCodeFontSize = localStorageAdapter.readNumber(STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE);
|
||||
if (noteCodeFontSize != null && noteCodeFontSize >= 10 && noteCodeFontSize <= 32) settings.noteCodeFontSize = noteCodeFontSize;
|
||||
|
||||
// Terminal
|
||||
const termTheme = localStorageAdapter.readString(STORAGE_KEY_TERM_THEME);
|
||||
@@ -556,12 +572,18 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (Array.isArray(commandBlocklist)) ai.commandBlocklist = commandBlocklist;
|
||||
const commandTimeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT);
|
||||
if (commandTimeout != null && Number.isFinite(commandTimeout)) ai.commandTimeout = commandTimeout;
|
||||
const responseIdleTimeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT);
|
||||
if (responseIdleTimeout != null && Number.isFinite(responseIdleTimeout)) {
|
||||
ai.responseIdleTimeout = responseIdleTimeout;
|
||||
}
|
||||
const maxIterations = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS);
|
||||
if (maxIterations != null && Number.isFinite(maxIterations)) ai.maxIterations = maxIterations;
|
||||
const agentModelMap = readRecordSetting<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP);
|
||||
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);
|
||||
@@ -645,6 +667,13 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
|
||||
if (settings.uiFontFamilyId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_FONT_FAMILY, settings.uiFontFamilyId);
|
||||
if (settings.uiLanguage != null) localStorageAdapter.writeString(STORAGE_KEY_UI_LANGUAGE, settings.uiLanguage);
|
||||
if (settings.customCSS != null) localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, settings.customCSS);
|
||||
if (settings.noteFontFamily != null) localStorageAdapter.writeString(STORAGE_KEY_VAULT_NOTES_FONT_FAMILY, settings.noteFontFamily);
|
||||
if (settings.noteFontSize != null && settings.noteFontSize >= 10 && settings.noteFontSize <= 32) {
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_VAULT_NOTES_FONT_SIZE, settings.noteFontSize);
|
||||
}
|
||||
if (settings.noteCodeFontSize != null && settings.noteCodeFontSize >= 10 && settings.noteCodeFontSize <= 32) {
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE, settings.noteCodeFontSize);
|
||||
}
|
||||
|
||||
// Terminal
|
||||
if (settings.terminalTheme != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, settings.terminalTheme);
|
||||
@@ -793,9 +822,13 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
|
||||
if (ai.defaultAgentId != null) localStorageAdapter.writeString(STORAGE_KEY_AI_DEFAULT_AGENT, ai.defaultAgentId);
|
||||
if (ai.commandBlocklist != null) localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, ai.commandBlocklist);
|
||||
if (ai.commandTimeout != null) localStorageAdapter.writeNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT, ai.commandTimeout);
|
||||
if (ai.responseIdleTimeout != null) {
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT, ai.responseIdleTimeout);
|
||||
}
|
||||
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);
|
||||
@@ -845,8 +878,10 @@ function notifyAIStateAfterSync(ai: NonNullable<SyncPayload['settings']>['ai']):
|
||||
if (ai.defaultAgentId != null) touched.push(STORAGE_KEY_AI_DEFAULT_AGENT);
|
||||
if (ai.commandBlocklist != null) touched.push(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
|
||||
if (ai.commandTimeout != null) touched.push(STORAGE_KEY_AI_COMMAND_TIMEOUT);
|
||||
if (ai.responseIdleTimeout != null) touched.push(STORAGE_KEY_AI_RESPONSE_IDLE_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',
|
||||
@@ -277,6 +279,18 @@ test('AI side panel re-renders when command timeout changes', () => {
|
||||
), false);
|
||||
});
|
||||
|
||||
test('AI side panel re-renders when response wait time changes', () => {
|
||||
const props = baseProps({
|
||||
isVisible: true,
|
||||
responseIdleTimeout: 120,
|
||||
});
|
||||
|
||||
assert.equal(aiChatSidePanelPropsAreEqual(
|
||||
props,
|
||||
{ ...props, responseIdleTimeout: 600 },
|
||||
), false);
|
||||
});
|
||||
|
||||
test('AI side panel skips re-render when only a sibling scope session object changes', () => {
|
||||
const own = session({ id: 'session-1', scope: { type: 'terminal', targetId: 'terminal-1' } });
|
||||
const siblingA = session({ id: 'session-2', scope: { type: 'terminal', targetId: 'terminal-2' } });
|
||||
|
||||
@@ -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,10 +275,14 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
setAgentModel,
|
||||
agentProviderMap,
|
||||
setAgentProvider,
|
||||
agentThinkingMap,
|
||||
setAgentThinking,
|
||||
updateProvider,
|
||||
globalPermissionMode,
|
||||
setGlobalPermissionMode,
|
||||
commandBlocklist,
|
||||
commandTimeout,
|
||||
responseIdleTimeout,
|
||||
maxIterations = 20,
|
||||
webSearchConfig,
|
||||
quickMessages = [],
|
||||
@@ -760,12 +768,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 +855,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 +876,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 +1027,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 +1052,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 +1192,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 +1206,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,12 +1346,14 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
await sendToCattyAgent(sessionId, sendScopeKey, modelPrompt, abortController, currentSession ?? undefined, assistantMsgId, {
|
||||
activeProvider: sendActiveProvider,
|
||||
activeModelId: sendActiveModelId,
|
||||
reasoningEffort: selectedCattyThinking,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeLabel,
|
||||
globalPermissionMode: sendPermissionMode,
|
||||
commandBlocklist,
|
||||
commandTimeout,
|
||||
responseIdleTimeout,
|
||||
terminalSessions,
|
||||
webSearchConfig,
|
||||
getExecutorContext: () => buildExecutorContextForScope(toolScope),
|
||||
@@ -1315,13 +1370,13 @@ 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,
|
||||
sendToExternalAgent, sendToCattyAgent, reportStreamError, autoTitleSession, t,
|
||||
abortControllersRef, terminalSessions, defaultTargetSession, providers, selectedAgentModel, updateSessionExternalSessionId,
|
||||
scopeType, scopeTargetId, scopeHostIds, scopeLabel, commandBlocklist, commandTimeout, webSearchConfig, buildExecutorContextForScope,
|
||||
scopeType, scopeTargetId, scopeHostIds, scopeLabel, commandBlocklist, commandTimeout, responseIdleTimeout, webSearchConfig, buildExecutorContextForScope,
|
||||
toolIntegrationMode,
|
||||
clearScopeDraft, showScopeSessionView, setActiveSessionId,
|
||||
flushDraftText, currentAgentConfig, buildExternalAgentRuntimeModelTarget,
|
||||
@@ -1357,12 +1412,14 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
{
|
||||
activeProvider: effectiveActiveProvider,
|
||||
activeModelId: effectiveActiveModelId,
|
||||
reasoningEffort: selectedCattyThinking,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeLabel,
|
||||
globalPermissionMode,
|
||||
commandBlocklist,
|
||||
commandTimeout,
|
||||
responseIdleTimeout,
|
||||
terminalSessions,
|
||||
webSearchConfig,
|
||||
getExecutorContext: () => buildExecutorContextForScope({
|
||||
@@ -1387,9 +1444,11 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
buildExecutorContextForScope,
|
||||
commandBlocklist,
|
||||
commandTimeout,
|
||||
responseIdleTimeout,
|
||||
currentAgentId,
|
||||
effectiveActiveModelId,
|
||||
effectiveActiveProvider,
|
||||
selectedCattyThinking,
|
||||
globalPermissionMode,
|
||||
isStreaming,
|
||||
scopeKey,
|
||||
@@ -1642,6 +1701,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
effectiveActiveProvider={effectiveActiveProvider}
|
||||
effectiveActiveModelId={effectiveActiveModelId}
|
||||
handleAgentProviderModelSelect={handleAgentProviderModelSelect}
|
||||
selectedCattyThinking={selectedCattyThinking}
|
||||
handleCattyThinkingSelect={handleCattyThinkingSelect}
|
||||
files={files}
|
||||
addFiles={addFiles}
|
||||
removeFile={removeFile}
|
||||
@@ -1699,10 +1760,14 @@ const AI_CHAT_SIDE_PANEL_AI_STATE_KEYS = [
|
||||
'setAgentModel',
|
||||
'agentProviderMap',
|
||||
'setAgentProvider',
|
||||
'agentThinkingMap',
|
||||
'setAgentThinking',
|
||||
'updateProvider',
|
||||
'globalPermissionMode',
|
||||
'setGlobalPermissionMode',
|
||||
'commandBlocklist',
|
||||
'commandTimeout',
|
||||
'responseIdleTimeout',
|
||||
'maxIterations',
|
||||
'webSearchConfig',
|
||||
'quickMessages',
|
||||
|
||||
@@ -68,12 +68,16 @@ 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;
|
||||
setGlobalPermissionMode?: (mode: AIPermissionMode) => void;
|
||||
commandBlocklist?: string[];
|
||||
commandTimeout?: number;
|
||||
responseIdleTimeout?: number;
|
||||
maxIterations?: number;
|
||||
|
||||
// Web search
|
||||
|
||||
@@ -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\}/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
createDomRenderer,
|
||||
dispatchDomEvent,
|
||||
flushEffects,
|
||||
installDomEnvironment,
|
||||
} from "./test-support/renderReactDom.tsx";
|
||||
|
||||
test("associates the transfer-center folder warning with every destructive Replace action", async (t) => {
|
||||
const env = installDomEnvironment();
|
||||
const previousMutationObserver = globalThis.MutationObserver;
|
||||
const previousNodeFilter = globalThis.NodeFilter;
|
||||
const previousHTMLInputElement = globalThis.HTMLInputElement;
|
||||
const previousElement = globalThis.Element;
|
||||
const previousRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
const previousCancelAnimationFrame = globalThis.cancelAnimationFrame;
|
||||
Object.defineProperties(globalThis, {
|
||||
MutationObserver: { configurable: true, writable: true, value: env.window.MutationObserver },
|
||||
NodeFilter: { configurable: true, writable: true, value: env.window.NodeFilter },
|
||||
HTMLInputElement: { configurable: true, writable: true, value: env.window.HTMLInputElement },
|
||||
Element: { configurable: true, writable: true, value: env.window.Element },
|
||||
requestAnimationFrame: {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0),
|
||||
},
|
||||
cancelAnimationFrame: { configurable: true, writable: true, value: clearTimeout },
|
||||
});
|
||||
|
||||
const { I18nProvider } = await import("../application/i18n/I18nProvider.tsx");
|
||||
const { sftpTransferCenterStore } = await import("../application/state/sftpTransferCenterStore.ts");
|
||||
const { GlobalSftpTransferCenter } = await import("./GlobalSftpTransferCenter.tsx");
|
||||
const { TooltipProvider } = await import("./ui/tooltip.tsx");
|
||||
const renderer = await createDomRenderer(env.document);
|
||||
const taskId = "accessible-folder-conflict";
|
||||
const unregisterOwner = sftpTransferCenterStore.registerOwner("accessibility-test-owner", {
|
||||
pause: () => {},
|
||||
resume: () => {},
|
||||
cancel: () => {},
|
||||
retry: () => {},
|
||||
prioritize: () => {},
|
||||
dismiss: () => {},
|
||||
ownsTask: () => true,
|
||||
resolveConflict: () => {},
|
||||
});
|
||||
sftpTransferCenterStore.publishOwner("accessibility-test-owner", [{
|
||||
id: taskId,
|
||||
fileName: "docs",
|
||||
sourcePath: "/source/docs",
|
||||
targetPath: "/destination/docs",
|
||||
sourceConnectionId: "source",
|
||||
targetConnectionId: "target",
|
||||
direction: "remote-to-remote",
|
||||
status: "attention",
|
||||
totalBytes: 0,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: 1,
|
||||
isDirectory: true,
|
||||
conflict: {
|
||||
transferId: taskId,
|
||||
fileName: "docs",
|
||||
sourcePath: "/source/docs",
|
||||
targetPath: "/destination/docs",
|
||||
isDirectory: true,
|
||||
existingType: "directory",
|
||||
applyToAllCount: 2,
|
||||
existingSize: 0,
|
||||
newSize: 0,
|
||||
existingModified: 1,
|
||||
newModified: 2,
|
||||
},
|
||||
}]);
|
||||
|
||||
t.after(async () => {
|
||||
await renderer.unmount();
|
||||
sftpTransferCenterStore.dismiss(taskId);
|
||||
unregisterOwner();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
Object.defineProperties(globalThis, {
|
||||
MutationObserver: { configurable: true, writable: true, value: previousMutationObserver },
|
||||
NodeFilter: { configurable: true, writable: true, value: previousNodeFilter },
|
||||
HTMLInputElement: { configurable: true, writable: true, value: previousHTMLInputElement },
|
||||
Element: { configurable: true, writable: true, value: previousElement },
|
||||
requestAnimationFrame: { configurable: true, writable: true, value: previousRequestAnimationFrame },
|
||||
cancelAnimationFrame: { configurable: true, writable: true, value: previousCancelAnimationFrame },
|
||||
});
|
||||
env.cleanup();
|
||||
});
|
||||
|
||||
await renderer.render(React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(TooltipProvider, null, React.createElement(GlobalSftpTransferCenter)),
|
||||
));
|
||||
await flushEffects();
|
||||
|
||||
const toggle = env.document.querySelector<HTMLButtonElement>("[data-section=global-sftp-transfer-toggle]");
|
||||
assert.ok(toggle, "transfer-center toggle should render");
|
||||
await dispatchDomEvent(toggle, new env.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
|
||||
const replaceButtons = Array.from(env.document.querySelectorAll<HTMLButtonElement>("button"))
|
||||
.filter((button) => button.textContent?.startsWith("Replace"));
|
||||
assert.equal(replaceButtons.length, 2);
|
||||
for (const button of replaceButtons) {
|
||||
const describedBy = button.getAttribute("aria-describedby");
|
||||
assert.ok(describedBy, `${button.textContent} should expose the folder deletion warning`);
|
||||
const warning = env.document.getElementById(describedBy);
|
||||
assert.match(warning?.textContent ?? "", /Replace deletes all destination files and sub-folders/);
|
||||
}
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import test from "node:test";
|
||||
import type { TransferTask } from "../domain/models";
|
||||
import {
|
||||
buildGlobalTransferProgressDisplay,
|
||||
getGlobalConflictActionPresentation,
|
||||
getGlobalTransferBadge,
|
||||
getGlobalTransferBatchEligibility,
|
||||
getGlobalTransferStatusOverride,
|
||||
@@ -16,6 +17,21 @@ import {
|
||||
splitBackgroundTransfers,
|
||||
} from "./GlobalSftpTransferCenter";
|
||||
|
||||
test("folder conflicts emphasize merge and mark replace as destructive in the transfer center", () => {
|
||||
assert.deepEqual(getGlobalConflictActionPresentation("merge", true), {
|
||||
variant: "default",
|
||||
destructiveReplace: false,
|
||||
});
|
||||
assert.deepEqual(getGlobalConflictActionPresentation("replace", true), {
|
||||
variant: "outline",
|
||||
destructiveReplace: true,
|
||||
});
|
||||
assert.deepEqual(getGlobalConflictActionPresentation("replace", false), {
|
||||
variant: "default",
|
||||
destructiveReplace: false,
|
||||
});
|
||||
});
|
||||
|
||||
const task = (id: string, status: TransferTask["status"], background = false) => ({
|
||||
id,
|
||||
fileName: id,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
ArrowDownToLine,
|
||||
ArrowDownUp,
|
||||
ArrowUpFromLine,
|
||||
@@ -26,7 +27,7 @@ import {
|
||||
import { transferRuntime } from "../application/state/sftp/transferRuntime";
|
||||
import { useGlobalSftpTransferActions } from "../application/state/useGlobalSftpTransferActions";
|
||||
export { getGlobalTransferBatchEligibility } from "../domain/sftpTransferActions";
|
||||
import type { TransferTask } from "../domain/models";
|
||||
import type { FileConflictAction, TransferTask } from "../domain/models";
|
||||
import { canReplaceSftpConflict } from "../domain/sftpConflict";
|
||||
import { estimateTransferEtaSeconds, formatFileSize, formatTransferEta } from "../application/state/sftp/utils";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -113,6 +114,18 @@ export function getGlobalTransferStatusOverride(task: Pick<TransferTask, "error"
|
||||
return task.error || task.pauseUnavailableReason;
|
||||
}
|
||||
|
||||
export function getGlobalConflictActionPresentation(
|
||||
action: FileConflictAction,
|
||||
destructiveDirectoryReplace: boolean,
|
||||
) {
|
||||
const safeMerge = destructiveDirectoryReplace && action === "merge";
|
||||
const destructiveReplace = destructiveDirectoryReplace && action === "replace";
|
||||
return {
|
||||
variant: safeMerge || (!destructiveDirectoryReplace && action === "replace") ? "default" : "outline",
|
||||
destructiveReplace,
|
||||
} as const;
|
||||
}
|
||||
|
||||
const BUCKETS: readonly GlobalTransferBucket[] = ["all", "active", "queued", "paused", "attention", "completed"];
|
||||
|
||||
export function getTasksForGlobalTransferBucket(
|
||||
@@ -334,6 +347,7 @@ function TransferRow({
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const folderReplaceWarningId = React.useId();
|
||||
// Optimistic spinner from click until store status moves off paused/interrupted.
|
||||
const [resumeClicked, setResumeClicked] = useState(false);
|
||||
const isDirParent = isDirectoryParentTask(task);
|
||||
@@ -671,6 +685,8 @@ function TransferRow({
|
||||
{task.status === "attention" && task.conflict && canControl && (() => {
|
||||
const conflict = task.conflict!;
|
||||
const canMerge = conflict.isDirectory && conflict.existingType === "directory";
|
||||
const destructiveDirectoryReplace = canMerge;
|
||||
const unresolvedFolderType = conflict.isDirectory && !conflict.existingType;
|
||||
const canReplace = canReplaceSftpConflict(conflict.isDirectory, conflict.existingType);
|
||||
const actions = [
|
||||
"stop",
|
||||
@@ -685,30 +701,53 @@ function TransferRow({
|
||||
...(canMerge ? (["merge"] as const) : []),
|
||||
...(canReplace ? (["replace"] as const) : []),
|
||||
] as const;
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap justify-end gap-1">
|
||||
{actions.map((action) => (
|
||||
const renderConflictAction = (action: FileConflictAction, applyToAll = false) => {
|
||||
const presentation = getGlobalConflictActionPresentation(action, destructiveDirectoryReplace);
|
||||
return (
|
||||
<Button
|
||||
key={action}
|
||||
variant={action === "replace" ? "default" : "outline"}
|
||||
key={applyToAll ? `all-${action}` : action}
|
||||
variant={presentation.variant}
|
||||
size="sm"
|
||||
className="h-6 px-2 text-[10px]"
|
||||
onClick={() => { void sftpTransferCenterStore.resolveConflict(task.id, action); }}
|
||||
className={cn(
|
||||
"h-6 px-2 text-[10px]",
|
||||
presentation.destructiveReplace
|
||||
&& "border-destructive/50 text-destructive hover:bg-destructive/10 hover:text-destructive",
|
||||
)}
|
||||
aria-describedby={presentation.destructiveReplace ? folderReplaceWarningId : undefined}
|
||||
onClick={() => { void sftpTransferCenterStore.resolveConflict(task.id, action, applyToAll); }}
|
||||
>
|
||||
{t(`sftp.conflict.action.${action}`)}
|
||||
{applyToAll && <> · {t("sftp.transferCenter.applyAll")}</>}
|
||||
</Button>
|
||||
))}
|
||||
{(conflict.applyToAllCount ?? 0) > 1 && applyAllActions.map((action) => (
|
||||
<Button
|
||||
key={`all-${action}`}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-[10px]"
|
||||
onClick={() => { void sftpTransferCenterStore.resolveConflict(task.id, action, true); }}
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div className="mt-2 space-y-2">
|
||||
{destructiveDirectoryReplace && (
|
||||
<div
|
||||
id={folderReplaceWarningId}
|
||||
className="flex items-start gap-1.5 rounded border border-destructive/40 bg-destructive/10 px-2 py-1.5 text-[10px] leading-4"
|
||||
>
|
||||
{t(`sftp.conflict.action.${action}`)} · {t("sftp.transferCenter.applyAll")}
|
||||
</Button>
|
||||
))}
|
||||
<AlertTriangle size={12} className="mt-0.5 shrink-0 text-destructive" />
|
||||
<p>
|
||||
{t("sftp.conflict.folderMergeHint")}{" "}
|
||||
<span className="font-medium text-destructive">
|
||||
{t("sftp.conflict.folderReplaceWarning")}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{unresolvedFolderType && (
|
||||
<div className="flex items-start gap-1.5 rounded border border-amber-500/40 bg-amber-500/10 px-2 py-1.5 text-[10px] leading-4">
|
||||
<AlertTriangle size={12} className="mt-0.5 shrink-0 text-amber-600" />
|
||||
<p>{t("sftp.conflict.folderUnknownDesc")}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap justify-end gap-1">
|
||||
{actions.map((action) => renderConflictAction(action))}
|
||||
{(conflict.applyToAllCount ?? 0) > 1
|
||||
&& applyAllActions.map((action) => renderConflictAction(action, true))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -3,11 +3,27 @@ import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildPaneMagnificationPaletteAction,
|
||||
buildPluginPaletteItems,
|
||||
getQuickSwitcherRowStateClass,
|
||||
shouldUseQuickSwitcherPointerNavigation,
|
||||
} from './QuickSwitcher';
|
||||
|
||||
test('command palette follows the active pane magnification state', () => {
|
||||
const t = (key: string) => key;
|
||||
assert.equal(buildPaneMagnificationPaletteAction('unavailable', t), null);
|
||||
assert.deepEqual(buildPaneMagnificationPaletteAction('focusable', t), {
|
||||
type: 'action',
|
||||
id: 'magnify-current-pane',
|
||||
title: 'terminal.paneMagnification.magnify',
|
||||
});
|
||||
assert.deepEqual(buildPaneMagnificationPaletteAction('focused', t), {
|
||||
type: 'action',
|
||||
id: 'restore-magnified-pane',
|
||||
title: 'terminal.paneMagnification.restore',
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps the new workspace action outside the scrollable results', () => {
|
||||
const source = readFileSync(new URL('./QuickSwitcher.tsx', import.meta.url), 'utf8');
|
||||
const actionIndex = source.indexOf('{/* Jump To hint + New Workspace action */}');
|
||||
|
||||
@@ -2,6 +2,8 @@ import {
|
||||
Folder,
|
||||
FolderLock,
|
||||
LayoutGrid,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Plus,
|
||||
Search,
|
||||
Terminal,
|
||||
@@ -11,6 +13,7 @@ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { Host, TerminalSession, TerminalSettings, Workspace } from "../types";
|
||||
import { KeyBinding } from "../domain/models";
|
||||
import type { PaneMagnificationState } from "../domain/paneMagnification";
|
||||
import { matchesSearchQuery } from "../lib/searchMatcher";
|
||||
import { buildQuickSwitcherShells, useDiscoveredShells, getShellIconPath, isMonochromeShellIcon } from "../lib/useDiscoveredShells";
|
||||
import { usePluginContributions } from "../application/state/usePluginContributions";
|
||||
@@ -34,6 +37,19 @@ type QuickSwitcherItem = QuickSwitcherItemBase & (
|
||||
| { type: "host" | "tab" | "workspace" | "action" | "shell" | "plugin-view"; commandId?: never }
|
||||
);
|
||||
|
||||
export function buildPaneMagnificationPaletteAction(
|
||||
state: PaneMagnificationState,
|
||||
t: (key: string) => string,
|
||||
): QuickSwitcherItem | null {
|
||||
if (state === 'focusable') {
|
||||
return { type: 'action', id: 'magnify-current-pane', title: t('terminal.paneMagnification.magnify') };
|
||||
}
|
||||
if (state === 'focused') {
|
||||
return { type: 'action', id: 'restore-magnified-pane', title: t('terminal.paneMagnification.restore') };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getQuickSwitcherRowStateClass(
|
||||
isSelected: boolean,
|
||||
isKeyboardNavigating: boolean,
|
||||
@@ -146,6 +162,9 @@ interface QuickSwitcherProps {
|
||||
keyBindings?: KeyBinding[];
|
||||
showSftpTab: boolean;
|
||||
terminalSettings?: Pick<TerminalSettings, "localShell" | "localShellArgs">;
|
||||
paneMagnificationState?: PaneMagnificationState;
|
||||
onMagnifyCurrentPane?: () => void;
|
||||
onRestoreMagnifiedPane?: () => void;
|
||||
}
|
||||
|
||||
const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
@@ -164,6 +183,9 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
keyBindings,
|
||||
showSftpTab,
|
||||
terminalSettings,
|
||||
paneMagnificationState = 'unavailable',
|
||||
onMagnifyCurrentPane,
|
||||
onRestoreMagnifiedPane,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const discoveredShells = useDiscoveredShells();
|
||||
@@ -287,6 +309,10 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
pluginContributions.snapshot.plugins,
|
||||
trimmedQuery,
|
||||
), [pluginContributions.snapshot.plugins, trimmedQuery]);
|
||||
const paneMagnificationAction = useMemo(
|
||||
() => buildPaneMagnificationPaletteAction(paneMagnificationState, t),
|
||||
[paneMagnificationState, t],
|
||||
);
|
||||
|
||||
// Memoize flat selectable items + visual rows (headers + items) for virtualization.
|
||||
const { flatItems, visualRows, itemIndexToVisualIndex } = useMemo(() => {
|
||||
@@ -343,6 +369,14 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
pushItem({ type: "action", id: "local-terminal" });
|
||||
}
|
||||
|
||||
if (paneMagnificationAction && (
|
||||
!trimmedQuery
|
||||
|| matchesSearchQuery(trimmedQuery, paneMagnificationAction.title ?? '', 'pane', 'magnify', 'zoom')
|
||||
)) {
|
||||
pushHeader("header:pane-actions", t("settings.shortcuts.category.navigation"));
|
||||
pushItem(paneMagnificationAction);
|
||||
}
|
||||
|
||||
if (pluginPaletteItems.length > 0) {
|
||||
pushHeader("header:plugins", t("settings.tab.plugins"));
|
||||
pluginPaletteItems.forEach((item) => pushItem(item));
|
||||
@@ -359,9 +393,11 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
filteredShells,
|
||||
filteredWorkspaces,
|
||||
pluginPaletteItems,
|
||||
paneMagnificationAction,
|
||||
results,
|
||||
shouldShowLocalTerminalFallback,
|
||||
t,
|
||||
trimmedQuery,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -421,6 +457,12 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
if (item.id === "local-terminal" && onCreateLocalTerminal) {
|
||||
onCreateLocalTerminal();
|
||||
onClose();
|
||||
} else if (item.id === 'magnify-current-pane' && onMagnifyCurrentPane) {
|
||||
onMagnifyCurrentPane();
|
||||
onClose();
|
||||
} else if (item.id === 'restore-magnified-pane' && onRestoreMagnifiedPane) {
|
||||
onRestoreMagnifiedPane();
|
||||
onClose();
|
||||
}
|
||||
break;
|
||||
case "shell": {
|
||||
@@ -679,6 +721,27 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === 'action' && (
|
||||
item.id === 'magnify-current-pane' || item.id === 'restore-magnified-pane'
|
||||
)) {
|
||||
const restoring = item.id === 'restore-magnified-pane';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`${rowClass} flex h-full w-full items-center gap-3 px-4 text-left`}
|
||||
onClick={() => handleItemSelect(item)}
|
||||
>
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground">
|
||||
{restoring ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
|
||||
</div>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">{item.title}</span>
|
||||
<kbd className="shrink-0 text-[10px] text-muted-foreground">
|
||||
{getHotkeyLabel('toggle-pane-zoom').replace(/ \+ /g, '+')}
|
||||
</kbd>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "plugin-command" || item.type === "plugin-view") {
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -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";
|
||||
@@ -25,6 +25,8 @@ import { useExternalMcpGrantPersister } from "./ai/useExternalMcpGrantPersister"
|
||||
import { setupMcpApprovalBridge } from "../infrastructure/ai/shared/approvalGate";
|
||||
import { usePluginContributions } from "../application/state/usePluginContributions";
|
||||
import { PluginContributionHost } from "./plugins/PluginContributionHost";
|
||||
import { matchesKeyBinding } from "../domain/models";
|
||||
import { isPrimaryModifierWBinding } from "../application/state/windowCommandClose";
|
||||
|
||||
const LazySettingsApplicationTab = lazy(() => import("./SettingsApplicationTab"));
|
||||
const LazySettingsAppearanceTab = lazy(() => import("./settings/tabs/SettingsAppearanceTab"));
|
||||
@@ -64,7 +66,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";
|
||||
@@ -219,6 +222,8 @@ const SettingsAITabContainer: React.FC = () => {
|
||||
setCommandBlocklist={aiState.setCommandBlocklist}
|
||||
commandTimeout={aiState.commandTimeout}
|
||||
setCommandTimeout={aiState.setCommandTimeout}
|
||||
responseIdleTimeout={aiState.responseIdleTimeout}
|
||||
setResponseIdleTimeout={aiState.setResponseIdleTimeout}
|
||||
maxIterations={aiState.maxIterations}
|
||||
setMaxIterations={aiState.setMaxIterations}
|
||||
webSearchConfig={aiState.webSearchConfig}
|
||||
@@ -298,7 +303,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();
|
||||
@@ -311,6 +316,13 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
const [activeTab, setActiveTab] = useState("application");
|
||||
const [mountedTabs, setMountedTabs] = useState(() => new Set(["application"]));
|
||||
const { available: pluginRuntimeAvailable } = usePluginContributions();
|
||||
const closeTabKeyStr = useMemo(() => {
|
||||
if (settings.hotkeyScheme === "disabled") return null;
|
||||
const binding = settings.keyBindings.find((item) => item.action === "closeTab");
|
||||
if (!binding) return null;
|
||||
return settings.hotkeyScheme === "mac" ? binding.mac : binding.pc;
|
||||
}, [settings.hotkeyScheme, settings.keyBindings]);
|
||||
const nativeCommandWClosesSettings = isPrimaryModifierWBinding(closeTabKeyStr, matchesKeyBinding, true);
|
||||
|
||||
useEffect(() => {
|
||||
notifyRendererReady();
|
||||
@@ -335,10 +347,20 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onWindowCommandCloseRequested(() => {
|
||||
if (!nativeCommandWClosesSettings) {
|
||||
(document.activeElement ?? window).dispatchEvent(new KeyboardEvent("keydown", {
|
||||
key: "w",
|
||||
code: "KeyW",
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
void closeSettingsWindow();
|
||||
});
|
||||
return () => unsubscribe?.();
|
||||
}, [closeSettingsWindow, onWindowCommandCloseRequested]);
|
||||
}, [closeSettingsWindow, nativeCommandWClosesSettings, onWindowCommandCloseRequested]);
|
||||
|
||||
useEffect(() => {
|
||||
setMountedTabs((prev) => {
|
||||
@@ -606,6 +628,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 +690,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>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,27 @@ test("main SftpView keeps browse sessions across top-tab switches", () => {
|
||||
assert.doesNotMatch(source, /interactive:\s*isActive/);
|
||||
});
|
||||
|
||||
test("SFTP magnification overlays one side while preserving the original two-pane geometry", async () => {
|
||||
const { resolveTwoPaneMagnificationStyle } = await import("../domain/paneMagnification.ts");
|
||||
|
||||
assert.deepEqual(resolveTwoPaneMagnificationStyle('left', true, false), {
|
||||
left: '0%', top: '0%', width: '50%', height: '100%', zIndex: 10,
|
||||
});
|
||||
assert.deepEqual(resolveTwoPaneMagnificationStyle('right', true, false), {
|
||||
left: '50%', top: '0%', width: '50%', height: '100%', zIndex: 10,
|
||||
});
|
||||
assert.deepEqual(resolveTwoPaneMagnificationStyle('right', true, true), {
|
||||
left: '12px', top: '12px', width: 'calc(100% - 24px)', height: 'calc(100% - 24px)', zIndex: 50,
|
||||
});
|
||||
});
|
||||
|
||||
test("SFTP keyboard focus tracks the active side and blocks the covered sibling", () => {
|
||||
const source = readFileSync(new URL("./SftpView.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /inert=\{magnifiedSide === 'right' \? true : undefined\}[\s\S]*onFocusCapture=\{\(\) => handlePaneFocus\("left"\)\}/);
|
||||
assert.match(source, /inert=\{magnifiedSide === 'left' \? true : undefined\}[\s\S]*onFocusCapture=\{\(\) => handlePaneFocus\("right"\)\}/);
|
||||
});
|
||||
|
||||
test("SftpView re-renders when host-key verification setting changes", async () => {
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
|
||||
+102
-5
@@ -14,13 +14,18 @@
|
||||
* - components/sftp/SftpHostPicker.tsx - Host selection dialog
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react";
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useIsSftpActive } from "../application/state/activeTabStore";
|
||||
import { activeTabStore as globalActiveTabStore, useIsSftpActive } from "../application/state/activeTabStore";
|
||||
import { useSftpState } from "../application/state/useSftpState";
|
||||
import { useSftpBackend } from "../application/state/useSftpBackend";
|
||||
import { getParentPath, isConcreteTransferTargetPath } from "../application/state/sftp/utils";
|
||||
import { HotkeyScheme, KeyBinding, TerminalSession } from "../domain/models";
|
||||
import {
|
||||
getPaneMagnificationShortcutLabel,
|
||||
resolveTwoPaneMagnificationStyle,
|
||||
type PaneMagnificationController,
|
||||
} from "../domain/paneMagnification";
|
||||
import { listSftpConnectedHosts, resolveSftpTransferSourceSessionId, sftpPickerSessionsEqual } from "../domain/sftpConnectedHosts";
|
||||
import { logger } from "../lib/logger";
|
||||
import { useRenderTracker } from "../lib/useRenderTracker";
|
||||
@@ -73,6 +78,7 @@ interface SftpViewProps {
|
||||
editorWordWrap: boolean;
|
||||
setEditorWordWrap: (enabled: boolean) => void;
|
||||
terminalSettings?: { verifyHostKeys: boolean; keepaliveInterval: number; keepaliveCountMax: number };
|
||||
paneMagnificationRef?: React.MutableRefObject<PaneMagnificationController | null>;
|
||||
}
|
||||
|
||||
const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
@@ -96,8 +102,10 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
terminalSettings,
|
||||
paneMagnificationRef,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const paneMagnificationShortcutLabel = getPaneMagnificationShortcutLabel(keyBindings, hotkeyScheme);
|
||||
const isActive = useIsSftpActive();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const dialogActionScopeIdRef = useRef("sftp-main-view");
|
||||
@@ -222,12 +230,71 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
|
||||
// Subscribe to focused side for visual indicator
|
||||
const focusedSide = useSftpFocusedSide();
|
||||
const [magnifiedSide, setMagnifiedSide] = useState<SftpFocusedSide | null>(null);
|
||||
const focusedSideRef = useRef(focusedSide);
|
||||
const magnifiedSideRef = useRef(magnifiedSide);
|
||||
focusedSideRef.current = focusedSide;
|
||||
magnifiedSideRef.current = magnifiedSide;
|
||||
const [isWideSplit, setIsWideSplit] = useState(true);
|
||||
const [showMagnificationHint, setShowMagnificationHint] = useState(false);
|
||||
const splitSurfaceRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const surface = splitSurfaceRef.current;
|
||||
if (!surface) return undefined;
|
||||
const update = () => setIsWideSplit(surface.clientWidth >= 1024);
|
||||
update();
|
||||
if (typeof ResizeObserver === 'undefined') return undefined;
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(surface);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!magnifiedSide) {
|
||||
setShowMagnificationHint(false);
|
||||
return undefined;
|
||||
}
|
||||
setShowMagnificationHint(true);
|
||||
const timerId = window.setTimeout(() => setShowMagnificationHint(false), 1800);
|
||||
return () => window.clearTimeout(timerId);
|
||||
}, [magnifiedSide]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!paneMagnificationRef) return undefined;
|
||||
const controller: PaneMagnificationController = {
|
||||
getState: () => {
|
||||
if (globalActiveTabStore.getActiveTabId() !== 'sftp') return 'unavailable';
|
||||
return magnifiedSideRef.current ? 'focused' : 'focusable';
|
||||
},
|
||||
focus: () => {
|
||||
if (globalActiveTabStore.getActiveTabId() !== 'sftp' || magnifiedSideRef.current) return false;
|
||||
setMagnifiedSide(focusedSideRef.current);
|
||||
return true;
|
||||
},
|
||||
restore: () => {
|
||||
if (globalActiveTabStore.getActiveTabId() !== 'sftp' || !magnifiedSideRef.current) return false;
|
||||
setMagnifiedSide(null);
|
||||
return true;
|
||||
},
|
||||
toggle: () => {
|
||||
if (globalActiveTabStore.getActiveTabId() !== 'sftp') return false;
|
||||
setMagnifiedSide((current) => current ? null : focusedSideRef.current);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
paneMagnificationRef.current = controller;
|
||||
return () => {
|
||||
if (paneMagnificationRef.current === controller) paneMagnificationRef.current = null;
|
||||
};
|
||||
}, [paneMagnificationRef]);
|
||||
|
||||
// Handle pane focus when clicking on a pane container
|
||||
// Clear the opposite side's selection so file operations only affect the focused pane
|
||||
const handlePaneFocus = useCallback((side: SftpFocusedSide, targetTabId?: string) => {
|
||||
const prevSide = sftpFocusStore.getFocusedSide();
|
||||
sftpFocusStore.setFocusedSide(side);
|
||||
setMagnifiedSide((current) => current ? side : null);
|
||||
if (prevSide !== side) {
|
||||
if (targetTabId) {
|
||||
keepOnlyPaneSelections(sftpRef.current, { side, tabId: targetTabId });
|
||||
@@ -481,10 +548,26 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
)}
|
||||
style={containerStyle}
|
||||
>
|
||||
<div className="flex-1 grid grid-cols-1 lg:grid-cols-2 min-h-0 border-t border-border/70">
|
||||
<div
|
||||
ref={splitSurfaceRef}
|
||||
className="relative flex-1 min-h-0 border-t border-border/70 overflow-hidden"
|
||||
data-section="sftp-main-split"
|
||||
data-magnified-side={magnifiedSide ?? undefined}
|
||||
>
|
||||
{magnifiedSide && (
|
||||
<div
|
||||
className="absolute inset-0 z-40 bg-background/55 backdrop-blur-[1px]"
|
||||
aria-hidden="true"
|
||||
data-section="pane-magnification-backdrop"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="relative border-r border-border/70 flex flex-col"
|
||||
className="absolute min-w-0 border-r border-border/70 bg-background flex flex-col transition-[left,top,width,height] duration-150 ease-out"
|
||||
style={resolveTwoPaneMagnificationStyle('left', isWideSplit, magnifiedSide === 'left')}
|
||||
data-sftp-pane-side="left"
|
||||
inert={magnifiedSide === 'right' ? true : undefined}
|
||||
onClick={() => handlePaneFocus("left")}
|
||||
onFocusCapture={() => handlePaneFocus("left")}
|
||||
>
|
||||
{/* Focus indicator triangle */}
|
||||
{focusedSide === "left" && (
|
||||
@@ -544,8 +627,12 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="relative flex flex-col"
|
||||
className="absolute min-w-0 bg-background flex flex-col transition-[left,top,width,height] duration-150 ease-out"
|
||||
style={resolveTwoPaneMagnificationStyle('right', isWideSplit, magnifiedSide === 'right')}
|
||||
data-sftp-pane-side="right"
|
||||
inert={magnifiedSide === 'left' ? true : undefined}
|
||||
onClick={() => handlePaneFocus("right")}
|
||||
onFocusCapture={() => handlePaneFocus("right")}
|
||||
>
|
||||
{/* Focus indicator triangle */}
|
||||
{focusedSide === "right" && (
|
||||
@@ -604,6 +691,15 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{magnifiedSide && showMagnificationHint && (
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-4 right-4 z-[60] rounded border border-border/70 bg-background/90 px-2 py-1 text-[10px] text-muted-foreground shadow-sm animate-in fade-in duration-150"
|
||||
data-section="pane-magnification-hint"
|
||||
>
|
||||
{t('terminal.paneMagnification.hint')}: {t('terminal.layer.sftp')}
|
||||
{paneMagnificationShortcutLabel ? ` · ${paneMagnificationShortcutLabel} / Esc` : ' · Esc'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SftpOverlays
|
||||
@@ -669,6 +765,7 @@ export const sftpViewAreEqual = (prev: SftpViewProps, next: SftpViewProps): bool
|
||||
prev.sftpUseCompressedUpload === next.sftpUseCompressedUpload &&
|
||||
prev.hotkeyScheme === next.hotkeyScheme &&
|
||||
prev.keyBindings === next.keyBindings &&
|
||||
prev.paneMagnificationRef === next.paneMagnificationRef &&
|
||||
prev.editorWordWrap === next.editorWordWrap &&
|
||||
prev.setEditorWordWrap === next.setEditorWordWrap &&
|
||||
// Only these terminal connection settings affect SFTP connection
|
||||
|
||||
@@ -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();
|
||||
@@ -1876,6 +1881,12 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
draggingPackagePathRef.current = pkg.path;
|
||||
setDraggingPackagePath(pkg.path);
|
||||
lastPreviewReorderRef.current = null;
|
||||
const sourceNode = e.currentTarget as HTMLElement;
|
||||
const handleNativeDragEnd = () => {
|
||||
sourceNode.removeEventListener('dragend', handleNativeDragEnd);
|
||||
resetSnippetDragState();
|
||||
};
|
||||
sourceNode.addEventListener('dragend', handleNativeDragEnd);
|
||||
}}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
@@ -1960,6 +1971,12 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
draggingSnippetIdRef.current = snippet.id;
|
||||
setDraggingSnippetId(snippet.id);
|
||||
lastPreviewReorderRef.current = null;
|
||||
const sourceNode = e.currentTarget as HTMLElement;
|
||||
const handleNativeDragEnd = () => {
|
||||
sourceNode.removeEventListener('dragend', handleNativeDragEnd);
|
||||
resetSnippetDragState();
|
||||
};
|
||||
sourceNode.addEventListener('dragend', handleNativeDragEnd);
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isMultiSelectMode) {
|
||||
|
||||
+111
-20
@@ -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";
|
||||
@@ -247,6 +249,7 @@ import {
|
||||
MAX_CONNECTION_LOG_DATA_CHARS,
|
||||
shouldDelayAutoRunSnippetInput,
|
||||
shouldHideConnectingDialogForConnectionReuse,
|
||||
shouldShowTerminalDisconnectedNotice,
|
||||
shouldShowTerminalConnectionDialog,
|
||||
type TerminalProps,
|
||||
} from "./terminal/terminalHelpers";
|
||||
@@ -271,6 +274,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
inWorkspace,
|
||||
isResizing,
|
||||
isFocusMode,
|
||||
isPaneMagnified,
|
||||
isFocused,
|
||||
isFocusedPane,
|
||||
fontFamilyId,
|
||||
@@ -309,6 +313,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onUpdateHost,
|
||||
onAddKnownHost,
|
||||
onExpandToFocus,
|
||||
onTogglePaneMagnification,
|
||||
onCommandExecuted,
|
||||
onCommandSubmitted,
|
||||
onSplitHorizontal,
|
||||
@@ -537,6 +542,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const reconnectPreparationTokenRef = useRef<symbol | null>(null);
|
||||
const autoReconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const autoReconnectLoopActiveRef = useRef(false);
|
||||
const [manualReconnectActive, setManualReconnectActive] = useState(false);
|
||||
const autoReconnectAttemptRef = useRef(0);
|
||||
const startReconnectRef = useRef<((mode: "manual" | "auto") => void) | null>(null);
|
||||
const wakeHibernatedRuntimeForReconnectRef = useRef<(() => Promise<boolean>) | null>(null);
|
||||
@@ -605,6 +611,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 &&
|
||||
@@ -875,6 +884,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
} | null>(null);
|
||||
const [isConnectionAwaitingUserInput, setIsConnectionAwaitingUserInput] = useState(false);
|
||||
const [isConnectionPastTcpDial, setIsConnectionPastTcpDial] = useState(false);
|
||||
const [reconnectNoticeMessage, setReconnectNoticeMessage] = useState<string | null>(null);
|
||||
|
||||
// pendingUploadEntries removed - drag-drop uploads now handled by SftpSidePanel
|
||||
const [isComposeBarOpen, setIsComposeBarOpen] = useStoredBoolean(
|
||||
@@ -1001,6 +1011,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) {
|
||||
@@ -1336,7 +1353,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const resolvedChainHosts =
|
||||
chainHosts;
|
||||
|
||||
const clearAutoReconnect = useCallback((options?: { stopLoop?: boolean }) => {
|
||||
const clearAutoReconnect = useCallback((options?: { stopLoop?: boolean; clearNotice?: boolean }) => {
|
||||
if (autoReconnectTimerRef.current) {
|
||||
clearTimeout(autoReconnectTimerRef.current);
|
||||
autoReconnectTimerRef.current = null;
|
||||
@@ -1345,12 +1362,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
autoReconnectLoopActiveRef.current = false;
|
||||
autoReconnectAttemptRef.current = 0;
|
||||
}
|
||||
if (options?.clearNotice !== false) setReconnectNoticeMessage(null);
|
||||
}, []);
|
||||
|
||||
const updateStatus = useCallback((next: TerminalSession["status"]) => {
|
||||
statusRef.current = next;
|
||||
setStatus(next);
|
||||
hasConnectedRef.current = next === "connected";
|
||||
if (next !== "connecting") {
|
||||
setManualReconnectActive(false);
|
||||
terminalReconnectRegistry.setActive(sessionId, false);
|
||||
}
|
||||
if (next === "connected") {
|
||||
hasEverConnectedRef.current = true;
|
||||
clearAutoReconnect();
|
||||
@@ -1393,6 +1415,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const attempt = autoReconnectAttemptRef.current;
|
||||
const seconds = Math.round(TERMINAL_AUTO_RECONNECT_DELAY_MS / 1000);
|
||||
const scheduledMessage = t("terminal.progress.autoReconnectScheduled", { seconds, attempt });
|
||||
setReconnectNoticeMessage(scheduledMessage);
|
||||
|
||||
setError(null);
|
||||
setShowLogs(true);
|
||||
@@ -1899,15 +1922,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 +1972,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;
|
||||
@@ -3386,18 +3421,32 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
const startReconnect = async (mode: "manual" | "auto" = "manual") => {
|
||||
if (attachExistingSession) return;
|
||||
if (
|
||||
reconnectPreparationTokenRef.current !== null
|
||||
|| reconnectWakeInFlightRef.current
|
||||
) return;
|
||||
setManualReconnectActive(mode === "manual");
|
||||
if (mode === "manual") terminalReconnectRegistry.setActive(sessionId, true);
|
||||
const reconnectAttemptMessage = mode === "auto"
|
||||
? t("terminal.progress.autoReconnectAttempt", { attempt: autoReconnectAttemptRef.current })
|
||||
: t("terminal.progress.reconnecting");
|
||||
setReconnectNoticeMessage(reconnectAttemptMessage);
|
||||
const restoreDisconnectedAfterReconnectFailure = () => {
|
||||
if (mode === "manual") setReconnectNoticeMessage(null);
|
||||
updateStatus("disconnected");
|
||||
};
|
||||
if (!termRef.current && hibernatedRef.current) {
|
||||
if (reconnectWakeInFlightRef.current) return;
|
||||
const wakeForReconnect = wakeHibernatedRuntimeForReconnectRef.current;
|
||||
if (!wakeForReconnect) {
|
||||
updateStatus("disconnected");
|
||||
restoreDisconnectedAfterReconnectFailure();
|
||||
return;
|
||||
}
|
||||
reconnectWakeInFlightRef.current = true;
|
||||
const wakeToken = Symbol();
|
||||
reconnectWakeInvalidateModeRef.current = "dispose";
|
||||
reconnectWakeTokenRef.current = wakeToken;
|
||||
updateStatus("connecting");
|
||||
if (mode === "auto") updateStatus("connecting");
|
||||
void wakeForReconnect().then((woke) => {
|
||||
if (reconnectWakeTokenRef.current !== wakeToken) {
|
||||
reconnectWakeInFlightRef.current = false;
|
||||
@@ -3412,7 +3461,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
startReconnectRef.current?.(mode);
|
||||
return;
|
||||
}
|
||||
updateStatus("disconnected");
|
||||
restoreDisconnectedAfterReconnectFailure();
|
||||
}).catch(() => {
|
||||
if (reconnectWakeTokenRef.current !== wakeToken) {
|
||||
reconnectWakeInFlightRef.current = false;
|
||||
@@ -3423,11 +3472,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
reconnectWakeTokenRef.current = null;
|
||||
reconnectWakeInFlightRef.current = false;
|
||||
updateStatus("disconnected");
|
||||
restoreDisconnectedAfterReconnectFailure();
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!termRef.current) return;
|
||||
if (!termRef.current) {
|
||||
restoreDisconnectedAfterReconnectFailure();
|
||||
return;
|
||||
}
|
||||
// Claim the retry before awaiting either script cancellation or backend
|
||||
// cleanup. A second reconnect/cancel invalidates this continuation.
|
||||
const retryToken = Symbol("retry");
|
||||
@@ -3452,6 +3504,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// Return to disconnected so the existing auto-reconnect loop can
|
||||
// schedule another attempt, including another exact stop request.
|
||||
updateStatus("disconnected");
|
||||
} else if (mode === "manual" && retryTokenStillCurrent()) {
|
||||
restoreDisconnectedAfterReconnectFailure();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -3466,7 +3520,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
|
||||
if (mode === "manual") {
|
||||
clearAutoReconnect();
|
||||
clearAutoReconnect({ clearNotice: false });
|
||||
// Manual reconnect skips the disconnected status transition that normally
|
||||
// clears these refs, so onConnect scripts would otherwise stay consumed.
|
||||
if (shouldResetConnectAutomationOnReconnect(
|
||||
@@ -3497,6 +3551,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
await cleanupSession({ retainOwnership: true });
|
||||
} catch (error) {
|
||||
finishReconnectPreparation();
|
||||
if (mode === "manual" && retryTokenStillCurrent()) {
|
||||
restoreDisconnectedAfterReconnectFailure();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!retryTokenStillCurrent()) {
|
||||
@@ -3506,6 +3563,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const term = termRef.current;
|
||||
if (!term) {
|
||||
finishReconnectPreparation();
|
||||
if (mode === "manual" && retryTokenStillCurrent()) {
|
||||
restoreDisconnectedAfterReconnectFailure();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// closeSession wiped preload ready listeners; re-arm before startMosh so a
|
||||
@@ -3531,9 +3591,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
updateStatus("connecting");
|
||||
setError(null);
|
||||
setProgressLogs((prev) => (
|
||||
mode === "auto"
|
||||
? [...prev, t("terminal.progress.autoReconnectAttempt", { attempt: autoReconnectAttemptRef.current })]
|
||||
: ["Retrying secure channel..."]
|
||||
[...prev, reconnectAttemptMessage]
|
||||
));
|
||||
setShowLogs(true);
|
||||
|
||||
@@ -3611,17 +3669,31 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
);
|
||||
}, [attachExistingSession, sessionId]);
|
||||
|
||||
const isReconnectActive = autoReconnectLoopActiveRef.current || manualReconnectActive;
|
||||
const shouldShowConnectionDialog = shouldShowTerminalConnectionDialog({
|
||||
status,
|
||||
isLocalConnection,
|
||||
isSerialConnection,
|
||||
isDisconnectedDialogDismissed,
|
||||
disconnectedNoticeMode: terminalSettings.disconnectedNoticeMode,
|
||||
hasEverConnected: hasEverConnectedRef.current,
|
||||
restoreState,
|
||||
isReconnectActive,
|
||||
requiresUserInput: auth.needsAuth || needsHostKeyVerification || isConnectionAwaitingUserInput,
|
||||
hideConnectingDialogForConnectionReuse: shouldHideConnectingDialogForConnectionReuse({
|
||||
reuseConnectionFromSessionId: connectionReuseAttemptSourceId,
|
||||
host,
|
||||
connectionReuseFellBack,
|
||||
}),
|
||||
});
|
||||
const showDisconnectedTerminalNotice = shouldShowTerminalDisconnectedNotice({
|
||||
status,
|
||||
disconnectedNoticeMode: terminalSettings.disconnectedNoticeMode,
|
||||
hasEverConnected: hasEverConnectedRef.current,
|
||||
restoreState,
|
||||
isReconnectActive,
|
||||
requiresUserInput: auth.needsAuth || needsHostKeyVerification || isConnectionAwaitingUserInput,
|
||||
});
|
||||
|
||||
const {
|
||||
handleDragEnter,
|
||||
@@ -3763,7 +3835,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
window.dispatchEvent(new CustomEvent('netcatty:script:recording:start', { detail: { sessionId } }));
|
||||
}, [sessionId, t]);
|
||||
|
||||
const renderControls = useCallback((opts?: { showClose?: boolean }) => (
|
||||
const renderControls = useCallback((opts?: { showClose?: boolean; restorePaneLayout?: boolean }) => (
|
||||
<TerminalToolbar
|
||||
sessionId={sessionId}
|
||||
workspaceId={workspaceId}
|
||||
@@ -3788,9 +3860,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}) ? () => setOsc7SetupOpen(true) : undefined}
|
||||
onUpdateHost={handleUpdateHostFromTerminal}
|
||||
showClose={opts?.showClose}
|
||||
// Workspace toolbar X closes/destroys this pane session. Detach to a
|
||||
// standalone tab remains a separate control (SquareArrowOutUpRight).
|
||||
onClose={() => onCloseSession?.(sessionId)}
|
||||
onClose={() => {
|
||||
if (opts?.restorePaneLayout) {
|
||||
onTogglePaneMagnification?.();
|
||||
return;
|
||||
}
|
||||
onCloseSession?.(sessionId);
|
||||
}}
|
||||
closeLabel={t(opts?.restorePaneLayout
|
||||
? 'terminal.paneMagnification.restore'
|
||||
: 'terminal.toolbar.closeSession')}
|
||||
isSearchOpen={isSearchOpen}
|
||||
onToggleSearch={handleToggleSearch}
|
||||
showLogButton
|
||||
@@ -3842,6 +3921,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onOpenHistory,
|
||||
onOpenTheme,
|
||||
onToggleComposeBar,
|
||||
onTogglePaneMagnification,
|
||||
setIsComposeBarOpen,
|
||||
handleUpdateHostFromTerminal,
|
||||
sessionId,
|
||||
@@ -3849,6 +3929,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
snippets,
|
||||
status,
|
||||
terminalEncoding,
|
||||
t,
|
||||
recorder,
|
||||
workspaceId,
|
||||
]);
|
||||
@@ -3920,6 +4001,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 +4126,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return false;
|
||||
},
|
||||
takePendingBuffer: () => {
|
||||
const pending = hibernatePendingBufferRef.current;
|
||||
const pending = hibernatePendingBufferRef.current + oscNotificationScannerRef.current.flush();
|
||||
hibernatePendingBufferRef.current = "";
|
||||
return pending;
|
||||
},
|
||||
@@ -4182,7 +4273,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, RefreshCcw, Sparkles, SquareArrowOutUpRight, Unplug, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost: !!onUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI: onAddSelectionToAI ? handleAddSelectionToAI : undefined, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect: (attachExistingSession || compactToolbar) ? undefined : handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
|
||||
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, RefreshCcw, Sparkles, SquareArrowOutUpRight, Unplug, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost: !!onUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI: onAddSelectionToAI ? handleAddSelectionToAI : undefined, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect: (attachExistingSession || compactToolbar) ? undefined : handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
|
||||
<ScriptExecutionOverlay
|
||||
run={activeScriptRun}
|
||||
onPause={() => { void pauseScriptRun(activeScriptRun.runId); }}
|
||||
@@ -4191,7 +4282,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onDismiss={dismissScriptOverlay}
|
||||
compactTopChrome={terminalSettings?.showHostInfoBar === false}
|
||||
/>
|
||||
) : null, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showConnectionControls: !attachExistingSession && !compactToolbar, showLogs, showSelectionAIAction: Boolean(showSelectionAIAction && onAddSelectionToAI), isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect: handlePasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />
|
||||
) : null, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showDisconnectedTerminalNotice, showConnectionControls: !attachExistingSession && !compactToolbar, showLogs, showSelectionAIAction: Boolean(showSelectionAIAction && onAddSelectionToAI), isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect: handlePasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, terminalReconnectAvailable: !attachExistingSession, reconnectNoticeMessage, timeLeft, toast, zmodem }} isPaneMagnified={isPaneMagnified} />
|
||||
<ScriptSaveRecordingDialog
|
||||
open={saveRecordingOpen}
|
||||
code={recordedCode}
|
||||
|
||||
Loaded 100 of 381 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user