mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 14:30:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b88f7a0bfd | ||
|
|
c1635a0f55 | ||
|
|
986551f146 | ||
|
|
2796975526 | ||
|
|
0cbb377385 | ||
|
|
ee3dce4cc5 | ||
|
|
854ae0d6f2 | ||
|
|
a20bb21af8 | ||
|
|
2fbc78fdc0 | ||
|
|
32d9824eb9 | ||
|
|
98ead6af1f | ||
|
|
595f42c84d | ||
|
|
025d0cec08 | ||
|
|
6ba594800f | ||
|
|
16caa48a2a | ||
|
|
cdcacaef28 | ||
|
|
90b6e514ef | ||
|
|
c10debea35 | ||
|
|
d6f98948e0 | ||
|
|
7770e86743 | ||
|
|
1f7d2f91c9 | ||
|
|
05231d3fba | ||
|
|
d534b80532 | ||
|
|
acd39df54b | ||
|
|
657aa5a637 | ||
|
|
1386357d23 | ||
|
|
0749847fa3 | ||
|
|
10c9318e96 | ||
|
|
136be6cdc6 | ||
|
|
28b111dae8 | ||
|
|
98a2cdf22b | ||
|
|
50a8c57023 | ||
|
|
569b3d1426 | ||
|
|
bd9dbf30f8 | ||
|
|
281948ee17 | ||
|
|
c47a116403 | ||
|
|
e70b501990 | ||
|
|
8c54a3b036 | ||
|
|
3f0ff88107 | ||
|
|
071ff09d8e | ||
|
|
45c9b9e86d | ||
|
|
ae7798f997 | ||
|
|
94224d41a8 | ||
|
|
324b7b47fd | ||
|
|
b1db158b09 | ||
|
|
331edcbcc5 | ||
|
|
c1563e3862 | ||
|
|
922c372cb8 | ||
|
|
4fdf892b6a | ||
|
|
c2361461f7 | ||
|
|
1e9e4a10e7 | ||
|
|
45d51d204c | ||
|
|
9f3603d4ae | ||
|
|
4ab29424b7 | ||
|
|
6c04cecbba | ||
|
|
1337eb355b | ||
|
|
584abffa74 | ||
|
|
6fd8d711e9 | ||
|
|
506bc849ee | ||
|
|
ff639063b0 | ||
|
|
4c00ec42f9 | ||
|
|
4e5ad952f8 | ||
|
|
9f86ae2c54 | ||
|
|
4683cbc299 | ||
|
|
4eebd091bf | ||
|
|
70e06201d3 | ||
|
|
2c953cd5cb | ||
|
|
8f1167ff34 | ||
|
|
f5f3b1bb20 | ||
|
|
98438de1c4 | ||
|
|
61f89e7f7d | ||
|
|
30d78ebc5a | ||
|
|
f23c0fa0e0 | ||
|
|
e2e23c29da | ||
|
|
2e9c021e68 | ||
|
|
b7491fd05a | ||
|
|
003f1e1cb9 | ||
|
|
fc30fd4b3c | ||
|
|
377cd2ae6f | ||
|
|
a50be64266 | ||
|
|
9a81b11317 | ||
|
|
2f22765133 | ||
|
|
98f260b047 | ||
|
|
42211fb432 | ||
|
|
52974c5e77 | ||
|
|
41f4dd0781 | ||
|
|
1d18ca9761 | ||
|
|
b310ec7a25 | ||
|
|
85524afc0d | ||
|
|
6b85fbbafc | ||
|
|
12971e1ca4 | ||
|
|
997bd2b3d7 | ||
|
|
db4cff0309 | ||
|
|
8709a5e354 | ||
|
|
e9587de1ea | ||
|
|
bd12dc663e | ||
|
|
24667b7163 | ||
|
|
108cea1e5d | ||
|
|
b2e08e3963 | ||
|
|
b1f199c4d7 | ||
|
|
ee4b09059e | ||
|
|
6b9b71ac8a | ||
|
|
9d98853091 | ||
|
|
7f90896dbc | ||
|
|
8c4575c0d1 | ||
|
|
232cbb9b67 | ||
|
|
d1ef4c4e33 | ||
|
|
ff298476f1 | ||
|
|
59ea442f5c | ||
|
|
124bab4761 | ||
|
|
526187f635 | ||
|
|
99095e2f43 | ||
|
|
8db0b40d74 | ||
|
|
71687491f0 | ||
|
|
c998b69bd8 | ||
|
|
d207a1fb44 | ||
|
|
19844e2831 | ||
|
|
13061fda9a | ||
|
|
5f403232ab | ||
|
|
6a9ed0fc9d | ||
|
|
12e1ec7dcd | ||
|
|
efa223d9db | ||
|
|
f726c6b4f6 | ||
|
|
eee7a8846c | ||
|
|
b09e468f04 | ||
|
|
686543b90a | ||
|
|
c89d3a780e | ||
|
|
fa72bb28ff | ||
|
|
3c96452513 | ||
|
|
cc511a6b43 | ||
|
|
1678404081 | ||
|
|
b2ea89a1a0 | ||
|
|
ffd6cb588c | ||
|
|
1263eaa31f | ||
|
|
cb2189a2e3 | ||
|
|
235eea1e80 | ||
|
|
0f85eaba8e | ||
|
|
bb680703e7 | ||
|
|
cfd778e7d5 | ||
|
|
5080d62a8b | ||
|
|
5883362f62 | ||
|
|
3b774ca9a1 | ||
|
|
6c1ff4e4ca | ||
|
|
6c93016460 | ||
|
|
8e4d6fe752 | ||
|
|
daaa51e090 | ||
|
|
81d88aed13 | ||
|
|
5d555c9eb9 | ||
|
|
7dc5103833 | ||
|
|
0020181b3e | ||
|
|
579daf8852 | ||
|
|
a3eae4d2ca | ||
|
|
7163e0e144 | ||
|
|
8050e0dab4 | ||
|
|
a759e11066 | ||
|
|
7071741315 | ||
|
|
52b5cbc300 | ||
|
|
490f033cfd | ||
|
|
c4333ae87a | ||
|
|
1cd4f1827f | ||
|
|
b3bf566db4 | ||
|
|
5dad3d0e65 | ||
|
|
4e2a9ef4d1 | ||
|
|
d6b926899d | ||
|
|
367c73afb9 | ||
|
|
25faca7f3f | ||
|
|
7f0500ec04 | ||
|
|
f03fcffc80 | ||
|
|
b299d34a6c | ||
|
|
7cba6bae2a | ||
|
|
1679e2984f | ||
|
|
0df189b83a | ||
|
|
1c5723ddc7 | ||
|
|
0699a8a25b | ||
|
|
7d321a4f03 | ||
|
|
99f4d4b5c0 | ||
|
|
32b46b1ef3 | ||
|
|
0e80c874d6 | ||
|
|
89ed8735e7 | ||
|
|
2d4c27b12c | ||
|
|
f575317de8 | ||
|
|
6e8abe2a19 | ||
|
|
20f0ef77ae | ||
|
|
983574ce8c | ||
|
|
cff6aa2906 | ||
|
|
c8adc97cef | ||
|
|
2dac87f56e | ||
|
|
f95cc754e7 | ||
|
|
270a02a372 | ||
|
|
23719e955e | ||
|
|
36b7c828e9 | ||
|
|
8539747e29 | ||
|
|
b88ba552f4 | ||
|
|
9ea1900f30 | ||
|
|
7366467831 | ||
|
|
5fdb56c243 | ||
|
|
e64df373a7 | ||
|
|
1c4be78060 | ||
|
|
fd3de5c6c1 | ||
|
|
c9a160556a | ||
|
|
54a85d8e99 | ||
|
|
ace0cbe578 | ||
|
|
ced31bbf0e | ||
|
|
c1d0a43a29 | ||
|
|
0ac753e298 | ||
|
|
fa3f17fa32 | ||
|
|
a35319f0ad | ||
|
|
33dec754d5 | ||
|
|
2029b21e0b | ||
|
|
97b1a2a336 | ||
|
|
f4ca635c1a |
No files matched your search
@@ -129,17 +129,18 @@ jobs:
|
||||
path: out/
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Windows x64 pinned standalone client.
|
||||
# Do not compile this in CI: the upstream Cygwin build can clear the
|
||||
# terminal and never render output on Windows. Ship the SHA256-pinned
|
||||
# FluentTerminal standalone binary verified by fetch-windows.sh.
|
||||
# Windows x64 pinned runtime bundle.
|
||||
# Do not compile this in CI: rebuilding the upstream Cygwin client here
|
||||
# previously regressed Windows startup. Ship the SHA256-pinned Netcatty
|
||||
# bundle with DLLs + terminfo, plus the FluentTerminal standalone fallback,
|
||||
# verified by fetch-windows.sh.
|
||||
# ------------------------------------------------------------------
|
||||
fetch-windows-x64:
|
||||
name: fetch-windows-x64
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Fetch pinned mosh-client.exe (win32-x64)
|
||||
- name: Fetch pinned mosh-client bundle (win32-x64)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export OUT_DIR="${GITHUB_WORKSPACE}/out"
|
||||
@@ -212,7 +213,7 @@ jobs:
|
||||
{
|
||||
printf '%s\n' 'Pre-built `mosh-client` binaries consumed by `scripts/fetch-mosh-binaries.cjs` during `npm run pack`.'
|
||||
printf 'Linux/macOS artifacts are built from `mobile-shell/mosh` upstream ref `%s`.\n' "${MOSH_REF}"
|
||||
printf '%s\n\n' 'Windows x64 is the SHA256-pinned FluentTerminal standalone `mosh-client.exe` fallback.'
|
||||
printf '%s\n\n' 'Windows x64 is the SHA256-pinned Netcatty runtime bundle, with the FluentTerminal standalone `mosh-client.exe` kept as a fallback asset.'
|
||||
printf 'Source workflow: %s/%s/actions/runs/%s\n' "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_RUN_ID}"
|
||||
printf 'Source commit: `%s`\n\n' "${GITHUB_SHA}"
|
||||
printf '%s\n' 'All artifacts are GPL-3.0; see `resources/mosh/README.md` for source provenance.'
|
||||
|
||||
@@ -30,12 +30,26 @@ Turn orchestration is centralized in **AgentRuntime**; the React hook `useAIChat
|
||||
|-------|--------|------|
|
||||
| Runtime | `agentRuntime.ts`, `globalAgentRuntime.ts` | Turn lifecycle, trace fan-out, per-turn ToolOutputStore / ToolResultDedup |
|
||||
| Drivers | `turnDrivers/cattyTurnDriver.ts`, `turnDrivers/externalSdkTurnDriver.ts` | Catty `streamText` + External SDK IPC; emit unified `AgentEvent`s |
|
||||
| Context | `contextManager.ts`, `tokenEstimator.ts`, `cattyRuntime.ts` | Pre-turn / 413 compaction, `prepareStepContext`, provider-aware token estimates |
|
||||
| Context | `contextManager.ts`, `contextBudget.ts`, `tokenEstimator.ts`, `sessionState.ts`, `staleContextPruner.ts`, `compactionPruner.ts`, `cattyRuntime.ts` | Pre-turn / step / 413 compaction, dynamic thresholds, SessionState reinjection, stale tool pruning |
|
||||
| Tools | `capabilityTools.ts`, `toolOutputStore.ts`, `toolResultDedup.ts` | Catalog tools, truncated output handles (`tool_output_read`), duplicate-read notices |
|
||||
| Trace | `traceStore.ts`, `agentEventAdapter.ts` | Session event log incl. `usage` and `CompactionTrace` |
|
||||
| Trace | `traceStore.ts`, `agentEventAdapter.ts` | Session event log incl. `usage`, `performance`, and `CompactionTrace` |
|
||||
|
||||
**Stop** always goes through `stopAgentTurn()` (UI, `/stop`, MCP). Do not add parallel abort paths in hooks.
|
||||
|
||||
### AI SDK v7 (Catty path)
|
||||
|
||||
Catty sidebar turns use **Vercel AI SDK 7** via `streamText` in `turnDrivers/cattyStreamProcessor.ts`. Key conventions:
|
||||
|
||||
| Concern | Module | Notes |
|
||||
|---------|--------|-------|
|
||||
| `runtimeContext` | `cattyRuntimeContext.ts` | Per-turn orchestration state (`chatSessionId`, `turnId`, `agentKind`, `permissionMode`, scope, `lastCompaction`). Passed to `prepareStep` and lifecycle callbacks. **Does not replace** pre-turn compaction or 413 handling. |
|
||||
| `toolsContext` | `capabilityTools.ts` | Per-tool keyed context (`bridge`, `getExecutorContext`, `toolOutputStore`, …). Tools read deps from `{ context }` in `execute`, not closure capture. |
|
||||
| `toolApproval` | `cattyToolApproval.ts` | Write-tool gating for Catty `streamText` only. Calls `requestApproval()` in confirm mode; observer auto-denies writes. **External MCP agents** still approve via main-process `mcpServerBridge` → `setupMcpApprovalBridge()` — unchanged. |
|
||||
| `timeout` | `streamTimeouts.ts` | `totalMs` / `stepMs` / `chunkMs` / `toolMs` on `streamText`; compaction `generateText` uses a shorter 90s timeout. |
|
||||
| Lifecycle | `cattyStreamProcessor.ts` | `onStart` → `model_call_start`; `onStepEnd` → per-step `step_end` usage; `onEnd` → turn-total `usage`; `finalStep.performance` → `performance` event. Distinct from `AgentRuntime` turn_start/turn_end. |
|
||||
|
||||
Compaction remains **`prepareTurnContext` / `compactCattyMessages`** (pre-turn + 413-retry). Step-level pruning is **`prepareStepContext`** only (typed compression + handle notices, no LLM summarize).
|
||||
|
||||
### Capability exposure (Round 2 + gap fill)
|
||||
|
||||
Single source of truth: `electron/capabilities/catalog/` + `electron/capabilities/codegen/toolSurfaces.cjs`.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useEffectEvent, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { activeTabStore, toEditorTabId, fromEditorTabId, isEditorTabId } from './application/state/activeTabStore';
|
||||
import { useAutoSync } from './application/state/useAutoSync';
|
||||
import { useManagedSourceSync } from './application/state/useManagedSourceSync';
|
||||
@@ -29,8 +29,8 @@ import { buildSshDeepLinkConnectionHost, buildSshDeepLinkHostDraft, findSshDeepL
|
||||
import { resolveHostAuth } from './domain/sshAuth';
|
||||
import { isEncryptedCredentialPlaceholder } from './domain/credentials';
|
||||
import {
|
||||
getFollowAppTerminalThemeSelectionUpdate,
|
||||
mergeTerminalHostUpdate,
|
||||
TERMINAL_THEME_AUTO,
|
||||
type TerminalHostUpdate,
|
||||
} from './domain/terminalAppearance';
|
||||
import { selectConnectionLogForTerminalDataCapture } from './domain/connectionLog';
|
||||
@@ -39,7 +39,8 @@ import { resolveCloseIntent } from './application/state/resolveCloseIntent';
|
||||
import { resolveSnippetsShortcutIntent } from './application/state/resolveSnippetsShortcutIntent';
|
||||
import { resolveWindowCommandCloseIntent } from './application/state/windowCommandClose';
|
||||
import { TERMINAL_THEMES } from './infrastructure/config/terminalThemes';
|
||||
import { customThemeStore, useCustomThemes } from './application/state/customThemeStore';
|
||||
import { useThemeRuntime, useTerminalAppearanceInjection } from './application/state/useThemeRuntime';
|
||||
import { useCustomThemes } from './application/state/customThemeStore';
|
||||
import type { SyncPayload } from './domain/sync';
|
||||
import { applySyncPayload, buildLocalVaultPayload, hasMeaningfulSyncData } from './application/syncPayload';
|
||||
import {
|
||||
@@ -65,8 +66,10 @@ import { getHostSearchMatch } from './lib/searchMatcher';
|
||||
import { useDiscoveredShells, resolveShellSetting } from './lib/useDiscoveredShells';
|
||||
import { Host, HostProtocol, KnownHost, SerialConfig, Snippet, SSHKey, TerminalSession } from './types';
|
||||
import { resolveSnippetCommand } from './components/SnippetExecutionProvider';
|
||||
import { AppView } from './application/app/AppView';
|
||||
import { isScriptSnippet } from './domain/snippetScript.ts';
|
||||
import { ScriptAutomationRoot } from './components/scripts/ScriptAutomationRoot';
|
||||
import { AppActiveTabChrome } from './application/app/AppActiveTabChrome';
|
||||
import { AppView } from './application/app/AppView';
|
||||
import { useAppStartupEffects } from './application/app/useAppStartupEffects';
|
||||
import { LogViewWrapper, SftpViewMount, TerminalLayerMount, VaultViewContainer } from './application/app/AppMounts';
|
||||
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleToggleThemeImpl, handleRootContextMenuImpl } from './application/app/AppHandlers';
|
||||
@@ -116,6 +119,10 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const {
|
||||
theme,
|
||||
setTheme,
|
||||
setLightUiThemeId,
|
||||
setDarkUiThemeId,
|
||||
lightUiThemeId,
|
||||
darkUiThemeId,
|
||||
resolvedTheme,
|
||||
accentMode,
|
||||
customAccent,
|
||||
@@ -123,8 +130,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
setTerminalThemeId,
|
||||
setTerminalThemeDarkId,
|
||||
setTerminalThemeLightId,
|
||||
terminalThemeDarkId,
|
||||
terminalThemeLightId,
|
||||
followAppTerminalTheme,
|
||||
currentTerminalTheme,
|
||||
terminalFontFamilyId,
|
||||
setTerminalFontFamilyId,
|
||||
terminalFontSize,
|
||||
@@ -278,6 +286,11 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
const handleRunSnippet = useCallback(
|
||||
async (snippet: Snippet, targetHosts: Host[]) => {
|
||||
if (targetHosts.length === 0) return;
|
||||
if (isScriptSnippet(snippet)) {
|
||||
runSnippet(snippet, targetHosts);
|
||||
return;
|
||||
}
|
||||
const command = await resolveSnippetCommand(snippet);
|
||||
if (command === null) return;
|
||||
runSnippet(snippet, targetHosts, command);
|
||||
@@ -292,6 +305,32 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// Active tab lookup maps
|
||||
// ---------------------------------------------------------------------------
|
||||
const customThemes = useCustomThemes();
|
||||
const themeRuntime = useThemeRuntime({
|
||||
terminalThemeId,
|
||||
terminalThemeDarkId,
|
||||
terminalThemeLightId,
|
||||
followAppTerminalTheme,
|
||||
resolvedTheme,
|
||||
lightUiThemeId,
|
||||
darkUiThemeId,
|
||||
accentMode,
|
||||
customAccent,
|
||||
customThemes,
|
||||
setTheme,
|
||||
setLightUiThemeId,
|
||||
setDarkUiThemeId,
|
||||
});
|
||||
useTerminalAppearanceInjection(themeRuntime.globalAppearance, {
|
||||
includeChromeSurfaces: followAppTerminalTheme,
|
||||
});
|
||||
const prevFollowAppTerminalThemeRef = useRef(followAppTerminalTheme);
|
||||
const clearThemeIntent = themeRuntime.clearIntent;
|
||||
useLayoutEffect(() => {
|
||||
if (prevFollowAppTerminalThemeRef.current === followAppTerminalTheme) return;
|
||||
prevFollowAppTerminalThemeRef.current = followAppTerminalTheme;
|
||||
clearThemeIntent();
|
||||
}, [followAppTerminalTheme, clearThemeIntent]);
|
||||
const currentTerminalTheme = themeRuntime.currentTerminalTheme;
|
||||
const editorTabs = useEditorTabs();
|
||||
|
||||
const hostById = useMemo(
|
||||
@@ -708,6 +747,10 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
type: 'note';
|
||||
noteId: string;
|
||||
requestId: number;
|
||||
} | {
|
||||
type: 'snippet';
|
||||
snippetId: string;
|
||||
requestId: number;
|
||||
} | null>(null);
|
||||
// Populated below so the hotkey dispatcher can open the Settings window
|
||||
// even though `handleOpenSettings` is declared further down in the file.
|
||||
@@ -913,6 +956,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
terminalSettings,
|
||||
resolveEffectiveHost,
|
||||
updateHosts,
|
||||
updateSnippets,
|
||||
customGroups,
|
||||
updateCustomGroups,
|
||||
notes,
|
||||
@@ -996,11 +1040,22 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
setActiveTabId('vault');
|
||||
}, [hosts, setActiveTabId]);
|
||||
|
||||
const handleOpenVaultSectionFromChat = useCallback((section: 'notes' | 'hosts') => {
|
||||
const handleOpenVaultSectionFromChat = useCallback((section: 'notes' | 'hosts' | 'snippets') => {
|
||||
setNavigateToSection(section);
|
||||
setActiveTabId('vault');
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const handleOpenVaultSnippetFromChat = useCallback((snippetId: string) => {
|
||||
vaultFocusRequestIdRef.current += 1;
|
||||
setVaultFocusRequest({
|
||||
type: 'snippet',
|
||||
snippetId,
|
||||
requestId: vaultFocusRequestIdRef.current,
|
||||
});
|
||||
setNavigateToSection('snippets');
|
||||
setActiveTabId('vault');
|
||||
}, [setActiveTabId]);
|
||||
|
||||
// Wrap updateSessionStatus to track lastConnectedAt on successful connection
|
||||
const handleSessionStatusChange = useCallback((sessionId: string, status: TerminalSession['status']) => {
|
||||
updateSessionStatus(sessionId, status);
|
||||
@@ -1052,17 +1107,17 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const handleToggleTheme = useCallback(() => { return handleToggleThemeImpl(() => ({ openSettingsWindow, resolvedTheme, setTheme, t, theme, toast })); }, [openSettingsWindow, resolvedTheme, setTheme, t, theme]);
|
||||
|
||||
const handleFollowAppTerminalThemeChange = useCallback((themeId: string) => {
|
||||
const selectedTheme = customThemeStore.getThemeById(themeId);
|
||||
if (!selectedTheme) return;
|
||||
const update = getFollowAppTerminalThemeSelectionUpdate(selectedTheme);
|
||||
if (update.terminalThemeDarkId) {
|
||||
setTerminalThemeDarkId(update.terminalThemeDarkId);
|
||||
themeRuntime.pickTheme(themeId);
|
||||
}, [themeRuntime]);
|
||||
|
||||
const handleDefaultTerminalThemeChange = useCallback((themeId: string) => {
|
||||
setTerminalThemeId(themeId);
|
||||
if (resolvedTheme === 'dark') {
|
||||
setTerminalThemeDarkId(TERMINAL_THEME_AUTO);
|
||||
} else {
|
||||
setTerminalThemeLightId(TERMINAL_THEME_AUTO);
|
||||
}
|
||||
if (update.terminalThemeLightId) {
|
||||
setTerminalThemeLightId(update.terminalThemeLightId);
|
||||
}
|
||||
setTheme(update.appTheme);
|
||||
}, [setTerminalThemeDarkId, setTerminalThemeLightId, setTheme]);
|
||||
}, [resolvedTheme, setTerminalThemeDarkId, setTerminalThemeId, setTerminalThemeLightId]);
|
||||
|
||||
const handleOpenQuickSwitcher = useCallback(() => {
|
||||
setIsQuickSwitcherOpen(true);
|
||||
@@ -1137,9 +1192,10 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
customAccent={customAccent}
|
||||
editorTabs={editorTabs}
|
||||
logViews={logViews}
|
||||
resolveSessionAppearance={themeRuntime.resolveFocusedAppearance}
|
||||
t={t}
|
||||
/>
|
||||
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, clearSessionFontSizeOverride, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deepLinkHostDraft, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleOpenHostFromVaultNote, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setVaultFocusRequest, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, renameSessionInline, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
|
||||
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, clearSessionFontSizeOverride, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deepLinkHostDraft, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, clearThemeIntent: themeRuntime.clearIntent, settleManualThemeIntent: themeRuntime.settleManualIntent, pickTerminalTheme: themeRuntime.pickTheme, resolveSessionAppearance: themeRuntime.resolveFocusedAppearance, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDefaultTerminalThemeChange, handleDeleteHost, handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleOpenHostFromVaultNote, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, renameSessionInline, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1171,6 +1227,7 @@ function AppWithProviders() {
|
||||
<I18nProvider locale={settings.uiLanguage}>
|
||||
<ToastProvider>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<ScriptAutomationRoot />
|
||||
<App settings={settings} />
|
||||
</TooltipProvider>
|
||||
</ToastProvider>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { updateActiveChromeThemeDeps } from '../state/activeChromeThemeSync';
|
||||
import { useActiveChromeTheme } from '../state/useActiveChromeTheme';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { resolveActiveChromeTheme } from './activeChromeTheme';
|
||||
import type { TerminalAppearanceHostScope, ResolvedAppearance } from '../../domain/terminalAppearanceRuntime';
|
||||
import type {
|
||||
Host,
|
||||
TerminalSession,
|
||||
@@ -32,6 +33,7 @@ interface AppActiveTabChromeProps {
|
||||
customAccent: string;
|
||||
editorTabs: readonly EditorTab[];
|
||||
logViews: readonly LogView[];
|
||||
resolveSessionAppearance?: (hostScope: TerminalAppearanceHostScope) => ResolvedAppearance;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
@@ -57,6 +59,7 @@ export function AppActiveTabChrome({
|
||||
customAccent,
|
||||
editorTabs,
|
||||
logViews,
|
||||
resolveSessionAppearance,
|
||||
t,
|
||||
}: AppActiveTabChromeProps) {
|
||||
const activeTabId = useActiveTabId();
|
||||
@@ -76,6 +79,7 @@ export function AppActiveTabChrome({
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
logViews,
|
||||
resolveSessionAppearance,
|
||||
sessionById,
|
||||
themeById,
|
||||
workspaceById,
|
||||
@@ -88,6 +92,7 @@ export function AppActiveTabChrome({
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
logViews,
|
||||
resolveSessionAppearance,
|
||||
sessionById,
|
||||
themeById,
|
||||
workspaceById,
|
||||
|
||||
@@ -2,9 +2,34 @@ import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("../../App.tsx", import.meta.url), "utf8");
|
||||
const appSource = readFileSync(new URL("../../App.tsx", import.meta.url), "utf8");
|
||||
const appViewSource = readFileSync(new URL("./AppView.tsx", import.meta.url), "utf8");
|
||||
const runtimeSource = readFileSync(new URL("../state/useThemeRuntime.ts", import.meta.url), "utf8");
|
||||
const settingsSource = readFileSync(new URL("../state/useSettingsState.ts", import.meta.url), "utf8");
|
||||
|
||||
test("follow-app terminal theme selection reads the latest custom theme store", () => {
|
||||
assert.match(source, /customThemeStore\.getThemeById\(themeId\)/);
|
||||
assert.doesNotMatch(source, /const selectedTheme = themeById\.get\(themeId\)/);
|
||||
test("follow-app terminal theme selection updates the matching UI theme via ThemeRuntime", () => {
|
||||
assert.match(runtimeSource, /getFollowAppTerminalThemeSelectionUpdate\(themeId\)/);
|
||||
assert.match(runtimeSource, /setDarkUiThemeId\(update\.uiThemeId\)/);
|
||||
assert.match(runtimeSource, /setLightUiThemeId\(update\.uiThemeId\)/);
|
||||
assert.match(runtimeSource, /setTheme\(update\.appTheme\)/);
|
||||
assert.doesNotMatch(runtimeSource, /isFollowAppIntentSettled\(userIntent\.themeId/);
|
||||
assert.match(appSource, /useThemeRuntime\(/);
|
||||
assert.match(appSource, /themeRuntime\.pickTheme\(themeId\)/);
|
||||
assert.match(appSource, /useTerminalAppearanceInjection/);
|
||||
assert.match(appSource, /includeChromeSurfaces: followAppTerminalTheme/);
|
||||
assert.match(appSource, /clearThemeIntent\(\)/);
|
||||
assert.match(runtimeSource, /injectTerminalAppearanceVars\(appearance\.theme, \{ includeChromeSurfaces \}\)/);
|
||||
assert.doesNotMatch(settingsSource, /pendingFollowAppTerminalThemeId/);
|
||||
assert.doesNotMatch(settingsSource, /applyFollowAppTerminalThemePick/);
|
||||
assert.match(settingsSource, /appearanceTransitionModeRef\.current = 'instant'/);
|
||||
assert.match(appViewSource, /data-terminal-appearance-root/);
|
||||
assert.match(appViewSource, /pickTerminalTheme=\{ctx\.pickTerminalTheme\}/);
|
||||
});
|
||||
|
||||
test("default terminal theme selection clears the current mode override", () => {
|
||||
assert.match(appSource, /const handleDefaultTerminalThemeChange = useCallback\(\(themeId: string\) => \{/);
|
||||
assert.match(appSource, /setTerminalThemeId\(themeId\)/);
|
||||
assert.match(appSource, /resolvedTheme === 'dark'[\s\S]*setTerminalThemeDarkId\(TERMINAL_THEME_AUTO\)/);
|
||||
assert.match(appSource, /setTerminalThemeLightId\(TERMINAL_THEME_AUTO\)/);
|
||||
assert.match(appViewSource, /onUpdateTerminalThemeId=\{handleDefaultTerminalThemeChange\}/);
|
||||
});
|
||||
@@ -577,9 +577,10 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
break;
|
||||
}
|
||||
case 'newTab':
|
||||
case 'openLocal':
|
||||
// Add connection log for local terminal
|
||||
case 'openLocal': {
|
||||
const sessionId = createLocalTerminalWithCurrentShell();
|
||||
addConnectionLogRef.current({
|
||||
sessionId,
|
||||
hostId: '',
|
||||
hostLabel: 'Local Terminal',
|
||||
hostname: 'localhost',
|
||||
@@ -590,8 +591,8 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
localHostname: systemInfoRef.current.hostname,
|
||||
saved: false,
|
||||
});
|
||||
createLocalTerminalWithCurrentShell();
|
||||
break;
|
||||
}
|
||||
case 'openHosts':
|
||||
setActiveTabId('vault');
|
||||
break;
|
||||
|
||||
@@ -42,3 +42,9 @@ test('host tree layer hides immediately when leaving work tab surfaces', () => {
|
||||
assert.match(hostTreeLayerSource, /getAppHostTreeLayerStyle\(surfaceVisible\)/);
|
||||
assert.doesNotMatch(hostTreeLayerSource, /layerVisible/);
|
||||
});
|
||||
|
||||
test('shared host tree theme follows active chrome resolution and manual chrome injection', () => {
|
||||
assert.match(hostTreeLayerSource, /resolveActiveChromeTheme/);
|
||||
assert.match(hostTreeLayerSource, /useManualTerminalChromeSurfaceInjection/);
|
||||
assert.match(hostTreeLayerSource, /resolveSessionAppearance/);
|
||||
});
|
||||
@@ -3,12 +3,17 @@ import React, { useMemo } from 'react';
|
||||
import { useActiveTabId } from '../state/activeTabStore';
|
||||
import type { EditorTab } from '../state/editorTabStore';
|
||||
import type { LogView } from '../state/logViewState';
|
||||
import { useManualTerminalChromeSurfaceInjection } from '../state/useManualTerminalChromeSurfaceInjection';
|
||||
import { TerminalHostTreeSidebar } from '../../components/terminalLayer/TerminalHostTreeSidebar';
|
||||
import type {
|
||||
ResolvedAppearance,
|
||||
TerminalAppearanceHostScope,
|
||||
} from '../../domain/terminalAppearanceRuntime';
|
||||
import type { GroupConfig, Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
|
||||
import { resolveActiveChromeTheme } from './activeChromeTheme';
|
||||
import {
|
||||
isHostTreeWorkTabSurface,
|
||||
resolveWorkTabActiveHostId,
|
||||
resolveWorkTabHostTreeTheme,
|
||||
} from './workTabSurface';
|
||||
|
||||
interface AppHostTreeLayerProps {
|
||||
@@ -27,6 +32,7 @@ interface AppHostTreeLayerProps {
|
||||
followAppTerminalTheme: boolean;
|
||||
hostById: ReadonlyMap<string, Host>;
|
||||
themeById: ReadonlyMap<string, TerminalTheme>;
|
||||
resolveSessionAppearance?: (hostScope: TerminalAppearanceHostScope) => ResolvedAppearance;
|
||||
onConnect: (host: Host) => void;
|
||||
onCreateLocalTerminal?: () => void;
|
||||
}
|
||||
@@ -55,6 +61,7 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
themeById,
|
||||
resolveSessionAppearance,
|
||||
onConnect,
|
||||
onCreateLocalTerminal,
|
||||
}) => {
|
||||
@@ -62,6 +69,14 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
|
||||
const sessionIds = useMemo(() => new Set(sessions.map((session) => session.id)), [sessions]);
|
||||
const workspaceIds = useMemo(() => new Set(workspaces.map((workspace) => workspace.id)), [workspaces]);
|
||||
const logViewIds = useMemo(() => new Set(logViews.map((logView) => logView.id)), [logViews]);
|
||||
const sessionById = useMemo(
|
||||
() => new Map(sessions.map((session) => [session.id, session])),
|
||||
[sessions],
|
||||
);
|
||||
const workspaceById = useMemo(
|
||||
() => new Map(workspaces.map((workspace) => [workspace.id, workspace])),
|
||||
[workspaces],
|
||||
);
|
||||
const surfaceVisible = isHostTreeWorkTabSurface({
|
||||
enabled,
|
||||
activeTabId,
|
||||
@@ -78,24 +93,41 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
|
||||
workspaces,
|
||||
}), [activeTabId, editorTabs, sessions, workspaces]);
|
||||
|
||||
const hostTreeTheme = useMemo(() => resolveWorkTabHostTreeTheme({
|
||||
activeHostId,
|
||||
accentMode,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
themeById,
|
||||
}), [
|
||||
activeHostId,
|
||||
const hostTreeTheme = useMemo(() => (
|
||||
resolveActiveChromeTheme({
|
||||
accentMode,
|
||||
activeTabId,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
editorTabs,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
logViews,
|
||||
resolveSessionAppearance,
|
||||
sessionById,
|
||||
themeById,
|
||||
workspaceById,
|
||||
}) ?? currentTerminalTheme
|
||||
), [
|
||||
accentMode,
|
||||
activeTabId,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
editorTabs,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
logViews,
|
||||
resolveSessionAppearance,
|
||||
sessionById,
|
||||
themeById,
|
||||
workspaceById,
|
||||
]);
|
||||
|
||||
useManualTerminalChromeSurfaceInjection(
|
||||
hostTreeTheme,
|
||||
!followAppTerminalTheme && surfaceVisible,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 flex min-h-0"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React, { Suspense, lazy, useMemo } from 'react';
|
||||
import React, { Suspense, lazy, useCallback, useMemo } from 'react';
|
||||
import { AlertTriangle, Download, Trash2 } from 'lucide-react';
|
||||
import { activeTabStore, toEditorTabId, useIsEditorTabActive } from '../state/activeTabStore';
|
||||
import { editorTabStore } from '../state/editorTabStore';
|
||||
@@ -8,6 +8,7 @@ import { useTerminalHostTreeLayoutWidth } from '../state/terminalHostTreeStore';
|
||||
import { TopTabs } from '../../components/TopTabs';
|
||||
import { VaultView } from '../../components/VaultView';
|
||||
import { QuickAddSnippetDialog } from '../../components/QuickAddSnippetDialog';
|
||||
import { QuickScriptEditorDialog } from '../../components/scripts/QuickScriptEditorDialog';
|
||||
import { AddToWorkspaceDialog } from '../../components/workspace/AddToWorkspaceDialog';
|
||||
import { KeyboardInteractiveModal } from '../../components/KeyboardInteractiveModal';
|
||||
import { PassphraseModal } from '../../components/PassphraseModal';
|
||||
@@ -22,6 +23,7 @@ import { toast } from '../../components/ui/toast';
|
||||
import { AppHostTreeLayer } from './AppHostTreeLayer';
|
||||
import { getUiThemeById } from '../../infrastructure/config/uiThemes';
|
||||
import { buildAppThemeCssVars } from '../state/settingsStateDefaults';
|
||||
import { useMainWindowInputFocusRecovery } from '../state/useMainWindowInputFocusRecovery';
|
||||
|
||||
const LazyProtocolSelectDialog = lazy(() => import('../../components/ProtocolSelectDialog'));
|
||||
const LazyQuickSwitcher = lazy(() =>
|
||||
@@ -53,21 +55,52 @@ const TextEditorTabFallback = ({ tabId }: { tabId: string }) => {
|
||||
type AppViewContext = Record<string, any>;
|
||||
|
||||
export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
const {
|
||||
resetSessionRename,
|
||||
resetWorkspaceRename,
|
||||
setAddToWorkspaceDialog,
|
||||
setIsCreateWorkspaceOpen,
|
||||
setIsQuickSwitcherOpen,
|
||||
setProtocolSelectHost,
|
||||
setQuickSearch,
|
||||
} = ctx;
|
||||
|
||||
const dismissTransientOverlays = useCallback(() => {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
setIsCreateWorkspaceOpen(false);
|
||||
setProtocolSelectHost(null);
|
||||
setAddToWorkspaceDialog(null);
|
||||
resetSessionRename();
|
||||
resetWorkspaceRename();
|
||||
}, [
|
||||
resetSessionRename,
|
||||
resetWorkspaceRename,
|
||||
setAddToWorkspaceDialog,
|
||||
setIsCreateWorkspaceOpen,
|
||||
setIsQuickSwitcherOpen,
|
||||
setProtocolSelectHost,
|
||||
setQuickSearch,
|
||||
]);
|
||||
|
||||
useMainWindowInputFocusRecovery({ onPageHidden: dismissTransientOverlays });
|
||||
|
||||
const {
|
||||
accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
|
||||
clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, closeWorkspace, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell,
|
||||
connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent,
|
||||
customGroups, currentTerminalTheme, deepLinkHostDraft, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
|
||||
followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost,
|
||||
followAppTerminalTheme,
|
||||
groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDefaultTerminalThemeChange, handleDeleteHost,
|
||||
handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
|
||||
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect,
|
||||
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect,
|
||||
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal,
|
||||
hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
||||
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
|
||||
passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename,
|
||||
resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
||||
sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen,
|
||||
setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
|
||||
passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
|
||||
resolveEmptyVaultConflict, resolvedTheme, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
||||
sessionRenameValue, sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
|
||||
setNavigateToSection, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
|
||||
setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
|
||||
sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
|
||||
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById,
|
||||
@@ -139,7 +172,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" onContextMenu={handleRootContextMenu}>
|
||||
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" data-terminal-appearance-root onContextMenu={handleRootContextMenu}>
|
||||
<TopTabs
|
||||
theme={resolvedTheme}
|
||||
hosts={hosts}
|
||||
@@ -193,6 +226,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
followAppTerminalTheme={followAppTerminalTheme}
|
||||
hostById={hostById}
|
||||
themeById={themeById}
|
||||
resolveSessionAppearance={resolveSessionAppearance}
|
||||
onConnect={handleConnectToHost}
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
/>
|
||||
@@ -299,6 +333,10 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
terminalTheme={currentTerminalTheme}
|
||||
terminalThemeId={terminalThemeId}
|
||||
followAppTerminalTheme={followAppTerminalTheme}
|
||||
pickTerminalTheme={ctx.pickTerminalTheme}
|
||||
clearThemeIntent={ctx.clearThemeIntent}
|
||||
settleManualThemeIntent={ctx.settleManualThemeIntent}
|
||||
resolveSessionAppearance={ctx.resolveSessionAppearance}
|
||||
accentMode={accentMode}
|
||||
customAccent={customAccent}
|
||||
terminalSettings={terminalSettings}
|
||||
@@ -309,7 +347,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
restoreTerminalCwd={settings.restoreTerminalCwd}
|
||||
keyBindings={keyBindings}
|
||||
onHotkeyAction={handleHotkeyAction}
|
||||
onUpdateTerminalThemeId={setTerminalThemeId}
|
||||
onUpdateTerminalThemeId={handleDefaultTerminalThemeChange}
|
||||
onUpdateFollowAppTerminalThemeId={handleFollowAppTerminalThemeChange}
|
||||
onUpdateTerminalFontFamilyId={setTerminalFontFamilyId}
|
||||
onUpdateTerminalFontSize={setTerminalFontSize}
|
||||
@@ -348,6 +386,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
onOpenVaultNoteFromChat={handleOpenVaultNoteFromChat}
|
||||
onOpenVaultHostFromChat={handleOpenVaultHostFromChat}
|
||||
onOpenVaultSectionFromChat={handleOpenVaultSectionFromChat}
|
||||
onOpenVaultSnippetFromChat={handleOpenVaultSnippetFromChat}
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
isBroadcastEnabled={isBroadcastEnabled}
|
||||
onToggleBroadcast={toggleBroadcast}
|
||||
@@ -362,6 +401,8 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
sftpShowHiddenFiles={sftpShowHiddenFiles}
|
||||
sftpUseCompressedUpload={sftpUseCompressedUpload}
|
||||
sftpAutoOpenSidebar={sftpAutoOpenSidebar}
|
||||
terminalSidePanelAutoOpen={settings.terminalSidePanelAutoOpen}
|
||||
terminalSidePanelAutoOpenTab={settings.terminalSidePanelAutoOpenTab}
|
||||
sftpFollowTerminalCwd={sftpFollowTerminalCwd}
|
||||
setSftpFollowTerminalCwd={setSftpFollowTerminalCwd}
|
||||
editorWordWrap={editorWordWrap}
|
||||
@@ -426,6 +467,21 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
updateSnippetPackages(Array.from(new Set([...snippetPackages, pkg])))
|
||||
}
|
||||
/>
|
||||
<QuickScriptEditorDialog
|
||||
snippets={snippets}
|
||||
packages={snippetPackages}
|
||||
hosts={hosts}
|
||||
customGroups={customGroups}
|
||||
onCreateSnippet={(snippet) => updateSnippets([...snippets, snippet])}
|
||||
onUpdateSnippet={(snippet) =>
|
||||
updateSnippets(snippets.map((s) => (s.id === snippet.id ? snippet : s)))
|
||||
}
|
||||
onCreatePackage={(pkg) =>
|
||||
updateSnippetPackages(Array.from(new Set([...snippetPackages, pkg])))
|
||||
}
|
||||
onUpdateHosts={updateHosts}
|
||||
onRunSnippet={runSnippet}
|
||||
/>
|
||||
|
||||
{/* Root-mounted AddToWorkspaceDialog — triggered by the focus-mode
|
||||
"+" button (mode='append') or QuickSwitcher's "New Workspace"
|
||||
|
||||
@@ -101,6 +101,146 @@ test("root pages use the normal application theme", () => {
|
||||
assert.equal(resolved, null);
|
||||
});
|
||||
|
||||
test("follow-app workspace split view always uses the global terminal theme", () => {
|
||||
const workspace: Workspace = {
|
||||
id: "ws-1",
|
||||
name: "Workspace",
|
||||
viewMode: "split",
|
||||
focusedSessionId: "session-1",
|
||||
root: {
|
||||
type: "split",
|
||||
direction: "horizontal",
|
||||
sizes: [50, 50],
|
||||
children: [
|
||||
{ type: "session", sessionId: "session-1" },
|
||||
{ type: "session", sessionId: "session-2" },
|
||||
],
|
||||
},
|
||||
} as unknown as Workspace;
|
||||
|
||||
const hostA = { id: "host-a", theme: hostTheme.id, themeOverride: true } as unknown as Host;
|
||||
const hostB = { id: "host-b", theme: logTheme.id, themeOverride: true } as unknown as Host;
|
||||
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
activeTabId: "ws-1",
|
||||
followAppTerminalTheme: true,
|
||||
hostById: new Map([
|
||||
["host-a", hostA],
|
||||
["host-b", hostB],
|
||||
]),
|
||||
sessionById: new Map([
|
||||
["session-1", { id: "session-1", hostId: "host-a" } as TerminalSession],
|
||||
["session-2", { id: "session-2", hostId: "host-b" } as TerminalSession],
|
||||
]),
|
||||
workspaceById: new Map([["ws-1", workspace]]),
|
||||
});
|
||||
|
||||
assert.equal(resolved?.id, currentTheme.id);
|
||||
});
|
||||
|
||||
test("manual workspace split view uses the focused session theme when panes differ", () => {
|
||||
const workspace: Workspace = {
|
||||
id: "ws-1",
|
||||
name: "Workspace",
|
||||
viewMode: "split",
|
||||
focusedSessionId: "session-2",
|
||||
root: {
|
||||
type: "split",
|
||||
direction: "horizontal",
|
||||
sizes: [50, 50],
|
||||
children: [
|
||||
{ type: "pane", sessionId: "session-1" },
|
||||
{ type: "pane", sessionId: "session-2" },
|
||||
],
|
||||
},
|
||||
} as unknown as Workspace;
|
||||
|
||||
const hostA = { id: "host-a", theme: hostTheme.id, themeOverride: true } as unknown as Host;
|
||||
const hostB = { id: "host-b", theme: logTheme.id, themeOverride: true } as unknown as Host;
|
||||
const focusedTheme = theme("focused-intent");
|
||||
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
activeTabId: "ws-1",
|
||||
hostById: new Map([
|
||||
["host-a", hostA],
|
||||
["host-b", hostB],
|
||||
]),
|
||||
sessionById: new Map([
|
||||
["session-1", { id: "session-1", hostId: "host-a" } as TerminalSession],
|
||||
["session-2", { id: "session-2", hostId: "host-b" } as TerminalSession],
|
||||
]),
|
||||
workspaceById: new Map([["ws-1", workspace]]),
|
||||
resolveSessionAppearance: ({ host }) => (
|
||||
host?.id === "host-b"
|
||||
? { themeId: focusedTheme.id, theme: focusedTheme, source: "intent", appThemeUpdate: null }
|
||||
: { themeId: hostTheme.id, theme: hostTheme, source: "host-override", appThemeUpdate: null }
|
||||
),
|
||||
});
|
||||
|
||||
assert.equal(resolved?.id, focusedTheme.id);
|
||||
});
|
||||
|
||||
test("manual split workspace falls back to the first tree session theme when focus is stale", () => {
|
||||
const workspace: Workspace = {
|
||||
id: "ws-1",
|
||||
name: "Workspace",
|
||||
viewMode: "split",
|
||||
focusedSessionId: "missing-session",
|
||||
root: {
|
||||
type: "split",
|
||||
direction: "horizontal",
|
||||
sizes: [50, 50],
|
||||
children: [
|
||||
{ type: "pane", sessionId: "session-1" },
|
||||
{ type: "pane", sessionId: "session-2" },
|
||||
],
|
||||
},
|
||||
} as unknown as Workspace;
|
||||
|
||||
const hostA = { id: "host-a", theme: hostTheme.id, themeOverride: true } as unknown as Host;
|
||||
const hostB = { id: "host-b", theme: logTheme.id, themeOverride: true } as unknown as Host;
|
||||
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
activeTabId: "ws-1",
|
||||
hostById: new Map([
|
||||
["host-a", hostA],
|
||||
["host-b", hostB],
|
||||
]),
|
||||
sessionById: new Map([
|
||||
["session-1", { id: "session-1", hostId: "host-a" } as TerminalSession],
|
||||
["session-2", { id: "session-2", hostId: "host-b" } as TerminalSession],
|
||||
]),
|
||||
workspaceById: new Map([["ws-1", workspace]]),
|
||||
});
|
||||
|
||||
assert.equal(resolved?.id, hostTheme.id);
|
||||
});
|
||||
|
||||
test("manual mode prefers runtime session appearance over stale host theme ids", () => {
|
||||
const intentTheme = theme("intent-theme");
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
activeTabId: "session-1",
|
||||
hostById: new Map([
|
||||
["host-1", { id: "host-1", theme: hostTheme.id, themeOverride: true } as unknown as Host],
|
||||
]),
|
||||
sessionById: new Map([
|
||||
["session-1", { id: "session-1", hostId: "host-1" } as TerminalSession],
|
||||
]),
|
||||
resolveSessionAppearance: () => ({
|
||||
themeId: intentTheme.id,
|
||||
theme: intentTheme,
|
||||
source: "intent",
|
||||
appThemeUpdate: null,
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(resolved?.id, intentTheme.id);
|
||||
});
|
||||
|
||||
test("chrome theme sync waits until a newly opened session is present in deps", () => {
|
||||
assert.equal(
|
||||
isActiveChromeThemeResolvable({
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { fromEditorTabId, isEditorTabId } from "../state/activeTabStore";
|
||||
|
||||
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from "../../domain/terminalAppearance";
|
||||
import type {
|
||||
ResolvedAppearance,
|
||||
TerminalAppearanceHostScope,
|
||||
} from "../../domain/terminalAppearanceRuntime";
|
||||
import { collectSessionIds } from "../../domain/workspace";
|
||||
import type { EditorTab } from "../state/editorTabStore";
|
||||
import type { LogView } from "../state/logViewState";
|
||||
import type { Host, TerminalSession, TerminalTheme, Workspace } from "../../types";
|
||||
import { resolveWorkspaceTargetSessionFromMap } from "./workTabSurface";
|
||||
|
||||
export type ResolveActiveChromeThemeInput = {
|
||||
accentMode: "theme" | "custom";
|
||||
activeTabId: string;
|
||||
@@ -9,6 +20,7 @@ export type ResolveActiveChromeThemeInput = {
|
||||
followAppTerminalTheme: boolean;
|
||||
hostById: Map<string, Host>;
|
||||
logViews: readonly LogView[];
|
||||
resolveSessionAppearance?: (hostScope: TerminalAppearanceHostScope) => ResolvedAppearance;
|
||||
sessionById: Map<string, TerminalSession>;
|
||||
themeById: Map<string, TerminalTheme>;
|
||||
workspaceById: Map<string, Workspace>;
|
||||
@@ -33,11 +45,6 @@ export function isActiveChromeThemeResolvable({
|
||||
if (sessionById.has(activeTabId)) return true;
|
||||
return false;
|
||||
}
|
||||
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from "../../domain/terminalAppearance";
|
||||
import { collectSessionIds } from "../../domain/workspace";
|
||||
import type { EditorTab } from "../state/editorTabStore";
|
||||
import type { LogView } from "../state/logViewState";
|
||||
import type { Host, TerminalSession, TerminalTheme, Workspace } from "../../types";
|
||||
|
||||
export function resolveActiveChromeTheme({
|
||||
accentMode,
|
||||
@@ -48,14 +55,23 @@ export function resolveActiveChromeTheme({
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
logViews,
|
||||
resolveSessionAppearance,
|
||||
sessionById,
|
||||
themeById,
|
||||
workspaceById,
|
||||
}: ResolveActiveChromeThemeInput): TerminalTheme | null {
|
||||
if (activeTabId === "vault" || activeTabId === "sftp") return null;
|
||||
|
||||
const resolveHostScope = (hostId: string): TerminalAppearanceHostScope => {
|
||||
const host = hostById.get(hostId) ?? null;
|
||||
return { host, isEphemeral: !host || !hostById.has(host.id) };
|
||||
};
|
||||
|
||||
const resolveHostTheme = (hostId: string): TerminalTheme => {
|
||||
if (followAppTerminalTheme) return currentTerminalTheme;
|
||||
if (resolveSessionAppearance) {
|
||||
return resolveSessionAppearance(resolveHostScope(hostId)).theme;
|
||||
}
|
||||
const host = hostById.get(hostId) ?? null;
|
||||
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
||||
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
|
||||
@@ -79,12 +95,10 @@ export function resolveActiveChromeTheme({
|
||||
|
||||
const workspace = workspaceById.get(activeTabId);
|
||||
if (workspace) {
|
||||
if (followAppTerminalTheme) return currentTerminalTheme;
|
||||
|
||||
if (workspace.viewMode === "focus") {
|
||||
const workspaceSessionIds = collectSessionIds(workspace.root);
|
||||
const focusedSession = (workspace.focusedSessionId
|
||||
? sessionById.get(workspace.focusedSessionId)
|
||||
: null)
|
||||
?? workspaceSessionIds.map((id) => sessionById.get(id)).find(Boolean);
|
||||
const focusedSession = resolveWorkspaceTargetSessionFromMap(workspace, sessionById);
|
||||
return focusedSession ? resolveSessionTheme(focusedSession) : null;
|
||||
}
|
||||
|
||||
@@ -95,7 +109,10 @@ export function resolveActiveChromeTheme({
|
||||
|
||||
const firstTheme = resolveSessionTheme(workspaceSessions[0]);
|
||||
const allSame = workspaceSessions.every((session) => resolveSessionTheme(session).id === firstTheme.id);
|
||||
return allSame ? firstTheme : null;
|
||||
if (allSame) return firstTheme;
|
||||
|
||||
const focusedSession = resolveWorkspaceTargetSessionFromMap(workspace, sessionById);
|
||||
return focusedSession ? resolveSessionTheme(focusedSession) : null;
|
||||
}
|
||||
|
||||
const session = sessionById.get(activeTabId);
|
||||
|
||||
@@ -70,13 +70,17 @@ const TOP_TABS_THEME_PROPERTIES = [
|
||||
'--muted-foreground',
|
||||
] as const;
|
||||
|
||||
let topTabsChromeThemeVarsApplied = false;
|
||||
|
||||
export function clearTopTabsChromeThemeVars(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
if (!topTabsChromeThemeVarsApplied) return;
|
||||
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
|
||||
if (!tabsRoot) return;
|
||||
for (const property of TOP_TABS_THEME_PROPERTIES) {
|
||||
removeStylePropertyIfSet(tabsRoot, property);
|
||||
}
|
||||
topTabsChromeThemeVarsApplied = false;
|
||||
}
|
||||
|
||||
export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
|
||||
@@ -107,6 +111,7 @@ export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-muted', 'hsl(var(--muted-foreground))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-active-bg', 'hsl(var(--background))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-accent', 'hsl(var(--accent))');
|
||||
topTabsChromeThemeVarsApplied = true;
|
||||
}
|
||||
|
||||
export function hasActiveChromeThemeDataset(): boolean {
|
||||
|
||||
@@ -119,11 +119,13 @@ test('terminal content surface is limited to sessions and workspaces', () => {
|
||||
test('shared host tree resolves active host ids across work tab types', () => {
|
||||
const sessions = [
|
||||
{ id: 'session-1', hostId: 'host-1' },
|
||||
{ id: 'session-2', hostId: 'host-2' },
|
||||
{ id: 'session-2', hostId: 'host-2', workspaceId: 'workspace-1' },
|
||||
] as TerminalSession[];
|
||||
const workspaces = [
|
||||
{ id: 'workspace-1', focusedSessionId: 'session-2' },
|
||||
] as Workspace[];
|
||||
const workspaces = [{
|
||||
id: 'workspace-1',
|
||||
focusedSessionId: 'session-2',
|
||||
root: { id: 'pane-2', type: 'pane', sessionId: 'session-2' },
|
||||
}] as Workspace[];
|
||||
const editorTabs = [
|
||||
{ id: 'file-1', hostId: 'host-3' },
|
||||
] as EditorTab[];
|
||||
@@ -134,6 +136,62 @@ test('shared host tree resolves active host ids across work tab types', () => {
|
||||
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'log-1', sessions, workspaces, editorTabs }), null);
|
||||
});
|
||||
|
||||
test('shared host tree falls back to the first workspace session when focused session is missing', () => {
|
||||
const sessions = [
|
||||
{ id: 'session-1', hostId: 'host-1', workspaceId: 'workspace-1' },
|
||||
{ id: 'session-2', hostId: 'host-2', workspaceId: 'workspace-1' },
|
||||
] as TerminalSession[];
|
||||
const workspaces = [{
|
||||
id: 'workspace-1',
|
||||
focusedSessionId: 'missing-session',
|
||||
root: {
|
||||
id: 'split-1',
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ id: 'pane-1', type: 'pane', sessionId: 'session-1' },
|
||||
{ id: 'pane-2', type: 'pane', sessionId: 'session-2' },
|
||||
],
|
||||
sizes: [0.5, 0.5],
|
||||
},
|
||||
}] as Workspace[];
|
||||
|
||||
assert.equal(resolveWorkTabActiveHostId({
|
||||
activeTabId: 'workspace-1',
|
||||
sessions,
|
||||
workspaces,
|
||||
editorTabs: [],
|
||||
}), 'host-1');
|
||||
});
|
||||
|
||||
test('shared host tree fallback prefers workspace tree order over sessions array order', () => {
|
||||
const sessions = [
|
||||
{ id: 'session-2', hostId: 'host-2', workspaceId: 'workspace-1' },
|
||||
{ id: 'session-1', hostId: 'host-1', workspaceId: 'workspace-1' },
|
||||
] as TerminalSession[];
|
||||
const workspaces = [{
|
||||
id: 'workspace-1',
|
||||
focusedSessionId: 'missing-session',
|
||||
root: {
|
||||
id: 'split-1',
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ id: 'pane-1', type: 'pane', sessionId: 'session-1' },
|
||||
{ id: 'pane-2', type: 'pane', sessionId: 'session-2' },
|
||||
],
|
||||
sizes: [0.5, 0.5],
|
||||
},
|
||||
}] as Workspace[];
|
||||
|
||||
assert.equal(resolveWorkTabActiveHostId({
|
||||
activeTabId: 'workspace-1',
|
||||
sessions,
|
||||
workspaces,
|
||||
editorTabs: [],
|
||||
}), 'host-1');
|
||||
});
|
||||
|
||||
test('shared host tree uses the active host theme when follow-app terminal theme is off', () => {
|
||||
const currentTheme = makeTheme('app-dark', 'dark', '#111111');
|
||||
const hostTheme = makeTheme('host-light', 'light', '#fafafa');
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
isEditorTabId,
|
||||
} from '../state/activeTabStore';
|
||||
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from '../../domain/terminalAppearance';
|
||||
import { collectSessionIds } from '../../domain/workspace';
|
||||
import type { EditorTab } from '../state/editorTabStore';
|
||||
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
|
||||
|
||||
@@ -97,6 +98,34 @@ export function isTerminalContentTabSurface({
|
||||
return sessionIds.has(activeTabId) || workspaceIds.has(activeTabId);
|
||||
}
|
||||
|
||||
export function resolveWorkspaceTargetSession(
|
||||
workspace: Workspace,
|
||||
sessions: readonly TerminalSession[],
|
||||
): TerminalSession | undefined {
|
||||
const sessionById = new Map(sessions.map((session) => [session.id, session]));
|
||||
return resolveWorkspaceTargetSessionFromMap(workspace, sessionById);
|
||||
}
|
||||
|
||||
export function resolveWorkspaceTargetSessionFromMap(
|
||||
workspace: Workspace,
|
||||
sessionById: ReadonlyMap<string, TerminalSession>,
|
||||
): TerminalSession | undefined {
|
||||
const orderedSessionIds = collectSessionIds(workspace.root);
|
||||
const workspaceSessionIdSet = new Set(orderedSessionIds);
|
||||
const focusedSession = workspace.focusedSessionId
|
||||
? sessionById.get(workspace.focusedSessionId)
|
||||
: undefined;
|
||||
const validFocusedSession = focusedSession && workspaceSessionIdSet.has(focusedSession.id)
|
||||
? focusedSession
|
||||
: undefined;
|
||||
if (validFocusedSession) return validFocusedSession;
|
||||
for (const sessionId of orderedSessionIds) {
|
||||
const session = sessionById.get(sessionId);
|
||||
if (session) return session;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveWorkTabActiveHostId({
|
||||
activeTabId,
|
||||
editorTabs,
|
||||
@@ -119,12 +148,8 @@ export function resolveWorkTabActiveHostId({
|
||||
const activeWorkspace = workspaces.find((workspace) => workspace.id === activeTabId);
|
||||
if (!activeWorkspace) return null;
|
||||
|
||||
const focusedSessionId = activeWorkspace.focusedSessionId;
|
||||
if (focusedSessionId) {
|
||||
return sessions.find((session) => session.id === focusedSessionId)?.hostId ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
const targetSession = resolveWorkspaceTargetSession(activeWorkspace, sessions);
|
||||
return targetSession?.hostId ?? null;
|
||||
}
|
||||
|
||||
export function resolveWorkTabHostTreeTheme({
|
||||
|
||||
@@ -4,6 +4,7 @@ import { enVaultMessages } from './en/vault';
|
||||
import { enTerminalMessages } from './en/terminal';
|
||||
import { enAiMessages } from './en/ai';
|
||||
import { enSystemManagerMessages } from './en/systemManager';
|
||||
import { enScriptsMessages } from './en/scripts';
|
||||
|
||||
export type { Messages } from './types';
|
||||
|
||||
@@ -13,6 +14,7 @@ const en: Messages = {
|
||||
...enTerminalMessages,
|
||||
...enAiMessages,
|
||||
...enSystemManagerMessages,
|
||||
...enScriptsMessages,
|
||||
};
|
||||
|
||||
export default en;
|
||||
@@ -4,6 +4,10 @@ export const enAiMessages: Messages = {
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent Settings',
|
||||
'ai.chat.preparing': 'Preparing…',
|
||||
'ai.chat.compactingContext': 'Compacting earlier context…',
|
||||
'ai.chat.compactingStep': 'Trimming context for next step…',
|
||||
'ai.chat.compactionRetry': 'Request was too large. Compacting context and retrying…',
|
||||
'ai.chat.compactionBanner': 'Context compacted: {before}K → {after}K tokens',
|
||||
'ai.title': 'AI',
|
||||
'ai.description': 'Configure AI providers, agents, and safety settings',
|
||||
'ai.providers': 'Providers',
|
||||
@@ -220,10 +224,10 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.noProvider': 'No AI provider is configured. Go to **Settings → AI → Providers** to add and enable a provider.',
|
||||
'ai.chat.toolDenied': 'Action was rejected by the user.',
|
||||
'ai.chat.toolApproved': 'Approved',
|
||||
'ai.chat.toolApprovalHint': 'Press Enter to approve once, Escape to reject',
|
||||
'ai.chat.toolApprovalHint': 'Enter once · Esc reject',
|
||||
'ai.chat.approve': 'Approve',
|
||||
'ai.chat.approveOnce': 'Allow once',
|
||||
'ai.chat.alwaysAllow': 'Always allow',
|
||||
'ai.chat.approveOnce': 'Once',
|
||||
'ai.chat.alwaysAllow': 'Always',
|
||||
'ai.chat.slashStopDesc': 'Stop the current AI turn and cancel in-flight tools',
|
||||
'ai.chat.reject': 'Reject',
|
||||
'ai.chat.toolLabel': 'Tool',
|
||||
@@ -393,6 +397,23 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.artifact.unavailableTitle': 'Unavailable',
|
||||
'ai.chat.artifact.noteMissing': 'This note is no longer in your Vault.',
|
||||
'ai.chat.artifact.hostMissing': 'This host is no longer in your Vault.',
|
||||
'ai.chat.artifact.snippetMissing': 'This snippet or script is no longer in your Vault.',
|
||||
'ai.chat.artifact.openSnippets': 'Open Snippets',
|
||||
'ai.chat.artifact.snippetsSummary': '{count} snippets in Vault',
|
||||
'ai.chat.artifact.scriptsSummary': '{count} scripts in Vault',
|
||||
'ai.chat.artifact.snippetFallback': 'Vault snippet',
|
||||
'ai.chat.artifact.scriptFallback': 'Automation script',
|
||||
'ai.chat.artifact.scriptLanguage': '{language} script',
|
||||
'ai.chat.artifact.snippetDeleted': 'Snippet deleted',
|
||||
'ai.chat.artifact.scriptDeleted': 'Script deleted',
|
||||
'ai.chat.artifact.snippetRan': 'Snippet executed',
|
||||
'ai.chat.artifact.scriptStarted': 'Script run started',
|
||||
'ai.chat.artifact.scriptRunStatus': 'Script run {status}',
|
||||
'ai.chat.artifact.scriptRunsSummary': '{count} script runs',
|
||||
'ai.chat.artifact.scriptRunStopped': 'Script run stopped',
|
||||
'ai.chat.artifact.scriptRunPaused': 'Script run paused',
|
||||
'ai.chat.artifact.scriptRunResumed': 'Script run resumed',
|
||||
'ai.chat.artifact.scriptReference': 'nct API reference',
|
||||
'zmodem.waitingForRemote': 'Waiting for remote...',
|
||||
'zmodem.uploading': 'Uploading',
|
||||
'zmodem.downloading': 'Downloading',
|
||||
|
||||
@@ -287,6 +287,23 @@ export const enCoreMessages: Messages = {
|
||||
'settings.appearance.uiFont.desc': 'Choose the font for the application interface',
|
||||
'settings.appearance.windowOpacity': 'Window Opacity',
|
||||
'settings.appearance.windowOpacity.desc': 'Adjust the transparency of the entire application window. Lower values also fade terminal text. Some Linux desktop environments may not support this.',
|
||||
'settings.appearance.appIcon': 'App Icon',
|
||||
'settings.appearance.appIcon.desc': 'Choose a dock/taskbar icon style to make Netcatty easier to spot when switching apps. Applies at runtime; the installed app bundle icon is unchanged.',
|
||||
'settings.appearance.appIcon.original': 'Original',
|
||||
'settings.appearance.appIcon.bright': 'Bright',
|
||||
'settings.appearance.appIcon.dark': 'Dark',
|
||||
'settings.appearance.appIcon.colorful': 'Colorful',
|
||||
'settings.appearance.appIcon.highContrast': 'High contrast',
|
||||
'settings.appearance.appIcon.group.classic': 'Colored backgrounds',
|
||||
'settings.appearance.appIcon.group.white': 'White background',
|
||||
'settings.appearance.appIcon.group.special': 'Hidden',
|
||||
'settings.appearance.appIcon.whiteNavy': 'White · Navy cat',
|
||||
'settings.appearance.appIcon.whiteSky': 'White · Sky cat',
|
||||
'settings.appearance.appIcon.whiteRose': 'White · Rose cat',
|
||||
'settings.appearance.appIcon.whiteEmerald': 'White · Emerald cat',
|
||||
'settings.appearance.appIcon.whiteAmber': 'White · Amber cat',
|
||||
'settings.appearance.appIcon.whiteViolet': 'White · Violet cat',
|
||||
'settings.appearance.appIcon.rainbow': 'Rainbow cat',
|
||||
// Settings > Terminal
|
||||
'settings.terminal.section.theme': 'Terminal Theme',
|
||||
'settings.terminal.themeModal.title': 'Select Theme',
|
||||
@@ -304,6 +321,7 @@ export const enCoreMessages: Messages = {
|
||||
'settings.terminal.section.keyboard': 'Keyboard',
|
||||
'settings.terminal.section.accessibility': 'Accessibility',
|
||||
'settings.terminal.section.behavior': 'Behavior',
|
||||
'settings.terminal.section.sidePanel': 'Side Panel',
|
||||
'settings.terminal.section.scrollback': 'Scrollback',
|
||||
'settings.terminal.section.keywordHighlight': 'Keyword highlighting',
|
||||
'settings.terminal.font.family': 'Font',
|
||||
@@ -351,6 +369,17 @@ export const enCoreMessages: Messages = {
|
||||
'settings.terminal.keyboard.optionArrowWordJump.desc':
|
||||
'Send Meta-b / Meta-f on Option+Left/Right so the shell moves by word, instead of the default ^[[1;3D / ^[[1;3C',
|
||||
'settings.terminal.accessibility.minimumContrastRatio': 'Minimum contrast ratio',
|
||||
'settings.terminal.sidePanel.autoOpen': 'Open side panel with terminal',
|
||||
'settings.terminal.sidePanel.autoOpen.desc': 'Automatically open a side panel when a terminal connects.',
|
||||
'settings.terminal.sidePanel.autoOpenPane': 'Default side panel',
|
||||
'settings.terminal.sidePanel.autoOpenPane.desc': 'Choose which panel opens first.',
|
||||
'settings.terminal.sidePanel.pane.sftp': 'SFTP',
|
||||
'settings.terminal.sidePanel.pane.scripts': 'Scripts',
|
||||
'settings.terminal.sidePanel.pane.history': 'History',
|
||||
'settings.terminal.sidePanel.pane.theme': 'Theme',
|
||||
'settings.terminal.sidePanel.pane.system': 'System',
|
||||
'settings.terminal.sidePanel.pane.notes': 'Notes',
|
||||
'settings.terminal.sidePanel.pane.ai': 'AI',
|
||||
'settings.terminal.accessibility.minimumContrastRatio.desc':
|
||||
'Adjust colors to meet contrast requirements (1 = disabled, 21 = max)',
|
||||
'settings.terminal.behavior.rightClick': 'Right-click behavior',
|
||||
@@ -558,6 +587,7 @@ export const enCoreMessages: Messages = {
|
||||
'sync.cloudSync': 'Cloud Sync',
|
||||
'sync.settings': 'Sync Settings',
|
||||
'sync.active': 'Cloud Sync Active',
|
||||
'sync.pending': 'Changes pending sync',
|
||||
'sync.syncing': 'Syncing...',
|
||||
'sync.error': 'Sync Error',
|
||||
'sync.notConfigured': 'Not Configured',
|
||||
@@ -630,7 +660,7 @@ export const enCoreMessages: Messages = {
|
||||
'vault.nav.keychain': 'Keychain',
|
||||
'vault.nav.proxies': 'Proxies',
|
||||
'vault.nav.portForwarding': 'Port Forwarding',
|
||||
'vault.nav.snippets': 'Snippets',
|
||||
'vault.nav.snippets': 'Scripts',
|
||||
'vault.nav.notes': 'Notes',
|
||||
'vault.nav.knownHosts': 'Known Hosts',
|
||||
'vault.nav.logs': 'Logs',
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
export const enScriptsMessages = {
|
||||
'scripts.meta.name': 'Name',
|
||||
'scripts.meta.language': 'Language',
|
||||
'scripts.meta.description': 'Description',
|
||||
'scripts.meta.descriptionPlaceholder': 'Optional notes about this script',
|
||||
'scripts.meta.trigger': 'Trigger',
|
||||
'scripts.meta.triggerPattern': 'Output pattern (regex)',
|
||||
'scripts.meta.code': 'Script',
|
||||
'scripts.trigger.manual': 'Manual run',
|
||||
'scripts.trigger.onConnect': 'Run on connect',
|
||||
'scripts.trigger.onOutput': 'Run on output match',
|
||||
'scripts.trigger.onOutputHint': 'Fires when server output matches the pattern (user keystroke echo is ignored). With no target hosts configured, listens on the current connected session; with targets, only those hosts. Disabled in alternate-screen apps such as vim or htop. Suppressed while another script is running on this session; rechecked after it finishes. For in-flow waits, use nct.screen.waitFor in script code.',
|
||||
'scripts.actions.save': 'Save',
|
||||
'scripts.actions.runNow': 'Run now',
|
||||
'scripts.actions.openEditor': 'Open editor',
|
||||
'scripts.actions.openEditorHint': 'Edit script in a larger window',
|
||||
'scripts.editor.modalTitle': 'Script editor',
|
||||
'scripts.editor.modalSubtitle': 'Edit metadata and script code in a larger workspace.',
|
||||
'scripts.editor.lineCount': '{count} lines',
|
||||
'scripts.editor.resize': 'Resize editor',
|
||||
'scripts.targets.hint': 'Automatic triggers apply to selected hosts, or to all hosts when that option is enabled.',
|
||||
'scripts.targets.connectOrderHint': 'Run order for connect scripts is configured per host under Host details → Automation.',
|
||||
'scripts.actions.runNowHint': 'Run on selected targets, or on all connectable hosts when that option is enabled.',
|
||||
'scripts.actions.runParallel': 'Run on all tabs (parallel)',
|
||||
'scripts.actions.runSequential': 'Run on all tabs (sequential)',
|
||||
'scripts.actions.skippedConnectingSessions': '{count} tab(s) still connecting and were skipped',
|
||||
'scripts.actions.noRunnableHosts': 'No connectable hosts match this script\'s targets',
|
||||
'scripts.sidePanel.library': 'Library',
|
||||
'scripts.sidePanel.running': 'Running',
|
||||
'scripts.sidePanel.newScript': 'New script',
|
||||
'scripts.running.empty': 'No scripts are running in this session.',
|
||||
'scripts.running.unnamed': 'Untitled script',
|
||||
'scripts.running.status.running': 'Running',
|
||||
'scripts.running.status.paused': 'Paused',
|
||||
'scripts.running.status.completed': 'Completed',
|
||||
'scripts.running.status.failed': 'Failed',
|
||||
'scripts.running.waitingFor': 'Waiting for {pattern}',
|
||||
'scripts.running.waitingForLabel': 'Waiting for',
|
||||
'scripts.running.waitingForShellPrompt': 'shell prompt (# or $)',
|
||||
'scripts.running.lastSent': 'Sent: {command}',
|
||||
'scripts.recording.start': 'Start recording',
|
||||
'scripts.recording.active': 'Stop recording',
|
||||
'scripts.recording.startHint': 'Record actions in the focused terminal and generate nct script code',
|
||||
'scripts.recording.unavailableHint': 'Recording is not available here — use the scripts sidebar on the right',
|
||||
'scripts.recording.activeHint': 'Recording this terminal. Type commands as usual; click Stop or use the REC control in the toolbar to finish and save.',
|
||||
'scripts.recording.started': 'Recording started — operate in the terminal',
|
||||
'scripts.recording.noSession': 'Connect a terminal first (single tab or workspace)',
|
||||
'scripts.recording.alreadyActive': 'Another terminal is already recording',
|
||||
'scripts.recording.stop': 'Stop recording',
|
||||
'scripts.recording.pause': 'Pause recording',
|
||||
'scripts.recording.resume': 'Resume recording',
|
||||
'scripts.recording.saveTitle': 'Save recorded script',
|
||||
'scripts.recording.namePlaceholder': 'Script name',
|
||||
'scripts.recording.packagePlaceholder': 'Save to folder',
|
||||
'scripts.recording.rootPackage': 'Root',
|
||||
'scripts.recording.save': 'Save',
|
||||
'scripts.recording.saveAndEdit': 'Save and edit',
|
||||
'scripts.recording.helpTitle': 'How to record a script',
|
||||
'scripts.recording.helpIntro': 'Recording turns what you do in the terminal into a reusable automation script — useful for deploys, health checks, and other repetitive tasks.',
|
||||
'scripts.recording.helpStep1': 'Connect to a host. A standalone terminal tab or a terminal inside a workspace both work — open the scripts sidebar on the right.',
|
||||
'scripts.recording.helpStep2': 'Click Start recording. A red REC badge in the terminal toolbar means recording is active.',
|
||||
'scripts.recording.helpStep3': 'Type commands in the terminal as you normally would. Each Enter press is captured as one step.',
|
||||
'scripts.recording.helpStep4': 'When you are done, click Stop recording, or use the stop control next to REC in the terminal toolbar.',
|
||||
'scripts.recording.helpStep5': 'Name the script and save. Choose Save and edit if you want to fine-tune the generated code in the script editor.',
|
||||
'scripts.recording.helpTipsTitle': 'Tips',
|
||||
'scripts.recording.helpTip1': 'Pauses longer than 1 second between actions are recorded as wait time so playback is not too fast.',
|
||||
'scripts.recording.helpTip2': 'After each command, recording waits for a shell prompt (such as $ or #) before the next step.',
|
||||
'scripts.recording.helpTip3': 'Password input is marked sensitive and is not stored in plain text in the script.',
|
||||
'scripts.recording.helpTip4': 'For vim, menus, or other interactive flows, record the main commands first, then refine the script manually.',
|
||||
'scripts.dialog.title': 'Script',
|
||||
'scripts.dialog.ok': 'OK',
|
||||
'scripts.dialog.waitForTimeoutTitle': 'Wait timed out',
|
||||
'scripts.dialog.retry': 'Retry',
|
||||
'scripts.dialog.skip': 'Skip',
|
||||
'scripts.dialog.abort': 'Abort',
|
||||
'scripts.running.stepProgress': 'Step {current} / {total}',
|
||||
'scripts.running.determinateProgress': '{label} {current}/{total}',
|
||||
'scripts.running.progressFallback': 'Progress',
|
||||
'scripts.running.operationsCount': '{count} operations',
|
||||
'scripts.running.opsPrefix': '',
|
||||
'scripts.running.opsSuffix': ' operations',
|
||||
'scripts.running.elapsedLabel': 'Elapsed',
|
||||
'scripts.running.lastSentLabel': 'Sent:',
|
||||
'scripts.running.elapsed': '{elapsed}',
|
||||
'scripts.running.completedSummary': 'Completed · {count} operations · {elapsed}',
|
||||
'scripts.running.dismissHint': 'Tap close to dismiss',
|
||||
'scripts.running.dismiss': 'Close',
|
||||
'scripts.running.viewLogs': 'View logs',
|
||||
'scripts.running.logTitle': '{name} · Run log',
|
||||
'scripts.running.logEmpty': 'No log output yet',
|
||||
'scripts.running.pause': 'Pause',
|
||||
'scripts.running.resume': 'Resume',
|
||||
'scripts.running.stop': 'Stop',
|
||||
'scripts.recording.saved': 'Script saved',
|
||||
'scripts.recording.savedNamed': 'Saved "{name}"',
|
||||
'scripts.observer.blocked': 'Observer mode blocks scripts that write to the terminal.',
|
||||
'vault.section.scripts': 'Scripts',
|
||||
'vault.nav.scripts': 'Scripts',
|
||||
'snippets.action.newScript': 'New automation script',
|
||||
'hostDetails.section.automation': 'Automation',
|
||||
'hostDetails.automation.loginScript': 'Login script',
|
||||
'hostDetails.automation.loginScriptPlaceholder': 'Select a script',
|
||||
'hostDetails.automation.none': 'None',
|
||||
'hostDetails.automation.outputTriggers': 'Output triggers',
|
||||
'hostDetails.automation.addTrigger': 'Add trigger',
|
||||
'hostDetails.automation.triggerPatternPlaceholder': 'Regex pattern',
|
||||
'hostDetails.automation.linkedScripts': 'Linked scripts',
|
||||
'hostDetails.automation.linkedScriptsEmpty': 'No scripts linked to this host yet.',
|
||||
'hostDetails.automation.linkScriptPlaceholder': 'Link existing script…',
|
||||
'hostDetails.automation.unlink': 'Unlink',
|
||||
'hostDetails.automation.equivalenceHint': 'Links add this host to the script\'s target list in Scripts. Both views stay in sync.',
|
||||
'hostDetails.automation.queueHint': 'Scripts run in order when this host connects. Global scripts run first, then this queue.',
|
||||
'hostDetails.automation.globalScripts': 'Global connect scripts',
|
||||
'hostDetails.automation.globalScriptsHint': 'Runs first on every connect. Reorder in Scripts library sort order.',
|
||||
'hostDetails.automation.connectQueue': 'This host\'s run queue',
|
||||
'hostDetails.automation.connectQueueEmpty': 'No connect scripts queued for this host yet.',
|
||||
'hostDetails.automation.addToQueuePlaceholder': 'Add script to queue…',
|
||||
'hostDetails.automation.moveUp': 'Move up',
|
||||
'hostDetails.automation.moveDown': 'Move down',
|
||||
'hostDetails.automation.removeFromQueue': 'Remove from queue',
|
||||
'hostDetails.automation.dragHandle': 'Drag to reorder',
|
||||
'hostDetails.automation.queueDragHint': 'Drag items to reorder the connect queue.',
|
||||
};
|
||||
@@ -127,6 +127,9 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.osc7Setup.targets': 'Possible files to update',
|
||||
'terminal.osc7Setup.command': 'Command to run',
|
||||
'terminal.osc7Setup.run': 'Run setup',
|
||||
'terminal.osc7Setup.running': 'Configuring...',
|
||||
'terminal.osc7Setup.configured': 'Directory tracking configured',
|
||||
'terminal.osc7Setup.failed': 'Directory tracking setup failed',
|
||||
'terminal.osc7Setup.sent': 'Directory tracking setup sent to terminal',
|
||||
'terminal.ymodem.selectFile': 'Select file to send',
|
||||
'terminal.ymodem.allFiles': 'All files',
|
||||
@@ -159,6 +162,7 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.auth.proxyCredentialsUnavailable': 'Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.',
|
||||
'terminal.auth.keyUnavailableFallbackPassword': 'Saved SSH key is unavailable on this device. Falling back to password authentication.',
|
||||
'terminal.progress.timeoutIn': 'Timeout in {seconds}s',
|
||||
'terminal.progress.waitingForUserInput': 'Waiting for user input',
|
||||
'terminal.progress.disconnected': 'Disconnected',
|
||||
'terminal.progress.cancelling': 'Cancelling...',
|
||||
'terminal.progress.startOver': 'Start over',
|
||||
@@ -576,24 +580,30 @@ export const enTerminalMessages: Messages = {
|
||||
'keychain.edit.exportToHost': 'Export to host',
|
||||
|
||||
// Snippets
|
||||
'snippets.searchPlaceholder': 'Search snippets...',
|
||||
'snippets.searchPlaceholder': 'Search scripts...',
|
||||
'snippets.action.newSnippet': 'New Snippet',
|
||||
'snippets.action.newPackage': 'New Package',
|
||||
'snippets.action.newPackage': 'New Script Package',
|
||||
'snippets.action.import': 'Import',
|
||||
'snippets.action.selectSnippets': 'Select snippets',
|
||||
'snippets.panel.newTitle': 'New Snippet',
|
||||
'snippets.panel.editTitle': 'Edit Snippet',
|
||||
'snippets.panel.newAutomationTitle': 'New Automation Script',
|
||||
'snippets.panel.editAutomationTitle': 'Edit Automation Script',
|
||||
'snippets.panel.resizeWidth': 'Resize panel width',
|
||||
'snippets.field.description': 'Action description',
|
||||
'snippets.field.descriptionPlaceholder': 'Example: check network load',
|
||||
'snippets.field.package': 'Add a Package',
|
||||
'snippets.field.packagePlaceholder': 'Select or create package',
|
||||
'snippets.field.createPackage': 'Create Package',
|
||||
'snippets.field.package': 'Add a script package',
|
||||
'snippets.field.packagePlaceholder': 'Select or create script package',
|
||||
'snippets.field.createPackage': 'Create Script Package',
|
||||
'snippets.field.scriptRequired': 'Script *',
|
||||
'snippets.scriptEditor.expand': 'Open in dialog',
|
||||
'snippets.scriptEditor.resize': 'Resize editor height',
|
||||
'snippets.scriptEditor.modalTitle': 'Edit script',
|
||||
'snippets.targets.title': 'Targets',
|
||||
'snippets.targets.add': 'Add targets',
|
||||
'snippets.targets.allHosts': 'Apply to all hosts',
|
||||
'snippets.targets.allHostsShort': 'All hosts',
|
||||
'snippets.targets.allHostsActive': 'Applies to every connectable host.',
|
||||
'snippets.history.title': 'Shell History',
|
||||
'snippets.history.subtitle': '{count} commands',
|
||||
'snippets.history.emptyTitle': 'No shell history yet',
|
||||
@@ -606,15 +616,17 @@ export const enTerminalMessages: Messages = {
|
||||
'snippets.history.time.minutesAgo': '{count}m ago',
|
||||
'snippets.history.time.hoursAgo': '{count}h ago',
|
||||
'snippets.history.time.daysAgo': '{count}d ago',
|
||||
'snippets.breadcrumb.allPackages': 'All packages',
|
||||
'snippets.breadcrumb.allPackages': 'All script packages',
|
||||
'snippets.breadcrumb.separator': '›',
|
||||
'snippets.empty.title': 'Create snippet',
|
||||
'snippets.empty.desc': 'Save your most used commands as snippets to reuse them in one click.',
|
||||
'snippets.empty.title': 'Create scripts',
|
||||
'snippets.empty.desc': 'Save common commands as code snippets, or write automation scripts for repeatable operations.',
|
||||
'snippets.search.noResults.title': 'No matches',
|
||||
'snippets.search.noResults.desc': 'No snippets or packages match "{query}". Try a different search term or clear the search to browse.',
|
||||
'snippets.section.packages': 'Packages',
|
||||
'snippets.section.snippets': 'Snippets',
|
||||
'snippets.package.count': '{count} snippet(s)',
|
||||
'snippets.search.noResults.desc': 'No scripts or script packages match "{query}". Try a different search term or clear the search to browse.',
|
||||
'snippets.section.packages': 'Script Packages',
|
||||
'snippets.section.snippets': 'Scripts',
|
||||
'snippets.kind.codeSnippet': 'Code snippet',
|
||||
'snippets.kind.automationScript': 'Automation script',
|
||||
'snippets.package.count': '{count} script(s)',
|
||||
'snippets.commandFallback': 'Command',
|
||||
'snippets.view.grid': 'Grid',
|
||||
'snippets.view.list': 'List',
|
||||
@@ -623,7 +635,7 @@ export const enTerminalMessages: Messages = {
|
||||
'snippets.selection.deselectAll': 'Deselect all',
|
||||
'snippets.selection.exportSelected': 'Export selected ({count})',
|
||||
'snippets.export.snippet': 'Export snippet',
|
||||
'snippets.export.package': 'Export package',
|
||||
'snippets.export.package': 'Export script package',
|
||||
'snippets.export.toast.empty': 'No snippets to export.',
|
||||
'snippets.export.toast.successTitle': 'Export ready',
|
||||
'snippets.export.toast.success': 'Exported {count} snippet(s).',
|
||||
@@ -639,26 +651,26 @@ export const enTerminalMessages: Messages = {
|
||||
'snippets.import.modal.chooseFile': 'Choose file',
|
||||
'snippets.import.modal.downloadExamples': 'Download samples',
|
||||
'snippets.import.modal.multipleFiles': '{count} files selected',
|
||||
'snippets.import.modal.parsedSummary': '{files} file(s), {total} snippet(s), {packages} package(s), {conflicts} duplicate command(s). Host bindings are ignored.',
|
||||
'snippets.import.modal.parsedSummary': '{files} file(s), {total} script(s), {packages} script package(s), {conflicts} duplicate command(s). Host bindings are ignored.',
|
||||
'snippets.import.modal.confirm': 'Confirm import',
|
||||
'snippets.import.conflict.title': 'Import snippets?',
|
||||
'snippets.import.conflict.desc': '{file} contains {total} snippet(s). {conflicts} duplicate command(s) already exist.',
|
||||
'snippets.import.conflict.hostBindingsNote': 'Host bindings are not imported or exported with snippets.',
|
||||
'snippets.import.conflict.skip': 'Skip duplicates',
|
||||
'snippets.import.conflict.overwrite': 'Overwrite duplicates',
|
||||
'snippets.packageDialog.title': 'New Package',
|
||||
'snippets.packageDialog.title': 'New Script Package',
|
||||
'snippets.packageDialog.parent': 'Parent: {parent}',
|
||||
'snippets.packageDialog.root': 'Root',
|
||||
'snippets.packageDialog.placeholder': 'e.g. ops/maintenance',
|
||||
'snippets.packageDialog.hint': 'Use "/" to create nested packages.',
|
||||
'snippets.packageDialog.hint': 'Use "/" to create nested script packages.',
|
||||
|
||||
// Snippets Rename Dialog
|
||||
'snippets.renameDialog.title': 'Rename Package',
|
||||
'snippets.renameDialog.title': 'Rename Script Package',
|
||||
'snippets.renameDialog.currentPath': 'Current path: {path}',
|
||||
'snippets.renameDialog.placeholder': 'Enter new name',
|
||||
'snippets.renameDialog.error.empty': 'Package name cannot be empty',
|
||||
'snippets.renameDialog.error.duplicate': 'A package with this name already exists',
|
||||
'snippets.renameDialog.error.invalidChars': 'Package name can only contain letters, numbers, hyphens, and underscores',
|
||||
'snippets.renameDialog.error.empty': 'Script package name cannot be empty',
|
||||
'snippets.renameDialog.error.duplicate': 'A script package with this name already exists',
|
||||
'snippets.renameDialog.error.invalidChars': 'Script package name can only contain letters, numbers, hyphens, and underscores',
|
||||
|
||||
'snippets.field.noAutoRun': 'Paste only (do not auto-execute)',
|
||||
// Snippet Shortkey
|
||||
|
||||
@@ -6,11 +6,17 @@ export const enVaultMessages: Messages = {
|
||||
'notes.action.newNote': 'New Note',
|
||||
'notes.action.newGroup': 'New Folder',
|
||||
'notes.action.importMarkdown': 'Import Markdown',
|
||||
'notes.action.exportNote': 'Export Note',
|
||||
'notes.action.exportGroup': 'Export Folder',
|
||||
'notes.action.exportAll': 'Export Notes',
|
||||
'notes.import.toast.success': 'Imported {count} note(s)',
|
||||
'notes.import.toast.noNotes': 'No Markdown files were imported.',
|
||||
'notes.import.toast.failed': 'Failed to import Markdown files.',
|
||||
'notes.import.toast.inProgress': 'A Markdown import is already in progress.',
|
||||
'notes.import.toast.skipped': 'Skipped {count} unsupported file(s).',
|
||||
'notes.export.toast.success': 'Exported {count} note(s)',
|
||||
'notes.export.toast.empty': 'No notes to export.',
|
||||
'notes.export.toast.failed': 'Failed to export notes.',
|
||||
'notes.codeBlock.copied': 'Copied',
|
||||
'notes.codeBlock.copyFailed': 'Failed to copy code block.',
|
||||
'notes.group.prompt': 'Folder path',
|
||||
@@ -477,6 +483,7 @@ export const enVaultMessages: Messages = {
|
||||
// Host Details
|
||||
'hostDetails.title.details': 'Host Details',
|
||||
'hostDetails.title.new': 'New Host',
|
||||
'vault.panel.resizeWidth': 'Resize panel width',
|
||||
'hostDetails.saveAria': 'Save',
|
||||
'hostDetails.section.address': 'Address',
|
||||
'hostDetails.hostname.placeholder': 'IP or Hostname',
|
||||
@@ -494,6 +501,8 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.notes.tab.edit': 'Edit',
|
||||
'hostDetails.notes.tab.preview': 'Preview',
|
||||
'hostDetails.notes.preview.empty': 'Nothing to preview yet.',
|
||||
'hostDetails.notes.toggle.show': 'Show notes editor',
|
||||
'hostDetails.notes.toggle.hide': 'Hide notes editor',
|
||||
'hostDetails.group.placeholder': 'Parent Group',
|
||||
'hostDetails.section.credentials': 'Credentials',
|
||||
'hostDetails.section.portCredentials': 'Port & Credentials',
|
||||
@@ -516,6 +525,7 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.icon.colorManual': 'Manual',
|
||||
'hostDetails.icon.colorChoice': 'Color',
|
||||
'hostDetails.icon.autoColorLabel': 'Current color',
|
||||
'hostDetails.icon.preview': 'Icon preview',
|
||||
'hostDetails.icon.showLibrary': 'Show icon library',
|
||||
'hostDetails.icon.hideLibrary': 'Hide icon library',
|
||||
'hostDetails.icon.autoUsesDistro': 'Use Linux Distribution icon and selected color for this host.',
|
||||
@@ -668,12 +678,17 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.proxyPanel.usernamePlaceholder': 'Username',
|
||||
'hostDetails.proxyPanel.passwordPlaceholder': 'Password',
|
||||
'hostDetails.proxyPanel.identities': 'Identities',
|
||||
'hostDetails.proxyPanel.keychainIdentity': 'Keychain identity',
|
||||
'hostDetails.proxyPanel.manualCredentials': 'Manual credentials',
|
||||
'hostDetails.proxyPanel.remove': 'Remove Proxy',
|
||||
'hostDetails.proxyPanel.savedProxy': 'Saved proxy',
|
||||
'hostDetails.proxyPanel.selectSaved': 'Select saved proxy',
|
||||
'hostDetails.proxyPanel.customProxy': 'Custom proxy',
|
||||
'hostDetails.proxyPanel.missing': 'Missing',
|
||||
'hostDetails.proxyPanel.missingSaved': 'Missing saved proxy',
|
||||
'hostDetails.proxyPanel.missingIdentity': 'Missing keychain identity',
|
||||
'hostDetails.proxyPanel.incompleteIdentity': 'Proxy identity needs a username and password',
|
||||
'hostDetails.proxyPanel.unreadableIdentity': 'Proxy identity password cannot be read on this device',
|
||||
'hostDetails.proxyPanel.error.required': 'Proxy host and port, or a ProxyCommand, are required.',
|
||||
'hostDetails.envVars': 'Environment Variables',
|
||||
'hostDetails.envVars.add': 'Add Environment Variable',
|
||||
@@ -705,6 +720,7 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.telnetOn': 'Telnet on',
|
||||
'hostDetails.port': 'port',
|
||||
'hostDetails.telnet.credentials': 'Credentials',
|
||||
'hostDetails.telnet.identity.placeholder': 'Select Telnet Identity',
|
||||
'hostDetails.telnet.username': 'Telnet Username',
|
||||
'hostDetails.telnet.password': 'Telnet Password',
|
||||
'hostDetails.charset.placeholder': 'Charset (e.g. UTF-8)',
|
||||
|
||||
@@ -374,6 +374,23 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.artifact.unavailableTitle': 'Недоступно',
|
||||
'ai.chat.artifact.noteMissing': 'Эта заметка больше не в Vault.',
|
||||
'ai.chat.artifact.hostMissing': 'Этот хост больше не в Vault.',
|
||||
'ai.chat.artifact.snippetMissing': 'Этот snippet или скрипт больше не в Vault.',
|
||||
'ai.chat.artifact.openSnippets': 'Открыть Snippets',
|
||||
'ai.chat.artifact.snippetsSummary': '{count} snippets в Vault',
|
||||
'ai.chat.artifact.scriptsSummary': '{count} scripts в Vault',
|
||||
'ai.chat.artifact.snippetFallback': 'Snippet Vault',
|
||||
'ai.chat.artifact.scriptFallback': 'Automation script',
|
||||
'ai.chat.artifact.scriptLanguage': 'Script {language}',
|
||||
'ai.chat.artifact.snippetDeleted': 'Snippet удален',
|
||||
'ai.chat.artifact.scriptDeleted': 'Script удален',
|
||||
'ai.chat.artifact.snippetRan': 'Snippet выполнен',
|
||||
'ai.chat.artifact.scriptStarted': 'Запуск script начат',
|
||||
'ai.chat.artifact.scriptRunStatus': 'Script run {status}',
|
||||
'ai.chat.artifact.scriptRunsSummary': '{count} script runs',
|
||||
'ai.chat.artifact.scriptRunStopped': 'Script run остановлен',
|
||||
'ai.chat.artifact.scriptRunPaused': 'Script run приостановлен',
|
||||
'ai.chat.artifact.scriptRunResumed': 'Script run возобновлен',
|
||||
'ai.chat.artifact.scriptReference': 'Справка nct API',
|
||||
'zmodem.waitingForRemote': 'Ожидание удалённой стороны...',
|
||||
'zmodem.uploading': 'Загрузка',
|
||||
'zmodem.downloading': 'Скачивание',
|
||||
|
||||
@@ -276,6 +276,23 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.appearance.uiFont.desc': 'Выберите шрифт для интерфейса приложения',
|
||||
'settings.appearance.windowOpacity': 'Прозрачность окна',
|
||||
'settings.appearance.windowOpacity.desc': 'Настройте прозрачность всего окна приложения. При низких значениях текст терминала тоже бледнеет. В некоторых средах Linux это может не поддерживаться.',
|
||||
'settings.appearance.appIcon': 'Значок приложения',
|
||||
'settings.appearance.appIcon.desc': 'Выберите стиль значка для Dock/панели задач, чтобы Netcatty было проще найти при переключении приложений. Применяется во время работы; значок установленного пакета не меняется.',
|
||||
'settings.appearance.appIcon.original': 'Оригинал',
|
||||
'settings.appearance.appIcon.bright': 'Яркий',
|
||||
'settings.appearance.appIcon.dark': 'Тёмный',
|
||||
'settings.appearance.appIcon.colorful': 'Цветной',
|
||||
'settings.appearance.appIcon.highContrast': 'Высокий контраст',
|
||||
'settings.appearance.appIcon.group.classic': 'Цветной фон',
|
||||
'settings.appearance.appIcon.group.white': 'Белый фон',
|
||||
'settings.appearance.appIcon.group.special': 'Скрытые',
|
||||
'settings.appearance.appIcon.whiteNavy': 'Белый · синий кот',
|
||||
'settings.appearance.appIcon.whiteSky': 'Белый · голубой кот',
|
||||
'settings.appearance.appIcon.whiteRose': 'Белый · розовый кот',
|
||||
'settings.appearance.appIcon.whiteEmerald': 'Белый · изумрудный кот',
|
||||
'settings.appearance.appIcon.whiteAmber': 'Белый · янтарный кот',
|
||||
'settings.appearance.appIcon.whiteViolet': 'Белый · фиолетовый кот',
|
||||
'settings.appearance.appIcon.rainbow': 'Радужный кот',
|
||||
// Settings > Terminal
|
||||
'settings.terminal.section.theme': 'Тема терминала',
|
||||
'settings.terminal.themeModal.title': 'Выберите тему',
|
||||
@@ -575,6 +592,7 @@ export const ruCoreMessages: Messages = {
|
||||
'sync.cloudSync': 'Облачная синхронизация',
|
||||
'sync.settings': 'Настройки синхронизации',
|
||||
'sync.active': 'Облачная синхронизация активна',
|
||||
'sync.pending': 'Есть несинхронизированные изменения',
|
||||
'sync.syncing': 'Синхронизация...',
|
||||
'sync.error': 'Ошибка синхронизации',
|
||||
'sync.notConfigured': 'Не настроено',
|
||||
|
||||
@@ -145,6 +145,9 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.osc7Setup.targets': 'Возможные файлы для обновления',
|
||||
'terminal.osc7Setup.command': 'Команда для запуска',
|
||||
'terminal.osc7Setup.run': 'Запустить настройку',
|
||||
'terminal.osc7Setup.running': 'Настройка...',
|
||||
'terminal.osc7Setup.configured': 'Отслеживание каталога настроено',
|
||||
'terminal.osc7Setup.failed': 'Не удалось настроить отслеживание каталога',
|
||||
'terminal.osc7Setup.sent': 'Настройка отслеживания каталога отправлена в терминал',
|
||||
'terminal.ymodem.selectFile': 'Выберите файл для отправки',
|
||||
'terminal.ymodem.allFiles': 'Все файлы',
|
||||
@@ -179,6 +182,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.auth.proxyCredentialsUnavailable': 'Учётные данные прокси не могут быть расшифрованы на этом устройстве. Откройте настройки хоста и заново введите пароль прокси.',
|
||||
'terminal.auth.keyUnavailableFallbackPassword': 'Сохранённый SSH-ключ недоступен на этом устройстве. Выполняется переход на аутентификацию по паролю.',
|
||||
'terminal.progress.timeoutIn': 'Тайм-аут через {seconds}с',
|
||||
'terminal.progress.waitingForUserInput': 'Ожидание ввода пользователя',
|
||||
'terminal.progress.disconnected': 'Отключено',
|
||||
'terminal.progress.cancelling': 'Отмена...',
|
||||
'terminal.progress.startOver': 'Начать заново',
|
||||
@@ -597,6 +601,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'snippets.action.selectSnippets': 'Выбрать сниппеты',
|
||||
'snippets.panel.newTitle': 'Новый сниппет',
|
||||
'snippets.panel.editTitle': 'Редактировать сниппет',
|
||||
'snippets.panel.resizeWidth': 'Изменить ширину панели',
|
||||
'snippets.field.description': 'Описание действия',
|
||||
'snippets.field.descriptionPlaceholder': 'Например: проверить сетевую нагрузку',
|
||||
'snippets.field.package': 'Добавить пакет',
|
||||
|
||||
@@ -6,11 +6,17 @@ export const ruVaultMessages: Messages = {
|
||||
'notes.action.newNote': 'Новая заметка',
|
||||
'notes.action.newGroup': 'Новая папка',
|
||||
'notes.action.importMarkdown': 'Импорт Markdown',
|
||||
'notes.action.exportNote': 'Экспорт заметки',
|
||||
'notes.action.exportGroup': 'Экспорт папки',
|
||||
'notes.action.exportAll': 'Экспорт заметок',
|
||||
'notes.import.toast.success': 'Импортировано заметок: {count}',
|
||||
'notes.import.toast.noNotes': 'Markdown-файлы не были импортированы.',
|
||||
'notes.import.toast.failed': 'Не удалось импортировать Markdown-файлы.',
|
||||
'notes.import.toast.inProgress': 'Импорт Markdown уже выполняется.',
|
||||
'notes.import.toast.skipped': 'Пропущено неподдерживаемых файлов: {count}.',
|
||||
'notes.export.toast.success': 'Экспортировано заметок: {count}',
|
||||
'notes.export.toast.empty': 'Нет заметок для экспорта.',
|
||||
'notes.export.toast.failed': 'Не удалось экспортировать заметки.',
|
||||
'notes.codeBlock.copied': 'Скопировано',
|
||||
'notes.codeBlock.copyFailed': 'Не удалось скопировать блок кода.',
|
||||
'notes.group.prompt': 'Путь папки',
|
||||
@@ -512,6 +518,7 @@ export const ruVaultMessages: Messages = {
|
||||
// Host Details
|
||||
'hostDetails.title.details': 'Сведения о хосте',
|
||||
'hostDetails.title.new': 'Новый хост',
|
||||
'vault.panel.resizeWidth': 'Изменить ширину панели',
|
||||
'hostDetails.saveAria': 'Сохранить',
|
||||
'hostDetails.section.address': 'Адрес',
|
||||
'hostDetails.hostname.placeholder': 'IP или имя хоста',
|
||||
@@ -529,6 +536,8 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.notes.tab.edit': 'Редактировать',
|
||||
'hostDetails.notes.tab.preview': 'Просмотр',
|
||||
'hostDetails.notes.preview.empty': 'Пока нечего просматривать.',
|
||||
'hostDetails.notes.toggle.show': 'Показать редактор заметок',
|
||||
'hostDetails.notes.toggle.hide': 'Скрыть редактор заметок',
|
||||
'hostDetails.group.placeholder': 'Родительская группа',
|
||||
'hostDetails.section.credentials': 'Учётные данные',
|
||||
'hostDetails.section.portCredentials': 'Порт и учётные данные',
|
||||
@@ -551,6 +560,7 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.icon.colorManual': 'Вручную',
|
||||
'hostDetails.icon.colorChoice': 'Цвет',
|
||||
'hostDetails.icon.autoColorLabel': 'Текущий цвет',
|
||||
'hostDetails.icon.preview': 'Предпросмотр значка',
|
||||
'hostDetails.icon.showLibrary': 'Показать библиотеку значков',
|
||||
'hostDetails.icon.hideLibrary': 'Скрыть библиотеку значков',
|
||||
'hostDetails.icon.autoUsesDistro': 'Использует значок дистрибутива Linux и выбранный цвет для этого хоста.',
|
||||
@@ -700,12 +710,17 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.proxyPanel.usernamePlaceholder': 'Имя пользователя',
|
||||
'hostDetails.proxyPanel.passwordPlaceholder': 'Пароль',
|
||||
'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.missingSaved': 'Сохранённый прокси отсутствует',
|
||||
'hostDetails.proxyPanel.missingIdentity': 'Идентификатор из связки ключей отсутствует',
|
||||
'hostDetails.proxyPanel.incompleteIdentity': 'Для идентификатора прокси нужны имя пользователя и пароль',
|
||||
'hostDetails.proxyPanel.unreadableIdentity': 'Пароль идентификатора прокси нельзя прочитать на этом устройстве',
|
||||
'hostDetails.proxyPanel.error.required': 'Требуются хост и порт прокси или ProxyCommand.',
|
||||
'hostDetails.envVars': 'Переменные окружения',
|
||||
'hostDetails.envVars.add': 'Добавить переменную окружения',
|
||||
@@ -736,6 +751,7 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.telnetOn': 'Telnet на',
|
||||
'hostDetails.port': 'порт',
|
||||
'hostDetails.telnet.credentials': 'Учётные данные',
|
||||
'hostDetails.telnet.identity.placeholder': 'Выберите идентификатор Telnet',
|
||||
'hostDetails.telnet.username': 'Имя пользователя Telnet',
|
||||
'hostDetails.telnet.password': 'Пароль Telnet',
|
||||
'hostDetails.charset.placeholder': 'Кодировка (например, UTF-8)',
|
||||
|
||||
@@ -12,6 +12,9 @@ const osc7Keys = [
|
||||
"terminal.osc7Setup.targets",
|
||||
"terminal.osc7Setup.command",
|
||||
"terminal.osc7Setup.run",
|
||||
"terminal.osc7Setup.running",
|
||||
"terminal.osc7Setup.configured",
|
||||
"terminal.osc7Setup.failed",
|
||||
"terminal.osc7Setup.sent",
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { zhCNVaultMessages } from './zh-CN/vault';
|
||||
import { zhCNTerminalMessages } from './zh-CN/terminal';
|
||||
import { zhCNAiMessages } from './zh-CN/ai';
|
||||
import { zhCnSystemManagerMessages } from './zh-CN/systemManager';
|
||||
import { zhCNScriptsMessages } from './zh-CN/scripts';
|
||||
|
||||
export type { Messages } from './types';
|
||||
|
||||
@@ -13,6 +14,7 @@ const zhCN: Messages = {
|
||||
...zhCNTerminalMessages,
|
||||
...zhCNAiMessages,
|
||||
...zhCnSystemManagerMessages,
|
||||
...zhCNScriptsMessages,
|
||||
};
|
||||
|
||||
export default zhCN;
|
||||
@@ -4,6 +4,10 @@ export const zhCNAiMessages: Messages = {
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent 设置',
|
||||
'ai.chat.preparing': '准备中…',
|
||||
'ai.chat.compactingContext': '正在压缩较早的上下文…',
|
||||
'ai.chat.compactingStep': '正在为下一步整理上下文…',
|
||||
'ai.chat.compactionRetry': '请求过大,正在压缩上下文并重试…',
|
||||
'ai.chat.compactionBanner': '上下文已压缩:{before}K → {after}K tokens',
|
||||
'ai.title': 'AI',
|
||||
'ai.description': '配置 AI 提供商、Agent 和安全设置',
|
||||
'ai.providers': '提供商',
|
||||
@@ -220,10 +224,10 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.noProvider': '尚未配置 AI 提供商。请前往 **设置 → AI → 提供商** 添加并启用一个提供商。',
|
||||
'ai.chat.toolDenied': '操作已被用户拒绝。',
|
||||
'ai.chat.toolApproved': '已批准',
|
||||
'ai.chat.toolApprovalHint': 'Enter 仅当次许可,Escape 拒绝',
|
||||
'ai.chat.toolApprovalHint': 'Enter 允许 · Esc 拒绝',
|
||||
'ai.chat.approve': '批准',
|
||||
'ai.chat.approveOnce': '仅当次许可',
|
||||
'ai.chat.alwaysAllow': '一律许可',
|
||||
'ai.chat.approveOnce': '允许',
|
||||
'ai.chat.alwaysAllow': '始终',
|
||||
'ai.chat.slashStopDesc': '停止当前 AI 回合并取消进行中的工具',
|
||||
'ai.chat.reject': '拒绝',
|
||||
'ai.chat.toolLabel': '工具',
|
||||
@@ -393,6 +397,23 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.artifact.unavailableTitle': '不可用',
|
||||
'ai.chat.artifact.noteMissing': '该笔记已不在 Vault 中。',
|
||||
'ai.chat.artifact.hostMissing': '该主机已不在 Vault 中。',
|
||||
'ai.chat.artifact.snippetMissing': '该 Snippet 或脚本已不在 Vault 中。',
|
||||
'ai.chat.artifact.openSnippets': '打开 Snippets',
|
||||
'ai.chat.artifact.snippetsSummary': 'Vault 中有 {count} 个 Snippet',
|
||||
'ai.chat.artifact.scriptsSummary': 'Vault 中有 {count} 个脚本',
|
||||
'ai.chat.artifact.snippetFallback': 'Vault Snippet',
|
||||
'ai.chat.artifact.scriptFallback': '自动化脚本',
|
||||
'ai.chat.artifact.scriptLanguage': '{language} 脚本',
|
||||
'ai.chat.artifact.snippetDeleted': 'Snippet 已删除',
|
||||
'ai.chat.artifact.scriptDeleted': '脚本已删除',
|
||||
'ai.chat.artifact.snippetRan': 'Snippet 已执行',
|
||||
'ai.chat.artifact.scriptStarted': '脚本运行已启动',
|
||||
'ai.chat.artifact.scriptRunStatus': '脚本运行 {status}',
|
||||
'ai.chat.artifact.scriptRunsSummary': '{count} 个脚本运行',
|
||||
'ai.chat.artifact.scriptRunStopped': '脚本运行已停止',
|
||||
'ai.chat.artifact.scriptRunPaused': '脚本运行已暂停',
|
||||
'ai.chat.artifact.scriptRunResumed': '脚本运行已恢复',
|
||||
'ai.chat.artifact.scriptReference': 'nct API 参考',
|
||||
'zmodem.waitingForRemote': '等待远端...',
|
||||
'zmodem.uploading': '上传中',
|
||||
'zmodem.downloading': '下载中',
|
||||
|
||||
@@ -69,6 +69,18 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.tab.system': '系统',
|
||||
'settings.terminal.connection.verifyHostKeys': '校验 SSH 主机密钥',
|
||||
'settings.terminal.connection.verifyHostKeys.desc': '连接到新的或已变化的 SSH 主机密钥前先询问。只建议在可信内网中关闭。',
|
||||
'settings.terminal.section.sidePanel': '侧边栏',
|
||||
'settings.terminal.sidePanel.autoOpen': '打开终端时展开侧边栏',
|
||||
'settings.terminal.sidePanel.autoOpen.desc': '终端连接成功后自动打开一个侧边栏。',
|
||||
'settings.terminal.sidePanel.autoOpenPane': '默认侧边栏',
|
||||
'settings.terminal.sidePanel.autoOpenPane.desc': '选择优先打开哪个面板。',
|
||||
'settings.terminal.sidePanel.pane.sftp': 'SFTP',
|
||||
'settings.terminal.sidePanel.pane.scripts': '脚本',
|
||||
'settings.terminal.sidePanel.pane.history': '历史',
|
||||
'settings.terminal.sidePanel.pane.theme': '主题',
|
||||
'settings.terminal.sidePanel.pane.system': '系统',
|
||||
'settings.terminal.sidePanel.pane.notes': '笔记',
|
||||
'settings.terminal.sidePanel.pane.ai': 'AI',
|
||||
|
||||
// Settings > System
|
||||
'settings.system.title': '系统',
|
||||
@@ -273,6 +285,23 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.appearance.uiFont.desc': '选择软件界面使用的字体',
|
||||
'settings.appearance.windowOpacity': '窗口透明度',
|
||||
'settings.appearance.windowOpacity.desc': '调节整个应用窗口的透明度,方便叠在其他内容上方。较低时终端文字也会变淡;部分 Linux 桌面环境可能不支持。',
|
||||
'settings.appearance.appIcon': '应用图标',
|
||||
'settings.appearance.appIcon.desc': '选择 Dock/任务栏图标风格,切换应用时更容易找到 Netcatty。运行时生效,安装包内的图标不会改变。',
|
||||
'settings.appearance.appIcon.original': '原始',
|
||||
'settings.appearance.appIcon.bright': '明亮',
|
||||
'settings.appearance.appIcon.dark': '深色',
|
||||
'settings.appearance.appIcon.colorful': '彩色',
|
||||
'settings.appearance.appIcon.highContrast': '高对比',
|
||||
'settings.appearance.appIcon.group.classic': '彩色底',
|
||||
'settings.appearance.appIcon.group.white': '白底',
|
||||
'settings.appearance.appIcon.group.special': '隐藏款',
|
||||
'settings.appearance.appIcon.whiteNavy': '白底 · 海军蓝猫',
|
||||
'settings.appearance.appIcon.whiteSky': '白底 · 天蓝猫',
|
||||
'settings.appearance.appIcon.whiteRose': '白底 · 玫红猫',
|
||||
'settings.appearance.appIcon.whiteEmerald': '白底 · 翡翠猫',
|
||||
'settings.appearance.appIcon.whiteAmber': '白底 · 琥珀猫',
|
||||
'settings.appearance.appIcon.whiteViolet': '白底 · 紫罗兰猫',
|
||||
'settings.appearance.appIcon.rainbow': '彩虹猫',
|
||||
// Context menus / common actions
|
||||
'action.newHost': '新建主机',
|
||||
'action.newSubfolder': '新建文件夹',
|
||||
@@ -287,6 +316,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sync.cloudSync': '云同步',
|
||||
'sync.settings': '同步设置',
|
||||
'sync.active': '云同步已启用',
|
||||
'sync.pending': '有待同步的更改',
|
||||
'sync.syncing': '正在同步…',
|
||||
'sync.error': '同步错误',
|
||||
'sync.notConfigured': '未配置',
|
||||
@@ -359,7 +389,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'vault.nav.keychain': '钥匙串',
|
||||
'vault.nav.proxies': '代理',
|
||||
'vault.nav.portForwarding': '端口转发',
|
||||
'vault.nav.snippets': '代码片段',
|
||||
'vault.nav.snippets': '脚本',
|
||||
'vault.nav.notes': '笔记',
|
||||
'vault.nav.knownHosts': '已知主机',
|
||||
'vault.nav.logs': '日志',
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
export const zhCNScriptsMessages = {
|
||||
'scripts.meta.name': '名称',
|
||||
'scripts.meta.language': '语言',
|
||||
'scripts.meta.description': '描述',
|
||||
'scripts.meta.descriptionPlaceholder': '可选,脚本的简要说明',
|
||||
'scripts.meta.trigger': '触发方式',
|
||||
'scripts.meta.triggerPattern': '输出匹配(正则)',
|
||||
'scripts.meta.code': '脚本代码',
|
||||
'scripts.trigger.manual': '手动运行',
|
||||
'scripts.trigger.onConnect': '连接时运行',
|
||||
'scripts.trigger.onOutput': '输出匹配时运行',
|
||||
'scripts.trigger.onOutputHint': '仅在服务端输出匹配正则时触发(会忽略本地按键回显)。未配置目标主机时,对当前已连接会话生效;配置目标后仅监听对应主机。进入备用屏幕的应用(如 vim、htop)期间不会触发。本 session 有其他脚本运行期间会抑制;脚本结束后再检查 buffer。流程内等待请用 nct.screen.waitFor。',
|
||||
'scripts.actions.save': '保存',
|
||||
'scripts.actions.runNow': '立即运行',
|
||||
'scripts.actions.openEditor': '弹窗编辑',
|
||||
'scripts.actions.openEditorHint': '在更大的窗口中编辑脚本',
|
||||
'scripts.editor.modalTitle': '脚本编辑器',
|
||||
'scripts.editor.modalSubtitle': '在更大的工作区中编辑脚本元数据和代码。',
|
||||
'scripts.editor.lineCount': '{count} 行',
|
||||
'scripts.editor.resize': '调整编辑器高度',
|
||||
'scripts.targets.hint': '自动触发作用于所选主机;勾选「对所有主机生效」则作用于全部可连接主机。',
|
||||
'scripts.targets.connectOrderHint': '连接脚本的执行顺序请在主机详情 → 自动化 中调整。',
|
||||
'scripts.actions.runNowHint': '在所选目标主机上运行;勾选「对所有主机生效」时在所有可连接主机上运行。',
|
||||
'scripts.actions.runParallel': '在所有标签页并行运行',
|
||||
'scripts.actions.runSequential': '在所有标签页顺序运行',
|
||||
'scripts.actions.skippedConnectingSessions': '有 {count} 个标签仍在连接中,已跳过',
|
||||
'scripts.actions.noRunnableHosts': '没有与脚本目标匹配的可连接主机',
|
||||
'scripts.sidePanel.library': '脚本库',
|
||||
'scripts.sidePanel.running': '运行中',
|
||||
'scripts.sidePanel.newScript': '新建脚本',
|
||||
'scripts.running.empty': '当前会话没有正在运行的脚本。',
|
||||
'scripts.running.unnamed': '未命名脚本',
|
||||
'scripts.running.status.running': '运行中',
|
||||
'scripts.running.status.paused': '已暂停',
|
||||
'scripts.running.status.completed': '已完成',
|
||||
'scripts.running.status.failed': '失败',
|
||||
'scripts.running.waitingFor': '等待 {pattern}',
|
||||
'scripts.running.waitingForLabel': '等待',
|
||||
'scripts.running.waitingForShellPrompt': 'Shell 提示符(# 或 $)',
|
||||
'scripts.running.lastSent': '已发送:{command}',
|
||||
'scripts.recording.start': '开始录制',
|
||||
'scripts.recording.active': '停止录制',
|
||||
'scripts.recording.startHint': '在当前终端会话中录制操作,自动生成 nct 脚本代码',
|
||||
'scripts.recording.unavailableHint': '此入口不支持录制,请用终端右侧脚本侧栏',
|
||||
'scripts.recording.activeHint': '正在录制当前终端。在终端中正常输入命令;点「停止录制」或终端工具栏的 REC 结束并保存。',
|
||||
'scripts.recording.started': '已开始录制,请在终端中操作',
|
||||
'scripts.recording.noSession': '请先连接终端(单机标签或工作区均可)',
|
||||
'scripts.recording.alreadyActive': '已有其他终端正在录制',
|
||||
'scripts.recording.stop': '停止录制',
|
||||
'scripts.recording.pause': '暂停录制',
|
||||
'scripts.recording.resume': '继续录制',
|
||||
'scripts.recording.saveTitle': '保存录制的脚本',
|
||||
'scripts.recording.namePlaceholder': '脚本名称',
|
||||
'scripts.recording.packagePlaceholder': '保存到文件夹',
|
||||
'scripts.recording.rootPackage': '根目录',
|
||||
'scripts.recording.save': '保存',
|
||||
'scripts.recording.saveAndEdit': '保存并编辑',
|
||||
'scripts.recording.helpTitle': '如何录制脚本',
|
||||
'scripts.recording.helpIntro': '录制会把你在终端里的操作自动转成可重复运行的自动化脚本,适合部署、巡检等重复任务。',
|
||||
'scripts.recording.helpStep1': '连接主机。单机终端标签或工作区里的终端都可以,打开右侧脚本侧栏即可。',
|
||||
'scripts.recording.helpStep2': '点击「开始录制」。终端工具栏出现红色 REC 表示已开始。',
|
||||
'scripts.recording.helpStep3': '在终端里像平时一样输入命令,按 Enter 执行。每次回车都会记为一步。',
|
||||
'scripts.recording.helpStep4': '全部操作完成后,点击「停止录制」,或点终端工具栏 REC 旁的停止按钮。',
|
||||
'scripts.recording.helpStep5': '填写脚本名称并保存。需要微调时可选「保存并编辑」,在脚本编辑器里修改生成的代码。',
|
||||
'scripts.recording.helpTipsTitle': '小提示',
|
||||
'scripts.recording.helpTip1': '两次操作之间停顿超过 1 秒,会自动加入等待时间,避免回放太快。',
|
||||
'scripts.recording.helpTip2': '每条命令后会等待 shell 提示符(如 $ 或 #)出现,再记录下一步,减少乱序。',
|
||||
'scripts.recording.helpTip3': '密码输入会被标记为敏感内容,不会明文写入脚本。',
|
||||
'scripts.recording.helpTip4': 'vim、交互式菜单等复杂操作,建议先录主干命令,再在编辑器里手动补充。',
|
||||
'scripts.dialog.title': '脚本',
|
||||
'scripts.dialog.ok': '确定',
|
||||
'scripts.dialog.waitForTimeoutTitle': '等待超时',
|
||||
'scripts.dialog.retry': '重试',
|
||||
'scripts.dialog.skip': '跳过',
|
||||
'scripts.dialog.abort': '终止',
|
||||
'scripts.running.stepProgress': '步骤 {current} / {total}',
|
||||
'scripts.running.determinateProgress': '{label} {current}/{total}',
|
||||
'scripts.running.progressFallback': '进度',
|
||||
'scripts.running.operationsCount': '已执行 {count} 个操作',
|
||||
'scripts.running.opsPrefix': '已执行 ',
|
||||
'scripts.running.opsSuffix': ' 个操作',
|
||||
'scripts.running.elapsedLabel': '耗时',
|
||||
'scripts.running.lastSentLabel': '已发送:',
|
||||
'scripts.running.elapsed': '耗时 {elapsed}',
|
||||
'scripts.running.completedSummary': '已完成 · 共 {count} 个操作 · {elapsed}',
|
||||
'scripts.running.dismissHint': '点击关闭',
|
||||
'scripts.running.dismiss': '关闭',
|
||||
'scripts.running.viewLogs': '查看日志',
|
||||
'scripts.running.logTitle': '{name} · 运行日志',
|
||||
'scripts.running.logEmpty': '暂无日志输出',
|
||||
'scripts.running.pause': '暂停',
|
||||
'scripts.running.resume': '继续',
|
||||
'scripts.running.stop': '停止',
|
||||
'scripts.recording.saved': '脚本已保存',
|
||||
'scripts.recording.savedNamed': '已保存「{name}」',
|
||||
'scripts.observer.blocked': 'Observer 模式禁止执行会写入终端的脚本。',
|
||||
'vault.section.scripts': '脚本',
|
||||
'vault.nav.scripts': '脚本',
|
||||
'snippets.action.newScript': '新建自动化脚本',
|
||||
'hostDetails.section.automation': '自动化',
|
||||
'hostDetails.automation.loginScript': '登录脚本',
|
||||
'hostDetails.automation.loginScriptPlaceholder': '选择脚本',
|
||||
'hostDetails.automation.none': '无',
|
||||
'hostDetails.automation.outputTriggers': '输出触发器',
|
||||
'hostDetails.automation.addTrigger': '添加触发器',
|
||||
'hostDetails.automation.triggerPatternPlaceholder': '正则表达式',
|
||||
'hostDetails.automation.linkedScripts': '已关联脚本',
|
||||
'hostDetails.automation.linkedScriptsEmpty': '尚未关联任何脚本。',
|
||||
'hostDetails.automation.linkScriptPlaceholder': '关联已有脚本…',
|
||||
'hostDetails.automation.unlink': '取消关联',
|
||||
'hostDetails.automation.equivalenceHint': '此处关联会将本主机加入 Scripts 中该脚本的目标列表,两侧视图同步。',
|
||||
'hostDetails.automation.queueHint': '连接本主机时按顺序运行脚本。全局脚本最先执行,再执行下方队列。',
|
||||
'hostDetails.automation.globalScripts': '全局连接脚本',
|
||||
'hostDetails.automation.globalScriptsHint': '每次连接时最先运行。顺序在脚本库排序中调整。',
|
||||
'hostDetails.automation.connectQueue': '本主机运行队列',
|
||||
'hostDetails.automation.connectQueueEmpty': '尚未为本主机配置连接脚本队列。',
|
||||
'hostDetails.automation.addToQueuePlaceholder': '添加脚本到队列…',
|
||||
'hostDetails.automation.moveUp': '上移',
|
||||
'hostDetails.automation.moveDown': '下移',
|
||||
'hostDetails.automation.removeFromQueue': '从队列移除',
|
||||
'hostDetails.automation.dragHandle': '拖拽排序',
|
||||
'hostDetails.automation.queueDragHint': '拖动条目即可调整连接时的运行顺序。',
|
||||
};
|
||||
@@ -14,6 +14,9 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'terminal.osc7Setup.targets': '可能写入的文件',
|
||||
'terminal.osc7Setup.command': '将执行的命令',
|
||||
'terminal.osc7Setup.run': '执行配置',
|
||||
'terminal.osc7Setup.running': '正在配置...',
|
||||
'terminal.osc7Setup.configured': '目录追踪已配置',
|
||||
'terminal.osc7Setup.failed': '目录追踪配置失败',
|
||||
'terminal.osc7Setup.sent': '目录追踪配置已发送到终端',
|
||||
'terminal.toolbar.timestampsEnable': '显示时间戳',
|
||||
'terminal.toolbar.timestampsDisable': '隐藏时间戳',
|
||||
@@ -459,12 +462,17 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'hostDetails.proxyPanel.usernamePlaceholder': '用户名',
|
||||
'hostDetails.proxyPanel.passwordPlaceholder': '密码',
|
||||
'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.missingSaved': '保存的代理不存在',
|
||||
'hostDetails.proxyPanel.missingIdentity': '钥匙串身份不存在',
|
||||
'hostDetails.proxyPanel.incompleteIdentity': '代理身份需要用户名和密码',
|
||||
'hostDetails.proxyPanel.unreadableIdentity': '此设备无法读取代理身份密码',
|
||||
'hostDetails.proxyPanel.error.required': '代理主机和端口,或 ProxyCommand 不能为空。',
|
||||
'hostDetails.envVars.title': '环境变量',
|
||||
'hostDetails.envVars.desc': '为 {host} 设置环境变量。',
|
||||
@@ -599,24 +607,30 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'keychain.edit.exportToHost': '导出到主机',
|
||||
|
||||
// Snippets
|
||||
'snippets.searchPlaceholder': '搜索代码片段...',
|
||||
'snippets.searchPlaceholder': '搜索脚本...',
|
||||
'snippets.action.newSnippet': '新建代码片段',
|
||||
'snippets.action.newPackage': '新建代码包',
|
||||
'snippets.action.newPackage': '新建脚本包',
|
||||
'snippets.action.import': '导入',
|
||||
'snippets.action.selectSnippets': '选择代码片段',
|
||||
'snippets.panel.newTitle': '新建代码片段',
|
||||
'snippets.panel.editTitle': '编辑代码片段',
|
||||
'snippets.panel.newAutomationTitle': '新建自动化脚本',
|
||||
'snippets.panel.editAutomationTitle': '编辑自动化脚本',
|
||||
'snippets.panel.resizeWidth': '调整面板宽度',
|
||||
'snippets.field.description': '描述',
|
||||
'snippets.field.descriptionPlaceholder': '例如:check network load',
|
||||
'snippets.field.package': '添加代码包',
|
||||
'snippets.field.packagePlaceholder': '选择或创建代码包',
|
||||
'snippets.field.createPackage': '创建代码包',
|
||||
'snippets.field.package': '添加脚本包',
|
||||
'snippets.field.packagePlaceholder': '选择或创建脚本包',
|
||||
'snippets.field.createPackage': '创建脚本包',
|
||||
'snippets.field.scriptRequired': '脚本 *',
|
||||
'snippets.scriptEditor.expand': '弹窗编辑',
|
||||
'snippets.scriptEditor.resize': '调整编辑器高度',
|
||||
'snippets.scriptEditor.modalTitle': '编辑脚本',
|
||||
'snippets.targets.title': '目标主机',
|
||||
'snippets.targets.add': '添加目标主机',
|
||||
'snippets.targets.allHosts': '对所有主机生效',
|
||||
'snippets.targets.allHostsShort': '所有主机',
|
||||
'snippets.targets.allHostsActive': '将作用于所有可连接的主机。',
|
||||
'snippets.history.title': 'Shell 历史',
|
||||
'snippets.history.subtitle': '{count} 条命令',
|
||||
'snippets.history.emptyTitle': '暂无 Shell 历史',
|
||||
@@ -629,15 +643,17 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'snippets.history.time.minutesAgo': '{count} 分钟前',
|
||||
'snippets.history.time.hoursAgo': '{count} 小时前',
|
||||
'snippets.history.time.daysAgo': '{count} 天前',
|
||||
'snippets.breadcrumb.allPackages': '全部代码包',
|
||||
'snippets.breadcrumb.allPackages': '全部脚本包',
|
||||
'snippets.breadcrumb.separator': '›',
|
||||
'snippets.empty.title': '创建代码片段',
|
||||
'snippets.empty.desc': '将常用命令保存为代码片段,一键复用。',
|
||||
'snippets.empty.title': '创建脚本',
|
||||
'snippets.empty.desc': '将常用命令保存为代码片段,或编写自动化脚本来处理重复运维任务。',
|
||||
'snippets.search.noResults.title': '无匹配结果',
|
||||
'snippets.search.noResults.desc': '没有代码片段或代码包与"{query}"匹配。换一个关键字,或清除搜索进行浏览。',
|
||||
'snippets.section.packages': '代码包',
|
||||
'snippets.section.snippets': '代码片段',
|
||||
'snippets.package.count': '{count} 个代码片段',
|
||||
'snippets.search.noResults.desc': '没有脚本或脚本包与"{query}"匹配。换一个关键字,或清除搜索进行浏览。',
|
||||
'snippets.section.packages': '脚本包',
|
||||
'snippets.section.snippets': '脚本',
|
||||
'snippets.kind.codeSnippet': '代码片段',
|
||||
'snippets.kind.automationScript': '自动化脚本',
|
||||
'snippets.package.count': '{count} 个脚本',
|
||||
'snippets.commandFallback': '命令',
|
||||
'snippets.view.grid': '网格',
|
||||
'snippets.view.list': '列表',
|
||||
@@ -646,7 +662,7 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'snippets.selection.deselectAll': '取消选择',
|
||||
'snippets.selection.exportSelected': '导出选中({count})',
|
||||
'snippets.export.snippet': '导出代码片段',
|
||||
'snippets.export.package': '导出代码包',
|
||||
'snippets.export.package': '导出脚本包',
|
||||
'snippets.export.toast.empty': '没有可导出的代码片段。',
|
||||
'snippets.export.toast.successTitle': '导出已准备好',
|
||||
'snippets.export.toast.success': '已导出 {count} 个代码片段。',
|
||||
@@ -662,26 +678,26 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'snippets.import.modal.chooseFile': '选择文件',
|
||||
'snippets.import.modal.downloadExamples': '下载示例文件',
|
||||
'snippets.import.modal.multipleFiles': '已选择 {count} 个文件',
|
||||
'snippets.import.modal.parsedSummary': '{files} 个文件,{total} 个代码片段,{packages} 个代码包,{conflicts} 个重复命令。主机绑定会被忽略。',
|
||||
'snippets.import.modal.parsedSummary': '{files} 个文件,{total} 个脚本,{packages} 个脚本包,{conflicts} 个重复命令。主机绑定会被忽略。',
|
||||
'snippets.import.modal.confirm': '确认导入',
|
||||
'snippets.import.conflict.title': '导入代码片段?',
|
||||
'snippets.import.conflict.desc': '“{file}”包含 {total} 个代码片段,其中 {conflicts} 个命令已存在。',
|
||||
'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',
|
||||
'snippets.packageDialog.hint': '使用 "/" 创建嵌套代码包。',
|
||||
'snippets.packageDialog.hint': '使用 "/" 创建嵌套脚本包。',
|
||||
|
||||
// Snippets Rename Dialog
|
||||
'snippets.renameDialog.title': '重命名代码包',
|
||||
'snippets.renameDialog.title': '重命名脚本包',
|
||||
'snippets.renameDialog.currentPath': '当前路径:{path}',
|
||||
'snippets.renameDialog.placeholder': '输入新名称',
|
||||
'snippets.renameDialog.error.empty': '代码包名称不能为空',
|
||||
'snippets.renameDialog.error.duplicate': '已存在同名的代码包',
|
||||
'snippets.renameDialog.error.invalidChars': '代码包名称只能包含字母、数字、连字符和下划线',
|
||||
'snippets.renameDialog.error.empty': '脚本包名称不能为空',
|
||||
'snippets.renameDialog.error.duplicate': '已存在同名的脚本包',
|
||||
'snippets.renameDialog.error.invalidChars': '脚本包名称只能包含字母、数字、连字符和下划线',
|
||||
|
||||
'snippets.field.noAutoRun': '仅粘贴(不自动执行)',
|
||||
// Snippet Shortkey
|
||||
|
||||
@@ -6,11 +6,17 @@ export const zhCNVaultMessages: Messages = {
|
||||
'notes.action.newNote': '新建笔记',
|
||||
'notes.action.newGroup': '新建文件夹',
|
||||
'notes.action.importMarkdown': '导入 Markdown',
|
||||
'notes.action.exportNote': '导出笔记',
|
||||
'notes.action.exportGroup': '导出文件夹',
|
||||
'notes.action.exportAll': '导出全部笔记',
|
||||
'notes.import.toast.success': '已导入 {count} 条笔记',
|
||||
'notes.import.toast.noNotes': '没有导入任何 Markdown 文件。',
|
||||
'notes.import.toast.failed': '导入 Markdown 文件失败。',
|
||||
'notes.import.toast.inProgress': '正在导入 Markdown 文件,请稍候。',
|
||||
'notes.import.toast.skipped': '已跳过 {count} 个不支持的文件。',
|
||||
'notes.export.toast.success': '已导出 {count} 条笔记',
|
||||
'notes.export.toast.empty': '没有可导出的笔记。',
|
||||
'notes.export.toast.failed': '导出笔记失败。',
|
||||
'notes.codeBlock.copied': '已复制',
|
||||
'notes.codeBlock.copyFailed': '复制代码块失败。',
|
||||
'notes.group.prompt': '文件夹路径',
|
||||
@@ -51,6 +57,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
// Host Details
|
||||
'hostDetails.title.details': '主机详情',
|
||||
'hostDetails.title.new': '新建主机',
|
||||
'vault.panel.resizeWidth': '调整面板宽度',
|
||||
'hostDetails.saveAria': '保存',
|
||||
'hostDetails.section.address': '地址',
|
||||
'hostDetails.hostname.placeholder': 'IP 或 主机名',
|
||||
@@ -68,6 +75,8 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.notes.tab.edit': '编辑',
|
||||
'hostDetails.notes.tab.preview': '预览',
|
||||
'hostDetails.notes.preview.empty': '暂无内容可预览。',
|
||||
'hostDetails.notes.toggle.show': '显示备注编辑框',
|
||||
'hostDetails.notes.toggle.hide': '隐藏备注编辑框',
|
||||
'hostDetails.group.placeholder': '父级 Group',
|
||||
'hostDetails.section.credentials': '凭据',
|
||||
'hostDetails.section.portCredentials': '端口与凭据',
|
||||
@@ -90,6 +99,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.icon.colorManual': '手动',
|
||||
'hostDetails.icon.colorChoice': '颜色',
|
||||
'hostDetails.icon.autoColorLabel': '当前颜色',
|
||||
'hostDetails.icon.preview': '图标预览',
|
||||
'hostDetails.icon.showLibrary': '展开图标库',
|
||||
'hostDetails.icon.hideLibrary': '收起图标库',
|
||||
'hostDetails.icon.autoUsesDistro': '使用 Linux 发行版图标和所选颜色显示此主机。',
|
||||
@@ -242,6 +252,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.telnetOn': 'Telnet on',
|
||||
'hostDetails.port': '端口',
|
||||
'hostDetails.telnet.credentials': '凭据',
|
||||
'hostDetails.telnet.identity.placeholder': '选择 Telnet 身份',
|
||||
'hostDetails.telnet.username': 'Telnet 用户名',
|
||||
'hostDetails.telnet.password': 'Telnet 密码',
|
||||
'hostDetails.charset.placeholder': '字符集(例如 UTF-8)',
|
||||
@@ -430,6 +441,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.auth.keyUnavailableFallbackPassword': '已保存 SSH 密钥在当前设备不可用,改用密码认证。',
|
||||
'terminal.connectionErrorTitle': '连接错误',
|
||||
'terminal.progress.timeoutIn': '将在 {seconds}s 后超时',
|
||||
'terminal.progress.waitingForUserInput': '等待用户输入',
|
||||
'terminal.progress.disconnected': '已断开',
|
||||
'terminal.progress.cancelling': '正在取消...',
|
||||
'terminal.progress.startOver': '重新开始',
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Messages } from './types';
|
||||
import { zhTWCoreMessages } from './zh-TW/core';
|
||||
import { zhTWVaultMessages } from './zh-TW/vault';
|
||||
import { zhTWTerminalMessages } from './zh-TW/terminal';
|
||||
import { zhTWAiMessages } from './zh-TW/ai';
|
||||
import { zhTwSystemManagerMessages } from './zh-TW/systemManager';
|
||||
import { zhTWScriptsMessages } from './zh-TW/scripts';
|
||||
|
||||
export type { Messages } from './types';
|
||||
|
||||
const zhTW: Messages = {
|
||||
...zhTWCoreMessages,
|
||||
...zhTWVaultMessages,
|
||||
...zhTWTerminalMessages,
|
||||
...zhTWAiMessages,
|
||||
...zhTwSystemManagerMessages,
|
||||
...zhTWScriptsMessages,
|
||||
};
|
||||
|
||||
export default zhTW;
|
||||
@@ -0,0 +1,427 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhTWAiMessages: Messages = {
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent 設定',
|
||||
'ai.chat.preparing': '準備中…',
|
||||
'ai.chat.compactingContext': '正在壓縮較早的上下文…',
|
||||
'ai.chat.compactingStep': '正在為下一步整理上下文…',
|
||||
'ai.chat.compactionRetry': '請求過大,正在壓縮上下文並重試…',
|
||||
'ai.chat.compactionBanner': '上下文已壓縮:{before}K → {after}K tokens',
|
||||
'ai.title': 'AI',
|
||||
'ai.description': '設定 AI 供應商、Agent 和安全設定',
|
||||
'ai.providers': '供應商',
|
||||
'ai.agents': 'Agent',
|
||||
'ai.providers.empty': '尚未設定供應商。新增一個供應商以開始使用。',
|
||||
'ai.providers.add': '新增供應商',
|
||||
'ai.providers.active': '使用中',
|
||||
'ai.providers.apiKeyConfigured': 'API Key 已設定',
|
||||
'ai.providers.noApiKey': '未設定 API Key',
|
||||
'ai.providers.configure': '設定',
|
||||
'ai.providers.remove': '移除',
|
||||
'ai.providers.name': '顯示名稱',
|
||||
'ai.providers.name.placeholder': '例如 我的供應商',
|
||||
'ai.providers.style': '協定風格',
|
||||
'ai.providers.style.anthropic': 'Anthropic 相容',
|
||||
'ai.providers.style.openai': 'OpenAI 相容',
|
||||
'ai.providers.style.google': 'Google 相容',
|
||||
'ai.providers.style.inherited': '預設',
|
||||
'ai.providers.style.help': '決定請求使用哪種 API 格式。當第三方端點的協定與其供應商型別不一致時,可手動覆蓋。',
|
||||
'ai.providers.icon.change': '修改圖示',
|
||||
'ai.providers.icon.upload': '上傳圖片',
|
||||
'ai.providers.icon.reset': '恢復預設',
|
||||
'ai.providers.icon.close': '收起',
|
||||
'ai.providers.icon.uploadedNote': '自訂圖示(64×64 WebP)',
|
||||
'ai.providers.icon.errorType': '請選擇圖片檔案。',
|
||||
'ai.providers.apiKey': 'API Key',
|
||||
'ai.providers.apiKey.placeholder': '輸入 API Key',
|
||||
'ai.providers.apiKey.decrypting': '解密中...',
|
||||
'ai.providers.baseUrl': 'Base URL',
|
||||
'ai.providers.skipTLSVerify': '跳過 TLS 憑證驗證(用於自我簽署憑證)',
|
||||
'ai.providers.defaultModel': '預設模型',
|
||||
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
|
||||
'ai.providers.contextWindow': '上下文視窗',
|
||||
'ai.providers.contextWindow.placeholder': '例如 128000',
|
||||
'ai.providers.contextWindow.help': '留空時優先使用模型列表傳回的值;如果沒有,Netcatty 會使用安全預設值。',
|
||||
'ai.providers.contextWindow.error': '請輸入正整數,或留空。',
|
||||
'ai.providers.refreshModels': '重新整理模型列表',
|
||||
'ai.providers.searchModel': '搜尋或輸入模型 ID...',
|
||||
'ai.providers.filterModels': '篩選模型...',
|
||||
'ai.providers.loadingModels': '載入模型中...',
|
||||
'ai.providers.noMatchingModels': '沒有相符的模型',
|
||||
'ai.providers.clickToLoadModels': '點選載入模型',
|
||||
'ai.providers.showingModels': '顯示前 100 個,共 {count} 個模型。輸入以篩選。',
|
||||
'ai.providers.advancedParams': '高階引數',
|
||||
'ai.providers.advancedParams.hint': '留空則使用供應商預設值。',
|
||||
'ai.providers.advancedParams.maxTokens.placeholder': '例如 4096',
|
||||
'ai.providers.advancedParams.default': '供應商預設',
|
||||
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
'ai.codex.title': 'Codex CLI',
|
||||
'ai.codex.description': '介接 OpenAI Codex。可以在這裡登入 ChatGPT,也可以在設定裡啟用相容 OpenAI 的 API Key 和自訂介面地址。',
|
||||
'ai.codex.detecting': '偵測中...',
|
||||
'ai.codex.notFound': '未找到',
|
||||
'ai.codex.awaitingLogin': '等待登入',
|
||||
'ai.codex.connectedChatGPT': '已透過 ChatGPT 連線',
|
||||
'ai.codex.connectedApiKey': '已透過 API Key 連線',
|
||||
'ai.codex.connectedCustomConfig': '使用 ~/.codex/config.toml 自訂 provider',
|
||||
'ai.codex.customConfigIncomplete': '偵測到自訂設定(缺少環境變數)',
|
||||
'ai.codex.customConfigHint': '使用 ~/.codex/config.toml 中設定的自訂 provider "{provider}",無需 ChatGPT 登入。',
|
||||
'ai.codex.customConfigMissingEnvKey': '警告:環境變數 {envKey} 未在目前 shell 中設定。請 export 它(或從包含該變數的 shell 啟動 netcatty),否則 Codex 無法完成認證。',
|
||||
'ai.codex.notConnected': '未連線',
|
||||
'ai.codex.statusUnknown': '狀態未知',
|
||||
'ai.codex.path': '路徑:',
|
||||
'ai.codex.notFoundHint': '在 PATH 中未找到 codex。請安裝或在下方指定執行檔路徑。',
|
||||
'ai.codex.customPathPlaceholder': '例如 /usr/local/bin/codex',
|
||||
'ai.codex.check': '檢查',
|
||||
'ai.codex.resetPath': '重置',
|
||||
'ai.codex.openLogin': '開啟登入',
|
||||
'ai.codex.logout': '登出',
|
||||
'ai.codex.connectChatGPT': '連線 ChatGPT',
|
||||
'ai.codex.refreshStatus': '重新整理狀態',
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
'ai.claude.description': 'Anthropic 的智慧程式設計助手。需要系統中已安裝 Claude Code CLI。',
|
||||
'ai.claude.detecting': '偵測中...',
|
||||
'ai.claude.detected': '已偵測到',
|
||||
'ai.claude.notFound': '未找到',
|
||||
'ai.claude.path': '路徑:',
|
||||
'ai.claude.notFoundHint': '在 PATH 中未找到 claude。請安裝或在下方指定執行檔路徑。',
|
||||
'ai.claude.customPathPlaceholder': '例如 /usr/local/bin/claude',
|
||||
'ai.claude.configSection': '認證與設定(可選)',
|
||||
'ai.claude.configDir': '設定目錄',
|
||||
'ai.claude.configDir.placeholder': '~/.claude(留空用預設)',
|
||||
'ai.claude.configDir.hint': '設定 CLAUDE_CONFIG_DIR —— 指向你已執行 `claude` 登入的目錄(含 settings.json 和憑證)。',
|
||||
'ai.claude.settings': 'Settings 檔案',
|
||||
'ai.claude.settings.placeholder': '~/team-settings.json(路徑,或內聯 {"model":"..."})',
|
||||
'ai.claude.settings.hint': '可選。settings.json 路徑或內聯 JSON,作為 SDK 的 `settings` 傳入。與上面的「設定目錄」互補且獨立(疊加合併,不是替換)。',
|
||||
'ai.claude.envVars': '環境變數',
|
||||
'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 GitHub Copilot CLI
|
||||
'ai.copilot.title': 'GitHub Copilot CLI',
|
||||
'ai.copilot.description': '介接 GitHub Copilot CLI。偵測到後即可作為外部程式設計 Agent 使用。',
|
||||
'ai.copilot.detecting': '偵測中...',
|
||||
'ai.copilot.detected': '已偵測到',
|
||||
'ai.copilot.notFound': '未找到',
|
||||
'ai.copilot.path': '路徑:',
|
||||
'ai.copilot.notFoundHint': '在 PATH 中未找到 copilot。請安裝或在下方指定執行檔路徑。',
|
||||
'ai.copilot.customPathPlaceholder': '例如 /usr/local/bin/copilot',
|
||||
'ai.copilot.check': '檢查',
|
||||
'ai.copilot.resetPath': '重置',
|
||||
|
||||
// AI Cursor SDK
|
||||
'ai.cursor.title': 'Cursor',
|
||||
'ai.cursor.description': '使用 Cursor SDK。',
|
||||
'ai.cursor.detecting': '偵測中...',
|
||||
'ai.cursor.detected': '可用',
|
||||
'ai.cursor.notFound': '不可用',
|
||||
'ai.cursor.path': '執行環境:',
|
||||
'ai.cursor.notFoundHint': '填寫 API Key 後即可使用。',
|
||||
'ai.cursor.notInstalledHint': '未偵測到 Cursor SDK。',
|
||||
'ai.cursor.installStatus': 'Cursor SDK',
|
||||
'ai.cursor.installed': '已偵測到',
|
||||
'ai.cursor.notInstalled': '未偵測到',
|
||||
'ai.cursor.apiKeyStatus': 'API Key',
|
||||
'ai.cursor.apiKeyConfigured': '已填寫',
|
||||
'ai.cursor.apiKeyMissing': '未填寫',
|
||||
'ai.cursor.apiKeyFromEnv': '來自環境變數',
|
||||
'ai.cursor.apiKey': 'API Key',
|
||||
'ai.cursor.apiKeyPlaceholder': '輸入 Cursor API Key',
|
||||
'ai.cursor.apiKeyPlaceholder.env': '已使用 CURSOR_API_KEY;填寫後會覆蓋',
|
||||
'ai.cursor.apiKeyEnvHint': '已偵測到本機 CURSOR_API_KEY。留空即可繼續使用,填寫儲存後會覆蓋它。',
|
||||
'ai.cursor.apiKeyOverrideHint': '目前優先使用這裡儲存的 Key;清空儲存後會回到 CURSOR_API_KEY。',
|
||||
'ai.cursor.saveApiKey': '儲存',
|
||||
'ai.cursor.saved': '已儲存',
|
||||
'ai.cursor.showApiKey': '顯示 API Key',
|
||||
'ai.cursor.hideApiKey': '隱藏 API Key',
|
||||
'ai.cursor.customPathPlaceholder': '例如 /usr/local/bin/cursor',
|
||||
'ai.cursor.check': '檢查',
|
||||
|
||||
// AI CodeBuddy Code
|
||||
'ai.codebuddy.title': 'CodeBuddy Code',
|
||||
'ai.codebuddy.description': '透過官方 Agent SDK(`@tencent-ai/agent-sdk`)介接 CodeBuddy Code。偵測到後即可作為外部程式設計 Agent 使用。',
|
||||
'ai.codebuddy.detecting': '偵測中...',
|
||||
'ai.codebuddy.detected': '已偵測到',
|
||||
'ai.codebuddy.notFound': '未找到',
|
||||
'ai.codebuddy.path': '路徑:',
|
||||
'ai.codebuddy.notFoundHint': '在 PATH 中未找到 codebuddy。請安裝或在下方指定執行檔路徑。',
|
||||
'ai.codebuddy.customPathPlaceholder': '例如 /usr/local/bin/codebuddy',
|
||||
'ai.codebuddy.check': '檢查',
|
||||
'ai.codebuddy.resetPath': '重置',
|
||||
'ai.codebuddy.configSection': '認證與設定(可選)',
|
||||
'ai.codebuddy.internetEnv': '網路環境',
|
||||
'ai.codebuddy.internetEnv.default': '預設(海外)',
|
||||
'ai.codebuddy.internetEnv.internal': 'Internal',
|
||||
'ai.codebuddy.internetEnv.ioa': 'IOA',
|
||||
'ai.codebuddy.internetEnv.hint': '設定 CODEBUDDY_INTERNET_ENVIRONMENT —— 受限網路環境請選擇 Internal 或 IOA。',
|
||||
'ai.codebuddy.envVars': '環境變數',
|
||||
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
|
||||
'ai.codebuddy.envVars.hint': '每行一個 KEY=VALUE,傳給 CodeBuddy agent。可在此設定 CODEBUDDY_API_KEY 或 CODEBUDDY_AUTH_TOKEN 完成認證。明文存在本機。',
|
||||
|
||||
// AI OpenCode
|
||||
'ai.opencode.title': 'OpenCode',
|
||||
'ai.opencode.description': '透過官方 SDK 介接 OpenCode。先在 OpenCode 裡設定 provider 和金鑰,偵測到後即可作為外部程式設計 Agent 使用。',
|
||||
'ai.opencode.detecting': '偵測中...',
|
||||
'ai.opencode.detected': '已偵測到',
|
||||
'ai.opencode.notFound': '未找到',
|
||||
'ai.opencode.path': '路徑:',
|
||||
'ai.opencode.notFoundHint': '在 PATH 中未找到 opencode。請安裝或在下方指定執行檔路徑。',
|
||||
'ai.opencode.customPathPlaceholder': '例如 /usr/local/bin/opencode',
|
||||
'ai.opencode.check': '檢查',
|
||||
'ai.opencode.resetPath': '重置',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': '預設 Agent',
|
||||
'ai.defaultAgent.description': '建立新 AI 工作階段時使用的 Agent',
|
||||
'ai.defaultAgent.catty': 'Catty(內建)',
|
||||
'ai.toolAccess.title': '工具介接',
|
||||
'ai.toolAccess.mode': 'Netcatty 介接模式',
|
||||
'ai.toolAccess.description': '選擇外部 Agent 存取 Netcatty 工作階段的方式。MCP 會公開內建伺服器,Skills + CLI 會引導 Agent 讀取本機 Skill 並呼叫 Netcatty CLI。',
|
||||
'ai.toolAccess.mode.mcp': 'MCP',
|
||||
'ai.toolAccess.mode.skills': 'Skills + CLI',
|
||||
'ai.userSkills.title': '使用者 Skills',
|
||||
'ai.userSkills.description': '開啟 Netcatty 的 Skills 資料夾以新增你自己的技能目錄。Netcatty 會自動掃描這些 skills,預設只注入輕量索引,只有在請求明顯命中某個 skill 時才展開正文。',
|
||||
'ai.userSkills.openFolder': '開啟 Skills 資料夾',
|
||||
'ai.userSkills.reload': '重新載入 Skills',
|
||||
'ai.userSkills.location': '位置',
|
||||
'ai.userSkills.loading': '正在掃描使用者 skills...',
|
||||
'ai.userSkills.summary': '已就緒 {ready} 個,警告 {warnings} 個',
|
||||
'ai.userSkills.empty': '暫未發現使用者 skills。開啟資料夾後可新增包含 SKILL.md 的技能目錄。',
|
||||
'ai.userSkills.unavailable': '目前環境不支援使用者 skills。',
|
||||
'ai.userSkills.status.ready': '正常',
|
||||
'ai.userSkills.status.warning': '警告',
|
||||
|
||||
// AI Quick Messages
|
||||
'ai.quickMessages.title': '快捷訊息',
|
||||
'ai.quickMessages.description': '建立常用提示詞,在 AI 聊天框輸入 / 或點選快捷按鈕即可插入到輸入框。與使用者 Skills 不同,快捷訊息會直接填入訊息內容。',
|
||||
'ai.quickMessages.add': '新增快捷訊息',
|
||||
'ai.quickMessages.createTitle': '新建快捷訊息',
|
||||
'ai.quickMessages.editTitle': '編輯快捷訊息',
|
||||
'ai.quickMessages.name': '名稱',
|
||||
'ai.quickMessages.name.placeholder': '例如:檢查磁碟空間',
|
||||
'ai.quickMessages.slug': '指令',
|
||||
'ai.quickMessages.slug.placeholder': 'disk-check',
|
||||
'ai.quickMessages.descriptionField': '說明(可選)',
|
||||
'ai.quickMessages.descriptionField.placeholder': '簡短描述這條快捷訊息的用途',
|
||||
'ai.quickMessages.content': '訊息內容',
|
||||
'ai.quickMessages.content.placeholder': '輸入選擇後要插入的完整提示詞...',
|
||||
'ai.quickMessages.empty': '還沒有快捷訊息。新增幾條常用提示,聊天時就能一鍵插入。',
|
||||
'ai.quickMessages.confirmDelete': '確定刪除快捷訊息「{name}」嗎?',
|
||||
'ai.quickMessages.error.nameRequired': '請填寫名稱。',
|
||||
'ai.quickMessages.error.invalidSlug': '指令只能包含小寫字母、數字和連字元。',
|
||||
'ai.quickMessages.error.contentRequired': '請填寫訊息內容。',
|
||||
'ai.quickMessages.error.slugTaken': '該指令已被其他快捷訊息使用。',
|
||||
'ai.quickMessages.error.slugConflictsWithSkill': '該指令與使用者 Skill「/{slug}」衝突,請換一個指令。',
|
||||
'ai.quickMessages.error.maxItems': '最多只能儲存 {max} 條快捷訊息。',
|
||||
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': '尚未設定 AI 供應商。請前往 **設定 → AI → 供應商** 新增並啟用一個供應商。',
|
||||
'ai.chat.toolDenied': '操作已被使用者拒絕。',
|
||||
'ai.chat.toolApproved': '已批准',
|
||||
'ai.chat.toolApprovalHint': 'Enter 允許 · Esc 拒絕',
|
||||
'ai.chat.approve': '批准',
|
||||
'ai.chat.approveOnce': '允許',
|
||||
'ai.chat.alwaysAllow': '始終',
|
||||
'ai.chat.slashStopDesc': '停止目前 AI 回合並取消進行中的工具',
|
||||
'ai.chat.reject': '拒絕',
|
||||
'ai.chat.toolLabel': '工具',
|
||||
'ai.chat.targetLabel': '目標',
|
||||
'ai.chat.permissionRequired': '需要權限',
|
||||
'ai.chat.permissionDescription': 'AI Agent 希望執行一個需要你批准的工具呼叫。',
|
||||
'ai.chat.commandBlocked': '此指令已被安全策略攔截,無法執行。',
|
||||
'ai.chat.recommendAllow': '允許',
|
||||
'ai.chat.recommendConfirm': '確認',
|
||||
'ai.chat.recommendDeny': '拒絕',
|
||||
'ai.chat.exportConversation': '匯出對話',
|
||||
'ai.chat.exportAs': '匯出為',
|
||||
'ai.chat.exportMarkdown': 'Markdown',
|
||||
'ai.chat.exportJSON': 'JSON',
|
||||
'ai.chat.exportPlainText': '純文字',
|
||||
'ai.chat.thinking': '思考中',
|
||||
'ai.chat.thoughtFor': '思考了 {duration}',
|
||||
'ai.chat.thought': '思考',
|
||||
'ai.chat.agents': 'Agents',
|
||||
'ai.chat.detectedOnMachine': '在本機偵測到',
|
||||
'ai.chat.rescan': '重新掃描',
|
||||
'ai.chat.permObserver': '觀察',
|
||||
'ai.chat.permConfirm': '確認',
|
||||
'ai.chat.permAuto': '自動',
|
||||
'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.recent': '最近',
|
||||
'ai.chat.viewAll': '檢視全部',
|
||||
'ai.chat.untitled': '無標題',
|
||||
'ai.chat.justNow': '剛剛',
|
||||
'ai.chat.minutesAgo': '{n}分鐘前',
|
||||
'ai.chat.hoursAgo': '{n}小時前',
|
||||
'ai.chat.daysAgo': '{n}天前',
|
||||
'ai.chat.newChat': '新對話',
|
||||
'ai.chat.allSessions': '所有工作階段',
|
||||
'ai.chat.loadEarlierMessages': '載入更早的訊息(還有 {n} 條)',
|
||||
'ai.chat.usedTools': '已使用 {n} 個工具',
|
||||
'ai.chat.loadMoreSessions': '載入更多工作階段(還有 {n} 條)',
|
||||
'ai.chat.noSessions': '沒有歷史工作階段',
|
||||
'ai.chat.retryHint': '你可以重新發送訊息來重試。',
|
||||
'ai.chat.approvalTimeout': '工具審批已超時(5 分鐘)。你可以重新發送訊息來重試。',
|
||||
'ai.chat.menuHosts': '主機',
|
||||
'ai.chat.menuContext': '上下文',
|
||||
'ai.chat.menuFiles': '檔案',
|
||||
'ai.chat.menuImage': '圖片',
|
||||
'ai.chat.menuMentionHost': '提及主機',
|
||||
'ai.chat.menuUserSkills': '使用者 Skills',
|
||||
'ai.chat.menuSlashCommands': '快捷指令',
|
||||
'ai.chat.slashCommands': '快捷指令',
|
||||
'ai.chat.slashQuickMessages': '快捷訊息',
|
||||
'ai.chat.slashUserSkills': '使用者 Skills',
|
||||
'ai.chat.quickMessages': '快捷指令',
|
||||
'ai.chat.slashNoResults': '沒有相符的指令',
|
||||
'ai.chat.slashEmptyHint': '可在 設定 → AI → 快捷訊息 中新增常用提示詞。',
|
||||
|
||||
// AI 聊天快捷入口
|
||||
'ai.chatShortcuts.title': '聊天快捷入口',
|
||||
'ai.chatShortcuts.selectionAction': '選取終端內容時顯示「新增到對話」',
|
||||
'ai.chatShortcuts.selectionAction.description': '在終端裡選取文字後顯示 AI 快捷按鈕。',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Codex 主程序處理器尚未載入。請完全重啟 Netcatty 或重啟 Electron 開發程序,然後重試。',
|
||||
|
||||
// AI Web Search
|
||||
'ai.webSearch.title': '網路搜尋',
|
||||
'ai.webSearch.enable': '啟用網路搜尋',
|
||||
'ai.webSearch.enable.description': '允許 AI 代理搜尋網際網路取得最新資訊。',
|
||||
'ai.webSearch.provider': '搜尋供應商',
|
||||
'ai.webSearch.provider.description': '選擇一個網路搜尋 API 供應商。',
|
||||
'ai.webSearch.apiKey': 'API 金鑰',
|
||||
'ai.webSearch.apiKey.description': '所選搜尋供應商的 API 金鑰。',
|
||||
'ai.webSearch.apiKey.placeholder': '輸入 API 金鑰...',
|
||||
'ai.webSearch.apiHost': 'API 地址',
|
||||
'ai.webSearch.apiHost.description': '自訂 API 端點。除非使用代理,否則保持預設值。',
|
||||
'ai.webSearch.apiHost.searxngDescription': 'SearXNG 例項的 URL(必填)。',
|
||||
'ai.webSearch.maxResults': '最大結果數',
|
||||
'ai.webSearch.maxResults.description': '搜尋傳回的最大結果數(1-20)。',
|
||||
|
||||
// AI Safety Settings
|
||||
'ai.safety.title': '安全',
|
||||
'ai.safety.permissionMode': '權限模式',
|
||||
'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.maxIterations': '最大迭代次數',
|
||||
'ai.safety.maxIterations.description': '防止 AI 失控執行的最大工具呼叫迴圈次數。外部 Agent 可能有自己的內部迭代限制,以其為準。',
|
||||
'ai.safety.blocklist': '指令黑名單',
|
||||
'ai.safety.blocklist.description': '用於攔截透過 Netcatty 執行的危險指令的正規表示式。',
|
||||
'ai.safety.blocklist.placeholder': '正規表示式...',
|
||||
'ai.safety.blocklist.reset': '恢復預設',
|
||||
'ai.safety.blocklist.add': '新增規則',
|
||||
'ai.safety.grants.title': '許可記憶表',
|
||||
'ai.safety.grants.heading': 'Confirm 模式放行規則',
|
||||
'ai.safety.grants.description': 'Confirm 模式會先詢問再執行;儲存為規則後,相符的同類操作會自動放行。規則對所有終端節點/工作階段生效,也可手動編輯。',
|
||||
'ai.safety.grants.empty': '尚無規則。可在審批時選擇「一律許可」,或手動新增。',
|
||||
'ai.safety.grants.capability': '能力',
|
||||
'ai.safety.grants.sessionPattern': '節點/工作階段模式',
|
||||
'ai.safety.grants.commandPattern': '指令模式(可選)',
|
||||
'ai.safety.grants.note': '備註(可選)',
|
||||
'ai.safety.grants.add': '新增規則',
|
||||
'ai.safety.grants.remove': '刪除',
|
||||
'ai.safety.grants.export': '匯出 JSON',
|
||||
'ai.safety.grants.import': '匯入 JSON',
|
||||
'ai.safety.note': '這些安全設定會約束經由 Netcatty 執行的操作。外部 Agent CLI 也可能提供本機工具,那部分由 Agent 自己的控制規則約束。',
|
||||
|
||||
// 統一終端工作區和頂部標籤的 tooltip 文案 (issue #954)
|
||||
'terminal.layer.addTerminal': '新增終端',
|
||||
'terminal.layer.switchToSplitView': '切換到分割檢視',
|
||||
'terminal.layer.sftp': '檔案傳輸',
|
||||
'terminal.layer.scripts': '指令碼',
|
||||
'terminal.layer.history': '指令歷史',
|
||||
'terminal.layer.theme': '主題',
|
||||
'terminal.layer.notes': '筆記',
|
||||
'terminal.layer.aiChat': 'AI 助手',
|
||||
'terminal.layer.movePanelLeft': '面板移至左側',
|
||||
'terminal.layer.movePanelRight': '面板移至右側',
|
||||
'terminal.layer.closePanel': '關閉面板',
|
||||
'terminal.layer.hostTree.search': '搜尋主機...',
|
||||
'terminal.layer.hostTree.searchButton': '搜尋',
|
||||
'terminal.layer.hostTree.tagsButton': '按標籤篩選',
|
||||
'terminal.layer.hostTree.newGroup': '新建分組',
|
||||
'terminal.layer.hostTree.localShell': '本機 Shell',
|
||||
'terminal.layer.hostTree.tagsEmpty': '暫無標籤',
|
||||
'terminal.layer.hostTree.clearTags': '清除篩選',
|
||||
'terminal.layer.hostTree.collapse': '收起主機列表',
|
||||
'terminal.layer.hostTree.expand': '展開主機列表',
|
||||
'terminal.layer.hostTree.empty': '沒有相符的主機',
|
||||
'terminal.layer.hostTree.details.host': '主機',
|
||||
'terminal.layer.hostTree.details.user': '使用者',
|
||||
'terminal.layer.hostTree.details.port': '埠',
|
||||
'terminal.layer.hostTree.details.protocol': '協定',
|
||||
'terminal.layer.hostTree.details.group': '分組',
|
||||
'terminal.layer.hostTree.details.tags': '標籤',
|
||||
'terminal.layer.hostTree.details.lastConnected': '最近連線',
|
||||
'topTabs.openQuickSwitcher': '開啟快速切換',
|
||||
'topTabs.moreTabs': '更多標籤頁',
|
||||
'topTabs.aiAssistant': 'AI 助手',
|
||||
'topTabs.windowOpacity': '視窗透明度',
|
||||
'topTabs.toggleTheme': '切換主題',
|
||||
'topTabs.openSettings': '開啟設定',
|
||||
'ai.chat.sessionHistory': '工作階段歷史',
|
||||
'ai.chat.attach': '附件',
|
||||
'ai.chat.terminalSelectionAttachment': '終端選區',
|
||||
'ai.chat.terminalSelectionLines': '{count} 行',
|
||||
'ai.chat.collapse': '收起',
|
||||
'ai.chat.expand': '展開',
|
||||
'ai.chat.enableAgent': '啟用 {name}',
|
||||
'ai.chat.artifact.noteFallback': 'Vault 筆記',
|
||||
'ai.chat.artifact.openNotes': '開啟筆記',
|
||||
'ai.chat.artifact.openHosts': '開啟主機',
|
||||
'ai.chat.artifact.notesSummary': 'Vault 中有 {count} 條筆記',
|
||||
'ai.chat.artifact.hostsSummary': 'Vault 中有 {count} 台主機',
|
||||
'ai.chat.artifact.hostsAdded': '已新增 {count} 台主機',
|
||||
'ai.chat.artifact.hostsPreview': '預覽 {count} 台主機',
|
||||
'ai.chat.artifact.failed': 'Vault 操作失敗',
|
||||
'ai.chat.artifact.unavailableTitle': '不可用',
|
||||
'ai.chat.artifact.noteMissing': '該筆記已不在 Vault 中。',
|
||||
'ai.chat.artifact.hostMissing': '該主機已不在 Vault 中。',
|
||||
'ai.chat.artifact.snippetMissing': '該 Snippet 或指令碼已不在 Vault 中。',
|
||||
'ai.chat.artifact.openSnippets': '開啟 Snippets',
|
||||
'ai.chat.artifact.snippetsSummary': 'Vault 中有 {count} 個 Snippet',
|
||||
'ai.chat.artifact.scriptsSummary': 'Vault 中有 {count} 個指令碼',
|
||||
'ai.chat.artifact.snippetFallback': 'Vault Snippet',
|
||||
'ai.chat.artifact.scriptFallback': '自動化指令碼',
|
||||
'ai.chat.artifact.scriptLanguage': '{language} 指令碼',
|
||||
'ai.chat.artifact.snippetDeleted': 'Snippet 已刪除',
|
||||
'ai.chat.artifact.scriptDeleted': '指令碼已刪除',
|
||||
'ai.chat.artifact.snippetRan': 'Snippet 已執行',
|
||||
'ai.chat.artifact.scriptStarted': '指令碼執行已啟動',
|
||||
'ai.chat.artifact.scriptRunStatus': '指令碼執行 {status}',
|
||||
'ai.chat.artifact.scriptRunsSummary': '{count} 個指令碼執行',
|
||||
'ai.chat.artifact.scriptRunStopped': '指令碼執行已停止',
|
||||
'ai.chat.artifact.scriptRunPaused': '指令碼執行已暫停',
|
||||
'ai.chat.artifact.scriptRunResumed': '指令碼執行已恢復',
|
||||
'ai.chat.artifact.scriptReference': 'nct API 參考',
|
||||
'zmodem.waitingForRemote': '等待遠端...',
|
||||
'zmodem.uploading': '上傳中',
|
||||
'zmodem.downloading': '下載中',
|
||||
'zmodem.cancelTransfer': '取消傳輸 (Ctrl+C)',
|
||||
'zmodem.overwrite.title': '遠端已存在同名檔案',
|
||||
'zmodem.overwrite.applyToRest': '套用到其餘衝突檔案',
|
||||
'zmodem.overwrite.overwrite': '覆蓋',
|
||||
'zmodem.overwrite.skip': '跳過',
|
||||
'zmodem.overwrite.cancel': '取消',
|
||||
'settings.shortcuts.resetToDefault': '重置為預設',
|
||||
};
|
||||
@@ -0,0 +1,731 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhTWCoreMessages: Messages = {
|
||||
// Common
|
||||
'common.save': '儲存',
|
||||
'common.cancel': '取消',
|
||||
'common.close': '關閉',
|
||||
'common.reset': '重置',
|
||||
'common.zoomIn': '放大',
|
||||
'common.zoomOut': '縮小',
|
||||
'common.settings': '設定',
|
||||
'common.search': '搜尋',
|
||||
'common.connect': '連線',
|
||||
'common.terminal': '終端',
|
||||
'common.create': '建立',
|
||||
'common.add': '新增',
|
||||
'common.rename': '重新命名',
|
||||
'common.refresh': '重新整理',
|
||||
'common.continue': '繼續',
|
||||
'common.enabled': '已啟用',
|
||||
'common.disabled': '已停用',
|
||||
'common.unknownError': '未知錯誤',
|
||||
'common.noResultsFound': '沒有相符結果',
|
||||
'common.back': '返回',
|
||||
'common.apply': '套用',
|
||||
'common.use': '使用',
|
||||
'common.useGlobal': '跟隨全域',
|
||||
'common.left': '左側',
|
||||
'common.right': '右側',
|
||||
'common.more': '更多',
|
||||
'common.selectAHost': '選擇主機',
|
||||
'sort.manual': '手動順序',
|
||||
'sort.az': 'A-z',
|
||||
'sort.za': 'Z-a',
|
||||
'sort.newest': '從新到舊',
|
||||
'sort.oldest': '從舊到新',
|
||||
'sort.group': '按分組',
|
||||
'field.label': 'Label',
|
||||
'field.type': '型別',
|
||||
'auth.keyType': '型別 {type}',
|
||||
'auth.showAllKeys': '顯示全部 keys',
|
||||
|
||||
// Dialogs / prompts
|
||||
'confirm.deleteHost': '刪除主機 "{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.renameWorkspace.title': '重新命名工作區',
|
||||
'dialog.renameSession.title': '重新命名工作階段',
|
||||
'field.name': '名稱',
|
||||
'placeholder.workspaceName': '工作區名稱',
|
||||
'placeholder.sessionName': '工作階段名稱',
|
||||
'toast.settingsUnavailable': '目前平台無法開啟設定視窗。',
|
||||
'credentials.protectionUnavailable.title': '憑證保護不可用',
|
||||
'credentials.protectionUnavailable.message': '目前裝置無法自動解密已儲存的密碼和金鑰。連線前請重新輸入憑證。',
|
||||
'credentials.protectionUnavailable.action': '開啟設定',
|
||||
|
||||
// Settings shell
|
||||
'settings.title': '設定',
|
||||
'settings.tab.application': '應用程式',
|
||||
'settings.tab.appearance': '外觀',
|
||||
'settings.tab.terminal': '終端',
|
||||
'settings.tab.shortcuts': '快捷鍵',
|
||||
'settings.tab.syncCloud': '同步與雲',
|
||||
'settings.tab.system': '系統',
|
||||
'settings.terminal.connection.verifyHostKeys': '驗證 SSH 主機金鑰',
|
||||
'settings.terminal.connection.verifyHostKeys.desc': '連線到新的或已變化的 SSH 主機金鑰前先詢問。只建議在可信內網中關閉。',
|
||||
'settings.terminal.section.sidePanel': '側邊欄',
|
||||
'settings.terminal.sidePanel.autoOpen': '開啟終端時展開側邊欄',
|
||||
'settings.terminal.sidePanel.autoOpen.desc': '終端連線成功後自動開啟一個側邊欄。',
|
||||
'settings.terminal.sidePanel.autoOpenPane': '預設側邊欄',
|
||||
'settings.terminal.sidePanel.autoOpenPane.desc': '選擇優先開啟哪個面板。',
|
||||
'settings.terminal.sidePanel.pane.sftp': 'SFTP',
|
||||
'settings.terminal.sidePanel.pane.scripts': '指令碼',
|
||||
'settings.terminal.sidePanel.pane.history': '歷史',
|
||||
'settings.terminal.sidePanel.pane.theme': '主題',
|
||||
'settings.terminal.sidePanel.pane.system': '系統',
|
||||
'settings.terminal.sidePanel.pane.notes': '筆記',
|
||||
'settings.terminal.sidePanel.pane.ai': 'AI',
|
||||
|
||||
// Settings > System
|
||||
'settings.system.title': '系統',
|
||||
'settings.system.description': '系統資訊與臨時檔案管理。',
|
||||
'settings.system.tempDirectory': '臨時檔案',
|
||||
'settings.system.location': '位置',
|
||||
'settings.system.fileCount': '檔案數量',
|
||||
'settings.system.totalSize': '佔用空間',
|
||||
'settings.system.openFolder': '開啟資料夾',
|
||||
'settings.system.refresh': '重新整理',
|
||||
'settings.system.clearTempFiles': '清理臨時檔案',
|
||||
'settings.system.clearing': '清理中...',
|
||||
'settings.system.clearResult': '已刪除 {deleted} 個檔案,{failed} 個失敗。',
|
||||
'settings.system.tempDirectoryHint': '臨時檔案在使用外部應用程式開啟遠端檔案時建立。SFTP 工作階段關閉時會自動清理。',
|
||||
'settings.system.credentials.title': '憑證保護',
|
||||
'settings.system.credentials.status': '狀態',
|
||||
'settings.system.credentials.checking': '檢查中...',
|
||||
'settings.system.credentials.available': '可用(系統鑰匙串正常)',
|
||||
'settings.system.credentials.unavailable': '不可用(無法解密已儲存憑證)',
|
||||
'settings.system.credentials.unknown': '未知(目前環境不支援)',
|
||||
'settings.system.credentials.unavailableHint': '在其他使用者或機器上加密的憑證無法在此處解密。請在目前裝置重新輸入並儲存憑證。',
|
||||
'settings.system.credentials.portabilityHint': '雲同步可跨裝置,因為使用主金鑰加密;本機 safeStorage 加密僅綁定目前系統使用者/裝置。',
|
||||
|
||||
// Settings > System > Crash Logs
|
||||
'settings.system.crashLogs.title': '崩潰日誌',
|
||||
'settings.system.crashLogs.description': '檢視主程序錯誤日誌,協助診斷異常行為。',
|
||||
'settings.system.crashLogs.noLogs': '未找到崩潰日誌。',
|
||||
'settings.system.crashLogs.entries': '{count} 條記錄',
|
||||
'settings.system.crashLogs.clear': '清除所有日誌',
|
||||
'settings.system.crashLogs.cleared': '已清除 {count} 個日誌檔案。',
|
||||
'settings.system.crashLogs.source': '來源',
|
||||
'settings.system.crashLogs.time': '時間',
|
||||
'settings.system.crashLogs.message': '訊息',
|
||||
'settings.system.crashLogs.stack': '堆疊追蹤',
|
||||
'settings.system.crashLogs.hint': '崩潰日誌保留 30 天,超期自動清理。',
|
||||
'settings.system.crashLogs.collapse': '收起',
|
||||
'settings.system.crashLogs.expand': '檢視詳情',
|
||||
|
||||
// Settings > System > Software Update
|
||||
'settings.update.title': '軟體更新',
|
||||
'settings.update.currentVersion': '目前版本',
|
||||
'settings.update.checkForUpdates': '檢查更新',
|
||||
'settings.update.checking': '檢查中...',
|
||||
'settings.update.upToDate': '目前已是最新版本。',
|
||||
'settings.update.available': '新版本 {version} 已釋出。',
|
||||
'settings.update.download': '下載更新',
|
||||
'settings.update.downloading': '正在下載... {percent}%',
|
||||
'settings.update.readyToInstall': '更新已下載,準備安裝。',
|
||||
'settings.update.restartNow': '重啟並更新',
|
||||
'settings.update.error': '檢查更新失敗。',
|
||||
'settings.update.downloadError': '下載失敗。',
|
||||
'settings.update.manualDownload': '前往 GitHub 下載',
|
||||
'settings.update.manualDownloadHint': '目前平台不支援自動更新,請前往 GitHub 下載最新版本。',
|
||||
'settings.update.hint': 'Netcatty 從 GitHub Releases 檢查更新。',
|
||||
'settings.update.lastCheckedJustNow': '剛剛',
|
||||
'settings.update.lastCheckedMinutesAgo': '{n} 分鐘前',
|
||||
'settings.update.lastCheckedHoursAgo': '{n} 小時前',
|
||||
'settings.update.lastCheckedPrefix': '上次檢查:',
|
||||
'settings.update.autoUpdateEnabled': '自動更新',
|
||||
'settings.update.autoUpdateEnabledDesc': '有新版本時自動檢查並下載更新。',
|
||||
|
||||
// Settings > Session Restore
|
||||
'settings.sessionRestore.title': '工作階段恢復',
|
||||
'settings.sessionRestore.restorePreviousSession': '恢復上次的標籤頁和工作區',
|
||||
'settings.sessionRestore.restorePreviousSessionDesc': '啟動時重新開啟上次的終端標籤頁和工作區佈局。',
|
||||
'settings.sessionRestore.restoreTerminalCwd': '恢復終端工作目錄',
|
||||
'settings.sessionRestore.restoreTerminalCwdDesc': '重新連線恢復的終端時,嘗試回到上次記錄的目錄;失敗時不會阻塞連線。',
|
||||
'terminal.restore.placeholder.title': '已恢復的工作階段',
|
||||
'terminal.restore.placeholder.desc': '此終端目前已斷開。準備好後可以重新連線,為這個標籤頁啟動新的 shell。',
|
||||
'terminal.restore.placeholder.reconnect': '重新連線',
|
||||
'terminal.restore.cwdLog': '正在恢復工作目錄:{cwd}',
|
||||
|
||||
// Settings > Session Logs
|
||||
'settings.sessionLogs.title': '工作階段日誌',
|
||||
'settings.sessionLogs.description': '設定工作階段日誌匯出和自動儲存設定。',
|
||||
'settings.sessionLogs.autoSave': '自動儲存',
|
||||
'settings.sessionLogs.enableAutoSave': '啟用自動儲存',
|
||||
'settings.sessionLogs.enableAutoSaveDesc': '在終端工作階段結束時自動儲存工作階段日誌。',
|
||||
'settings.sessionLogs.directory': '儲存目錄',
|
||||
'settings.sessionLogs.noDirectory': '未選擇目錄',
|
||||
'settings.sessionLogs.browse': '瀏覽',
|
||||
'settings.sessionLogs.openFolder': '開啟資料夾',
|
||||
'settings.sessionLogs.directoryHint': '日誌將按主機名組織在子目錄中。',
|
||||
'settings.sessionLogs.format': '日誌格式',
|
||||
'settings.sessionLogs.formatDesc': '選擇儲存日誌檔案的格式。',
|
||||
'settings.sessionLogs.formatTxt': '純文字 (.txt)',
|
||||
'settings.sessionLogs.formatRaw': '原始格式 (.log)',
|
||||
'settings.sessionLogs.formatHtml': 'HTML (.html)',
|
||||
'settings.sessionLogs.timestamps': '新增時間戳',
|
||||
'settings.sessionLogs.timestampsDesc': '為純文字和 HTML 日誌的每一行新增本機時間。',
|
||||
'settings.sessionLogs.hint': '工作階段日誌用於記錄終端輸出,便於故障排查和審計。',
|
||||
|
||||
// Settings > SSH Debug Logs
|
||||
'settings.sshDebugLogs.title': 'SSH 除錯日誌',
|
||||
'settings.sshDebugLogs.enable': '啟用 SSH 除錯日誌',
|
||||
'settings.sshDebugLogs.enableDesc': '記錄連線、認證、握手、斷開和錯誤原因,不記錄終端輸出。',
|
||||
'settings.sshDebugLogs.location': '日誌位置',
|
||||
'settings.sshDebugLogs.status': '狀態',
|
||||
'settings.sshDebugLogs.statusOn': '已開啟',
|
||||
'settings.sshDebugLogs.statusOff': '未開啟',
|
||||
'settings.sshDebugLogs.size': '大小',
|
||||
'settings.sshDebugLogs.hint': '開啟後,新發起的 SSH 連線會寫入診斷資訊,方便排查堡壘機、認證和異常斷開問題。',
|
||||
|
||||
// Settings > Global Hotkey (Quake Mode)
|
||||
'settings.globalHotkey.title': '全域快捷鍵',
|
||||
'settings.globalHotkey.toggleWindow': '切換視窗',
|
||||
'settings.globalHotkey.toggleWindowDesc': '按下組合鍵以設定顯示/隱藏視窗的全域快捷鍵。',
|
||||
'settings.globalHotkey.notSet': '未設定',
|
||||
'settings.globalHotkey.reset': '恢復預設',
|
||||
'settings.globalHotkey.closeToTray': '關閉時最小化到系統匣',
|
||||
'settings.globalHotkey.closeToTrayDesc': '啟用後,關閉視窗將最小化到系統匣而不是結束程式。',
|
||||
'settings.globalHotkey.enabled': '啟用全域快捷鍵',
|
||||
'settings.globalHotkey.enabledDesc': '註冊系統級鍵盤快捷鍵。停用後將取消所有全域快捷鍵註冊。',
|
||||
'settings.globalHotkey.hint': '全域快捷鍵在系統範圍內工作,可快速顯示或隱藏視窗(下拉式終端風格)。',
|
||||
|
||||
// Tray Panel
|
||||
'tray.openMainWindow': '開啟主視窗',
|
||||
'tray.sessions': '工作階段',
|
||||
'tray.portForwarding': '埠轉發',
|
||||
'tray.status.connected': '已連線',
|
||||
'tray.status.connecting': '連線中',
|
||||
'tray.status.disconnected': '已斷開',
|
||||
'tray.status.active': '已啟用',
|
||||
'tray.status.inactive': '未啟用',
|
||||
'tray.status.error': '錯誤',
|
||||
'tray.recentHosts': '最近連線的主機',
|
||||
'tray.empty.title': '一切都很安靜',
|
||||
'tray.empty.subtitle': '去連線個伺服器吧,它們想念你了 🚀',
|
||||
'tray.quit': '結束 Netcatty',
|
||||
|
||||
// Vault Sidebar
|
||||
'vault.sidebar.collapse': '收起側邊欄',
|
||||
'vault.sidebar.expand': '展開側邊欄',
|
||||
'vault.sidebar.resize': '調整側邊欄寬度',
|
||||
|
||||
// Settings > Application
|
||||
'settings.application.checkUpdates': '檢查更新',
|
||||
'settings.application.reportProblem': '反饋問題',
|
||||
'settings.application.reportProblem.subtitle': '生成預填的 GitHub issue',
|
||||
'settings.application.community': '社群',
|
||||
'settings.application.community.subtitle': 'GitHub Discussions',
|
||||
'settings.application.github': 'GitHub',
|
||||
'settings.application.github.subtitle': '原始碼',
|
||||
'settings.application.whatsNew': '更新內容',
|
||||
'settings.application.whatsNew.subtitle': '檢視釋出說明',
|
||||
'settings.application.openExternal.failedTitle': '無法開啟連結',
|
||||
'settings.application.openExternal.failedBody': '系統瀏覽器和內建瀏覽器視窗都無法開啟該連結。',
|
||||
'settings.vault.title': '主機庫',
|
||||
'settings.vault.showRecentHosts': '顯示最近連線的主機',
|
||||
'settings.vault.showRecentHostsDesc': '在主機列表頂部顯示最近連線過的主機',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': '根目錄只顯示未分組主機',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': '開啟後,主機庫根目錄的主機列表只顯示沒有分組的主機,已分組主機請從左側分組進入檢視。',
|
||||
'settings.vault.showSftpTab': '顯示 SFTP 標籤頁',
|
||||
'settings.vault.showSftpTabDesc': '在頂部標籤欄顯示獨立的 SFTP 檢視。關閉後可改用工作階段內左側的 SFTP 側欄。',
|
||||
'settings.vault.showHostTreeSidebar': '顯示主機列表側欄',
|
||||
'settings.vault.showHostTreeSidebarDesc': '在終端和編輯器標籤頁顯示主機列表側欄及頂部開關。',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': '發現新版本',
|
||||
'update.available.message': '新版本 {version} 已釋出,點選前往下載。',
|
||||
'update.checking': '正在檢查更新...',
|
||||
'update.upToDate.title': '已是最新版本',
|
||||
'update.upToDate.message': '目前版本 ({version}) 已是最新。',
|
||||
'update.error': '檢查更新失敗',
|
||||
'update.downloadNow': '立即下載',
|
||||
'update.viewInSettings': '在設定中檢視',
|
||||
'update.readyToInstall.title': '更新已就緒',
|
||||
'update.readyToInstall.message': '版本 {version} 已下載完成,準備安裝。',
|
||||
'update.restartNow': '立即重啟',
|
||||
'update.downloadFailed.title': '更新失敗',
|
||||
'update.downloadFailed.message': '下載更新失敗,可前往 GitHub 手動下載。',
|
||||
'update.needsSave.title': '有未儲存內容',
|
||||
'update.needsSave.message': '請先儲存已開啟的編輯器,然後再次點選「立即重啟」以安裝更新。',
|
||||
'update.openReleases': '開啟 Releases',
|
||||
'update.remindLater': '稍後提醒',
|
||||
'update.skipVersion': '跳過此版本',
|
||||
|
||||
// Settings > Appearance
|
||||
'settings.appearance.uiTheme': '介面主題',
|
||||
'settings.appearance.theme': '主題',
|
||||
'settings.appearance.theme.desc': '選擇淺色、深色或跟隨系統設定',
|
||||
'settings.appearance.theme.light': '淺色',
|
||||
'settings.appearance.theme.dark': '深色',
|
||||
'settings.appearance.theme.system': '系統',
|
||||
'settings.appearance.accentColor': '強調色',
|
||||
'settings.appearance.customColor': '自訂顏色',
|
||||
'settings.appearance.accentColor.mode': '使用自訂強調色',
|
||||
'settings.appearance.accentColor.mode.desc': '覆蓋主題自帶的強調色',
|
||||
'settings.appearance.accentColor.custom': '自訂強調色',
|
||||
'settings.appearance.themeColor': '主題色',
|
||||
'settings.appearance.themeColor.desc': '為淺色與深色主題選擇預設配色',
|
||||
'settings.appearance.themeColor.light': '淺色主題',
|
||||
'settings.appearance.themeColor.dark': '深色主題',
|
||||
'settings.appearance.customCss': '自訂 CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'使用自訂 CSS 個人化介面,修改會立即生效。主要 UI 區塊都公開了 [data-section="..."] 屬性供你定位,比如:snippets-panel、host-details-panel、group-details-panel、serial-host-details-panel、ai-chat-panel、vault-sidebar、vault-main、vault-hosts-header、vault-host-list、vault-view、terminal-workspace、terminal-workspace-sidebar(Focus 模式終端列表)、terminal-host-tree-sidebar、terminal-host-tree-sidebar-content、terminal-host-tree-sidebar-row、terminal-side-panel(SFTP/指令碼/主題/AI 側欄,開啟時生效)、terminal-side-panel-tabs、terminal-side-panel-content、terminal-sftp-panel、terminal-sftp-host-header、terminal-sftp-pane、terminal-sftp-toolbar、terminal-sftp-path、terminal-sftp-filter-bar、terminal-sftp-list、terminal-sftp-list-header、terminal-sftp-list-row、terminal-sftp-tree、terminal-sftp-tree-row、terminal-sftp-transfer-queue、terminal-sftp-transfer-row、terminal-split-pane、terminal-split-resizer、top-tabs、top-tabs-host-tree-toggle、top-tabs-quick-switcher-toggle。',
|
||||
'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/* 也可在 設定 → 終端 → 工作區聚焦指示 → 聚焦窗格顯示邊框 */',
|
||||
'settings.appearance.language': '語言',
|
||||
'settings.appearance.language.desc': '選擇介面語言',
|
||||
'settings.appearance.uiFont': '介面字型',
|
||||
'settings.appearance.uiFont.desc': '選擇軟體介面使用的字型',
|
||||
'settings.appearance.windowOpacity': '視窗透明度',
|
||||
'settings.appearance.windowOpacity.desc': '調節整個應用程式視窗的透明度,方便疊在其他內容上方。較低時終端文字也會變淡;部分 Linux 桌面環境可能不支援。',
|
||||
'settings.appearance.appIcon': '應用程式圖示',
|
||||
'settings.appearance.appIcon.desc': '選擇 Dock/工作列圖示風格,切換應用程式時更容易找到 Netcatty。執行時生效,安裝包內的圖示不會改變。',
|
||||
'settings.appearance.appIcon.original': '原始',
|
||||
'settings.appearance.appIcon.bright': '明亮',
|
||||
'settings.appearance.appIcon.dark': '深色',
|
||||
'settings.appearance.appIcon.colorful': '彩色',
|
||||
'settings.appearance.appIcon.highContrast': '高對比',
|
||||
'settings.appearance.appIcon.group.classic': '彩色底',
|
||||
'settings.appearance.appIcon.group.white': '白底',
|
||||
'settings.appearance.appIcon.group.special': '隱藏款',
|
||||
'settings.appearance.appIcon.whiteNavy': '白底 · 海軍藍貓',
|
||||
'settings.appearance.appIcon.whiteSky': '白底 · 天藍貓',
|
||||
'settings.appearance.appIcon.whiteRose': '白底 · 玫紅貓',
|
||||
'settings.appearance.appIcon.whiteEmerald': '白底 · 翡翠貓',
|
||||
'settings.appearance.appIcon.whiteAmber': '白底 · 琥珀貓',
|
||||
'settings.appearance.appIcon.whiteViolet': '白底 · 紫羅蘭貓',
|
||||
'settings.appearance.appIcon.rainbow': '彩虹貓',
|
||||
// Context menus / common actions
|
||||
'action.newHost': '新建主機',
|
||||
'action.newSubfolder': '新建資料夾',
|
||||
'action.copyPublicKey': '複製公鑰',
|
||||
'action.keyExport': '匯出金鑰',
|
||||
'action.edit': '編輯',
|
||||
'action.delete': '刪除',
|
||||
'action.remove': '移除',
|
||||
'action.convertToHost': '轉換為主機',
|
||||
|
||||
// Sync
|
||||
'sync.cloudSync': '雲同步',
|
||||
'sync.settings': '同步設定',
|
||||
'sync.active': '雲同步已啟用',
|
||||
'sync.syncing': '正在同步…',
|
||||
'sync.error': '同步錯誤',
|
||||
'sync.notConfigured': '未設定',
|
||||
'sync.failed': '同步失敗',
|
||||
'sync.connected': '已連線',
|
||||
'sync.syncNow': '立即同步',
|
||||
'sync.recentActivity': '最近活動',
|
||||
'sync.pending': '有待同步的變更',
|
||||
'sync.history.uploaded': '已 Upload',
|
||||
'sync.history.downloaded': '已 Download',
|
||||
'sync.history.resolved': '已處理',
|
||||
'sync.toast.completedMessage': '同步完成',
|
||||
'sync.toast.errorTitle': '同步錯誤',
|
||||
'sync.autoSync.failedTitle': '同步失敗',
|
||||
'sync.autoSync.inspectFailedTitle': '同步已暫停',
|
||||
'sync.autoSync.inspectFailedMessage': '無法存取雲端以檢查變更。資料改動或下次啟動時會自動重試。',
|
||||
'sync.autoSync.syncedTitle': '已從雲端同步',
|
||||
'sync.autoSync.syncedMessage': '你的資料已從雲端更新。',
|
||||
'sync.autoSync.noProvider': '未連線雲同步 provider。請開啟 設定 → Sync & Cloud 進行連線。',
|
||||
'sync.autoSync.alreadySyncing': '同步正在進行中。',
|
||||
'sync.autoSync.restoreInProgress': '另一個視窗中的本機備份恢復正在進行中,請等待其完成。',
|
||||
'sync.autoSync.interruptedApplyTitle': '同步已暫停 — 上次恢復未完成',
|
||||
'sync.autoSync.interruptedApplyMessage': '上次本機恢復過程未正常結束,本機資料可能處於半套用狀態。請開啟「設定 → Sync & Cloud → 恢復」,從保護性備份中恢復後再讓自動同步繼續。',
|
||||
'sync.autoSync.vaultLocked': 'Vault 處於鎖定狀態。請開啟 設定 → Sync & Cloud 解鎖。',
|
||||
'sync.autoSync.conflictDetected': '偵測到同步衝突。請開啟 設定 → Sync & Cloud 處理。',
|
||||
'sync.autoSync.syncFailed': '同步失敗',
|
||||
'sync.autoSync.restoredTitle': '已恢復',
|
||||
'sync.autoSync.restoredMessage': '已從雲端恢復主機庫資料。',
|
||||
'sync.autoSync.keptLocalTitle': '已保留本機資料',
|
||||
'sync.autoSync.keptLocalMessage': '保留了空的本機主機庫,未套用雲端資料。',
|
||||
'sync.autoSync.emptyVaultConflict.title': '偵測到空主機庫',
|
||||
'sync.autoSync.emptyVaultConflict.description': '本機主機庫為空,但云端有資料。這通常發生在應用程式更新或儲存重置之後。請選擇如何處理:',
|
||||
'sync.autoSync.emptyVaultConflict.cloudLabel': '雲端',
|
||||
'sync.autoSync.emptyVaultConflict.restore': '從雲端恢復',
|
||||
'sync.autoSync.emptyVaultConflict.restoreDesc': '推薦 — 從雲端備份恢復主機、金鑰和程式碼片段',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmpty': '保持為空',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': '從頭開始,使用空的主機庫',
|
||||
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} 台主機,{keys} 個金鑰,{snippets} 個程式碼片段,{notes} 條筆記,{proxyProfiles} 個代理',
|
||||
'sync.autoSync.emptyVaultManual': '無法同步:本機 vault 為空。請先從本機備份恢復,或在同步面板裡使用"強制推送"。',
|
||||
|
||||
'sync.blocked.title': '同步已暫停',
|
||||
'sync.blocked.reason.bulkShrink': '即將從雲端刪除 {baseCount} 條 {entityType} 中的 {lost} 條(縮減 {percent}%)。',
|
||||
'sync.blocked.reason.largeShrink': '即將從雲端刪除 {lost} 條 {entityType}。',
|
||||
'sync.blocked.detail': '通常是本機狀態異常(鑰匙串故障、資料載入不全)導致。請從本機備份恢復,如果確實要刪這些條目請使用強制推送。',
|
||||
'sync.blocked.restoreButton': '從本機備份恢復',
|
||||
'sync.blocked.forcePushButton': '強制推送',
|
||||
|
||||
'sync.forcePush.title': '確認強制推送',
|
||||
'sync.forcePush.body': '你將從雲端移除 {lost} 條 {entityType},此操作不可復原。繼續?',
|
||||
'sync.forcePush.confirm': '確認推送',
|
||||
'sync.forcePush.cancel': '取消',
|
||||
|
||||
'sync.entityType.hosts': '主機',
|
||||
'sync.entityType.keys': '金鑰',
|
||||
'sync.entityType.identities': '身份',
|
||||
'sync.entityType.proxyProfiles': '代理設定',
|
||||
'sync.entityType.snippets': '程式碼片段',
|
||||
'sync.entityType.customGroups': '分組',
|
||||
'sync.entityType.snippetPackages': '片段包',
|
||||
'sync.entityType.knownHosts': '主機金鑰記錄',
|
||||
'sync.entityType.portForwardingRules': '埠轉發規則',
|
||||
'sync.entityType.groupConfigs': '分組設定',
|
||||
|
||||
'sync.credentialsUnavailable': '目前裝置無法解密部分已儲存憑證。請先在本機重新輸入憑證後再同步。',
|
||||
'time.never': '從未',
|
||||
'time.justNow': '剛剛',
|
||||
'time.minutesAgo': '{minutes} 分鐘前',
|
||||
|
||||
// Vault navigation
|
||||
'vault.nav.hosts': '主機',
|
||||
'vault.nav.keychain': '鑰匙串',
|
||||
'vault.nav.proxies': '代理',
|
||||
'vault.nav.portForwarding': '埠轉發',
|
||||
'vault.nav.snippets': '程式碼片段',
|
||||
'vault.nav.notes': '筆記',
|
||||
'vault.nav.knownHosts': '已知主機',
|
||||
'vault.nav.logs': '日誌',
|
||||
|
||||
'proxyProfiles.action.add': '新增代理',
|
||||
'proxyProfiles.search.placeholder': '搜尋代理…',
|
||||
'proxyProfiles.section.proxies': '代理',
|
||||
'proxyProfiles.count.items': '{count} 項',
|
||||
'proxyProfiles.empty.title': '暫無代理',
|
||||
'proxyProfiles.empty.desc': '建立可重複使用的 HTTP、SOCKS5 或指令代理,然後在主機詳情裡選擇。',
|
||||
'proxyProfiles.usage': '已關聯 {count} 處',
|
||||
'proxyProfiles.copyName': '{name} 副本',
|
||||
'proxyProfiles.panel.newTitle': '新建代理',
|
||||
'proxyProfiles.field.name': '代理名稱',
|
||||
'proxyProfiles.error.required': '名稱和代理詳情不能為空。',
|
||||
'proxyProfiles.error.port': '埠必須在 1 到 65535 之間。',
|
||||
'proxyProfiles.viewMode': '代理顯示方式',
|
||||
'proxyProfiles.delete.title': '刪除代理?',
|
||||
'proxyProfiles.delete.desc': '刪除 "{name}" 會同時從 {count} 個主機或分組設定中解除關聯。',
|
||||
|
||||
'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.ungrouped': '未分組',
|
||||
'vault.groups.field.name': '分組名稱',
|
||||
'vault.groups.placeholder.example': '例如:Production',
|
||||
'vault.groups.parentLabel': '父級',
|
||||
'vault.groups.pathLabel': '路徑',
|
||||
'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.none': '無',
|
||||
'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.managedSource.unmanage': '取消託管',
|
||||
'vault.managedSource.unmanageSuccess': '已取消託管分組',
|
||||
|
||||
'vault.hosts.header.entries': '{count} 條',
|
||||
'vault.hosts.header.live': '{count} 個線上',
|
||||
|
||||
// Vault hosts header/actions
|
||||
'vault.hosts.search.placeholder': '查詢主機或 ssh user@hostname / ssh -p 2222 user@hostname…',
|
||||
'vault.hosts.connect': '連線',
|
||||
'vault.view.grid': '網格',
|
||||
'vault.view.list': '列表',
|
||||
'vault.view.tree': '樹形',
|
||||
'vault.tree.expandAll': '展開全部',
|
||||
'vault.tree.collapseAll': '摺疊全部',
|
||||
'vault.hosts.newHost': '新建主機',
|
||||
'vault.hosts.newGroup': '新建分組',
|
||||
'vault.hosts.import': '匯入',
|
||||
'vault.hosts.export': '匯出',
|
||||
'vault.hosts.export.toast.success': '已匯出 {count} 個主機到 CSV',
|
||||
'vault.hosts.export.toast.successWithSkipped': '已匯出 {count} 個主機到 CSV(跳過 {skipped} 個不支援的主機)',
|
||||
'vault.hosts.export.toast.noHosts': '沒有主機可匯出',
|
||||
'vault.hosts.allHosts': '全部主機',
|
||||
'vault.hosts.pinned': '已置頂',
|
||||
'vault.hosts.recentlyConnected': '最近連線',
|
||||
'vault.hosts.pinToTop': '置頂',
|
||||
'vault.hosts.unpin': '取消置頂',
|
||||
'vault.hosts.copyCredentials': '複製賬密資訊',
|
||||
'vault.hosts.copyCredentials.toast.success': '賬密資訊已複製到剪貼簿',
|
||||
'vault.hosts.copyCredentials.toast.noPassword': '該主機未儲存密碼',
|
||||
'vault.hosts.multiSelect': '多選',
|
||||
'vault.hosts.selected': '已選擇 {count} 項',
|
||||
'vault.hosts.selectAll': '全選',
|
||||
'vault.hosts.deselectAll': '取消全選',
|
||||
'vault.hosts.deleteSelected': '刪除 ({count})',
|
||||
'vault.hosts.deleteMultiple.success': '已刪除 {count} 個主機',
|
||||
'vault.hosts.connectSelected': '連線 ({count})',
|
||||
'vault.hosts.connectMultiple.success': '正在連線 {count} 個主機',
|
||||
'vault.hosts.moveToGroup.success': '已將 {host} 移動到 {group}',
|
||||
'vault.hosts.errors.nameRequired': '主機名稱不能為空。',
|
||||
'vault.hosts.empty.title': '設定你的主機',
|
||||
'vault.hosts.empty.desc': '儲存主機以快速連線到你的伺服器、虛擬機器和容器。',
|
||||
'deepLink.ssh.invalid': '無效的 ssh:// 連結',
|
||||
|
||||
// Vault import
|
||||
'vault.import.title': '新增資料到你的 Vault',
|
||||
'vault.import.desc': '從常見工具遷移連線資訊。選擇一種格式開始匯入。',
|
||||
'vault.import.chooseFormat': '選擇檔案格式',
|
||||
'vault.import.csv.tip': '批次匯入:可使用 CSV 模板填寫後匯入。',
|
||||
'vault.import.csv.downloadTemplate': '下載 CSV 模板',
|
||||
'vault.import.toast.start': '正在從 {format} 匯入...',
|
||||
'vault.import.toast.completedTitle': '匯入完成',
|
||||
'vault.import.toast.failedTitle': '匯入失敗',
|
||||
'vault.import.toast.noEntries': '{format} 檔案中沒有可匯入的條目。',
|
||||
'vault.import.toast.noNewHosts': '從 {format} 沒有匯入到新的主機。',
|
||||
'vault.import.toast.summary': '已匯入 {count} 個主機(跳過 {skipped},重複 {duplicates})。',
|
||||
'vault.import.toast.firstIssue': '首個問題:{issue}',
|
||||
'vault.import.sshConfig.chooseMode': '選擇如何匯入你的 SSH config 檔案。',
|
||||
'vault.import.sshConfig.modeQuestion': '你希望如何匯入?',
|
||||
'vault.import.sshConfig.importOnly': '僅匯入',
|
||||
'vault.import.sshConfig.importOnlyDesc': '一次性匯入,修改不會同步回檔案。',
|
||||
'vault.import.sshConfig.managed': '託管同步',
|
||||
'vault.import.sshConfig.managedDesc': '保持同步,修改會自動儲存回檔案。',
|
||||
'vault.import.sshConfig.managedGroup': 'ssh config',
|
||||
'vault.import.sshConfig.managedSuccess': '已匯入 {count} 個主機,檔案已託管。',
|
||||
'vault.import.sshConfig.alreadyManaged': '該檔案已被託管。',
|
||||
'vault.import.sshConfig.alreadyManagedDesc': '該檔案已在分組 "{group}" 下託管。如需重新匯入,請先移除現有的託管源。',
|
||||
'vault.import.sshConfig.noFilePath': '無法託管此檔案。',
|
||||
'vault.import.sshConfig.noFilePathDesc': '無法確定檔案路徑。託管同步需要存取檔案系統。',
|
||||
|
||||
// Known Hosts
|
||||
'knownHosts.search.placeholder': '搜尋已知主機...',
|
||||
'knownHosts.action.scanSystem': '掃描系統',
|
||||
'knownHosts.action.importFile': '匯入檔案',
|
||||
'knownHosts.action.browseFile': '瀏覽檔案',
|
||||
'knownHosts.empty.title': '暫無已知主機',
|
||||
'knownHosts.empty.desc':
|
||||
'Known Hosts 是你之前連線過的 SSH server。匯入系統的 known_hosts 檔案以開始。',
|
||||
'knownHosts.results.showingLimited': '顯示 {shown}/{total} 個主機。使用搜索查詢特定主機。',
|
||||
'knownHosts.toast.scanUnavailable': '目前平台無法掃描系統 known_hosts。',
|
||||
'knownHosts.toast.scanNoFile': '未找到系統 known_hosts 檔案。',
|
||||
'knownHosts.toast.scanNoEntries': 'known_hosts 中沒有可用條目。',
|
||||
'knownHosts.toast.scanImported': '已匯入 {count} 個新主機。',
|
||||
'knownHosts.toast.scanNoNew': '沒有發現新的主機。',
|
||||
'knownHosts.toast.scanFailed': '掃描系統 known_hosts 失敗。',
|
||||
|
||||
// Port Forwarding
|
||||
'pf.empty.title': '設定埠轉發規則',
|
||||
'pf.empty.desc': '儲存埠轉發規則,用於存取資料庫、Web 應用程式等服務。',
|
||||
'pf.title': '埠轉發規則',
|
||||
'pf.rulesCount': '{count} 條規則',
|
||||
'pf.wizard.editTitle': '編輯埠轉發規則',
|
||||
'pf.wizard.newTitle': '新建埠轉發規則',
|
||||
'pf.wizard.saveChanges': '儲存修改',
|
||||
'pf.wizard.done': '完成',
|
||||
'pf.wizard.continue': '繼續',
|
||||
'pf.wizard.cancel': '取消',
|
||||
'pf.wizard.skipWizard': '跳過精靈',
|
||||
'pf.error.hostNotFound': '未找到主機',
|
||||
'pf.toast.titleWithLabel': '埠轉發規則: {label}',
|
||||
'pf.type.local': '本機轉發',
|
||||
'pf.type.remote': '遠端轉發',
|
||||
'pf.type.dynamic': '動態轉發',
|
||||
'pf.type.menu.local': '本機轉發',
|
||||
'pf.type.menu.remote': '遠端轉發',
|
||||
'pf.type.menu.dynamic': '動態轉發',
|
||||
'pf.type.local.desc': '本機轉發讓你像存取本機一樣存取遠端服務埠。',
|
||||
'pf.type.remote.desc': '遠端轉發在遠端開啟埠,並將連線轉發到本機(目前)主機。',
|
||||
'pf.type.dynamic.desc': '動態轉發將 Netcatty 作為 SOCKS 代理使用。',
|
||||
'pf.wizard.type.title': '選擇埠轉發型別:',
|
||||
'pf.wizard.localConfig.title': '設定本機埠與繫結地址:',
|
||||
'pf.wizard.localConfig.desc': '該埠會在本機(目前裝置)開啟,並接收流量。',
|
||||
'pf.wizard.localConfig.localPort': '本機埠 *',
|
||||
'pf.wizard.bindAddress': '繫結地址',
|
||||
'pf.wizard.remoteHost.title': '選擇遠端主機:',
|
||||
'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.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.label.title': '設定 Label:',
|
||||
'pf.wizard.label.placeholder.dynamic': '例如:SOCKS Proxy',
|
||||
'pf.wizard.label.placeholder.default': '例如:MySQL Production',
|
||||
'pf.wizard.label.placeholder.remoteRule': '例如:Remote Rule',
|
||||
'pf.wizard.placeholders.portExample': '例如:{port}',
|
||||
|
||||
// SFTP
|
||||
'sftp.newFolder': '新建資料夾',
|
||||
'sftp.newFile': '新建檔案',
|
||||
'sftp.filter': '篩選',
|
||||
'sftp.filter.placeholder': '按檔名篩選...',
|
||||
'sftp.bookmark.add': '收藏此路徑',
|
||||
'sftp.bookmark.remove': '取消收藏',
|
||||
'sftp.bookmark.list': '收藏路徑',
|
||||
'sftp.bookmark.addGlobal': '+全域',
|
||||
'sftp.bookmark.addGlobalTooltip': '儲存為全域收藏(所有主機共享)',
|
||||
'sftp.bookmark.empty': '暫無收藏路徑',
|
||||
'sftp.columns.name': '名稱',
|
||||
'sftp.columns.modified': '修改時間',
|
||||
'sftp.columns.size': '大小',
|
||||
'sftp.columns.kind': '型別',
|
||||
'sftp.columns.actions': '操作',
|
||||
'sftp.emptyDirectory': '空目錄',
|
||||
'sftp.nav.up': '返回上層',
|
||||
'sftp.nav.home': '返回主目錄',
|
||||
'sftp.nav.refresh': '重新整理',
|
||||
'sftp.upload': '上傳',
|
||||
'sftp.uploadFiles': '上傳檔案',
|
||||
'sftp.uploadFolder': '上傳資料夾',
|
||||
'sftp.dragDropToUpload': '拖拽檔案到這裡上傳',
|
||||
'sftp.retry': '重試',
|
||||
'sftp.context.open': '開啟',
|
||||
'sftp.context.navigateTo': '跳轉到這裡',
|
||||
'sftp.context.moveTo': '移動到...',
|
||||
'sftp.context.moveToParent': '移動到上級目錄',
|
||||
'sftp.moveTo.title': '移動到目錄',
|
||||
'sftp.moveTo.placeholder': '輸入目標目錄路徑',
|
||||
'sftp.moveTo.confirm': '移動',
|
||||
'sftp.moveTo.pathNotFound': '目錄不存在或無法存取',
|
||||
'sftp.context.download': '下載',
|
||||
'sftp.context.copyToOtherPane': '複製到另一側',
|
||||
'sftp.copyCurrentPath': '複製目前路徑',
|
||||
'sftp.copyCurrentPath.success': '已複製目前路徑',
|
||||
'sftp.copyCurrentPath.error': '無法複製目前路徑',
|
||||
'sftp.viewMode.label': '檢視模式',
|
||||
'sftp.viewMode.list': '列表檢視',
|
||||
'sftp.viewMode.tree': '樹形檢視',
|
||||
'sftp.viewMode.switchToList': '切換到列表檢視',
|
||||
'sftp.viewMode.switchToTree': '切換到樹形檢視',
|
||||
'sftp.tree.loadError': '載入目錄失敗',
|
||||
'sftp.tree.loading': '載入中...',
|
||||
'sftp.kind.folder': '資料夾',
|
||||
'sftp.context.rename': '重新命名',
|
||||
'sftp.context.permissions': '權限',
|
||||
'sftp.context.delete': '刪除',
|
||||
'sftp.context.refresh': '重新整理',
|
||||
'sftp.context.uploadFiles': '上傳檔案...',
|
||||
'sftp.context.uploadFilesHere': '上傳檔案到這裡...',
|
||||
'sftp.context.uploadFolder': '上傳資料夾...',
|
||||
'sftp.context.uploadFolderHere': '上傳資料夾到這裡...',
|
||||
'sftp.context.downloadSelected': '下載選取項({count})',
|
||||
'sftp.context.deleteSelected': '刪除選取項({count})',
|
||||
'sftp.dropFilesHere': '拖拽檔案到這裡',
|
||||
'sftp.itemsCount': '{count} 個專案',
|
||||
'sftp.selectedCount': '已選 {count} 個',
|
||||
'sftp.path.doubleClickToEdit': '雙擊編輯路徑',
|
||||
'sftp.showHiddenPaths': '隱藏的路徑',
|
||||
'sftp.task.waiting': '等待中...',
|
||||
'sftp.transfer.preparing': '準備中...',
|
||||
'sftp.status.loading': '載入中...',
|
||||
'sftp.status.uploading': '上傳中...',
|
||||
'sftp.status.ready': '就緒',
|
||||
'sftp.transfers': '傳輸',
|
||||
'sftp.transfers.active': '{count} 個進行中',
|
||||
'sftp.transfers.clearCompleted': '清除已完成',
|
||||
'sftp.transfers.calculatingTotal': '正在統計總大小...',
|
||||
'sftp.transfers.filesCount': '{count} 個檔案',
|
||||
'sftp.transfers.filesProgress': '{current}/{total} 個檔案',
|
||||
'sftp.transfers.expandChildren': '展開檔案',
|
||||
'sftp.transfers.collapseChildren': '收起檔案',
|
||||
'sftp.transfers.expandChildList': '展開詳情',
|
||||
'sftp.transfers.collapseChildList': '收起',
|
||||
'sftp.transfers.retryAction': '重試',
|
||||
'sftp.transfers.dismissAction': '移除',
|
||||
'sftp.transfers.openTargetFolder': '開啟目標目錄',
|
||||
'sftp.transfers.openTargetFolderError': '無法開啟目標目錄',
|
||||
'sftp.transfers.copyTargetPath': '複製目標路徑',
|
||||
'sftp.transfers.copyTargetPathSuccess': '已複製目標路徑',
|
||||
'sftp.transfers.copyTargetPathError': '無法複製目標路徑',
|
||||
'sftp.transfers.resizeNameColumn': '調整檔名列寬',
|
||||
'sftp.transfers.dragToResize': '拖拽調整高度',
|
||||
'sftp.goUp': '上一級',
|
||||
'sftp.goToTerminalCwd': '定位到終端目前目錄',
|
||||
'sftp.followTerminalCwd': '追隨終端目錄',
|
||||
'sftp.followTerminalCwd.enable': '開啟追隨終端目錄',
|
||||
'sftp.followTerminalCwd.disable': '關閉追隨終端目錄',
|
||||
'sftp.encoding.label': '檔名編碼',
|
||||
'sftp.encoding.auto': '自動',
|
||||
'sftp.encoding.utf8': 'UTF-8',
|
||||
'sftp.encoding.gb18030': 'GB18030',
|
||||
'sftp.goHome': '返回主目錄',
|
||||
'sftp.folderName': '資料夾名稱',
|
||||
'sftp.folderName.placeholder': '輸入資料夾名稱',
|
||||
'sftp.fileName': '檔名稱',
|
||||
'sftp.fileName.placeholder': '輸入檔名稱',
|
||||
'sftp.prompt.newFolderName': '新建資料夾名稱?',
|
||||
'sftp.rename.title': '重新命名',
|
||||
'sftp.rename.newName': '新名稱',
|
||||
'sftp.rename.placeholder': '輸入新名稱',
|
||||
'sftp.confirm.deleteOne': '刪除 "{name}"?',
|
||||
'sftp.deleteConfirm.single': '刪除 "{name}"?',
|
||||
'sftp.deleteConfirm.title': '刪除 {count} 個專案?',
|
||||
'sftp.deleteConfirm.desc': '此操作不可復原,將刪除以下內容:',
|
||||
'sftp.deleteConfirm.descSingle': '此操作不可復原。',
|
||||
'sftp.deleteConfirm.host': '主機',
|
||||
'sftp.deleteConfirm.path': '路徑',
|
||||
'sftp.error.loadFailed': '載入目錄失敗',
|
||||
'sftp.error.downloadFailed': '下載失敗',
|
||||
'sftp.error.uploadFailed': '上傳失敗',
|
||||
'sftp.error.deleteFailed': '刪除失敗',
|
||||
'sftp.error.createFolderFailed': '建立資料夾失敗',
|
||||
'sftp.error.createFileFailed': '建立檔案失敗',
|
||||
'sftp.error.invalidFileName': '檔名包含非法字元:{chars}',
|
||||
'sftp.error.reservedName': '此檔名是系統保留名稱',
|
||||
'sftp.overwrite.title': '檔案已存在',
|
||||
'sftp.overwrite.desc': '名為"{name}"的檔案已存在。是否要替換它?',
|
||||
'sftp.overwrite.confirm': '替換',
|
||||
'sftp.error.renameFailed': '重新命名失敗',
|
||||
'sftp.picker.title': '選擇主機',
|
||||
'sftp.picker.desc': '為{side}窗格選擇主機',
|
||||
'sftp.picker.searchPlaceholder': '搜尋主機...',
|
||||
'sftp.picker.local.title': '本機檔案系統',
|
||||
'sftp.picker.local.desc': '瀏覽本機檔案',
|
||||
'sftp.picker.local.badge': '本機',
|
||||
'sftp.picker.noMatch': '沒有相符的主機',
|
||||
'sftp.permissions.title': '編輯權限',
|
||||
'sftp.permissions.owner': '所有者',
|
||||
'sftp.permissions.group': '群組',
|
||||
'sftp.permissions.others': '其他',
|
||||
'sftp.permissions.octal': '八進位制',
|
||||
'sftp.permissions.symbolic': '符號',
|
||||
'sftp.permissions.success': '權限已更新',
|
||||
'sftp.permissions.failed': '權限更新失敗',
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': '搜尋主機或標籤頁',
|
||||
'qs.jumpTo': '跳轉到',
|
||||
'qs.localTerminal': '本機終端',
|
||||
'qs.localShells': '本機 Shell',
|
||||
'qs.default': '預設',
|
||||
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
export const zhTWScriptsMessages = {
|
||||
'scripts.meta.name': '名稱',
|
||||
'scripts.meta.language': '語言',
|
||||
'scripts.meta.description': '描述',
|
||||
'scripts.meta.descriptionPlaceholder': '可選,指令碼的簡要說明',
|
||||
'scripts.meta.trigger': '觸發方式',
|
||||
'scripts.meta.triggerPattern': '輸出比對(正規表示式)',
|
||||
'scripts.meta.code': '指令碼程式碼',
|
||||
'scripts.trigger.manual': '手動執行',
|
||||
'scripts.trigger.onConnect': '連線時執行',
|
||||
'scripts.trigger.onOutput': '輸出比對時執行',
|
||||
'scripts.trigger.onOutputHint': '僅在伺服器端輸出比對到正規表示式時觸發(會忽略本機按鍵回顯)。未設定目標主機時,對目前已連線的工作階段生效;設定目標後僅監聽對應主機。進入備用畫面的應用程式(如 vim、htop)期間不會觸發。本 session 有其他指令碼執行期間會抑制;指令碼結束後再檢查 buffer。流程內等待請用 nct.screen.waitFor。',
|
||||
'scripts.actions.save': '儲存',
|
||||
'scripts.actions.runNow': '立即執行',
|
||||
'scripts.actions.openEditor': '彈出視窗編輯',
|
||||
'scripts.actions.openEditorHint': '在更大的視窗中編輯指令碼',
|
||||
'scripts.editor.modalTitle': '指令碼編輯器',
|
||||
'scripts.editor.modalSubtitle': '在更大的工作區中編輯指令碼的中繼資料與程式碼。',
|
||||
'scripts.editor.lineCount': '{count} 行',
|
||||
'scripts.editor.resize': '調整編輯器高度',
|
||||
'scripts.targets.hint': '自動觸發會作用於所選主機;勾選「對所有主機生效」則作用於全部可連線的主機。',
|
||||
'scripts.targets.connectOrderHint': '連線指令碼的執行順序請在主機詳情 → 自動化 中調整。',
|
||||
'scripts.actions.runNowHint': '在所選目標主機上執行;勾選「對所有主機生效」時在所有可連線的主機上執行。',
|
||||
'scripts.actions.runParallel': '在所有標籤頁並行執行',
|
||||
'scripts.actions.runSequential': '在所有標籤頁依序執行',
|
||||
'scripts.actions.skippedConnectingSessions': '有 {count} 個標籤仍在連線中,已跳過',
|
||||
'scripts.actions.noRunnableHosts': '沒有與指令碼目標相符的可連線主機',
|
||||
'scripts.sidePanel.library': '指令碼庫',
|
||||
'scripts.sidePanel.running': '執行中',
|
||||
'scripts.sidePanel.newScript': '新建指令碼',
|
||||
'scripts.running.empty': '目前工作階段沒有正在執行的指令碼。',
|
||||
'scripts.running.unnamed': '未命名指令碼',
|
||||
'scripts.running.status.running': '執行中',
|
||||
'scripts.running.status.paused': '已暫停',
|
||||
'scripts.running.status.completed': '已完成',
|
||||
'scripts.running.status.failed': '失敗',
|
||||
'scripts.running.waitingFor': '等待 {pattern}',
|
||||
'scripts.running.waitingForLabel': '等待',
|
||||
'scripts.running.waitingForShellPrompt': 'Shell 提示字元(# 或 $)',
|
||||
'scripts.running.lastSent': '已傳送:{command}',
|
||||
'scripts.recording.start': '開始錄製',
|
||||
'scripts.recording.active': '停止錄製',
|
||||
'scripts.recording.startHint': '在目前終端工作階段中錄製操作,自動產生 nct 指令碼程式碼',
|
||||
'scripts.recording.unavailableHint': '此入口不支援錄製,請用終端右側的指令碼側欄',
|
||||
'scripts.recording.activeHint': '正在錄製目前終端。在終端中正常輸入指令;點「停止錄製」或終端工具列的 REC 結束並儲存。',
|
||||
'scripts.recording.started': '已開始錄製,請在終端中操作',
|
||||
'scripts.recording.noSession': '請先連線終端(單一標籤或工作區皆可)',
|
||||
'scripts.recording.alreadyActive': '已有其他終端正在錄製',
|
||||
'scripts.recording.stop': '停止錄製',
|
||||
'scripts.recording.pause': '暫停錄製',
|
||||
'scripts.recording.resume': '繼續錄製',
|
||||
'scripts.recording.saveTitle': '儲存錄製的指令碼',
|
||||
'scripts.recording.namePlaceholder': '指令碼名稱',
|
||||
'scripts.recording.packagePlaceholder': '儲存到資料夾',
|
||||
'scripts.recording.rootPackage': '根目錄',
|
||||
'scripts.recording.save': '儲存',
|
||||
'scripts.recording.saveAndEdit': '儲存並編輯',
|
||||
'scripts.recording.helpTitle': '如何錄製指令碼',
|
||||
'scripts.recording.helpIntro': '錄製會把你在終端裡的操作自動轉成可重複執行的自動化指令碼,適合部署、巡檢等重複性任務。',
|
||||
'scripts.recording.helpStep1': '連線主機。單一終端標籤或工作區裡的終端都可以,開啟右側的指令碼側欄即可。',
|
||||
'scripts.recording.helpStep2': '點選「開始錄製」。終端工具列出現紅色 REC 表示已開始。',
|
||||
'scripts.recording.helpStep3': '在終端裡像平常一樣輸入指令,按 Enter 執行。每次按 Enter 都會記為一步。',
|
||||
'scripts.recording.helpStep4': '全部操作完成後,點選「停止錄製」,或點終端工具列 REC 旁的停止按鈕。',
|
||||
'scripts.recording.helpStep5': '填寫指令碼名稱並儲存。需要微調時可選「儲存並編輯」,在指令碼編輯器裡修改產生的程式碼。',
|
||||
'scripts.recording.helpTipsTitle': '小提示',
|
||||
'scripts.recording.helpTip1': '兩次操作之間停頓超過 1 秒,會自動加入等待時間,避免回放太快。',
|
||||
'scripts.recording.helpTip2': '每條指令後會等待 shell 提示字元(如 $ 或 #)出現,再記錄下一步,減少亂序。',
|
||||
'scripts.recording.helpTip3': '密碼輸入會被標記為敏感內容,不會以明文寫入指令碼。',
|
||||
'scripts.recording.helpTip4': 'vim、互動式選單等複雜操作,建議先錄主幹指令,再在編輯器裡手動補充。',
|
||||
'scripts.dialog.title': '指令碼',
|
||||
'scripts.dialog.ok': '確定',
|
||||
'scripts.dialog.waitForTimeoutTitle': '等待逾時',
|
||||
'scripts.dialog.retry': '重試',
|
||||
'scripts.dialog.skip': '跳過',
|
||||
'scripts.dialog.abort': '終止',
|
||||
'scripts.running.stepProgress': '步驟 {current} / {total}',
|
||||
'scripts.running.determinateProgress': '{label} {current}/{total}',
|
||||
'scripts.running.progressFallback': '進度',
|
||||
'scripts.running.operationsCount': '已執行 {count} 個操作',
|
||||
'scripts.running.opsPrefix': '已執行 ',
|
||||
'scripts.running.opsSuffix': ' 個操作',
|
||||
'scripts.running.elapsedLabel': '耗時',
|
||||
'scripts.running.lastSentLabel': '已傳送:',
|
||||
'scripts.running.elapsed': '耗時 {elapsed}',
|
||||
'scripts.running.completedSummary': '已完成 · 共 {count} 個操作 · {elapsed}',
|
||||
'scripts.running.dismissHint': '點選關閉',
|
||||
'scripts.running.dismiss': '關閉',
|
||||
'scripts.running.viewLogs': '檢視日誌',
|
||||
'scripts.running.logTitle': '{name} · 執行日誌',
|
||||
'scripts.running.logEmpty': '暫無日誌輸出',
|
||||
'scripts.running.pause': '暫停',
|
||||
'scripts.running.resume': '繼續',
|
||||
'scripts.running.stop': '停止',
|
||||
'scripts.recording.saved': '指令碼已儲存',
|
||||
'scripts.recording.savedNamed': '已儲存「{name}」',
|
||||
'scripts.observer.blocked': 'Observer 模式禁止執行會寫入終端的指令碼。',
|
||||
'vault.section.scripts': '指令碼',
|
||||
'vault.nav.scripts': '指令碼',
|
||||
'snippets.action.newScript': '新建自動化指令碼',
|
||||
'hostDetails.section.automation': '自動化',
|
||||
'hostDetails.automation.loginScript': '登入指令碼',
|
||||
'hostDetails.automation.loginScriptPlaceholder': '選擇指令碼',
|
||||
'hostDetails.automation.none': '無',
|
||||
'hostDetails.automation.outputTriggers': '輸出觸發器',
|
||||
'hostDetails.automation.addTrigger': '新增觸發器',
|
||||
'hostDetails.automation.triggerPatternPlaceholder': '正規表示式',
|
||||
'hostDetails.automation.linkedScripts': '已關聯指令碼',
|
||||
'hostDetails.automation.linkedScriptsEmpty': '尚未關聯任何指令碼。',
|
||||
'hostDetails.automation.linkScriptPlaceholder': '關聯既有指令碼…',
|
||||
'hostDetails.automation.unlink': '取消關聯',
|
||||
'hostDetails.automation.equivalenceHint': '此處關聯會將本主機加入 Scripts 中該指令碼的目標列表,兩側檢視同步。',
|
||||
'hostDetails.automation.queueHint': '連線本主機時依序執行指令碼。全域指令碼最先執行,再執行下方佇列。',
|
||||
'hostDetails.automation.globalScripts': '全域連線指令碼',
|
||||
'hostDetails.automation.globalScriptsHint': '每次連線時最先執行。順序在指令碼庫排序中調整。',
|
||||
'hostDetails.automation.connectQueue': '本主機執行佇列',
|
||||
'hostDetails.automation.connectQueueEmpty': '尚未為本主機設定連線指令碼佇列。',
|
||||
'hostDetails.automation.addToQueuePlaceholder': '新增指令碼到佇列…',
|
||||
'hostDetails.automation.moveUp': '上移',
|
||||
'hostDetails.automation.moveDown': '下移',
|
||||
'hostDetails.automation.removeFromQueue': '從佇列移除',
|
||||
'hostDetails.automation.dragHandle': '拖曳排序',
|
||||
'hostDetails.automation.queueDragHint': '拖動項目即可調整連線時的執行順序。',
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhTwSystemManagerMessages: Messages = {
|
||||
'terminal.layer.system': '系統',
|
||||
|
||||
'systemManager.noSession': '沒有使用中的終端工作階段。',
|
||||
'systemManager.notConnected': '請先連線到主機以管理程序與服務。',
|
||||
'systemManager.empty': '暫無資料。',
|
||||
'systemManager.tabs.processes': '程序',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.popup.loading': '正在開啟終端…',
|
||||
'systemManager.popup.startupFailed': '啟動指令未順利完成。請確認目標仍可使用後再試一次。',
|
||||
|
||||
'systemManager.errors.loadProcesses': '載入程序列表失敗',
|
||||
'systemManager.errors.loadTmux': '載入 tmux 工作階段失敗',
|
||||
'systemManager.errors.loadTmuxWindows': '載入 tmux 視窗失敗',
|
||||
'systemManager.errors.loadTmuxPanes': '載入 tmux 窗格失敗',
|
||||
'systemManager.errors.loadTmuxClients': '載入 tmux 用戶端失敗',
|
||||
'systemManager.errors.actionFailed': '操作失敗',
|
||||
'systemManager.errors.loadDocker': '載入容器列表失敗',
|
||||
'systemManager.errors.loadDockerStats': '載入容器效能資料失敗',
|
||||
'systemManager.errors.loadDockerImages': '載入映像列表失敗',
|
||||
'systemManager.errors.sshChannelUnavailable': '伺服器拒絕開啟新的執行通道。請稍後重試,或重新連線目前主機。',
|
||||
|
||||
'systemManager.processes.search': '搜尋程序…',
|
||||
'systemManager.processes.command': '指令',
|
||||
'systemManager.processes.user': '使用者',
|
||||
'systemManager.processes.term': '終止',
|
||||
'systemManager.processes.kill': '強制終止',
|
||||
'systemManager.processes.stop': '暫停 (SIGSTOP)',
|
||||
'systemManager.processes.cont': '繼續 (SIGCONT)',
|
||||
'systemManager.processes.hup': '掛斷 (SIGHUP)',
|
||||
'systemManager.processes.renice': '調整優先順序',
|
||||
'systemManager.processes.renicePrompt': 'Nice 值 (-20 到 19)',
|
||||
'systemManager.processes.reniceInvalid': 'Nice 值必須在 -20 到 19 之間',
|
||||
'systemManager.processes.confirmKill': '向程序 {{pid}} 傳送 SIGKILL?',
|
||||
'systemManager.processes.confirmSignal': '向程序 {{pid}} 傳送 SIG{{signal}}?',
|
||||
'systemManager.processes.filter.all': '全部',
|
||||
'systemManager.processes.filter.running': '執行中',
|
||||
'systemManager.processes.ppid': '父程序 PID',
|
||||
'systemManager.processes.rss': '實體記憶體',
|
||||
'systemManager.processes.vsz': '虛擬記憶體',
|
||||
'systemManager.processes.elapsed': '執行時長',
|
||||
'systemManager.processes.stat': '狀態',
|
||||
'systemManager.processes.meta': '{{count}} 個程序',
|
||||
'systemManager.processes.loading': '正在載入程序…',
|
||||
'systemManager.processes.loadingMore': '正在載入更多程序…',
|
||||
'systemManager.processes.state.running': '執行中',
|
||||
'systemManager.processes.state.sleeping': '睡眠',
|
||||
'systemManager.processes.state.stopped': '已暫停',
|
||||
'systemManager.processes.state.zombie': '殭屍',
|
||||
'systemManager.processes.sort.cpu': 'CPU',
|
||||
'systemManager.processes.sort.mem': '記憶體',
|
||||
'systemManager.processes.sort.pid': 'PID',
|
||||
'systemManager.processes.sort.command': '指令',
|
||||
'systemManager.processes.sort.user': '使用者',
|
||||
|
||||
'systemManager.common.dismiss': '關閉',
|
||||
'systemManager.common.checkingAvailability': '正在檢查可用狀態…',
|
||||
'systemManager.common.loading': '正在載入…',
|
||||
'systemManager.common.loadingDetails': '正在載入詳情…',
|
||||
'systemManager.common.loadingStats': '正在載入效能資料…',
|
||||
|
||||
'systemManager.tmux.new': '新建',
|
||||
'systemManager.tmux.search': '搜尋工作階段…',
|
||||
'systemManager.tmux.newSessionTitle': '新建 tmux 工作階段',
|
||||
'systemManager.tmux.newSessionDesc': '為工作階段命名,並可選在啟動時執行指令碼。',
|
||||
'systemManager.tmux.newSessionTabCustom': '自訂指令',
|
||||
'systemManager.tmux.newSessionTabSnippet': '從程式碼片段',
|
||||
'systemManager.tmux.pickSnippet': '從程式碼片段選擇',
|
||||
'systemManager.tmux.pickSnippetEmpty': '暫無程式碼片段,可在指令碼側欄或倉庫中新增。',
|
||||
'systemManager.tmux.selectedSnippet': '已選片段:{{label}}',
|
||||
'systemManager.tmux.newSessionName': '工作階段名稱',
|
||||
'systemManager.tmux.newSessionCommand': '啟動指令',
|
||||
'systemManager.tmux.newSessionCommandPlaceholder': '例如 htop 或 npm run dev(可選)',
|
||||
'systemManager.tmux.newSessionCommandHint': '留空則建立預設 shell 工作階段。',
|
||||
'systemManager.tmux.creating': '建立中…',
|
||||
'systemManager.tmux.newSessionPlaceholder': 'my-session',
|
||||
'systemManager.tmux.newSessionRequired': '請先輸入工作階段名稱',
|
||||
'systemManager.tmux.empty': '沒有 tmux 工作階段',
|
||||
'systemManager.tmux.attach': '附加',
|
||||
'systemManager.tmux.attached': '已附加',
|
||||
'systemManager.tmux.detached': '未附加',
|
||||
'systemManager.tmux.windows': '{{count}} 個視窗',
|
||||
'systemManager.tmux.created': '建立時間',
|
||||
'systemManager.tmux.activity': '活動時間',
|
||||
'systemManager.tmux.rename': '重新命名',
|
||||
'systemManager.tmux.detach': '全部分離',
|
||||
'systemManager.tmux.killSession': '結束工作階段',
|
||||
'systemManager.tmux.killServer': '結束 tmux 伺服器',
|
||||
'systemManager.tmux.loadingDetails': '正在載入詳情…',
|
||||
'systemManager.tmux.clients': '已附加用戶端',
|
||||
'systemManager.tmux.windowList': '視窗',
|
||||
'systemManager.tmux.newWindow': '新建視窗',
|
||||
'systemManager.tmux.newWindowPlaceholder': '視窗名稱(可選)',
|
||||
'systemManager.tmux.noWindows': '沒有視窗',
|
||||
'systemManager.tmux.unavailable': '此主機未偵測到 tmux',
|
||||
'systemManager.docker.unavailable': '此主機未偵測到 Docker',
|
||||
'systemManager.tmux.windowsMismatch': '工作階段顯示有 {{count}} 個視窗,但 list-windows 未傳回任何視窗',
|
||||
'systemManager.tmux.lastCommand': '最後執行的指令:{{command}}',
|
||||
'systemManager.tmux.noPanes': '沒有窗格',
|
||||
'systemManager.tmux.panes': '{{count}} 個窗格',
|
||||
'systemManager.tmux.active': '使用中',
|
||||
'systemManager.tmux.unnamedWindow': '未命名視窗',
|
||||
'systemManager.tmux.unnamedPane': '未命名窗格',
|
||||
'systemManager.tmux.attachWindow': '附加到視窗',
|
||||
'systemManager.tmux.selectWindow': '選擇視窗',
|
||||
'systemManager.tmux.killWindow': '關閉視窗',
|
||||
'systemManager.tmux.killPane': '關閉窗格',
|
||||
'systemManager.tmux.splitHorizontal': '水平分割',
|
||||
'systemManager.tmux.splitVertical': '垂直分割',
|
||||
'systemManager.tmux.sendKeys': '傳送按鍵',
|
||||
'systemManager.tmux.sendKeysTo': '向視窗 {{window}} 窗格 {{pane}} 傳送按鍵',
|
||||
'systemManager.tmux.sendKeysPlaceholder': '指令或文字…',
|
||||
'systemManager.tmux.renameSessionPrompt': '重新命名工作階段',
|
||||
'systemManager.tmux.renameWindowPrompt': '重新命名視窗',
|
||||
'systemManager.tmux.windowName': '視窗名稱',
|
||||
'systemManager.tmux.confirmKillSession': '確定結束 tmux 工作階段「{{name}}」?',
|
||||
'systemManager.tmux.confirmDetachSession': '確定將所有用戶端從「{{name}}」分離?',
|
||||
'systemManager.tmux.confirmKillWindow': '確定關閉視窗「{{name}}」?',
|
||||
'systemManager.tmux.confirmKillPane': '確定關閉窗格 #{{index}}?',
|
||||
'systemManager.tmux.confirmKillServer': '確定結束 tmux 伺服器?所有工作階段將被終止。',
|
||||
'systemManager.tmux.meta': '{{count}} 個工作階段',
|
||||
|
||||
'systemManager.docker.title': '容器',
|
||||
'systemManager.docker.subTabs.containers': '容器',
|
||||
'systemManager.docker.subTabs.images': '映像',
|
||||
'systemManager.docker.empty': '未找到容器',
|
||||
'systemManager.docker.imagesEmpty': '未找到映像',
|
||||
'systemManager.docker.search': '搜尋容器…',
|
||||
'systemManager.docker.searchImages': '搜尋映像…',
|
||||
'systemManager.docker.filter.all': '全部',
|
||||
'systemManager.docker.filter.running': '執行中',
|
||||
'systemManager.docker.filter.stopped': '已停止',
|
||||
'systemManager.docker.filter.paused': '已暫停',
|
||||
'systemManager.docker.shell': 'Shell',
|
||||
'systemManager.docker.logs': '日誌',
|
||||
'systemManager.docker.details': '詳情',
|
||||
'systemManager.docker.inspect': 'Inspect',
|
||||
'systemManager.docker.imageInspect': '映像 Inspect',
|
||||
'systemManager.docker.confirmRemove': '確定刪除此容器?',
|
||||
'systemManager.docker.confirmKill': '確定強制終止此容器?',
|
||||
'systemManager.docker.confirmRemoveImage': '確定刪除映像「{{name}}」?',
|
||||
'systemManager.docker.confirmPrune': '確定清理懸空映像?',
|
||||
'systemManager.docker.confirmPruneAll': '確定清理所有未使用映像?',
|
||||
'systemManager.docker.pause': '暫停',
|
||||
'systemManager.docker.unpause': '恢復',
|
||||
'systemManager.docker.restart': '重新啟動',
|
||||
'systemManager.docker.kill': '強制終止',
|
||||
'systemManager.docker.renamePrompt': '容器名稱',
|
||||
'systemManager.docker.prune': '清理懸空',
|
||||
'systemManager.docker.pruneAll': '清理全部',
|
||||
'systemManager.docker.tag': '標記',
|
||||
'systemManager.docker.tagRepoPrompt': '儲存庫名稱',
|
||||
'systemManager.docker.tagNamePrompt': '標籤名稱',
|
||||
'systemManager.docker.meta': '{{count}} 個容器',
|
||||
'systemManager.docker.imagesMeta': '{{count}} 個映像',
|
||||
'systemManager.docker.start': '啟動',
|
||||
'systemManager.docker.stop': '停止',
|
||||
|
||||
'systemManager.inspect.status': '狀態',
|
||||
'systemManager.inspect.image': '映像',
|
||||
'systemManager.inspect.created': '建立時間',
|
||||
'systemManager.inspect.started': '啟動時間',
|
||||
'systemManager.inspect.restartPolicy': '重新啟動原則',
|
||||
'systemManager.inspect.command': '啟動指令',
|
||||
'systemManager.inspect.ports': '連接埠',
|
||||
'systemManager.inspect.networks': '網路',
|
||||
'systemManager.inspect.mounts': '掛載',
|
||||
'systemManager.inspect.env': '環境變數',
|
||||
'systemManager.inspect.labels': '標籤',
|
||||
'systemManager.inspect.tags': '映像標籤',
|
||||
'systemManager.inspect.digests': '摘要',
|
||||
'systemManager.inspect.size': '大小',
|
||||
'systemManager.inspect.platform': '平台',
|
||||
'systemManager.inspect.workdir': '工作目錄',
|
||||
'systemManager.inspect.exposedPorts': '對外開放的連接埠',
|
||||
'systemManager.inspect.showRaw': 'JSON',
|
||||
'systemManager.inspect.hideRaw': '收起 JSON',
|
||||
};
|
||||
@@ -0,0 +1,797 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhTWTerminalMessages: Messages = {
|
||||
'terminal.sudoHint.pressEnter': '按 Enter 貼上 sudo 密碼',
|
||||
'terminal.menu.rename': '重新命名',
|
||||
'terminal.menu.uploadClipboardImage': '上傳剪貼簿圖片',
|
||||
'terminal.clipboardImageUpload.noImage': '剪貼簿中沒有圖片',
|
||||
'terminal.clipboardImageUpload.failed': '無法上傳剪貼簿圖片',
|
||||
'terminal.toolbar.configureOsc7': '設定目錄追蹤',
|
||||
'terminal.toolbar.detach': '移出到獨立標籤',
|
||||
'terminal.menu.detach': '從工作區移出',
|
||||
'terminal.osc7Setup.title': '設定目錄追蹤',
|
||||
'terminal.osc7Setup.desc': 'Netcatty 會為目前遠端使用者新增 OSC 7 提示字元設定。這樣在 sudo 或 su 之後,SFTP 也能繼續跟隨終端目錄。',
|
||||
'terminal.osc7Setup.targets': '可能寫入的檔案',
|
||||
'terminal.osc7Setup.command': '將執行的指令',
|
||||
'terminal.osc7Setup.run': '執行設定',
|
||||
'terminal.osc7Setup.running': '正在設定...',
|
||||
'terminal.osc7Setup.configured': '目錄追蹤已設定',
|
||||
'terminal.osc7Setup.failed': '目錄追蹤設定失敗',
|
||||
'terminal.osc7Setup.sent': '目錄追蹤設定已傳送到終端',
|
||||
'terminal.toolbar.timestampsEnable': '顯示時間戳',
|
||||
'terminal.toolbar.timestampsDisable': '隱藏時間戳',
|
||||
'terminal.connection.protocol.et': 'EternalTerminal',
|
||||
'terminal.et.proxyUnsupported': 'EternalTerminal 目前不支援 Netcatty 的代理設定。請改用 SSH,或移除該主機的代理。',
|
||||
'terminal.et.multiJumpUnsupported': 'EternalTerminal 目前在 Netcatty 中最多支援一個跳板機。',
|
||||
// Command history side panel
|
||||
'history.scope.label': '歷史範圍',
|
||||
'history.tab.host': '主機',
|
||||
'history.tab.global': '全域',
|
||||
'history.searchPlaceholder': '搜尋歷史指令...',
|
||||
'history.loading': '正在讀取遠端歷史...',
|
||||
'history.meta.count': '{count} 條',
|
||||
'history.empty.noSession': '請先開啟一個遠端工作階段以檢視其指令歷史。',
|
||||
'history.empty.unsupportedProtocol': '僅 SSH/Mosh/ET 工作階段支援指令歷史。',
|
||||
'history.empty.noHistory': '該主機上未找到指令歷史。',
|
||||
'history.empty.noGlobalHistory': '暫無全域指令歷史。你執行的指令會記錄在這裡。',
|
||||
'history.action.refresh': '重新整理',
|
||||
'history.action.retry': '重試',
|
||||
'history.action.paste': '貼上到終端',
|
||||
'history.action.run': '在終端執行',
|
||||
'history.action.saveAsSnippet': '儲存為程式碼片段',
|
||||
// SFTP File Opener
|
||||
'sftp.context.copyPath': '複製檔案路徑',
|
||||
'sftp.context.openWith': '開啟方式...',
|
||||
'sftp.context.edit': '編輯',
|
||||
'sftp.context.preview': '預覽',
|
||||
'sftp.opener.title': '開啟方式',
|
||||
'sftp.opener.desc': '選擇一個應用程式來開啟此檔案',
|
||||
'sftp.opener.builtInEditor': '內建編輯器',
|
||||
'sftp.opener.editDescription': '編輯文字檔案',
|
||||
'sftp.opener.builtInImageViewer': '內建圖片預覽',
|
||||
'sftp.opener.previewDescription': '預覽圖片',
|
||||
'sftp.opener.systemApp': '選擇應用程式...',
|
||||
'sftp.opener.systemAppDescription': '從本機選擇一個應用程式',
|
||||
'sftp.opener.onlySystemApp': '此檔案只能用外部應用程式開啟',
|
||||
'sftp.opener.noAppsAvailable': '無可用應用程式',
|
||||
'sftp.opener.noExtension': '無副檔名檔案',
|
||||
'sftp.opener.setDefault': '始終使用此方式開啟 {ext} 檔案',
|
||||
'sftp.opener.confirmTitle': '設為預設?',
|
||||
'sftp.opener.confirmDescription': '是否始終使用 {app} 開啟 {ext} 檔案?',
|
||||
'sftp.opener.yesRemember': '是,記住此選擇',
|
||||
'sftp.opener.justOnce': '僅此一次',
|
||||
'sftp.opener.confirm.title': '設定預設應用程式',
|
||||
'sftp.opener.confirm.desc': '是否始終使用此應用程式開啟 .{ext} 檔案?',
|
||||
'sftp.editor.title': '文字編輯器',
|
||||
'sftp.editor.save': '儲存到遠端',
|
||||
'sftp.editor.saving': '儲存中...',
|
||||
'sftp.editor.saved': '儲存成功',
|
||||
'sftp.editor.saveFailed': '儲存檔案失敗',
|
||||
'sftp.editor.unsavedChanges': '你有未儲存的更改。確定要關閉嗎?',
|
||||
'sftp.editor.syntaxHighlight': '語法標示',
|
||||
'sftp.preview.title': '圖片預覽',
|
||||
'sftp.preview.zoomIn': '放大',
|
||||
'sftp.preview.zoomOut': '縮小',
|
||||
'sftp.preview.resetZoom': '重置縮放',
|
||||
'sftp.preview.fitToWindow': '適應視窗',
|
||||
|
||||
// Settings > SFTP File Associations
|
||||
'settings.tab.sftpFileAssociations': 'SFTP',
|
||||
'settings.sftp.transferConcurrency': '傳輸併發數',
|
||||
'settings.sftp.transferConcurrency.desc': '上傳或下載資料夾時並行傳輸的檔案數量。較高的值可能提高速度,但可能導致某些伺服器過載。',
|
||||
'settings.sftp.defaultOpener': '預設檔案開啟方式',
|
||||
'settings.sftp.defaultOpener.desc': '選擇沒有特定檔案關聯時的預設開啟方式',
|
||||
'settings.sftp.defaultOpener.ask': '每次詢問',
|
||||
'settings.sftp.defaultOpener.askDesc': '每次開啟檔案時彈出選擇對話方塊',
|
||||
'settings.sftp.defaultOpener.builtInDesc': '預設使用內建編輯器開啟文字檔案',
|
||||
'settings.sftp.defaultOpener.systemApp': '選擇應用程式...',
|
||||
'settings.sftp.defaultOpener.systemAppDesc': '預設使用指定的外部應用程式開啟檔案',
|
||||
'settings.sftpFileAssociations.title': 'SFTP 檔案關聯',
|
||||
'settings.sftpFileAssociations.desc': '設定按副檔名開啟檔案的預設應用程式',
|
||||
'settings.sftpFileAssociations.extension': '副檔名',
|
||||
'settings.sftpFileAssociations.application': '應用程式',
|
||||
'settings.sftpFileAssociations.noAssociations': '未設定檔案關聯',
|
||||
'settings.sftpFileAssociations.remove': '移除',
|
||||
'settings.sftpFileAssociations.removeConfirm': '確定移除 .{ext} 的關聯嗎?',
|
||||
|
||||
// Settings > SFTP Behavior
|
||||
'settings.sftp.doubleClickBehavior': '雙擊行為',
|
||||
'settings.sftp.doubleClickBehavior.desc': '選擇在 SFTP 檢視中雙擊檔案時的操作',
|
||||
'settings.sftp.doubleClickBehavior.open': '開啟檔案',
|
||||
'settings.sftp.doubleClickBehavior.transfer': '傳輸到另一側',
|
||||
'settings.sftp.doubleClickBehavior.openDesc': '使用預設應用程式開啟檔案',
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': '將檔案傳輸到另一窗格的使用中主機',
|
||||
'settings.sshDeepLink.title': 'SSH 連結',
|
||||
'settings.sshDeepLink.enable': '用 Netcatty 開啟 ssh:// 連結',
|
||||
'settings.sshDeepLink.enableDesc': '允許 Netcatty 接管來自瀏覽器和其他應用程式的 ssh:// 連結。',
|
||||
|
||||
// Settings > SFTP Auto Sync
|
||||
'settings.sftp.autoSync': '自動同步到遠端',
|
||||
'settings.sftp.autoSync.desc': '使用外部應用程式開啟檔案時,自動將檔案更改同步回遠端伺服器',
|
||||
'settings.sftp.autoSync.enable': '啟用自動同步',
|
||||
'settings.sftp.autoSync.enableDesc': '在外部應用程式中儲存檔案時,更改將自動上傳到遠端伺服器',
|
||||
|
||||
// Settings > SFTP 自動開啟側欄
|
||||
'settings.sftp.autoOpenSidebar': '連線時自動開啟側欄',
|
||||
'settings.sftp.autoOpenSidebar.desc': '連線到主機時自動開啟 SFTP 檔案瀏覽器側欄',
|
||||
'settings.sftp.autoOpenSidebar.enable': '啟用自動開啟側欄',
|
||||
'settings.sftp.autoOpenSidebar.enableDesc': '當終端工作階段連線到遠端主機時,SFTP 側欄將自動開啟',
|
||||
|
||||
'settings.sftp.followTerminalCwd': '追隨終端目錄',
|
||||
'settings.sftp.followTerminalCwd.desc': '在側欄 SFTP 中自動跟隨終端目前工作目錄變化(可在工具欄切換)',
|
||||
'settings.sftp.followTerminalCwd.enable': '預設開啟追隨終端目錄',
|
||||
'settings.sftp.followTerminalCwd.enableDesc': '開啟側欄 SFTP 時預設啟用追隨模式,終端執行 cd 後檔案瀏覽器會自動跳轉',
|
||||
|
||||
'settings.sftp.defaultViewMode': '預設檢視模式',
|
||||
'settings.sftp.defaultViewMode.desc': '選擇開啟新 SFTP 標籤頁時的預設檢視模式。每個主機的偏好設定會覆蓋此全域設定。',
|
||||
'settings.sftp.defaultViewMode.list': '列表檢視',
|
||||
'settings.sftp.defaultViewMode.listDesc': '以平面列表顯示目前目錄的檔案',
|
||||
'settings.sftp.defaultViewMode.tree': '樹形檢視',
|
||||
'settings.sftp.defaultViewMode.treeDesc': '以層級樹形結構顯示檔案',
|
||||
|
||||
'sftp.autoSync.success': '檔案已同步到遠端:{fileName}',
|
||||
'sftp.autoSync.error': '同步檔案失敗:{error}',
|
||||
|
||||
// SFTP Folder Upload Progress
|
||||
'sftp.upload.progress': '正在上傳 {current}/{total} 個檔案...',
|
||||
'sftp.upload.uploading': '正在上傳...',
|
||||
'sftp.upload.compressing': '正在壓縮...',
|
||||
'sftp.upload.extracting': '正在解壓...',
|
||||
'sftp.upload.scanning': '正在掃描檔案...',
|
||||
'sftp.upload.completed': '已完成',
|
||||
'sftp.upload.compressed': '壓縮傳輸',
|
||||
'sftp.upload.currentFile': '目前: {fileName}',
|
||||
'sftp.upload.cancelled': '上傳已取消',
|
||||
'sftp.upload.cancel': '取消',
|
||||
'sftp.upload.completedToPath': '已上傳至 {path}',
|
||||
|
||||
// SFTP Download
|
||||
'sftp.download.completed': '已下載',
|
||||
'sftp.download.cancelled': '下載已取消',
|
||||
|
||||
// SFTP Reconnecting
|
||||
'sftp.reconnecting.title': '正在重連...',
|
||||
'sftp.reconnecting.desc': '連線已斷開,正在嘗試重新連線',
|
||||
'sftp.reconnected': '連線已恢復',
|
||||
'sftp.error.reconnectFailed': '重連失敗,請重試。',
|
||||
'sftp.error.connectionLostManual': '連線已斷開,請手動重新連線。',
|
||||
'sftp.error.connectionLostReconnecting': '連線已斷開,正在重連...',
|
||||
'sftp.error.sessionLost': 'SFTP 工作階段已斷開,請重新連線。',
|
||||
|
||||
// Settings > SFTP Show Hidden Files
|
||||
'settings.sftp.showHiddenFiles': '顯示隱藏檔案',
|
||||
'settings.sftp.showHiddenFiles.desc': '在 SFTP 檔案瀏覽器中顯示隱藏檔案(Unix/macOS 點檔案和 Windows 隱藏屬性檔案)。',
|
||||
'settings.sftp.showHiddenFiles.enable': '顯示隱藏檔案',
|
||||
'settings.sftp.showHiddenFiles.enableDesc': '瀏覽本機和遠端檔案系統時顯示隱藏檔案',
|
||||
|
||||
// Settings > SFTP Compressed Upload
|
||||
'settings.sftp.compressedUpload': '資料夾壓縮傳輸',
|
||||
'settings.sftp.compressedUpload.desc': '上傳前壓縮資料夾,可大幅減少傳輸時間。',
|
||||
'settings.sftp.compressedUpload.enable': '啟用資料夾壓縮',
|
||||
'settings.sftp.compressedUpload.enableDesc': '自動使用 tar 壓縮資料夾後再傳輸。需要伺服器支援 tar 指令,不支援時自動回退到普通傳輸。',
|
||||
|
||||
// Settings > Terminal
|
||||
'settings.terminal.section.theme': '終端主題',
|
||||
'settings.terminal.themeModal.title': '選擇主題',
|
||||
'settings.terminal.themeModal.darkThemes': '深色主題',
|
||||
'settings.terminal.themeModal.lightThemes': '淺色主題',
|
||||
'settings.terminal.theme.selectButton': '選擇主題',
|
||||
'settings.terminal.theme.followApp': '跟隨應用程式主題',
|
||||
'settings.terminal.theme.followApp.desc': '終端背景色自動配合目前應用程式主題,保持視覺一致性。',
|
||||
'settings.terminal.theme.darkTheme': '深色模式終端主題',
|
||||
'settings.terminal.theme.lightTheme': '淺色模式終端主題',
|
||||
'settings.terminal.theme.auto': '自動(跟隨介面主題)',
|
||||
'settings.terminal.theme.autoDesc': '跟隨目前介面主題預設',
|
||||
'settings.terminal.section.font': '字型',
|
||||
'settings.terminal.section.cursor': '游標',
|
||||
'settings.terminal.section.keyboard': '鍵盤',
|
||||
'settings.terminal.section.accessibility': '無障礙',
|
||||
'settings.terminal.section.behavior': '行為',
|
||||
'settings.terminal.section.scrollback': '捲動緩衝區',
|
||||
'settings.terminal.section.keywordHighlight': '關鍵字標示',
|
||||
'settings.terminal.font.family': '字型',
|
||||
'settings.terminal.font.family.desc': '終端字型',
|
||||
'settings.terminal.font.cjk': '中文 / CJK 字型',
|
||||
'settings.terminal.font.cjk.desc': '用於渲染中 / 日 / 韓字元的字型;"Auto" 會按主字型智慧搭配',
|
||||
'settings.terminal.font.cjk.option.auto': 'Auto · 按主字型智慧搭配',
|
||||
'settings.terminal.font.cjk.option.sarasaSC': 'Sarasa Mono SC (更紗黑體 簡)',
|
||||
'settings.terminal.font.cjk.option.sarasaTC': 'Sarasa Mono TC (更紗黑體 繁)',
|
||||
'settings.terminal.font.cjk.option.mapleCN': 'Maple Mono CN',
|
||||
'settings.terminal.font.cjk.option.sourceHan': 'Source Han Mono SC (思源等寬)',
|
||||
'settings.terminal.font.cjk.option.notoCJK': 'Noto Sans Mono CJK SC',
|
||||
'settings.terminal.font.cjk.option.lxgwWenkai': 'LXGW WenKai Mono (霞鶩文楷等寬)',
|
||||
'settings.terminal.font.cjk.option.simSun': 'SimSun (宋體)',
|
||||
'settings.terminal.font.cjk.option.legacy': '{font} · 不推薦(非等寬字型)',
|
||||
'settings.terminal.font.size': '字型大小',
|
||||
'settings.terminal.font.size.desc': '終端文字大小',
|
||||
'settings.terminal.font.weight': '字重',
|
||||
'settings.terminal.font.weight.desc': '常規文字字重 (100-900)',
|
||||
'settings.terminal.font.weight.thin': '極細',
|
||||
'settings.terminal.font.weight.extraLight': '特細',
|
||||
'settings.terminal.font.weight.light': '細',
|
||||
'settings.terminal.font.weight.normal': '常規',
|
||||
'settings.terminal.font.weight.medium': '中等',
|
||||
'settings.terminal.font.weight.semiBold': '半粗',
|
||||
'settings.terminal.font.weight.bold': '粗',
|
||||
'settings.terminal.font.weight.extraBold': '特粗',
|
||||
'settings.terminal.font.weight.black': '黑體',
|
||||
'settings.terminal.font.weightBold': '粗體字重',
|
||||
'settings.terminal.font.weightBold.desc': '粗體文字字重 (100-900)',
|
||||
'settings.terminal.font.smoothing': '字型平滑',
|
||||
'settings.terminal.font.smoothing.desc': '使用 macOS 原生字型抗鋸齒',
|
||||
'settings.terminal.font.linePadding': '行間距',
|
||||
'settings.terminal.font.linePadding.desc': '行之間的額外間距 (0-10)',
|
||||
'settings.terminal.font.emulationType': '終端模擬型別',
|
||||
'settings.terminal.cursor.style': '游標樣式',
|
||||
'settings.terminal.cursor.style.block': '塊',
|
||||
'settings.terminal.cursor.style.bar': '豎線',
|
||||
'settings.terminal.cursor.style.underline': '下劃線',
|
||||
'settings.terminal.cursor.blink': '游標閃爍',
|
||||
'settings.terminal.keyboard.altAsMeta': '將 Option 作為 Meta 鍵',
|
||||
'settings.terminal.keyboard.altAsMeta.desc': '使用 Option (Alt) 作為 Meta 鍵,而不是用於輸入特殊字元',
|
||||
'settings.terminal.keyboard.optionArrowWordJump': 'Option+←/→ 按單字跳轉',
|
||||
'settings.terminal.keyboard.optionArrowWordJump.desc': '按 Option+左/右 時傳送 Meta-b / Meta-f,讓 Shell 按單字移動游標(而非預設的 ^[[1;3D / ^[[1;3C)',
|
||||
'settings.terminal.accessibility.minimumContrastRatio': '最小對比度',
|
||||
'settings.terminal.accessibility.minimumContrastRatio.desc': '調整顏色以滿足對比度要求 (1 = 停用, 21 = 最大)',
|
||||
'settings.terminal.behavior.rightClick': '右鍵行為',
|
||||
'settings.terminal.behavior.rightClick.desc': '在終端中右鍵時執行的操作',
|
||||
'settings.terminal.behavior.rightClick.menu': '顯示選單',
|
||||
'settings.terminal.behavior.rightClick.paste': '貼上',
|
||||
'settings.terminal.behavior.rightClick.selectWord': '選擇單字',
|
||||
'settings.terminal.behavior.copyOnSelect': '選擇即複製',
|
||||
'settings.terminal.behavior.copyOnSelect.desc': '自動複製選取的文字。在 tmux/vim 滑鼠模式下,macOS 按住 Option,Windows/Linux 按住 Shift 拖選即可選取文字',
|
||||
'settings.terminal.behavior.middleClickPaste': '中鍵貼上',
|
||||
'settings.terminal.behavior.middleClickPaste.desc': '中鍵點選時貼上剪貼簿內容',
|
||||
'settings.terminal.behavior.middleClick': '中鍵行為',
|
||||
'settings.terminal.behavior.middleClick.desc': '在終端中點選滑鼠中鍵時執行的操作',
|
||||
'settings.terminal.behavior.middleClick.menu': '顯示選單',
|
||||
'settings.terminal.behavior.middleClick.paste': '貼上',
|
||||
'settings.terminal.behavior.middleClick.disabled': '無動作',
|
||||
'settings.terminal.behavior.bracketedPaste': '括號貼上模式',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'貼上文字時使用轉義序列包裹,以便終端區分貼上和鍵入。如果出現 ^[[200~ 字樣請關閉此選項。',
|
||||
'settings.terminal.behavior.clearWipesScrollback': '`clear` 同時清空捲動緩衝',
|
||||
'settings.terminal.behavior.clearWipesScrollback.desc':
|
||||
'`clear` 指令同時清空捲動緩衝(POSIX 預設行為)。關閉則保留歷史。',
|
||||
'settings.terminal.behavior.preserveSelectionOnInput': '輸入時保留選區',
|
||||
'settings.terminal.behavior.preserveSelectionOnInput.desc':
|
||||
'鍵盤輸入時不清除滑鼠選取的文字,方便選取路徑後輸入 `sz ` 之類指令再貼上。',
|
||||
'settings.terminal.behavior.forcePromptNewLine': '提示字元另起一行',
|
||||
'settings.terminal.behavior.forcePromptNewLine.desc':
|
||||
'當指令輸出的最後一行未以換行符結束時,將識別到的 shell 提示字元移動到下一行顯示。',
|
||||
'settings.terminal.behavior.dynamicTabTitle': '動態標籤頁標題',
|
||||
'settings.terminal.behavior.dynamicTabTitle.desc':
|
||||
'選擇標籤頁什麼時候跟隨 Shell 報告的視窗標題。',
|
||||
'settings.terminal.behavior.dynamicTabTitle.off': '關閉',
|
||||
'settings.terminal.behavior.dynamicTabTitle.agent': '僅 Agent',
|
||||
'settings.terminal.behavior.dynamicTabTitle.all': '全部工作階段',
|
||||
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 剪貼簿',
|
||||
'settings.terminal.behavior.osc52Clipboard.desc':
|
||||
'允許遠端程式(tmux、vim 等)透過 OSC-52 轉義序列存取本機剪貼簿。',
|
||||
'settings.terminal.behavior.osc52Clipboard.off': '關閉',
|
||||
'settings.terminal.behavior.osc52Clipboard.writeOnly': '僅寫入',
|
||||
'settings.terminal.behavior.osc52Clipboard.readWrite': '讀寫',
|
||||
'settings.terminal.behavior.osc52Clipboard.prompt': '寫入 + 讀取時詢問',
|
||||
'terminal.osc52.readPrompt.title': '剪貼簿讀取請求',
|
||||
'terminal.osc52.readPrompt.desc': '遠端程式正在請求讀取你的剪貼簿,是否允許?',
|
||||
'terminal.osc52.readPrompt.allow': '允許',
|
||||
'terminal.osc52.readPrompt.deny': '拒絕',
|
||||
'settings.terminal.behavior.scrollOnInput': '輸入時自動捲動',
|
||||
'settings.terminal.behavior.scrollOnInput.desc': '輸入時將終端捲動到底部',
|
||||
'settings.terminal.behavior.scrollOnOutput': '輸出時自動捲動',
|
||||
'settings.terminal.behavior.scrollOnOutput.desc': '有新輸出時將終端捲動到底部',
|
||||
'settings.terminal.behavior.scrollOnKeyPress': '按鍵時自動捲動',
|
||||
'settings.terminal.behavior.scrollOnKeyPress.desc': '按鍵(例如 Enter)時將終端捲動到底部',
|
||||
'settings.terminal.behavior.scrollOnPaste': '貼上時自動捲動',
|
||||
'settings.terminal.behavior.scrollOnPaste.desc': '貼上文字時將終端捲動到底部',
|
||||
'settings.terminal.behavior.smoothScrolling': '平滑捲動',
|
||||
'settings.terminal.behavior.smoothScrolling.desc': '捲動終端可視區域時使用平滑動畫',
|
||||
'settings.terminal.behavior.linkModifier': '連結修飾鍵',
|
||||
'settings.terminal.behavior.linkModifier.desc': '按住此鍵再點選終端中的連結',
|
||||
'settings.terminal.behavior.linkModifier.none': '無(直接點選)',
|
||||
'settings.terminal.behavior.linkModifier.ctrl': 'Ctrl',
|
||||
'settings.terminal.behavior.linkModifier.alt': 'Alt / Option',
|
||||
'settings.terminal.behavior.linkModifier.meta': 'Cmd / Win',
|
||||
'settings.terminal.scrollback.desc': '限制終端行數。設為 0 表示不限制。',
|
||||
'settings.terminal.scrollback.rows': '行數 *',
|
||||
'settings.terminal.section.startupCommand': '啟動指令',
|
||||
'settings.terminal.startupCommandDelay.label': '啟動指令延遲(毫秒)',
|
||||
'settings.terminal.startupCommandDelay.desc': '連線建立後等待多久再發送啟動指令;啟動指令為多行時,行與行之間也使用該間隔。慢連線可調大。',
|
||||
'settings.terminal.keywordHighlight.title': '關鍵字標示',
|
||||
'settings.terminal.keywordHighlight.resetColors': '重置為預設顏色',
|
||||
'settings.terminal.keywordHighlight.resetDefaults': '把內建規則恢復為預設',
|
||||
'settings.terminal.keywordHighlight.resetBuiltIn': '恢復內建標籤與正規表示式',
|
||||
'settings.terminal.keywordHighlight.addCustom': '新增自訂規則',
|
||||
'settings.terminal.keywordHighlight.editCustom': '編輯規則',
|
||||
'settings.terminal.keywordHighlight.editBuiltIn': '編輯內建規則',
|
||||
'settings.terminal.keywordHighlight.labelField': '標籤與顏色',
|
||||
'settings.terminal.keywordHighlight.labelPlaceholder': '標籤(如 Down)',
|
||||
'settings.terminal.keywordHighlight.patternField': '正規表示式',
|
||||
'settings.terminal.keywordHighlight.patternPlaceholder': '每行一個正規表示式(如 \\bdown\\b)',
|
||||
'settings.terminal.keywordHighlight.patternHint': '每行一個正規表示式。比對時忽略大小寫,全域比對。',
|
||||
'settings.terminal.keywordHighlight.invalidPattern': '無效的正規表示式',
|
||||
'settings.terminal.keywordHighlight.preview': '預覽',
|
||||
'settings.terminal.section.localShell': '本機 Shell',
|
||||
'settings.terminal.localShell.shell': 'Shell 執行檔',
|
||||
'settings.terminal.localShell.shell.desc': 'Shell 執行檔的路徑(例如 /bin/zsh、pwsh.exe)。留空使用系統預設。',
|
||||
'settings.terminal.localShell.shell.placeholder': '系統預設',
|
||||
'settings.terminal.localShell.shell.detected': '偵測到',
|
||||
'settings.terminal.localShell.shell.notFound': '未找到 Shell 執行檔',
|
||||
'settings.terminal.localShell.shell.isDirectory': '路徑是目錄,不是執行檔',
|
||||
'settings.terminal.localShell.shell.default': '系統預設',
|
||||
'settings.terminal.localShell.shell.custom': '自訂...',
|
||||
'settings.terminal.localShell.shell.customPath': 'Shell 執行檔路徑',
|
||||
'settings.terminal.localShell.shell.customArgs': '啟動引數',
|
||||
'settings.terminal.localShell.shell.customArgs.placeholder': '例如 --login -i',
|
||||
'settings.terminal.localShell.shell.customArgs.desc': '傳給 Shell 的啟動引數。部分 Shell 必須指定才能正常工作,例如 msys2 bash 需要 --login -i 才能載入環境變數。',
|
||||
'settings.terminal.localShell.shell.commonPaths': '常用路徑',
|
||||
'settings.terminal.localShell.shell.pathValid': '路徑有效',
|
||||
'settings.terminal.localShell.startDir': '起始目錄',
|
||||
'settings.terminal.localShell.startDir.desc': '開啟本機終端時的起始目錄。留空使用使用者主目錄。',
|
||||
'settings.terminal.localShell.startDir.placeholder': '使用者主目錄',
|
||||
'settings.terminal.localShell.startDir.notFound': '目錄不存在',
|
||||
'settings.terminal.localShell.startDir.isFile': '路徑是檔案,不是目錄',
|
||||
'settings.terminal.section.connection': '連線',
|
||||
'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.x11Display.placeholder': '自動(:0 或 DISPLAY)',
|
||||
'settings.terminal.section.serverStats': '伺服器狀態(Linux)',
|
||||
'settings.terminal.serverStats.show': '顯示伺服器狀態',
|
||||
'settings.terminal.serverStats.show.desc': '在終端狀態列顯示 CPU、記憶體和磁碟使用情況(僅限 Linux 伺服器)。',
|
||||
'settings.terminal.serverStats.refreshInterval': '重新整理間隔',
|
||||
'settings.terminal.serverStats.refreshInterval.desc': '伺服器狀態重新整理的頻率。',
|
||||
'settings.terminal.serverStats.seconds': '秒',
|
||||
'settings.terminal.section.systemManager': '系統管理',
|
||||
'settings.terminal.systemManager.processRefreshInterval': '程序列表重新整理間隔',
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': '系統管理側欄中程序列表的重新整理頻率。',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux 工作階段重新整理間隔',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'tmux 工作階段列表的重新整理頻率。',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker 容器列表重新整理間隔',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Docker 容器列表的重新整理頻率。',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker 效能資料重新整理間隔',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Docker 容器 CPU/記憶體/網路指標的重新整理頻率。',
|
||||
|
||||
// Settings > Terminal > Rendering
|
||||
'settings.terminal.section.rendering': '渲染',
|
||||
'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.hibernateHiddenTabsDelay': '休眠延遲',
|
||||
'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.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 > Workspace Focus Indicator
|
||||
'settings.terminal.section.workspaceFocus': '工作區焦點提示',
|
||||
'settings.terminal.workspaceFocus.style': '焦點提示樣式',
|
||||
'settings.terminal.workspaceFocus.style.desc': '在分割檢視中如何標識目前聚焦的窗格。',
|
||||
'settings.terminal.workspaceFocus.dim': '淡化未聚焦窗格',
|
||||
'settings.terminal.workspaceFocus.border': '為聚焦窗格顯示邊框',
|
||||
|
||||
// Settings > Terminal > Autocomplete
|
||||
'settings.terminal.section.autocomplete': '自動補全',
|
||||
'settings.terminal.autocomplete.enabled': '啟用自動補全',
|
||||
'settings.terminal.autocomplete.enabled.desc': '輸入時根據歷史指令和指令規範顯示補全建議。',
|
||||
'settings.terminal.autocomplete.ghostText': '行內建議',
|
||||
'settings.terminal.autocomplete.ghostText.desc': '在游標後顯示灰色的建議文字(類似 fish shell)。',
|
||||
'settings.terminal.autocomplete.popupMenu': '彈出選單',
|
||||
'settings.terminal.autocomplete.popupMenu.desc': '顯示包含多個建議的浮動列表。',
|
||||
|
||||
// Settings > Shortcuts
|
||||
'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.section.custom': '自訂快捷鍵',
|
||||
'settings.shortcuts.resetAll': '全部重置',
|
||||
'settings.shortcuts.recording': '請按鍵...',
|
||||
'settings.shortcuts.none': '無',
|
||||
'settings.shortcuts.setDisabled': '設為停用',
|
||||
'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.close-session': '關閉工作階段窗格',
|
||||
'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.move-focus': '在分割間移動焦點',
|
||||
'settings.shortcuts.binding.split-horizontal': '水平分割',
|
||||
'settings.shortcuts.binding.split-vertical': '垂直分割',
|
||||
'settings.shortcuts.binding.toggle-pane-zoom': '切換窗格縮放',
|
||||
'settings.shortcuts.binding.open-hosts': '開啟主機列表',
|
||||
'settings.shortcuts.binding.open-local': '開啟本機終端',
|
||||
'settings.shortcuts.binding.open-sftp': '開啟 SFTP',
|
||||
'settings.shortcuts.binding.open-settings': '開啟設定',
|
||||
'settings.shortcuts.binding.port-forwarding': '開啟埠轉發',
|
||||
'settings.shortcuts.binding.command-palette': '開啟指令面板',
|
||||
'settings.shortcuts.binding.quick-switch': '快速切換',
|
||||
'settings.shortcuts.binding.new-workspace': '新建工作區',
|
||||
'settings.shortcuts.binding.snippets': '開啟程式碼片段',
|
||||
'settings.shortcuts.binding.broadcast': '切換廣播模式',
|
||||
'settings.shortcuts.binding.toggle-side-panel': '切換側邊欄',
|
||||
'settings.shortcuts.binding.sftp-copy': '複製檔案',
|
||||
'settings.shortcuts.binding.sftp-cut': '剪下檔案',
|
||||
'settings.shortcuts.binding.sftp-paste': '貼上檔案',
|
||||
'settings.shortcuts.binding.sftp-select-all': '全選檔案',
|
||||
'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-open': '開啟檔案 / 進入目錄',
|
||||
'settings.shortcuts.binding.sftp-go-parent': '轉到上級目錄',
|
||||
'settings.shortcuts.binding.sftp-navigate-to': '轉到選取的目錄',
|
||||
|
||||
// Host Details (sub-panels)
|
||||
'hostDetails.proxyPanel.title': '透過 HTTP/SOCKS5/指令代理',
|
||||
'hostDetails.proxyPanel.hostPlaceholder': '代理主機',
|
||||
'hostDetails.proxyPanel.command': 'ProxyCommand',
|
||||
'hostDetails.proxyPanel.commandPlaceholder': 'cloudflared access ssh --hostname %h',
|
||||
'hostDetails.proxyPanel.commandHelp': '使用 %h 表示目標主機,%p 表示目標埠,%% 表示字面百分號。',
|
||||
'hostDetails.proxyPanel.credentials': '憑證',
|
||||
'hostDetails.proxyPanel.usernamePlaceholder': '使用者名稱',
|
||||
'hostDetails.proxyPanel.passwordPlaceholder': '密碼',
|
||||
'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.missingSaved': '儲存的代理不存在',
|
||||
'hostDetails.proxyPanel.missingIdentity': '鑰匙串身份不存在',
|
||||
'hostDetails.proxyPanel.incompleteIdentity': '代理身份需要使用者名稱和密碼',
|
||||
'hostDetails.proxyPanel.unreadableIdentity': '此裝置無法讀取代理身份密碼',
|
||||
'hostDetails.proxyPanel.error.required': '代理主機和埠,或 ProxyCommand 不能為空。',
|
||||
'hostDetails.envVars.title': '環境變數',
|
||||
'hostDetails.envVars.desc': '為 {host} 設定環境變數。',
|
||||
'hostDetails.envVars.note': '部分 SSH 伺服器預設只允許以 LC_ 和 LANG_ 為字首的變數。',
|
||||
'hostDetails.envVars.variable': '變數',
|
||||
'hostDetails.envVars.value': '值',
|
||||
'hostDetails.envVars.newVariable': '新變數',
|
||||
'hostDetails.envVars.variableName': '變數名',
|
||||
'hostDetails.chain.title': '編輯鏈路',
|
||||
'hostDetails.chain.desc': '新增另一台主機將建立到 {host} 的連線。',
|
||||
'hostDetails.chain.addHost': '新增主機',
|
||||
'hostDetails.chain.target': '目標',
|
||||
'hostDetails.chain.availableHosts': '可用主機',
|
||||
'hostDetails.chain.clear': '清空',
|
||||
'hostDetails.group.title': '新建分組',
|
||||
'hostDetails.group.general': '常規',
|
||||
'hostDetails.group.namePlaceholder': '分組名稱',
|
||||
'hostDetails.group.parentPlaceholder': '父分組',
|
||||
'hostDetails.group.cloudSync': '雲同步',
|
||||
'hostDetails.group.addProtocol': '新增協定',
|
||||
|
||||
// Keychain
|
||||
'keychain.filter.key': '金鑰',
|
||||
'keychain.filter.certificate': '憑證',
|
||||
'keychain.action.generateKey': '生成金鑰',
|
||||
'keychain.action.importKey': '匯入金鑰',
|
||||
'keychain.action.newIdentity': '新建身份',
|
||||
'keychain.action.importCertificate': '匯入憑證',
|
||||
'keychain.view.grid': '網格',
|
||||
'keychain.view.list': '列表',
|
||||
'keychain.section.keys': '金鑰',
|
||||
'keychain.section.identities': '身份',
|
||||
'keychain.count.items': '{count} 項',
|
||||
'keychain.empty.title': '設定金鑰',
|
||||
'keychain.empty.desc': '匯入或生成 SSH 金鑰用於安全認證。',
|
||||
'keychain.panel.generateKey': '生成金鑰',
|
||||
'keychain.panel.newKey': '新建金鑰',
|
||||
'keychain.panel.keyDetails': '金鑰詳情',
|
||||
'keychain.panel.editKey': '編輯金鑰',
|
||||
'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.export.exportTo': '匯出到 *',
|
||||
'keychain.export.selectHost': '選擇主機',
|
||||
'keychain.export.location': '位置 ~ $1 *',
|
||||
'keychain.export.filename': '檔名 ~ $2 *',
|
||||
'keychain.export.note': '金鑰匯出目前僅支援 {unix} 系統。請在 {advanced} 部分自訂匯出指令碼。',
|
||||
'keychain.export.script': '指令碼 *',
|
||||
'keychain.export.scriptPlaceholder': '匯出指令碼...',
|
||||
'keychain.export.missingCredentials': '主機未儲存密碼或金鑰。請先為該主機新增密碼憑證。',
|
||||
'keychain.export.successTitle': '匯出成功',
|
||||
'keychain.export.successMessage': '已匯出公鑰並繫結到 {host}',
|
||||
'keychain.export.failedTitle': '匯出失敗',
|
||||
'keychain.export.failedMessage': '匯出金鑰失敗:{error}',
|
||||
'keychain.export.failedPrefix': '匯出失敗:{error}',
|
||||
'keychain.export.exitCode': '指令結束代碼 {code}',
|
||||
'keychain.export.exporting': '匯出中...',
|
||||
'keychain.export.exportAndAttach': '匯出並繫結',
|
||||
'keychain.export.title': '金鑰匯出',
|
||||
'keychain.export.exportToRequired': '匯出到 *',
|
||||
'keychain.export.selectHostPlaceholder': '選擇主機...',
|
||||
'keychain.export.locationLabel': '位置 ~ $1 *',
|
||||
'keychain.export.filenameLabel': '檔名 ~ $2 *',
|
||||
'keychain.export.advanced': '高階',
|
||||
'keychain.export.note.supportsOnly': '金鑰匯出目前僅支援',
|
||||
'keychain.export.note.systems': '系統。',
|
||||
'keychain.export.note.use': '請使用',
|
||||
'keychain.export.note.customize': '部分自訂匯出指令碼。',
|
||||
'keychain.export.scriptRequired': '指令碼 *',
|
||||
'keychain.export.exportToHost': '匯出到主機',
|
||||
'keychain.export.failedGeneric': '匯出失敗:{message}',
|
||||
'keychain.field.label': 'Label',
|
||||
'keychain.field.labelRequired': 'Label *',
|
||||
'keychain.field.labelPlaceholder': '金鑰 Label',
|
||||
'keychain.field.privateKeyRequired': '私鑰 *',
|
||||
'keychain.field.publicKey': '公鑰',
|
||||
'keychain.field.certificatePlaceholder': '憑證內容(可選)',
|
||||
'keychain.generate.keyType': '金鑰型別',
|
||||
'keychain.generate.keySize': '金鑰長度',
|
||||
'keychain.generate.labelPlaceholder': '金鑰 Label',
|
||||
'keychain.generate.passphrasePlaceholder': 'Passphrase(可選)',
|
||||
'keychain.generate.savePassphrase': '儲存 Passphrase',
|
||||
'keychain.generate.generate': '生成',
|
||||
'keychain.generate.generateSave': '生成並儲存',
|
||||
'keychain.import.dropHint': '將金鑰檔案拖到這裡',
|
||||
'keychain.import.importFromFile': '從檔案匯入',
|
||||
'keychain.import.saveKey': '儲存金鑰',
|
||||
'keychain.import.importedKeyLabel': '已匯入金鑰',
|
||||
'keychain.identity.usernameRequired': '使用者名稱 *',
|
||||
'keychain.identity.method.passwordOnly': '密碼',
|
||||
'keychain.identity.summary.password': '認證密碼',
|
||||
'keychain.identity.summary.key': '認證金鑰',
|
||||
'keychain.identity.summary.certificate': '認證憑證',
|
||||
'keychain.identity.summary.passwordAndKey': '認證密碼與金鑰',
|
||||
'keychain.identity.summary.passwordAndCertificate': '認證密碼與憑證',
|
||||
'keychain.identity.summary.none': '無憑證',
|
||||
'keychain.identity.selectCredential': '選擇{kind}',
|
||||
'keychain.identity.save': '儲存',
|
||||
'keychain.identity.update': '更新',
|
||||
'keychain.keyDialog.newTitle': '新建金鑰',
|
||||
'keychain.keyDialog.newDesc': '新增新的 SSH 金鑰',
|
||||
'keychain.keyDialog.editTitle': '編輯金鑰',
|
||||
'keychain.keyDialog.editDesc': '更新此 SSH 金鑰',
|
||||
'keychain.keyDialog.updateKey': '更新金鑰',
|
||||
|
||||
// Tabs
|
||||
'tabs.closeSessionAria': '關閉工作階段',
|
||||
'tabs.closeLogViewAria': '關閉日誌檢視',
|
||||
'tabs.logPrefix': '日誌:',
|
||||
'tabs.logLocal': '本機',
|
||||
'tabs.copyTab': '複製標籤頁',
|
||||
'tabs.copyTabToNewWindow': '複製標籤頁到新視窗',
|
||||
'tabs.copyTabToNewWindowFailed': '無法在新視窗開啟標籤頁',
|
||||
'tabs.closeOthers': '關閉其他標籤',
|
||||
'tabs.closeToRight': '關閉右側標籤',
|
||||
'tabs.closeAll': '關閉所有標籤',
|
||||
'keychain.edit.labelRequired': 'Label *',
|
||||
'keychain.edit.keyLabelPlaceholder': '金鑰 Label',
|
||||
'keychain.edit.privateKeyRequired': '私鑰 *',
|
||||
'keychain.edit.publicKey': '公鑰',
|
||||
'keychain.edit.certificate': '憑證',
|
||||
'keychain.edit.certificatePlaceholder': '憑證內容(可選)',
|
||||
'keychain.edit.filePath': '檔案路徑',
|
||||
'keychain.edit.keyExport': '金鑰匯出',
|
||||
'keychain.edit.exportToHost': '匯出到主機',
|
||||
|
||||
// Snippets
|
||||
'snippets.searchPlaceholder': '搜尋程式碼片段...',
|
||||
'snippets.action.newSnippet': '新建程式碼片段',
|
||||
'snippets.action.newPackage': '新建程式碼包',
|
||||
'snippets.action.import': '匯入',
|
||||
'snippets.action.selectSnippets': '選擇程式碼片段',
|
||||
'snippets.panel.newTitle': '新建程式碼片段',
|
||||
'snippets.panel.editTitle': '編輯程式碼片段',
|
||||
'snippets.field.description': '描述',
|
||||
'snippets.field.descriptionPlaceholder': '例如:check network load',
|
||||
'snippets.field.package': '新增程式碼包',
|
||||
'snippets.field.packagePlaceholder': '選擇或建立程式碼包',
|
||||
'snippets.field.createPackage': '建立程式碼包',
|
||||
'snippets.field.scriptRequired': '指令碼 *',
|
||||
'snippets.scriptEditor.expand': '彈出視窗編輯',
|
||||
'snippets.scriptEditor.resize': '調整編輯器高度',
|
||||
'snippets.scriptEditor.modalTitle': '編輯指令碼',
|
||||
'snippets.targets.title': '目標主機',
|
||||
'snippets.targets.add': '新增目標主機',
|
||||
'snippets.history.title': 'Shell 歷史',
|
||||
'snippets.history.subtitle': '{count} 條指令',
|
||||
'snippets.history.emptyTitle': '暫無 Shell 歷史',
|
||||
'snippets.history.emptyDesc': '你執行過的指令會顯示在這裡',
|
||||
'snippets.history.loadMore': '載入更多',
|
||||
'snippets.history.separator': '•',
|
||||
'snippets.history.labelPlaceholder': '為此程式碼片段設定一個 Label',
|
||||
'snippets.history.saveAsSnippet': '儲存為程式碼片段',
|
||||
'snippets.history.time.justNow': '剛剛',
|
||||
'snippets.history.time.minutesAgo': '{count} 分鐘前',
|
||||
'snippets.history.time.hoursAgo': '{count} 小時前',
|
||||
'snippets.history.time.daysAgo': '{count} 天前',
|
||||
'snippets.breadcrumb.allPackages': '全部程式碼包',
|
||||
'snippets.breadcrumb.separator': '›',
|
||||
'snippets.empty.title': '建立程式碼片段',
|
||||
'snippets.empty.desc': '將常用指令儲存為程式碼片段,一鍵重複使用。',
|
||||
'snippets.panel.newAutomationTitle': '新建自動化指令碼',
|
||||
'snippets.panel.editAutomationTitle': '編輯自動化指令碼',
|
||||
'snippets.panel.resizeWidth': '調整面板寬度',
|
||||
'snippets.targets.allHosts': '對所有主機生效',
|
||||
'snippets.targets.allHostsShort': '所有主機',
|
||||
'snippets.targets.allHostsActive': '將作用於所有可連線的主機。',
|
||||
'snippets.kind.codeSnippet': '程式碼片段',
|
||||
'snippets.kind.automationScript': '自動化指令碼',
|
||||
'snippets.search.noResults.title': '無相符結果',
|
||||
'snippets.search.noResults.desc': '沒有程式碼片段或程式碼包與"{query}"相符。換一個關鍵字,或清除搜尋進行瀏覽。',
|
||||
'snippets.section.packages': '程式碼包',
|
||||
'snippets.section.snippets': '程式碼片段',
|
||||
'snippets.package.count': '{count} 個程式碼片段',
|
||||
'snippets.commandFallback': '指令',
|
||||
'snippets.view.grid': '網格',
|
||||
'snippets.view.list': '列表',
|
||||
'snippets.selection.selected': '已選擇 {count} 個',
|
||||
'snippets.selection.selectVisible': '選擇目前顯示',
|
||||
'snippets.selection.deselectAll': '取消選擇',
|
||||
'snippets.selection.exportSelected': '匯出選取({count})',
|
||||
'snippets.export.snippet': '匯出程式碼片段',
|
||||
'snippets.export.package': '匯出程式碼包',
|
||||
'snippets.export.toast.empty': '沒有可匯出的程式碼片段。',
|
||||
'snippets.export.toast.successTitle': '匯出已準備好',
|
||||
'snippets.export.toast.success': '已匯出 {count} 個程式碼片段。',
|
||||
'snippets.import.toast.empty': '沒有找到可匯入的程式碼片段。',
|
||||
'snippets.import.toast.failedTitle': '匯入失敗',
|
||||
'snippets.import.toast.invalidDesc': '這不是有效的 Netcatty 程式碼片段檔案。',
|
||||
'snippets.import.toast.successTitle': '匯入完成',
|
||||
'snippets.import.toast.summary': '已匯入 {imported} 個,覆蓋 {overwritten} 個,跳過 {skipped} 個。',
|
||||
'snippets.import.modal.title': '匯入程式碼片段',
|
||||
'snippets.import.modal.desc': '選擇一個或多個 Netcatty 程式碼片段 JSON 檔案。匯入前會先解析並展示結果,不會直接寫入。',
|
||||
'snippets.import.modal.exampleTitle': '範例 JSON',
|
||||
'snippets.import.modal.noFile': '還沒有選擇檔案。可以參考下面的格式,也可以直接使用程式碼片段 JSON 陣列,然後選擇一個或多個檔案。',
|
||||
'snippets.import.modal.chooseFile': '選擇檔案',
|
||||
'snippets.import.modal.downloadExamples': '下載範例檔案',
|
||||
'snippets.import.modal.multipleFiles': '已選擇 {count} 個檔案',
|
||||
'snippets.import.modal.parsedSummary': '{files} 個檔案,{total} 個程式碼片段,{packages} 個程式碼包,{conflicts} 個重複指令。主機繫結會被忽略。',
|
||||
'snippets.import.modal.confirm': '確認匯入',
|
||||
'snippets.import.conflict.title': '匯入程式碼片段?',
|
||||
'snippets.import.conflict.desc': '“{file}”包含 {total} 個程式碼片段,其中 {conflicts} 個指令已存在。',
|
||||
'snippets.import.conflict.hostBindingsNote': '程式碼片段的主機繫結不會被匯入或匯出。',
|
||||
'snippets.import.conflict.skip': '跳過重複項',
|
||||
'snippets.import.conflict.overwrite': '覆蓋重複項',
|
||||
'snippets.packageDialog.title': '新建程式碼包',
|
||||
'snippets.packageDialog.parent': '父級:{parent}',
|
||||
'snippets.packageDialog.root': '根目錄',
|
||||
'snippets.packageDialog.placeholder': '例如:ops/maintenance',
|
||||
'snippets.packageDialog.hint': '使用 "/" 建立巢狀程式碼包。',
|
||||
|
||||
// Snippets Rename Dialog
|
||||
'snippets.renameDialog.title': '重新命名程式碼包',
|
||||
'snippets.renameDialog.currentPath': '目前路徑:{path}',
|
||||
'snippets.renameDialog.placeholder': '輸入新名稱',
|
||||
'snippets.renameDialog.error.empty': '程式碼包名稱不能為空',
|
||||
'snippets.renameDialog.error.duplicate': '已存在同名的程式碼包',
|
||||
'snippets.renameDialog.error.invalidChars': '程式碼包名稱只能包含字母、數字、連字元和下劃線',
|
||||
|
||||
'snippets.field.noAutoRun': '僅貼上(不自動執行)',
|
||||
// Snippet Shortkey
|
||||
'snippets.field.shortkey': '快捷鍵',
|
||||
'snippets.shortkey.placeholder': '點選設定快捷鍵',
|
||||
'snippets.shortkey.recording': '請按下快捷鍵組合...',
|
||||
'snippets.shortkey.hint': '在終端中按下此快捷鍵可快速傳送指令。',
|
||||
'snippets.shortkey.clear': '清除快捷鍵',
|
||||
'snippets.shortkey.error.systemConflict': '此快捷鍵與系統快捷鍵衝突',
|
||||
'snippets.shortkey.error.snippetConflict': '此快捷鍵已被程式碼片段使用:{name}',
|
||||
|
||||
'snippets.variables.dialogTitle': '填寫變數',
|
||||
'snippets.variables.dialogDesc': '執行「{label}」前請填寫以下變數。',
|
||||
'snippets.variables.hint': '變數值將原樣插入指令碼(不會進行 shell 轉義)。',
|
||||
'snippets.variables.preview': '預覽',
|
||||
'snippets.variables.placeholder': '請輸入',
|
||||
'snippets.variables.placeholderDefault': '預設:{value}',
|
||||
'snippets.variables.required': '請填寫此變數',
|
||||
'snippets.variables.run': '執行',
|
||||
'snippets.field.variablesHelp': '在指令碼中使用 {{名稱}} 或 {{名稱:預設值}} 定義變數。',
|
||||
'snippets.field.variablesDetected': '變數',
|
||||
'snippets.field.variableDefault': '預設 {value}',
|
||||
|
||||
// Serial Port
|
||||
'serial.button': '序列埠',
|
||||
'serial.modal.title': '連線序列埠',
|
||||
'serial.modal.desc': '設定序列埠連線引數',
|
||||
'serial.field.port': '序列埠',
|
||||
'serial.field.selectPort': '選擇序列埠...',
|
||||
'serial.field.baudRate': '波特率',
|
||||
'serial.field.dataBits': '資料位',
|
||||
'serial.field.stopBits': '停止位',
|
||||
'serial.field.stopBits15Warning': '1.5 停止位在 Windows 下可能不被所有裝置支援',
|
||||
'serial.field.parity': '同位位元',
|
||||
'serial.field.flowControl': '流控制',
|
||||
'serial.noPorts': '未偵測到序列埠裝置。請連線裝置後重新整理。',
|
||||
'serial.field.customPort': '自訂序列埠路徑',
|
||||
'serial.field.customPortPlaceholder': '例如 /dev/ttys001 或 COM1',
|
||||
'serial.type.hardware': '硬體',
|
||||
'serial.type.pseudo': '虛擬終端',
|
||||
'serial.type.custom': '自訂',
|
||||
'serial.parity.none': '無',
|
||||
'serial.parity.even': '偶同位',
|
||||
'serial.parity.odd': '奇同位',
|
||||
'serial.parity.mark': 'Mark',
|
||||
'serial.parity.space': 'Space',
|
||||
'serial.flowControl.none': '無',
|
||||
'serial.flowControl.xon/xoff': 'XON/XOFF (軟體)',
|
||||
'serial.flowControl.rts/cts': 'RTS/CTS (硬體)',
|
||||
'serial.field.localEcho': '強制本機回顯',
|
||||
'serial.field.localEchoDesc': '本機回顯輸入字元(用於沒有遠端回顯的裝置)',
|
||||
'serial.field.lineMode': '行模式',
|
||||
'serial.field.lineModeDesc': '緩衝輸入,按回車後傳送(而不是逐字元傳送)',
|
||||
'serial.field.charset': '字元編碼',
|
||||
'serial.connectionError': '連線序列埠失敗',
|
||||
'serial.field.baudRatePlaceholder': '選擇或輸入波特率...',
|
||||
'serial.field.baudRateEmpty': '輸入自訂波特率',
|
||||
'serial.field.customBaudRate': '使用自訂波特率',
|
||||
'serial.field.saveConfig': '儲存設定',
|
||||
'serial.field.saveConfigDesc': '將此序列埠設定儲存到主機列表以便快速存取',
|
||||
'serial.field.configLabel': '設定名稱',
|
||||
'serial.field.configLabelPlaceholder': '例如 Arduino Uno',
|
||||
'serial.connectAndSave': '連線並儲存',
|
||||
'serial.edit.title': '序列埠設定',
|
||||
|
||||
// Keyboard Interactive Authentication (2FA/MFA)
|
||||
'keyboard.interactive.title': '需要驗證',
|
||||
'keyboard.interactive.desc': '伺服器需要額外的身份驗證。',
|
||||
'keyboard.interactive.descWithHost': '伺服器 {hostname} 需要額外的身份驗證。',
|
||||
'keyboard.interactive.response': '響應',
|
||||
'keyboard.interactive.enterCode': '輸入驗證碼',
|
||||
'keyboard.interactive.enterResponse': '輸入響應',
|
||||
'keyboard.interactive.submit': '提交',
|
||||
'keyboard.interactive.verifying': '驗證中...',
|
||||
'keyboard.interactive.savePassword': '儲存密碼',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'SSH 金鑰密碼',
|
||||
'passphrase.desc': '請輸入 {keyName} 的密碼',
|
||||
'passphrase.descWithHost': '請輸入 {keyName} 的密碼以連線到 {hostname}',
|
||||
'passphrase.label': '密碼',
|
||||
'passphrase.keyPath': '金鑰',
|
||||
'passphrase.unlock': '解鎖',
|
||||
'passphrase.unlocking': '解鎖中...',
|
||||
'passphrase.skip': '跳過',
|
||||
'passphrase.remember': '記住此密碼',
|
||||
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': '自動換行',
|
||||
'sftp.editor.maximize': '最大化',
|
||||
'sftp.editor.unsavedTitle': '未儲存的修改',
|
||||
'sftp.editor.unsavedMessage': '{fileName} 有未儲存的修改,是否儲存後關閉?',
|
||||
'sftp.editor.discardChanges': '不儲存',
|
||||
'sftp.editor.saveAndClose': '儲存並關閉',
|
||||
'sftp.editor.quitBlockedByDirty': '存在未儲存的編輯器,請先處理後再離開',
|
||||
|
||||
};
|
||||
@@ -0,0 +1,822 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhTWVaultMessages: Messages = {
|
||||
// Notes
|
||||
'notes.search.placeholder': '搜尋筆記、主機、標籤...',
|
||||
'notes.action.newNote': '新建筆記',
|
||||
'notes.action.newGroup': '新建資料夾',
|
||||
'notes.action.importMarkdown': '匯入 Markdown',
|
||||
'notes.action.exportNote': '匯出筆記',
|
||||
'notes.action.exportGroup': '匯出資料夾',
|
||||
'notes.action.exportAll': '匯出全部筆記',
|
||||
'notes.import.toast.success': '已匯入 {count} 條筆記',
|
||||
'notes.import.toast.noNotes': '沒有匯入任何 Markdown 檔案。',
|
||||
'notes.import.toast.failed': '匯入 Markdown 檔案失敗。',
|
||||
'notes.import.toast.inProgress': '正在匯入 Markdown 檔案,請稍候。',
|
||||
'notes.import.toast.skipped': '已跳過 {count} 個不支援的檔案。',
|
||||
'notes.export.toast.success': '已匯出 {count} 條筆記',
|
||||
'notes.export.toast.empty': '沒有可匯出的筆記。',
|
||||
'notes.export.toast.failed': '匯出筆記失敗。',
|
||||
'notes.codeBlock.copied': '已複製',
|
||||
'notes.codeBlock.copyFailed': '複製程式碼塊失敗。',
|
||||
'notes.group.prompt': '資料夾路徑',
|
||||
'notes.group.all': '筆記本',
|
||||
'notes.group.ungrouped': '未分組',
|
||||
'notes.section.notebooks': '筆記本',
|
||||
'notes.section.notes': '筆記',
|
||||
'notes.mode.edit': '編輯',
|
||||
'notes.mode.split': '分割',
|
||||
'notes.mode.preview': '預覽',
|
||||
'notes.title.placeholder': '筆記標題',
|
||||
'notes.editor.placeholder': '在這裡寫 Markdown 筆記...',
|
||||
'notes.preview.empty': '暫無內容可預覽。',
|
||||
'notes.empty.title': '還沒有筆記',
|
||||
'notes.empty.desc': '可以記錄運維手冊、臨時備忘、伺服器交接資訊。',
|
||||
'notes.empty.group': '這個筆記本里還沒有筆記。',
|
||||
'notes.search.noResults': '沒有找到筆記。',
|
||||
|
||||
// Select Host panel
|
||||
'selectHost.title': '選擇主機',
|
||||
'selectHost.noHostsFound': '未找到主機',
|
||||
'selectHost.newHost': '新建主機',
|
||||
'selectHost.continue': '繼續',
|
||||
'selectHost.continueWithCount': '繼續(已選 {count} 個)',
|
||||
|
||||
// Quick Connect
|
||||
'quickConnect.knownHost.title': '確認要連線嗎?',
|
||||
'quickConnect.knownHost.authenticity': '無法驗證 {hostname} 的真實性。',
|
||||
'quickConnect.knownHost.fingerprintLabel': '{keyType} fingerprint (SHA256):',
|
||||
'quickConnect.knownHost.addQuestion': '是否將它加入 Known Hosts?',
|
||||
'quickConnect.knownHost.addAndContinue': '加入並繼續',
|
||||
'quickConnect.addKey': '新增 key',
|
||||
'quickConnect.warning.unparsedOptions': '部分 SSH 引數已被忽略: {options}',
|
||||
|
||||
// Protocol select dialog
|
||||
'protocolSelect.chooseProtocol': '選擇協定',
|
||||
'protocolSelect.port': '埠:',
|
||||
// Host Details
|
||||
'hostDetails.title.details': '主機詳情',
|
||||
'hostDetails.title.new': '新建主機',
|
||||
'hostDetails.saveAria': '儲存',
|
||||
'hostDetails.section.address': '地址',
|
||||
'hostDetails.hostname.placeholder': 'IP 或 主機名',
|
||||
'hostDetails.section.general': '通用',
|
||||
'hostDetails.section.sftp': 'SFTP 設定',
|
||||
'hostDetails.sftp.sudo': 'Sudo 提權模式',
|
||||
'hostDetails.sftp.sudo.desc': '使用儲存的密碼自動取得 Root 權限',
|
||||
'hostDetails.sftp.sudo.passwordWarning': 'Sudo 模式需要密碼。請在上方設定密碼,或確保伺服器允許免密 sudo。',
|
||||
'hostDetails.sftp.encoding': '檔名編碼',
|
||||
'hostDetails.sftp.encoding.desc': '選擇用於解碼和傳送 SFTP 檔名的編碼。',
|
||||
'hostDetails.label.placeholder': '名稱(例如:Production Server)',
|
||||
'hostDetails.notes.label': '備註',
|
||||
'hostDetails.notes.placeholder': '硬體設定、專案、客戶、地域、角色...',
|
||||
'hostDetails.notes.help': '支援 Markdown。請勿在此存放密碼或私鑰。',
|
||||
'hostDetails.notes.tab.edit': '編輯',
|
||||
'hostDetails.notes.tab.preview': '預覽',
|
||||
'hostDetails.notes.preview.empty': '暫無內容可預覽。',
|
||||
'hostDetails.notes.toggle.show': '顯示備註編輯框',
|
||||
'hostDetails.notes.toggle.hide': '隱藏備註編輯框',
|
||||
'hostDetails.group.placeholder': '父級 Group',
|
||||
'hostDetails.section.credentials': '憑證',
|
||||
'hostDetails.section.portCredentials': '埠與憑證',
|
||||
'hostDetails.section.appearance': '外觀',
|
||||
'hostDetails.distro.title': 'Linux 發行版',
|
||||
'hostDetails.distro.desc': '可在連線後自動探測,也可以手動覆蓋圖示所用的發行版。',
|
||||
'hostDetails.icon.title': '主機圖示',
|
||||
'hostDetails.icon.preview': '圖示預覽',
|
||||
'vault.panel.resizeWidth': '調整面板寬度',
|
||||
'hostDetails.icon.sectionTitle': '顏色與圖示',
|
||||
'hostDetails.icon.desc': '選擇此主機使用的圖示來源和顏色。',
|
||||
'hostDetails.icon.source': '來源',
|
||||
'hostDetails.icon.manualLabel': '手動指定',
|
||||
'hostDetails.icon.tab.brand': '品牌',
|
||||
'hostDetails.icon.tab.type': '型別',
|
||||
'hostDetails.icon.mode.auto': '自動',
|
||||
'hostDetails.icon.mode.custom': '自訂',
|
||||
'hostDetails.icon.reset': '重置主機圖示',
|
||||
'hostDetails.icon.colorLabel': '圖示顏色',
|
||||
'hostDetails.icon.colorDefault': '預設',
|
||||
'hostDetails.icon.colorAuto': '自動',
|
||||
'hostDetails.icon.colorManual': '手動',
|
||||
'hostDetails.icon.colorChoice': '顏色',
|
||||
'hostDetails.icon.autoColorLabel': '目前顏色',
|
||||
'hostDetails.icon.showLibrary': '展開圖示庫',
|
||||
'hostDetails.icon.hideLibrary': '收起圖示庫',
|
||||
'hostDetails.icon.autoUsesDistro': '使用 Linux 發行版圖示和所選顏色顯示此主機。',
|
||||
'hostDetails.icon.customOverridesDistro': '內建圖示會替換此主機的 Linux 發行版圖示。',
|
||||
'hostDetails.icon.option.server': '伺服器',
|
||||
'hostDetails.icon.option.terminal': '終端',
|
||||
'hostDetails.icon.option.database': '資料庫',
|
||||
'hostDetails.icon.option.cloud': '雲主機',
|
||||
'hostDetails.icon.option.router': '路由器',
|
||||
'hostDetails.icon.option.shield': '安全',
|
||||
'hostDetails.icon.option.code': '程式碼',
|
||||
'hostDetails.icon.option.box': '節點',
|
||||
'hostDetails.icon.option.globe': '公網',
|
||||
'hostDetails.icon.option.cpu': '計算',
|
||||
'hostDetails.icon.option.hard-drive': '儲存',
|
||||
'hostDetails.icon.option.network': '網路',
|
||||
'hostDetails.icon.option.wifi': '無線',
|
||||
'hostDetails.icon.option.lock': '鎖定',
|
||||
'hostDetails.icon.option.key': '金鑰',
|
||||
'hostDetails.icon.option.monitor': '顯示器',
|
||||
'hostDetails.icon.option.container': '容器',
|
||||
'hostDetails.icon.option.activity': '活動',
|
||||
'hostDetails.icon.option.zap': '高速',
|
||||
'hostDetails.icon.option.server-cog': '伺服器設定',
|
||||
'hostDetails.icon.color.blue': '藍色',
|
||||
'hostDetails.icon.color.green': '綠色',
|
||||
'hostDetails.icon.color.red': '紅色',
|
||||
'hostDetails.icon.color.amber': '琥珀色',
|
||||
'hostDetails.icon.color.purple': '紫色',
|
||||
'hostDetails.icon.color.cyan': '青色',
|
||||
'hostDetails.icon.color.orange': '橙色',
|
||||
'hostDetails.icon.color.slate': '石板灰',
|
||||
'hostDetails.icon.color.violet': '紫羅蘭',
|
||||
'hostDetails.icon.color.pink': '粉色',
|
||||
'hostDetails.icon.color.rose': '玫瑰紅',
|
||||
'hostDetails.icon.color.lime': '青檸',
|
||||
'hostDetails.icon.color.teal': '藍綠色',
|
||||
'hostDetails.icon.color.sky': '天藍',
|
||||
'hostDetails.icon.color.indigo': '靛藍',
|
||||
'hostDetails.icon.color.zinc': '鋅灰',
|
||||
'hostDetails.icon.color.custom': '自訂顏色',
|
||||
'hostDetails.distro.mode': '來源',
|
||||
'hostDetails.distro.mode.auto': '自動探測',
|
||||
'hostDetails.distro.mode.manual': '手動覆蓋',
|
||||
'hostDetails.distro.detectedLabel': '目前值',
|
||||
'hostDetails.distro.manualLabel': '手動指定',
|
||||
'hostDetails.distro.pending': '首次連線後自動探測',
|
||||
'hostDetails.distro.unknown': '未知',
|
||||
'hostDetails.distro.option.linux': '通用 Linux',
|
||||
'hostDetails.distro.option.ubuntu': 'Ubuntu',
|
||||
'hostDetails.distro.option.debian': 'Debian',
|
||||
'hostDetails.distro.option.centos': 'CentOS',
|
||||
'hostDetails.distro.option.rocky': 'Rocky Linux',
|
||||
'hostDetails.distro.option.fedora': 'Fedora',
|
||||
'hostDetails.distro.option.arch': 'Arch Linux',
|
||||
'hostDetails.distro.option.alpine': 'Alpine',
|
||||
'hostDetails.distro.option.amazon': 'Amazon Linux',
|
||||
'hostDetails.distro.option.opensuse': 'openSUSE / SLES',
|
||||
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
|
||||
'hostDetails.distro.option.almalinux': 'AlmaLinux',
|
||||
'hostDetails.distro.option.alinux': '阿里雲 Linux',
|
||||
'hostDetails.distro.option.openeuler': 'openEuler',
|
||||
'hostDetails.distro.option.oracle': 'Oracle Linux',
|
||||
'hostDetails.distro.option.kali': 'Kali Linux',
|
||||
'hostDetails.distro.option.cisco': '思科',
|
||||
'hostDetails.distro.option.juniper': '瞻博網路',
|
||||
'hostDetails.distro.option.huawei': '華為',
|
||||
'hostDetails.distro.option.h3c': '新華三 / H3C',
|
||||
'hostDetails.distro.option.hpe': '慧與 / HPE',
|
||||
'hostDetails.distro.option.mikrotik': 'MikroTik',
|
||||
'hostDetails.distro.option.fortinet': '飛塔',
|
||||
'hostDetails.distro.option.paloalto': 'Palo Alto Networks',
|
||||
'hostDetails.distro.option.zyxel': '合勤',
|
||||
'hostDetails.distro.option.ruijie': '銳捷',
|
||||
'hostDetails.section.mosh': 'Mosh',
|
||||
'hostDetails.section.et': 'EternalTerminal',
|
||||
'hostDetails.et.port': 'ET 服務埠',
|
||||
'hostDetails.et.port.desc': 'etserver 監聽埠(預設 2022)',
|
||||
'hostDetails.username.placeholder': '使用者名稱',
|
||||
'hostDetails.password.placeholder': '密碼',
|
||||
'hostDetails.password.show': '顯示密碼',
|
||||
'hostDetails.password.hide': '隱藏密碼',
|
||||
'hostDetails.password.save': '儲存密碼',
|
||||
'hostDetails.identity.suggestions': '身份',
|
||||
'hostDetails.identity.missing': '身份不存在',
|
||||
'hostDetails.credential.keyCertificate': '金鑰 / 憑證 / 本機金鑰',
|
||||
'hostDetails.credential.key': '金鑰',
|
||||
'hostDetails.credential.certificate': '憑證',
|
||||
'hostDetails.credential.localKeyFile': '本機金鑰檔案',
|
||||
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
|
||||
'hostDetails.credential.browseKeyFile': '瀏覽…',
|
||||
'hostDetails.credential.missing': '憑證不存在',
|
||||
'hostDetails.keys.search': '搜尋金鑰…',
|
||||
'hostDetails.keys.empty': '暫無金鑰',
|
||||
'hostDetails.certs.search': '搜尋憑證…',
|
||||
'hostDetails.certs.empty': '暫無憑證',
|
||||
'hostDetails.agentForwarding': '轉發 SSH 金鑰',
|
||||
'hostDetails.agentForwarding.desc': '允許遠端伺服器使用本機 SSH 金鑰(例如用於 git 操作)',
|
||||
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent 不可用',
|
||||
'hostDetails.agentForwarding.agentNotRunningHint': '未偵測到 SSH Agent。請啟用 Windows OpenSSH Authentication Agent 服務,或使用相容的 Agent(如 Bitwarden、1Password、gpg-agent)。',
|
||||
'hostDetails.section.agentForwarding': 'SSH 代理',
|
||||
'hostDetails.x11Forwarding': '轉發 X11 圖形應用程式',
|
||||
'hostDetails.x11Forwarding.desc': '本機執行 X 服務時,讓遠端圖形程式顯示在本機桌面。',
|
||||
'hostDetails.section.x11Forwarding': 'X11 轉發',
|
||||
'hostDetails.section.deviceType': '裝置型別',
|
||||
'hostDetails.deviceType': '網路裝置模式',
|
||||
'hostDetails.deviceType.desc': '適用於透過 SSH 連線的網路裝置(交換機、路由器、防火牆)。指令將原樣傳送,不進行 Shell 包裝,相容華為 VRP、Cisco IOS 等廠商 CLI。',
|
||||
'hostDetails.deviceType.warning': 'AI 代理指令將直接傳送,無法取得結束代碼。僅建議在裝置不執行標準 Shell 時啟用。',
|
||||
'hostDetails.section.sshAlgorithms': 'SSH 演算法',
|
||||
'hostDetails.section.terminalBehavior': '終端行為',
|
||||
'hostDetails.lineTimestamps': '顯示輸出時間',
|
||||
'hostDetails.lineTimestamps.desc': '在終端輸出行旁邊顯示本機時間,不改變終端文字內容。',
|
||||
'hostDetails.legacyAlgorithms': '允許舊版演算法',
|
||||
'hostDetails.legacyAlgorithms.desc': '啟用已棄用的 SSH 演算法(diffie-hellman-group1、ssh-dss、3des-cbc 等)以連線老舊網路裝置。',
|
||||
'hostDetails.legacyAlgorithms.warning': '這些演算法存在已知安全漏洞,僅建議在老舊裝置不支援現代加密時啟用。',
|
||||
'hostDetails.skipEcdsaHostKey': '跳過 ECDSA 主機金鑰',
|
||||
'hostDetails.skipEcdsaHostKey.desc': '某些老款華為 / 思科交換機的 ECDSA 主機金鑰簽名不規範,會導致連線報 "signature verification failed"。開啟後用戶端不再 advertise ecdsa-sha2-*,強制使用 RSA / Ed25519。',
|
||||
'hostDetails.algorithms.advanced': '高階演算法設定',
|
||||
'hostDetails.algorithms.advanced.desc': '針對單個 host 自訂各分類的演算法清單。不勾選 = 使用預設;勾選子集後將完全替換預設列表。設定錯誤可能導致無法連線。',
|
||||
'hostDetails.algorithms.inheritedNotice': '目前組已設定以下分類的演算法 override:{categories}。本面板的"恢復預設"只會回到組的設定,而不是 NetCatty 預設列表。若要忽略組的限制,請到組的演算法設定裡取消。',
|
||||
'hostDetails.algorithms.customized': '已自訂',
|
||||
'hostDetails.algorithms.reset': '恢復預設',
|
||||
'hostDetails.algorithms.category.kex': '金鑰交換 (KEX)',
|
||||
'hostDetails.algorithms.category.cipher': '加密演算法 (Cipher)',
|
||||
'hostDetails.algorithms.category.hmac': '完整性演算法 (HMAC)',
|
||||
'hostDetails.algorithms.category.serverHostKey': '主機金鑰 (Host Key)',
|
||||
'hostDetails.algorithms.category.compress': '壓縮 (Compression)',
|
||||
'hostDetails.section.keepalive': '工作階段保活',
|
||||
'hostDetails.keepalive.override': '為此主機單獨設定',
|
||||
'hostDetails.keepalive.desc': '為該主機使用專屬的保活策略,而不是跟隨全域設定。適用於不響應 keepalive@openssh.com 請求的老舊路由器 / 交換機——將間隔設為 0 可對該主機徹底關閉保活。',
|
||||
'hostDetails.keepalive.interval': '間隔(秒)',
|
||||
'hostDetails.keepalive.countMax': '最大無響應保活次數',
|
||||
'hostDetails.keepalive.disabledHint': '間隔為 0 時該主機不傳送保活包,僅依賴 TCP 層超時偵測斷連。',
|
||||
'hostDetails.backspaceBehavior': 'Backspace 行為',
|
||||
'hostDetails.backspaceBehavior.default': '預設',
|
||||
'hostDetails.jumpHosts': '透過主機代理',
|
||||
'hostDetails.jumpHosts.hops': '{count} 跳',
|
||||
'hostDetails.jumpHosts.direct': '直連',
|
||||
'hostDetails.jumpHosts.configure': '設定代理主機',
|
||||
'hostDetails.proxy': '透過 HTTP/SOCKS5/指令代理',
|
||||
'hostDetails.proxy.none': '無',
|
||||
'hostDetails.proxy.edit': '編輯代理',
|
||||
'hostDetails.proxy.configure': '設定代理',
|
||||
'hostDetails.envVars': '環境變數',
|
||||
'hostDetails.envVars.add': '新增環境變數',
|
||||
'hostDetails.startupCommand': '啟動指令',
|
||||
'hostDetails.startupCommand.placeholder': '連線後執行的指令(例如:cd /app && ls)',
|
||||
'hostDetails.startupCommand.help': 'SSH 連線建立後將自動執行該指令。',
|
||||
'hostDetails.otherProtocols': '其他協定',
|
||||
'hostDetails.telnetOn': 'Telnet on',
|
||||
'hostDetails.port': '埠',
|
||||
'hostDetails.telnet.credentials': '憑證',
|
||||
'hostDetails.telnet.identity.placeholder': '選擇 Telnet 身份',
|
||||
'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.addTag': '新增標籤...',
|
||||
'hostDetails.createTag': '建立標籤',
|
||||
'hostDetails.createGroup': '建立分組',
|
||||
|
||||
// Host form (legacy modal)
|
||||
'hostForm.title.edit': '編輯主機',
|
||||
'hostForm.title.new': '新建主機',
|
||||
'hostForm.desc.edit': '更新該主機的連線資訊',
|
||||
'hostForm.desc.new': '建立一個新的 SSH 主機條目',
|
||||
'hostForm.field.label': '名稱',
|
||||
'hostForm.placeholder.label': 'My Production Server',
|
||||
'hostForm.field.hostname': 'Hostname / IP',
|
||||
'hostForm.placeholder.hostname': '192.168.1.1',
|
||||
'hostForm.field.port': '埠',
|
||||
'hostForm.field.username': '使用者名稱',
|
||||
'hostForm.field.osType': '作業系統型別',
|
||||
'hostForm.placeholder.selectOs': '選擇作業系統',
|
||||
'hostForm.field.group': '分組',
|
||||
'hostForm.placeholder.group': '例如:AWS、DigitalOcean',
|
||||
'hostForm.field.tags': '標籤',
|
||||
'hostForm.placeholder.addTag': '新增標籤…',
|
||||
'hostForm.auth.method': '認證方式',
|
||||
'hostForm.auth.password': '密碼',
|
||||
'hostForm.auth.sshKey': 'SSH金鑰',
|
||||
'hostForm.auth.selectKey': '選擇 SSH金鑰',
|
||||
'hostForm.auth.noKeys': '暫無金鑰',
|
||||
'hostForm.auth.noKeysHint': '鑰匙串中未找到 SSH金鑰,請先建立一個。',
|
||||
'hostForm.saveHost': '儲存主機',
|
||||
|
||||
// Connection logs
|
||||
'logs.table.date': '日期',
|
||||
'logs.table.user': '使用者',
|
||||
'logs.table.host': '主機',
|
||||
'logs.table.saved': '收藏',
|
||||
'logs.empty.title': '暫無連線日誌',
|
||||
'logs.empty.desc': '當你連線主機或開啟本機終端後,這裡會顯示連線歷史。',
|
||||
'logs.loadMore': '載入更多 ({count} 條)',
|
||||
'logs.ongoing': '進行中',
|
||||
'logs.localTerminal': '本機終端',
|
||||
'logs.action.save': '收藏',
|
||||
'logs.action.unsave': '取消收藏',
|
||||
'logs.action.delete': '刪除',
|
||||
|
||||
// Log view
|
||||
'logView.customizeAppearance': '自訂外觀',
|
||||
'logView.appearance': '外觀',
|
||||
'logView.readOnly': '只讀',
|
||||
'logView.export': '匯出',
|
||||
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': '開啟 SFTP',
|
||||
'terminal.toolbar.availableAfterConnect': '連線後可用',
|
||||
'terminal.toolbar.sendYmodem': 'YMODEM 傳送',
|
||||
'terminal.toolbar.receiveYmodem': 'YMODEM 接收',
|
||||
'terminal.toolbar.sftp': 'SFTP',
|
||||
'terminal.toolbar.more': '更多操作',
|
||||
'terminal.toolbar.scripts': '指令碼',
|
||||
'terminal.toolbar.history': '指令歷史',
|
||||
'terminal.toolbar.library': '庫',
|
||||
'terminal.toolbar.noSnippets': '暫無程式碼片段',
|
||||
'terminal.toolbar.terminalSettings': '終端設定',
|
||||
'terminal.toolbar.searchTerminal': '搜尋終端',
|
||||
'terminal.toolbar.search': '搜尋',
|
||||
'terminal.toolbar.startSessionLog': '開始工作階段日誌',
|
||||
'terminal.toolbar.stopSessionLog': '停止工作階段日誌',
|
||||
'terminal.toolbar.broadcast': '廣播',
|
||||
'terminal.toolbar.broadcastEnable': '啟用廣播模式',
|
||||
'terminal.toolbar.broadcastDisable': '關閉廣播模式',
|
||||
'terminal.toolbar.composeBar': '撰寫欄',
|
||||
'terminal.composeBar.placeholder': '在此輸入指令,按回車傳送...',
|
||||
'terminal.composeBar.send': '傳送',
|
||||
'terminal.composeBar.close': '關閉撰寫欄',
|
||||
'terminal.composeBar.broadcasting': '正在廣播到所有工作階段',
|
||||
'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.toolbar.focus': '聚焦',
|
||||
'terminal.toolbar.focusMode': '聚焦模式',
|
||||
'terminal.toolbar.detach': '移出到獨立標籤',
|
||||
'terminal.toolbar.encoding': '終端編碼',
|
||||
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
||||
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
||||
'terminal.toolbar.closeSession': '關閉工作階段',
|
||||
'terminal.toolbar.hostHighlight.title': '主機關鍵字標示',
|
||||
'terminal.toolbar.hostHighlight.noRules': '此主機未定義自訂標示規則',
|
||||
'terminal.toolbar.hostHighlight.addRule': '新增新規則',
|
||||
'terminal.toolbar.hostHighlight.labelPlaceholder': '標籤(例如:錯誤)',
|
||||
'terminal.toolbar.hostHighlight.patternPlaceholder': '正規表示式(例如:\\bfailed\\b)',
|
||||
'terminal.toolbar.hostHighlight.invalidPattern': '無效的正規表示式',
|
||||
'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.serverStats.cpu': 'CPU 使用率',
|
||||
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
|
||||
'terminal.serverStats.memory': '記憶體使用',
|
||||
'terminal.serverStats.memoryDetails': '記憶體詳情',
|
||||
'terminal.serverStats.memUsed': '已用',
|
||||
'terminal.serverStats.memBuffers': '緩衝區',
|
||||
'terminal.serverStats.memCached': '快取',
|
||||
'terminal.serverStats.memFree': '空閒',
|
||||
'terminal.serverStats.swap': '交換空間',
|
||||
'terminal.serverStats.swapUsed': '已用交換',
|
||||
'terminal.serverStats.swapFree': '空閒交換',
|
||||
'terminal.serverStats.swapTotal': '總計',
|
||||
'terminal.serverStats.topProcesses': '記憶體佔用前十程序',
|
||||
'terminal.serverStats.disk': '磁碟使用(根分割槽)',
|
||||
'terminal.serverStats.diskDetails': '已掛載磁碟',
|
||||
'terminal.serverStats.network': '網路速度',
|
||||
'terminal.serverStats.latency': '延遲',
|
||||
'terminal.serverStats.networkDetails': '網路介面',
|
||||
'terminal.serverStats.noData': '暫無資料',
|
||||
'terminal.dragDrop.localTitle': '拖放以插入路徑',
|
||||
'terminal.dragDrop.localMessage': '檔案路徑將被插入到終端',
|
||||
'terminal.dragDrop.remoteTitle': '拖放以上傳檔案',
|
||||
'terminal.dragDrop.remoteZmodemMessage': '檔案將透過 ZMODEM(PTY)上傳',
|
||||
'terminal.dragDrop.remoteSftpMessage': '檔案將透過 SFTP 上傳',
|
||||
'terminal.dragDrop.noFiles': '沒有可上傳的檔案',
|
||||
'terminal.dragDrop.notConnected': '無法拖放檔案 - 終端未連線',
|
||||
'terminal.dragDrop.errorTitle': '拖放錯誤',
|
||||
'terminal.dragDrop.errorMessage': '處理拖放檔案失敗',
|
||||
'terminal.search.placeholder': '搜尋…',
|
||||
'terminal.search.noResults': '無結果',
|
||||
'terminal.search.prevMatch': '上一個相符 (Shift+Enter)',
|
||||
'terminal.search.nextMatch': '下一個相符 (Enter)',
|
||||
'terminal.menu.copy': '複製',
|
||||
'terminal.menu.paste': '貼上',
|
||||
'terminal.menu.addSelectionToAI': '新增到對話',
|
||||
'terminal.menu.pasteSelection': '貼上選取文字',
|
||||
'terminal.menu.selectAll': '全選',
|
||||
'terminal.menu.reconnect': '重新連線',
|
||||
'terminal.menu.sendYmodem': 'YMODEM 傳送',
|
||||
'terminal.menu.receiveYmodem': 'YMODEM 接收',
|
||||
'terminal.menu.splitHorizontal': '水平分割',
|
||||
'terminal.menu.splitVertical': '垂直分割',
|
||||
'terminal.menu.clearBuffer': '清空緩衝區',
|
||||
'terminal.menu.closeTerminal': '關閉終端',
|
||||
'terminal.menu.rename': '重新命名',
|
||||
'terminal.menu.detach': '從工作區移出',
|
||||
'terminal.menu.detachSession': '移出 {name}',
|
||||
'terminal.ymodem.selectFile': '選擇要傳送的檔案',
|
||||
'terminal.ymodem.allFiles': '所有檔案',
|
||||
'terminal.ymodem.started': '正在透過 YMODEM 傳送 {fileName}',
|
||||
'terminal.ymodem.complete': 'YMODEM 已傳送 {fileName}',
|
||||
'terminal.ymodem.failed': 'YMODEM 傳送失敗',
|
||||
'terminal.ymodem.selectReceiveDirectory': '選擇接收檔案儲存位置',
|
||||
'terminal.ymodem.receiveStarted': '正在透過 YMODEM 接收...',
|
||||
'terminal.ymodem.receiveComplete': 'YMODEM 已接收 {fileName}',
|
||||
'terminal.ymodem.receiveCompleteMultiple': 'YMODEM 已接收 {count} 個檔案',
|
||||
'terminal.ymodem.receiveEmpty': '沒有接收到 YMODEM 檔案',
|
||||
'terminal.ymodem.receiveFailed': 'YMODEM 接收失敗',
|
||||
'terminal.ymodem.unavailable': 'YMODEM 目前不可用',
|
||||
'terminal.selection.addToAI': '新增到對話',
|
||||
'terminal.selection.addToAIDesc': '將選取的終端輸出作為附件加入 AI 草稿',
|
||||
'terminal.auth.password': '密碼',
|
||||
'terminal.auth.sshKey': 'SSH Key',
|
||||
'terminal.auth.username': '使用者名稱',
|
||||
'terminal.auth.username.placeholder': 'root',
|
||||
'terminal.auth.passwordLabel': '密碼',
|
||||
'terminal.auth.password.placeholder': '輸入密碼',
|
||||
'terminal.auth.passphrase': '密碼短語',
|
||||
'terminal.auth.passphrase.placeholder': '可選:所選私鑰的密碼短語',
|
||||
'terminal.auth.certificate': '憑證',
|
||||
'terminal.auth.selectKey': '選擇金鑰',
|
||||
'terminal.auth.noKeysHint': '暫無金鑰,請先在鑰匙串中新增。',
|
||||
'terminal.auth.continueSave': '繼續並儲存',
|
||||
'terminal.auth.credentialsUnavailable': '目前裝置無法解密已儲存憑證,請重新輸入並再次儲存。',
|
||||
'terminal.auth.jumpCredentialsUnavailable': '某個跳板機的已儲存憑證無法在目前裝置解密,請到主機設定中重新填寫。',
|
||||
'terminal.auth.proxyCredentialsUnavailable': '代理憑證無法在目前裝置解密,請到主機設定中重新填寫代理密碼。',
|
||||
'terminal.auth.keyUnavailableFallbackPassword': '已儲存 SSH 金鑰在目前裝置不可用,改用密碼認證。',
|
||||
'terminal.connectionErrorTitle': '連線錯誤',
|
||||
'terminal.progress.timeoutIn': '將在 {seconds}s 後超時',
|
||||
'terminal.progress.waitingForUserInput': '等待使用者輸入',
|
||||
'terminal.progress.disconnected': '已斷開',
|
||||
'terminal.progress.cancelling': '正在取消...',
|
||||
'terminal.progress.startOver': '重新開始',
|
||||
'terminal.connection.dismissDisconnectedDialog': '關閉斷連提示',
|
||||
'terminal.connection.chainOf': 'Chain {current} / {total}',
|
||||
'terminal.connection.showLogs': '顯示日誌',
|
||||
'terminal.connection.hideLogs': '隱藏日誌',
|
||||
'terminal.connection.protocol.ssh': 'SSH',
|
||||
'terminal.connection.protocol.telnet': 'Telnet',
|
||||
'terminal.connection.protocol.mosh': 'Mosh',
|
||||
'terminal.connection.protocol.serial': '序列埠',
|
||||
'terminal.connection.protocol.local': '本機終端',
|
||||
'terminal.hostKey.unknownTitle': '確認主機指紋',
|
||||
'terminal.hostKey.changedTitle': '主機指紋已變化',
|
||||
'terminal.hostKey.unknownDescription': '尚未確認 {host} 的真實性。',
|
||||
'terminal.hostKey.changedDescription': '{host} 的已儲存指紋與目前伺服器不一致。',
|
||||
'terminal.hostKey.fingerprintLabel': '{keyType} 指紋為 SHA256:',
|
||||
'terminal.hostKey.savedFingerprintLabel': '已儲存的指紋',
|
||||
'terminal.hostKey.unknownHint': '如果這個指紋屬於你預期連線的伺服器,可以記住它。',
|
||||
'terminal.hostKey.changedHint': '只有在你確認這台主機確實變更過時才繼續。',
|
||||
'terminal.hostKey.addAndContinue': '記住並繼續',
|
||||
'terminal.hostKey.updateAndContinue': '更新並繼續',
|
||||
'terminal.themeModal.title': 'Terminal 外觀',
|
||||
'terminal.themeModal.tab.theme': '主題',
|
||||
'terminal.themeModal.tab.font': '字型',
|
||||
'terminal.themeModal.tab.custom': '自訂',
|
||||
'terminal.themeModal.globalTheme': '全域主題',
|
||||
'terminal.themeModal.globalFont': '全域字型',
|
||||
'terminal.themeModal.fontSize': '字型大小',
|
||||
'terminal.themeModal.fontWeight': '字型粗細',
|
||||
'terminal.themeModal.livePreview': '即時預覽',
|
||||
'terminal.themeModal.themeType': '{type} 主題',
|
||||
'terminal.hiddenTheme.title': '目前隱藏主題',
|
||||
'terminal.hiddenTheme.desc': '這個主題已從手動選擇列表中隱藏;當你選擇其他可見主題後,它會被替換。',
|
||||
'topTabs.toggleTheme.systemExitTitle': '目前正在跟隨系統主題',
|
||||
'topTabs.toggleTheme.systemExitMessage': '請到設定裡選擇固定的淺色或深色主題。',
|
||||
'topTabs.toggleTheme.openSettings': '開啟設定',
|
||||
|
||||
// Custom Themes
|
||||
'terminal.customTheme.section': '自訂主題',
|
||||
'terminal.customTheme.yourThemes': '我的主題',
|
||||
'terminal.customTheme.new': '新建主題',
|
||||
'terminal.customTheme.newDesc': '克隆目前主題並自訂',
|
||||
'terminal.customTheme.newTitle': '新建自訂主題',
|
||||
'terminal.customTheme.editTitle': '編輯主題',
|
||||
'terminal.customTheme.import': '匯入 .itermcolors',
|
||||
'terminal.customTheme.importDesc': '從 iTerm2 配色方案檔案匯入',
|
||||
'terminal.customTheme.importError': '無法解析所選檔案,請確保它是有效的 .itermcolors XML 檔案。',
|
||||
'terminal.customTheme.delete': '刪除主題',
|
||||
'terminal.customTheme.confirmDelete': '確認刪除',
|
||||
'terminal.customTheme.name': '名稱',
|
||||
'terminal.customTheme.namePlaceholder': '我的自訂主題',
|
||||
'terminal.customTheme.type': '型別',
|
||||
'terminal.customTheme.group.general': '通用',
|
||||
'terminal.customTheme.group.normal': '標準色',
|
||||
'terminal.customTheme.group.bright': '標示色',
|
||||
'terminal.customTheme.color.background': '背景',
|
||||
'terminal.customTheme.color.foreground': '前景',
|
||||
'terminal.customTheme.color.cursor': '游標',
|
||||
'terminal.customTheme.color.selection': '選區',
|
||||
'terminal.customTheme.color.black': '黑色',
|
||||
'terminal.customTheme.color.red': '紅色',
|
||||
'terminal.customTheme.color.green': '綠色',
|
||||
'terminal.customTheme.color.yellow': '黃色',
|
||||
'terminal.customTheme.color.blue': '藍色',
|
||||
'terminal.customTheme.color.magenta': '品紅',
|
||||
'terminal.customTheme.color.cyan': '青色',
|
||||
'terminal.customTheme.color.white': '白色',
|
||||
'terminal.customTheme.color.brightBlack': '亮黑',
|
||||
'terminal.customTheme.color.brightRed': '亮紅',
|
||||
'terminal.customTheme.color.brightGreen': '亮綠',
|
||||
'terminal.customTheme.color.brightYellow': '亮黃',
|
||||
'terminal.customTheme.color.brightBlue': '亮藍',
|
||||
'terminal.customTheme.color.brightMagenta': '亮品紅',
|
||||
'terminal.customTheme.color.brightCyan': '亮青色',
|
||||
'terminal.customTheme.color.brightWhite': '亮白',
|
||||
|
||||
'cloudSync.gate.title': '端到端加密同步',
|
||||
'cloudSync.gate.desc':
|
||||
'資料會在本機加密後再同步,雲端不會看到明文。設定主金鑰以啟用安全同步。',
|
||||
'cloudSync.gate.masterKey': '主金鑰',
|
||||
'cloudSync.gate.confirmMasterKey': '確認主金鑰',
|
||||
'cloudSync.gate.placeholder': '輸入一個強密碼',
|
||||
'cloudSync.gate.confirmPlaceholder': '再次輸入密碼',
|
||||
'cloudSync.gate.mismatch': '兩次輸入的密碼不一致',
|
||||
'cloudSync.gate.warning':
|
||||
'我已瞭解:如果忘記主金鑰,資料無法恢復,且沒有密碼重置功能。',
|
||||
'cloudSync.gate.enableVault': '啟用加密 Vault',
|
||||
'cloudSync.gate.enabledToast': '已啟用加密 Vault',
|
||||
'cloudSync.gate.setupFailed': '設定主金鑰失敗',
|
||||
'cloudSync.passwordStrength.tooShort': '太短',
|
||||
'cloudSync.passwordStrength.weak': '弱',
|
||||
'cloudSync.passwordStrength.moderate': '一般',
|
||||
'cloudSync.passwordStrength.strong': '強',
|
||||
'cloudSync.passwordStrength.veryStrong': '非常強',
|
||||
'cloudSync.provider.notConnected': '未連線',
|
||||
'cloudSync.provider.sync': '同步',
|
||||
'cloudSync.provider.connect': '連線',
|
||||
'cloudSync.provider.connecting': '連線中...',
|
||||
'cloudSync.provider.webdav': 'WebDAV',
|
||||
'cloudSync.provider.webdav.desc': '連線到自建 WebDAV 端點',
|
||||
'cloudSync.provider.s3': 'S3 相容儲存',
|
||||
'cloudSync.provider.s3.desc': '連線到 S3 相容物件儲存',
|
||||
'cloudSync.provider.comingSoon': '即將支援',
|
||||
'cloudSync.webdav.title': 'WebDAV 設定',
|
||||
'cloudSync.webdav.desc': '設定 WebDAV 端點用於加密同步。',
|
||||
'cloudSync.webdav.endpoint': '端點地址',
|
||||
'cloudSync.webdav.authType': '認證方式',
|
||||
'cloudSync.webdav.auth.basic': 'Basic',
|
||||
'cloudSync.webdav.auth.digest': 'Digest',
|
||||
'cloudSync.webdav.auth.token': 'Token',
|
||||
'cloudSync.webdav.username': '使用者名稱',
|
||||
'cloudSync.webdav.password': '密碼',
|
||||
'cloudSync.webdav.token': 'Token',
|
||||
'cloudSync.webdav.showSecret': '顯示金鑰',
|
||||
'cloudSync.webdav.allowInsecure': '允許不安全的連線(忽略憑證錯誤)',
|
||||
'cloudSync.webdav.validation.endpoint': '請輸入有效的 WebDAV 端點。',
|
||||
'cloudSync.webdav.validation.credentials': '請輸入使用者名稱和密碼。',
|
||||
'cloudSync.webdav.validation.token': '請輸入 Token。',
|
||||
'cloudSync.s3.title': 'S3 設定',
|
||||
'cloudSync.s3.desc': '連線到 S3 相容物件儲存以進行加密同步。',
|
||||
'cloudSync.s3.endpoint': '端點地址',
|
||||
'cloudSync.s3.region': 'Region',
|
||||
'cloudSync.s3.bucket': 'Bucket',
|
||||
'cloudSync.s3.accessKeyId': 'Access Key ID',
|
||||
'cloudSync.s3.secretAccessKey': 'Secret Access Key',
|
||||
'cloudSync.s3.sessionToken': 'Session Token(可選)',
|
||||
'cloudSync.s3.prefix': 'Key 字首(可選)',
|
||||
'cloudSync.s3.forcePathStyle': '強制使用 path-style URL(適用於 MinIO/R2 等)',
|
||||
'cloudSync.s3.showSecret': '顯示金鑰',
|
||||
'cloudSync.s3.validation.required': '端點、Region、Bucket、Access Key 與 Secret 必填。',
|
||||
'cloudSync.smb.title': 'SMB 設定',
|
||||
'cloudSync.smb.desc': '連線到 SMB/CIFS 檔案共享以進行加密同步。',
|
||||
'cloudSync.smb.share': '共享路徑',
|
||||
'cloudSync.smb.username': '使用者名稱',
|
||||
'cloudSync.smb.password': '密碼',
|
||||
'cloudSync.smb.domain': '域(可選)',
|
||||
'cloudSync.smb.domainPlaceholder': '例如:WORKGROUP',
|
||||
'cloudSync.smb.port': '埠(可選)',
|
||||
'cloudSync.smb.showSecret': '顯示密碼',
|
||||
'cloudSync.smb.validation.share': '共享路徑必填。',
|
||||
'cloudSync.smb.validation.port': '埠必須是 1 到 65535 之間的數字。',
|
||||
'cloudSync.connect.smb.success': 'SMB 已連線',
|
||||
'cloudSync.connect.smb.failedTitle': 'SMB 連線失敗',
|
||||
'cloudSync.provider.smb': 'SMB 共享',
|
||||
'cloudSync.connect.webdav.success': 'WebDAV 已連線',
|
||||
'cloudSync.connect.webdav.failedTitle': 'WebDAV 連線失敗',
|
||||
'cloudSync.connect.s3.success': 'S3 已連線',
|
||||
'cloudSync.connect.s3.failedTitle': 'S3 連線失敗',
|
||||
'cloudSync.lastSync.never': '從未',
|
||||
'cloudSync.lastSync.justNow': '剛剛',
|
||||
'cloudSync.lastSync.minutesAgo': '{minutes} 分鐘前',
|
||||
'cloudSync.changeKey': '更改 Key',
|
||||
'cloudSync.providers.title': '雲服務',
|
||||
'cloudSync.syncAll': '同步所有已連線的服務',
|
||||
'cloudSync.autoSync.title': '自動同步',
|
||||
'cloudSync.autoSync.desc': '發生變更時自動同步',
|
||||
'cloudSync.strategy.title': '同步策略',
|
||||
'cloudSync.strategy.desc': '當本機和雲端都發生變化時,選擇如何處理。',
|
||||
'cloudSync.strategy.smartMerge': '智慧合併(推薦)',
|
||||
'cloudSync.strategy.smartMergeDesc': '儘量保留兩邊的變化;如果無法安全判斷,會再讓你手動選擇。',
|
||||
'cloudSync.strategy.preferCloud': '雲端優先',
|
||||
'cloudSync.strategy.preferCloudDesc': '兩邊都有變化時,下載雲端版本,並替換本機變化。',
|
||||
'cloudSync.strategy.preferLocal': '本機優先',
|
||||
'cloudSync.strategy.preferLocalDesc': '兩邊都有變化時,上傳本機版本,並替換雲端變化。',
|
||||
'cloudSync.status.title': '同步狀態',
|
||||
'cloudSync.status.localVersion': '本機版本',
|
||||
'cloudSync.status.remoteVersion': '遠端版本',
|
||||
'cloudSync.history.title': '同步歷史',
|
||||
'cloudSync.history.upload': '上傳',
|
||||
'cloudSync.history.download': '下載',
|
||||
'cloudSync.history.resolved': '已解決',
|
||||
'cloudSync.history.error': '錯誤',
|
||||
'cloudSync.localBackups.title': '本機備份歷史',
|
||||
'cloudSync.localBackups.desc': 'Netcatty 會在版本變化前,以及恢復主機庫前,自動留下一份本機恢復點。',
|
||||
'cloudSync.localBackups.retentionTitle': '備份保留數量',
|
||||
'cloudSync.localBackups.retentionDesc': '設定 Netcatty 最多保留多少份本機備份。',
|
||||
'cloudSync.localBackups.maxCount': '最多保留',
|
||||
'cloudSync.localBackups.maxSaved': '已儲存保留數量:{count}',
|
||||
'cloudSync.localBackups.maxInvalid': '請輸入 1 到 100 之間的數字。',
|
||||
'cloudSync.localBackups.empty': '還沒有本機備份。',
|
||||
'cloudSync.localBackups.reason.appVersionChange': '版本變化前',
|
||||
'cloudSync.localBackups.reason.beforeRestore': '恢復前',
|
||||
'cloudSync.localBackups.versionChange': '{from} -> {to}',
|
||||
'cloudSync.localBackups.counts': '{hosts} 台主機,{keys} 個金鑰,{snippets} 個程式碼片段,{notes} 條筆記',
|
||||
'cloudSync.localBackups.restore': '恢復',
|
||||
'cloudSync.localBackups.restoreSuccess': '已恢復本機備份。',
|
||||
'cloudSync.localBackups.restoreFailedTitle': '恢復失敗',
|
||||
'cloudSync.localBackups.restoreMissing': '找不到這份備份。',
|
||||
'cloudSync.localBackups.protectiveBackupFailed': '無法建立保護性備份,已中止恢復以避免覆蓋目前資料。請先解決底層問題(例如鑰匙串存取)後重試。詳情:{message}',
|
||||
'cloudSync.localBackups.restoreConfirmTitle': '確認恢復此備份?',
|
||||
'cloudSync.localBackups.restoreConfirmDesc': '目前的主機、金鑰、程式碼片段與設定將被替換為此備份中的內容。系統會先自動建立一個保護性快照,便於復原。',
|
||||
'cloudSync.localBackups.restoreConfirmButton': '恢復',
|
||||
'cloudSync.localBackups.restoreConfirmCancel': '取消',
|
||||
'cloudSync.localBackups.unavailableTitle': '無法使用本機備份',
|
||||
'cloudSync.localBackups.unavailableDesc': '目前平台未提供受支援的安全金鑰庫,Netcatty 無法安全地寫入本機備份。請在支援系統鑰匙串的環境中執行,或改用雲同步保留恢復點。',
|
||||
'cloudSync.localBackups.lockedTitle': '需要主金鑰',
|
||||
'cloudSync.localBackups.lockedDesc': '請先設定或解鎖主金鑰再恢復備份,以確保恢復後的憑證仍保持加密。',
|
||||
'cloudSync.revisionHistory.viewButton': '歷史版本',
|
||||
'cloudSync.revisionHistory.title': '主機庫版本歷史',
|
||||
'cloudSync.revisionHistory.description': '瀏覽並恢復 Gist 修訂歷史中的舊版主機庫資料。',
|
||||
'cloudSync.revisionHistory.empty': '未找到修訂記錄。',
|
||||
'cloudSync.revisionHistory.current': '目前版本',
|
||||
'cloudSync.revisionHistory.revision': '修訂',
|
||||
'cloudSync.revisionHistory.revisionPreview': '修訂內容',
|
||||
'cloudSync.revisionHistory.device': '裝置',
|
||||
'cloudSync.revisionHistory.hosts': '主機',
|
||||
'cloudSync.revisionHistory.keys': '金鑰',
|
||||
'cloudSync.revisionHistory.snippets': '程式碼片段',
|
||||
'cloudSync.revisionHistory.notes': '筆記',
|
||||
'cloudSync.revisionHistory.identities': '身份',
|
||||
'cloudSync.revisionHistory.restoreButton': '恢復此版本',
|
||||
'cloudSync.revisionHistory.restored': '已從選取的修訂恢復主機庫資料。',
|
||||
'cloudSync.revisionHistory.revisionNotFound': '修訂未找到或不包含主機庫資料。',
|
||||
'cloudSync.revisionHistory.decryptFailed': '無法解密此修訂。可能是使用了不同的主金鑰加密的。',
|
||||
'cloudSync.changeKey.title': '更改主金鑰',
|
||||
'cloudSync.changeKey.current': '目前主金鑰',
|
||||
'cloudSync.changeKey.new': '新的主金鑰',
|
||||
'cloudSync.changeKey.confirmNew': '確認新的主金鑰',
|
||||
'cloudSync.changeKey.currentPlaceholder': '輸入目前主金鑰',
|
||||
'cloudSync.changeKey.newPlaceholder': '輸入新的主金鑰',
|
||||
'cloudSync.changeKey.confirmPlaceholder': '再次輸入新的主金鑰',
|
||||
'cloudSync.changeKey.fillAll': '請填寫所有欄位',
|
||||
'cloudSync.changeKey.minLength': '新的主金鑰至少 8 個字元',
|
||||
'cloudSync.changeKey.notMatch': '兩次輸入的主金鑰不一致',
|
||||
'cloudSync.changeKey.incorrectCurrent': '目前主金鑰不正確',
|
||||
'cloudSync.changeKey.failed': '更改主金鑰失敗',
|
||||
'cloudSync.changeKey.desc': '這將重新加密 Vault,請務必記住新的主金鑰。',
|
||||
'cloudSync.changeKey.showKeys': '顯示主金鑰',
|
||||
'cloudSync.changeKey.updatedToast': '主金鑰已更新',
|
||||
'cloudSync.changeKey.updateButton': '更新主金鑰',
|
||||
'cloudSync.unlock.title': '輸入主金鑰',
|
||||
'cloudSync.unlock.masterKey': '主金鑰',
|
||||
'cloudSync.unlock.desc': '僅需輸入一次主金鑰以啟用加密同步,之後會透過系統 Keychain 安全儲存。',
|
||||
'cloudSync.unlock.placeholder': '輸入你的主金鑰',
|
||||
'cloudSync.unlock.empty': '請輸入主金鑰',
|
||||
'cloudSync.unlock.incorrect': '主金鑰不正確',
|
||||
'cloudSync.unlock.failed': '解鎖 Vault 失敗',
|
||||
'cloudSync.unlock.showKey': '顯示主金鑰',
|
||||
'cloudSync.unlock.notNow': '暫不',
|
||||
'cloudSync.unlock.readyToast': 'Vault 已就緒',
|
||||
'cloudSync.unlock.unlockButton': '解鎖',
|
||||
'cloudSync.header.vaultReady': 'Vault 已就緒',
|
||||
'cloudSync.header.preparingVault': '正在準備 Vault...',
|
||||
'cloudSync.header.providersConnected': '已連線 {count} 個 provider',
|
||||
'cloudSync.githubFlow.title': '連線到 GitHub',
|
||||
'cloudSync.githubFlow.desc': '複製下面的 code,並在 GitHub 頁面輸入以授權 Netcatty。',
|
||||
'cloudSync.githubFlow.copyCode': '複製 code',
|
||||
'cloudSync.githubFlow.copied': '已複製',
|
||||
'cloudSync.githubFlow.openGitHub': '開啟 GitHub',
|
||||
'cloudSync.githubFlow.waiting': '等待授權...',
|
||||
'cloudSync.conflict.title': '偵測到版本衝突',
|
||||
'cloudSync.conflict.desc': '選擇保留哪個版本',
|
||||
'cloudSync.conflict.local': '本機',
|
||||
'cloudSync.conflict.cloud': '雲端',
|
||||
'cloudSync.conflict.detailsTitle': '發生變化的資料',
|
||||
'cloudSync.conflict.detailsCounts': '本機 {local} · 雲端 {cloud} · 衝突 {conflicts}',
|
||||
'cloudSync.conflict.entity.hosts': '主機',
|
||||
'cloudSync.conflict.entity.keys': '金鑰',
|
||||
'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.snippetPackages': '片段包',
|
||||
'cloudSync.conflict.entity.portForwardingRules': '埠轉發',
|
||||
'cloudSync.conflict.entity.groupConfigs': '分組設定',
|
||||
'cloudSync.conflict.entity.settings': '設定',
|
||||
'cloudSync.conflict.keepLocal': '覆蓋雲端(保留本機)',
|
||||
'cloudSync.conflict.useCloud': '下載雲端(覆蓋本機)',
|
||||
'cloudSync.connect.browserContinue': '請在瀏覽器中完成授權',
|
||||
'cloudSync.connect.browserCancelled': '已取消上一個瀏覽器授權流程',
|
||||
'cloudSync.connect.github.success': 'GitHub 已連線',
|
||||
'cloudSync.connect.github.failedTitle': 'GitHub 連線失敗',
|
||||
'cloudSync.connect.github.timeout': '連線 GitHub 超時,請檢查網路或代理設定。',
|
||||
'cloudSync.connect.github.networkError': '無法存取 GitHub,請檢查網路或代理設定。',
|
||||
'cloudSync.connect.google.failedTitle': 'Google 連線失敗',
|
||||
'cloudSync.connect.onedrive.failedTitle': 'OneDrive 連線失敗',
|
||||
'cloudSync.sync.success': '已同步到 {provider}',
|
||||
'cloudSync.sync.failed': '同步失敗',
|
||||
'cloudSync.sync.failedTitle': '同步失敗',
|
||||
'cloudSync.sync.errorTitle': '同步錯誤',
|
||||
'cloudSync.resolve.downloaded': '已下載雲端資料',
|
||||
'cloudSync.resolve.uploaded': '已上傳本機資料',
|
||||
'cloudSync.resolve.failedTitle': '衝突處理失敗',
|
||||
'cloudSync.clearLocal.title': '清空本機資料',
|
||||
'cloudSync.clearLocal.desc': '重置本機版本和同步歷史。下次同步將從雲端下載。',
|
||||
'cloudSync.clearLocal.button': '清空',
|
||||
'cloudSync.clearLocal.dialog.title': '清空本機 Vault 資料?',
|
||||
'cloudSync.clearLocal.dialog.desc': '這將重置本機版本為 0 並清除同步歷史。下次同步時會從雲端下載資料,替換本機資料。',
|
||||
'cloudSync.clearLocal.dialog.cancel': '取消',
|
||||
'cloudSync.clearLocal.dialog.confirm': '確認清空',
|
||||
'cloudSync.clearLocal.toast.title': '本機資料已清空',
|
||||
'cloudSync.clearLocal.toast.desc': '本機版本已重置為 0。同步以從雲端下載資料。',
|
||||
|
||||
// Common (additional)
|
||||
'common.searchPlaceholder': '搜尋...',
|
||||
'common.import': '匯入',
|
||||
'common.generate': '生成',
|
||||
'common.delete': '刪除',
|
||||
'common.edit': '編輯',
|
||||
'sftp.context.openWithDefault': '系統預設程式開啟',
|
||||
'common.clear': '清除',
|
||||
'common.optional': '可選',
|
||||
'common.selectPlaceholder': '請選擇...',
|
||||
'common.error': '錯誤',
|
||||
'common.validation': '驗證',
|
||||
'common.saveChanges': '儲存修改',
|
||||
'common.advanced': '高階',
|
||||
'common.selectAHostPlaceholder': '選擇主機...',
|
||||
|
||||
// Actions
|
||||
'action.duplicate': '複製',
|
||||
'action.open': '開啟',
|
||||
'action.copy': '複製',
|
||||
'action.run': '執行',
|
||||
'action.start': '啟動',
|
||||
'action.stop': '停止',
|
||||
|
||||
// Port Forwarding (form)
|
||||
'pf.form.labelPlaceholder': '規則標籤',
|
||||
'pf.form.intermediateHost': '中轉主機 *',
|
||||
'pf.form.createRule': '建立規則',
|
||||
'pf.form.openWizard': '打開向導',
|
||||
'pf.form.openWizardTitle': '開啟埠轉發精靈',
|
||||
'pf.action.newForwarding': '新建轉發',
|
||||
'pf.view.grid': '網格',
|
||||
'pf.view.list': '列表',
|
||||
'pf.rule.summary.local': '本機 {bindAddress}:{localPort} -> SSH 隧道 -> 遠端 {remoteHost}:{remotePort}',
|
||||
'pf.rule.summary.remote': '遠端 {bindAddress}:{localPort} -> SSH 隧道 -> 本機 {remoteHost}:{remotePort}',
|
||||
'pf.rule.summary.dynamic': 'SOCKS 監聽於 {bindAddress}:{localPort}',
|
||||
'pf.tooltip.relayHost': '中轉主機',
|
||||
'pf.tooltip.hostLabel': '主機',
|
||||
'pf.tooltip.hostAddress': '地址',
|
||||
'pf.tooltip.noHost': '未設定中轉主機',
|
||||
'pf.tooltip.localDesc': '本機埠轉發:透過 SSH 隧道存取遠端服務',
|
||||
'pf.tooltip.remoteDesc': '遠端埠轉發:將本機服務公開給遠端主機',
|
||||
'pf.tooltip.dynamicDesc': '動態 SOCKS 代理:透過 SSH 隧道轉發流量',
|
||||
'pf.deleteActive.title': '刪除正在執行的埠轉發?',
|
||||
'pf.deleteActive.desc': '埠轉發規則 "{label}" 目前正在執行。刪除前將先關閉轉發連線。',
|
||||
'pf.deleteActive.confirm': '關閉並刪除',
|
||||
'pf.form.autoStart': '自動啟動',
|
||||
'pf.form.autoStartDesc': '應用程式啟動時自動開啟此規則',
|
||||
|
||||
// SFTP (pane + conflict)
|
||||
'sftp.pane.local': '本機',
|
||||
'sftp.pane.remote': '遠端',
|
||||
'sftp.pane.selectHost': '選擇主機',
|
||||
'sftp.pane.selectHostToStart': '先選擇一個主機',
|
||||
'sftp.pane.chooseFilesystem': '選擇要瀏覽的本機或遠端檔案系統',
|
||||
'sftp.tabs.addTab': '新建標籤頁',
|
||||
'sftp.tabs.closeTab': '關閉標籤頁',
|
||||
'sftp.tabs.newTab': '新標籤頁',
|
||||
'sftp.tabs.copyDefaultPath': '複製標籤頁(預設路徑)',
|
||||
'sftp.tabs.copyCurrentPath': '複製並跳轉到目前路徑',
|
||||
'sftp.conflict.title': '檔案衝突',
|
||||
'sftp.conflict.desc': '目標位置已存在同名檔案',
|
||||
'sftp.conflict.alreadyExistsSuffix': '已存在',
|
||||
'sftp.conflict.existingFile': '已有檔案',
|
||||
'sftp.conflict.newFile': '新檔案',
|
||||
'sftp.conflict.size': '大小:',
|
||||
'sftp.conflict.modified': '修改時間:',
|
||||
'sftp.conflict.applyToAll': '將此操作套用到剩餘的 {count} 個衝突',
|
||||
'sftp.conflict.action.stop': '停止',
|
||||
'sftp.conflict.action.skip': '跳過',
|
||||
'sftp.conflict.action.keepBoth': '保留兩者',
|
||||
'sftp.conflict.action.duplicate': '建立副本',
|
||||
'sftp.conflict.action.merge': '合併',
|
||||
'sftp.conflict.action.replace': '替換',
|
||||
|
||||
// SFTP Upload Phases
|
||||
'sftp.upload.phase.compressing': '正在壓縮',
|
||||
'sftp.upload.phase.uploading': '正在上傳',
|
||||
'sftp.upload.phase.extracting': '正在解壓',
|
||||
'sftp.upload.phase.compressed': '壓縮傳輸',
|
||||
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import en, { type Messages } from './locales/en';
|
||||
import zhCN from './locales/zh-CN';
|
||||
import zhTW from './locales/zh-TW';
|
||||
import ru from './locales/ru';
|
||||
|
||||
// Keep keys stable; add new locales by adding another import and map entry.
|
||||
@@ -9,5 +10,6 @@ export const MESSAGES_BY_LOCALE: Record<string, Messages> = {
|
||||
en,
|
||||
ru,
|
||||
'zh-CN': zhCN,
|
||||
'zh-TW': zhTW,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { isActiveChromeThemeResolvable, resolveActiveChromeTheme } from '../app/activeChromeTheme';
|
||||
import { clearTopTabsChromeThemeVars } from '../app/topTabsChromeTheme';
|
||||
import type { TerminalAppearanceHostScope, ResolvedAppearance } from '../../domain/terminalAppearanceRuntime';
|
||||
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
|
||||
import { activeTabStore } from './activeTabStore';
|
||||
import type { EditorTab } from './editorTabStore';
|
||||
@@ -15,6 +16,7 @@ export type ActiveChromeThemeDeps = {
|
||||
followAppTerminalTheme: boolean;
|
||||
hostById: Map<string, Host>;
|
||||
logViews: readonly LogView[];
|
||||
resolveSessionAppearance?: (hostScope: TerminalAppearanceHostScope) => ResolvedAppearance;
|
||||
sessionById: Map<string, TerminalSession>;
|
||||
themeById: Map<string, TerminalTheme>;
|
||||
workspaceById: Map<string, Workspace>;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
setScriptRuns,
|
||||
waitForScriptRun,
|
||||
} from './scriptAutomationCoordinator.ts';
|
||||
|
||||
test('waitForScriptRun resolves when run is already completed on subscribe', async () => {
|
||||
const runId = 'run-already-done';
|
||||
setScriptRuns([{
|
||||
runId,
|
||||
scriptId: 's1',
|
||||
sessionId: 'sess1',
|
||||
status: 'completed',
|
||||
startedAt: Date.now() - 1000,
|
||||
endedAt: Date.now(),
|
||||
logs: [],
|
||||
}]);
|
||||
|
||||
const run = await waitForScriptRun(runId, { timeoutMs: 5000 });
|
||||
assert.equal(run.runId, runId);
|
||||
assert.equal(run.status, 'completed');
|
||||
});
|
||||
|
||||
test('waitForScriptRun rejects when run already failed on subscribe', async () => {
|
||||
const runId = 'run-already-failed';
|
||||
setScriptRuns([{
|
||||
runId,
|
||||
sessionId: 'sess1',
|
||||
status: 'failed',
|
||||
startedAt: Date.now() - 1000,
|
||||
endedAt: Date.now(),
|
||||
error: 'boom',
|
||||
logs: [],
|
||||
}]);
|
||||
|
||||
await assert.rejects(
|
||||
() => waitForScriptRun(runId, { timeoutMs: 5000 }),
|
||||
/boom/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import type { Snippet } from '@/domain/models';
|
||||
import { isScriptSnippet, scriptContainsWriteOperations } from '@/domain/snippetScript.ts';
|
||||
import { localStorageAdapter } from '@/infrastructure/persistence/localStorageAdapter.ts';
|
||||
import { STORAGE_KEY_AI_PERMISSION_MODE } from '@/infrastructure/config/storageKeys.ts';
|
||||
import type { AIPermissionMode } from '@/infrastructure/ai/types.ts';
|
||||
import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts';
|
||||
import type { ScriptRun } from '@/types/global/netcatty-bridge-script.d.ts';
|
||||
|
||||
type RunsListener = (runs: ScriptRun[]) => void;
|
||||
|
||||
let runs: ScriptRun[] = [];
|
||||
const runsListeners = new Set<RunsListener>();
|
||||
|
||||
function readPermissionMode(): AIPermissionMode {
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
|
||||
if (stored === 'observer' || stored === 'confirm' || stored === 'auto') return stored;
|
||||
return 'confirm';
|
||||
}
|
||||
|
||||
export function subscribeScriptRuns(listener: RunsListener): () => void {
|
||||
runsListeners.add(listener);
|
||||
queueMicrotask(() => {
|
||||
if (runsListeners.has(listener)) {
|
||||
listener(runs);
|
||||
}
|
||||
});
|
||||
return () => runsListeners.delete(listener);
|
||||
}
|
||||
|
||||
export function setScriptRuns(nextRuns: ScriptRun[]) {
|
||||
runs = nextRuns;
|
||||
runsListeners.forEach((listener) => listener(runs));
|
||||
}
|
||||
|
||||
export function getActiveScriptRunForSession(sessionId: string): ScriptRun | undefined {
|
||||
return runs.find((run) =>
|
||||
run.sessionId === sessionId && (run.status === 'running' || run.status === 'paused'),
|
||||
);
|
||||
}
|
||||
|
||||
export async function runAutomationScript(params: {
|
||||
snippet: Snippet;
|
||||
sessionId: string;
|
||||
sessionIds?: string[];
|
||||
mode?: 'sequential' | 'parallel';
|
||||
sessionMeta?: {
|
||||
connected?: boolean;
|
||||
hostname?: string;
|
||||
username?: string;
|
||||
};
|
||||
}): Promise<{ runId: string; runIds: string[] }> {
|
||||
const permissionMode = readPermissionMode();
|
||||
if (permissionMode === 'observer' && scriptContainsWriteOperations(params.snippet.command)) {
|
||||
throw new Error('Observer mode blocks scripts that write to the terminal.');
|
||||
}
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.scriptRun) {
|
||||
throw new Error('Script bridge unavailable');
|
||||
}
|
||||
return bridge.scriptRun({
|
||||
scriptId: params.snippet.id,
|
||||
scriptLabel: params.snippet.label,
|
||||
content: params.snippet.command,
|
||||
sessionId: params.sessionId,
|
||||
sessionIds: params.sessionIds,
|
||||
mode: params.mode,
|
||||
permissionMode,
|
||||
sessionMeta: params.sessionMeta,
|
||||
});
|
||||
}
|
||||
|
||||
const TERMINAL_SCRIPT_STATUSES = new Set<ScriptRun['status']>(['completed', 'failed']);
|
||||
|
||||
export function waitForScriptRun(
|
||||
runId: string,
|
||||
options: { signal?: AbortSignal; timeoutMs?: number } = {},
|
||||
): Promise<ScriptRun> {
|
||||
const existing = runs.find((entry) => entry.runId === runId);
|
||||
if (existing && TERMINAL_SCRIPT_STATUSES.has(existing.status)) {
|
||||
if (existing.status === 'completed') {
|
||||
return Promise.resolve(existing);
|
||||
}
|
||||
return Promise.reject(new Error(existing.error || 'Script failed'));
|
||||
}
|
||||
|
||||
const timeoutMs = options.timeoutMs ?? 3_600_000;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
let unsubscribe: () => void = () => {};
|
||||
|
||||
const finish = (handler: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
unsubscribe();
|
||||
options.signal?.removeEventListener('abort', onAbort);
|
||||
handler();
|
||||
};
|
||||
|
||||
const onAbort = () => {
|
||||
finish(() => reject(new Error('Aborted')));
|
||||
};
|
||||
|
||||
const settleRun = (run: ScriptRun | undefined) => {
|
||||
if (!run || !TERMINAL_SCRIPT_STATUSES.has(run.status)) return;
|
||||
if (run.status === 'completed') {
|
||||
finish(() => resolve(run));
|
||||
return;
|
||||
}
|
||||
finish(() => reject(new Error(run.error || 'Script failed')));
|
||||
};
|
||||
|
||||
unsubscribe = subscribeScriptRuns((currentRuns) => {
|
||||
settleRun(currentRuns.find((entry) => entry.runId === runId));
|
||||
});
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
finish(() => reject(new Error('Script run timed out')));
|
||||
}, timeoutMs);
|
||||
|
||||
options.signal?.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
export async function runConnectScriptsSequential(params: {
|
||||
scripts: Snippet[];
|
||||
sessionId: string;
|
||||
signal?: AbortSignal;
|
||||
onScriptStart?: (snippet: Snippet) => void;
|
||||
onScriptComplete?: (snippet: Snippet) => void;
|
||||
sessionMeta?: {
|
||||
connected?: boolean;
|
||||
hostname?: string;
|
||||
username?: string;
|
||||
};
|
||||
}): Promise<void> {
|
||||
for (const snippet of params.scripts) {
|
||||
if (params.signal?.aborted) {
|
||||
throw new Error('Aborted');
|
||||
}
|
||||
params.onScriptStart?.(snippet);
|
||||
const { runId } = await runAutomationScript({
|
||||
snippet,
|
||||
sessionId: params.sessionId,
|
||||
sessionMeta: params.sessionMeta,
|
||||
});
|
||||
await waitForScriptRun(runId, { signal: params.signal });
|
||||
params.onScriptComplete?.(snippet);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSnippetOrScript(params: {
|
||||
snippet: Snippet;
|
||||
sessionId: string;
|
||||
runSnippetText: (command: string, noAutoRun?: boolean) => void;
|
||||
command: string;
|
||||
}) {
|
||||
if (isScriptSnippet(params.snippet)) {
|
||||
await runAutomationScript({
|
||||
snippet: params.snippet,
|
||||
sessionId: params.sessionId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
params.runSnippetText(params.command, params.snippet.noAutoRun);
|
||||
}
|
||||
|
||||
export async function stopScriptRun(runId: string): Promise<{ ok: boolean }> {
|
||||
const result = await netcattyBridge.get()?.scriptStop?.(runId);
|
||||
return { ok: result?.ok !== false };
|
||||
}
|
||||
|
||||
export async function pauseScriptRun(runId: string): Promise<{ ok: boolean }> {
|
||||
const result = await netcattyBridge.get()?.scriptPause?.(runId);
|
||||
return { ok: result?.ok !== false };
|
||||
}
|
||||
|
||||
export async function resumeScriptRun(runId: string): Promise<{ ok: boolean }> {
|
||||
const result = await netcattyBridge.get()?.scriptResume?.(runId);
|
||||
return { ok: result?.ok !== false };
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
type ScriptRecordingSnapshot = {
|
||||
sessionId: string | null;
|
||||
isPaused: boolean;
|
||||
};
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
let snapshot: ScriptRecordingSnapshot = { sessionId: null, isPaused: false };
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
function emit() {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
export function getScriptRecordingSnapshot(): ScriptRecordingSnapshot {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function subscribeScriptRecording(listener: Listener): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
export function setScriptRecordingState(sessionId: string | null, isPaused = false): void {
|
||||
if (snapshot.sessionId === sessionId && snapshot.isPaused === isPaused) return;
|
||||
snapshot = { sessionId, isPaused };
|
||||
emit();
|
||||
}
|
||||
@@ -39,14 +39,19 @@ import {
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
STORAGE_KEY_APP_ICON_VARIANT,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import {
|
||||
clampWindowOpacity,
|
||||
isValidUiFontId,
|
||||
migrateIncomingTerminalFontId,
|
||||
} from './settingsStateDefaults';
|
||||
import { isTerminalSidePanelAutoOpenTab, type TerminalSidePanelAutoOpenTab } from '../../domain/terminalSidePanelAutoOpen';
|
||||
|
||||
interface UseSettingsIpcSyncParams {
|
||||
enabled?: boolean;
|
||||
@@ -73,12 +78,15 @@ interface UseSettingsIpcSyncParams {
|
||||
setIsHotkeyRecordingState: Dispatch<SetStateAction<boolean>>;
|
||||
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setWindowOpacity: Dispatch<SetStateAction<number>>;
|
||||
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
|
||||
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpAutoOpenSidebar: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
|
||||
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
|
||||
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
|
||||
setTerminalSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
|
||||
setTerminalSidePanelAutoOpenTabState: Dispatch<SetStateAction<TerminalSidePanelAutoOpenTab>>;
|
||||
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
|
||||
setRestorePreviousSessionState: Dispatch<SetStateAction<boolean>>;
|
||||
setRestoreTerminalCwdState: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -110,12 +118,15 @@ export function useSettingsIpcSync({
|
||||
setIsHotkeyRecordingState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setWindowOpacity,
|
||||
setAppIconVariant,
|
||||
setAutoUpdateEnabled,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setWorkspaceFocusStyleState,
|
||||
setShowHostTreeSidebarState,
|
||||
setTerminalSidePanelAutoOpenState,
|
||||
setTerminalSidePanelAutoOpenTabState,
|
||||
setDisableTerminalFontZoomState,
|
||||
setRestorePreviousSessionState,
|
||||
setRestoreTerminalCwdState,
|
||||
@@ -226,6 +237,10 @@ export function useSettingsIpcSync({
|
||||
const nextOpacity = clampWindowOpacity(value);
|
||||
setWindowOpacity((prev) => (prev === nextOpacity ? prev : nextOpacity));
|
||||
}
|
||||
if (key === STORAGE_KEY_APP_ICON_VARIANT) {
|
||||
const nextVariant = resolveAppIconVariant(value);
|
||||
setAppIconVariant((prev) => (prev === nextVariant ? prev : nextVariant));
|
||||
}
|
||||
if (key === STORAGE_KEY_AUTO_UPDATE_ENABLED && typeof value === 'boolean') {
|
||||
setAutoUpdateEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
@@ -246,6 +261,12 @@ export function useSettingsIpcSync({
|
||||
if (key === STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR && typeof value === 'boolean') {
|
||||
setShowHostTreeSidebarState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN && typeof value === 'boolean') {
|
||||
setTerminalSidePanelAutoOpenState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB && isTerminalSidePanelAutoOpenTab(value)) {
|
||||
setTerminalSidePanelAutoOpenTabState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM && typeof value === 'boolean') {
|
||||
setDisableTerminalFontZoomState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
@@ -275,6 +296,7 @@ export function useSettingsIpcSync({
|
||||
setFollowAppTerminalThemeState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setWindowOpacity,
|
||||
setAppIconVariant,
|
||||
setHotkeyScheme,
|
||||
setIsHotkeyRecordingState,
|
||||
setSessionLogsDir,
|
||||
@@ -287,6 +309,8 @@ export function useSettingsIpcSync({
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setShowHostTreeSidebarState,
|
||||
setTerminalSidePanelAutoOpenState,
|
||||
setTerminalSidePanelAutoOpenTabState,
|
||||
setDisableTerminalFontZoomState,
|
||||
setRestorePreviousSessionState,
|
||||
setRestoreTerminalCwdState,
|
||||
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
|
||||
@@ -47,7 +49,9 @@ import {
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
STORAGE_KEY_APP_ICON_VARIANT,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
|
||||
import {
|
||||
clampWindowOpacity,
|
||||
isValidHslToken,
|
||||
@@ -56,6 +60,7 @@ import {
|
||||
isValidUiThemeId,
|
||||
migrateIncomingTerminalFontId,
|
||||
} from './settingsStateDefaults';
|
||||
import { isTerminalSidePanelAutoOpenTab, type TerminalSidePanelAutoOpenTab } from '../../domain/terminalSidePanelAutoOpen';
|
||||
|
||||
interface UseSettingsStorageSyncParams {
|
||||
enabled?: boolean;
|
||||
@@ -83,6 +88,8 @@ interface UseSettingsStorageSyncParams {
|
||||
showOnlyUngroupedHostsInRoot: boolean;
|
||||
showSftpTab: boolean;
|
||||
showHostTreeSidebar: boolean;
|
||||
terminalSidePanelAutoOpen: boolean;
|
||||
terminalSidePanelAutoOpenTab: TerminalSidePanelAutoOpenTab;
|
||||
shellOnlyTabNumberShortcuts: boolean;
|
||||
disableTerminalFontZoom: boolean;
|
||||
restorePreviousSession: boolean;
|
||||
@@ -97,6 +104,7 @@ interface UseSettingsStorageSyncParams {
|
||||
globalHotkeyEnabled: boolean;
|
||||
autoUpdateEnabled: boolean;
|
||||
windowOpacity: number;
|
||||
appIconVariant: AppIconVariant;
|
||||
setTheme: Dispatch<SetStateAction<'dark' | 'light' | 'system'>>;
|
||||
setLightUiThemeId: Dispatch<SetStateAction<string>>;
|
||||
setDarkUiThemeId: Dispatch<SetStateAction<string>>;
|
||||
@@ -123,6 +131,8 @@ interface UseSettingsStorageSyncParams {
|
||||
setShowOnlyUngroupedHostsInRootState: Dispatch<SetStateAction<boolean>>;
|
||||
setShowSftpTabState: Dispatch<SetStateAction<boolean>>;
|
||||
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
|
||||
setTerminalSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
|
||||
setTerminalSidePanelAutoOpenTabState: Dispatch<SetStateAction<TerminalSidePanelAutoOpenTab>>;
|
||||
setShellOnlyTabNumberShortcutsState: Dispatch<SetStateAction<boolean>>;
|
||||
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
|
||||
setRestorePreviousSessionState: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -136,6 +146,7 @@ interface UseSettingsStorageSyncParams {
|
||||
setSshDeepLinkEnabledState: (enabled: boolean) => void;
|
||||
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setWindowOpacity: Dispatch<SetStateAction<number>>;
|
||||
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
|
||||
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
|
||||
setSftpTransferConcurrencyState: Dispatch<SetStateAction<number>>;
|
||||
@@ -150,18 +161,18 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
|
||||
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
|
||||
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
}: UseSettingsStorageSyncParams) {
|
||||
// Fix 4: Keep a ref snapshot of current settings so the storage event handler
|
||||
@@ -173,9 +184,9 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
});
|
||||
settingsSnapshotRef.current = {
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
@@ -183,9 +194,9 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
};
|
||||
|
||||
// Listen for storage changes from other windows (cross-window sync)
|
||||
@@ -404,6 +415,17 @@ export function useSettingsStorageSync({
|
||||
setShowHostTreeSidebarState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.terminalSidePanelAutoOpen) {
|
||||
setTerminalSidePanelAutoOpenState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB && e.newValue !== null) {
|
||||
if (isTerminalSidePanelAutoOpenTab(e.newValue) && e.newValue !== s.terminalSidePanelAutoOpenTab) {
|
||||
setTerminalSidePanelAutoOpenTabState(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.shellOnlyTabNumberShortcuts) {
|
||||
@@ -448,6 +470,12 @@ export function useSettingsStorageSync({
|
||||
setWindowOpacity(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_APP_ICON_VARIANT && e.newValue !== null) {
|
||||
const newValue = resolveAppIconVariant(e.newValue);
|
||||
if (newValue !== s.appIconVariant) {
|
||||
setAppIconVariant(newValue);
|
||||
}
|
||||
}
|
||||
// Sync workspace focus style from other windows
|
||||
if (e.key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && e.newValue !== null) {
|
||||
if (e.newValue === 'dim' || e.newValue === 'border') {
|
||||
@@ -478,6 +506,7 @@ export function useSettingsStorageSync({
|
||||
setFollowAppTerminalThemeState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setWindowOpacity,
|
||||
setAppIconVariant,
|
||||
setHotkeyScheme,
|
||||
setLightUiThemeId,
|
||||
setSessionLogsDir,
|
||||
@@ -496,6 +525,8 @@ export function useSettingsStorageSync({
|
||||
setSftpUseCompressedUpload,
|
||||
setShowOnlyUngroupedHostsInRootState,
|
||||
setShowHostTreeSidebarState,
|
||||
setTerminalSidePanelAutoOpenState,
|
||||
setTerminalSidePanelAutoOpenTabState,
|
||||
setShowRecentHostsState,
|
||||
setShowSftpTabState,
|
||||
setShellOnlyTabNumberShortcutsState,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { resolveCurrentTerminalTheme } from "./settingsTerminalTheme";
|
||||
|
||||
const commonArgs = {
|
||||
terminalThemeId: "netcatty-dark",
|
||||
customThemes: [],
|
||||
resolvedTheme: "dark" as const,
|
||||
lightUiThemeId: "snow",
|
||||
darkUiThemeId: "github",
|
||||
accentMode: "theme" as const,
|
||||
customAccent: "",
|
||||
};
|
||||
|
||||
test("follow-app terminal theme uses pending pick before resolvedTheme catches up", () => {
|
||||
const theme = resolveCurrentTerminalTheme({
|
||||
...commonArgs,
|
||||
followAppTerminalTheme: true,
|
||||
pendingFollowAppTerminalThemeId: "system-flexoki-light",
|
||||
resolvedTheme: "dark",
|
||||
lightUiThemeId: "flexoki",
|
||||
darkUiThemeId: "github",
|
||||
terminalThemeDarkId: "dracula",
|
||||
terminalThemeLightId: "solarized-light",
|
||||
});
|
||||
|
||||
assert.equal(theme.id, "system-flexoki-light");
|
||||
});
|
||||
|
||||
test("follow-app terminal theme ignores manual per-mode terminal picks", () => {
|
||||
const theme = resolveCurrentTerminalTheme({
|
||||
...commonArgs,
|
||||
followAppTerminalTheme: true,
|
||||
terminalThemeDarkId: "dracula",
|
||||
terminalThemeLightId: "solarized-light",
|
||||
});
|
||||
|
||||
assert.equal(theme.id, "system-github-dark");
|
||||
});
|
||||
|
||||
test("manual terminal theme uses the active per-mode pick", () => {
|
||||
const theme = resolveCurrentTerminalTheme({
|
||||
...commonArgs,
|
||||
followAppTerminalTheme: false,
|
||||
terminalThemeDarkId: "dracula",
|
||||
terminalThemeLightId: "solarized-light",
|
||||
});
|
||||
|
||||
assert.equal(theme.id, "dracula");
|
||||
});
|
||||
|
||||
test("manual terminal theme preserves the saved global theme until a per-mode pick exists", () => {
|
||||
const theme = resolveCurrentTerminalTheme({
|
||||
...commonArgs,
|
||||
terminalThemeId: "dracula",
|
||||
followAppTerminalTheme: false,
|
||||
terminalThemeDarkId: "auto",
|
||||
terminalThemeLightId: "auto",
|
||||
});
|
||||
|
||||
assert.equal(theme.id, "dracula");
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { TerminalTheme } from '../../domain/models';
|
||||
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
|
||||
import { applyCustomAccentToTerminalTheme, resolveFollowedTerminalThemeId } from '../../domain/terminalAppearance';
|
||||
import { TERMINAL_THEMES, getBuiltinTerminalThemeById } from '../../infrastructure/config/terminalThemes';
|
||||
import {
|
||||
applyCustomAccentToTerminalTheme,
|
||||
resolveFollowAppTerminalThemeId,
|
||||
resolveManualTerminalThemeId,
|
||||
} from '../../domain/terminalAppearance';
|
||||
|
||||
interface ResolveCurrentTerminalThemeParams {
|
||||
terminalThemeId: string;
|
||||
@@ -8,6 +12,7 @@ interface ResolveCurrentTerminalThemeParams {
|
||||
terminalThemeLightId: string;
|
||||
customThemes: TerminalTheme[];
|
||||
followAppTerminalTheme: boolean;
|
||||
pendingFollowAppTerminalThemeId?: string | null;
|
||||
resolvedTheme: 'light' | 'dark';
|
||||
lightUiThemeId: string;
|
||||
darkUiThemeId: string;
|
||||
@@ -21,6 +26,7 @@ export function resolveCurrentTerminalTheme({
|
||||
terminalThemeLightId,
|
||||
customThemes,
|
||||
followAppTerminalTheme,
|
||||
pendingFollowAppTerminalThemeId = null,
|
||||
resolvedTheme,
|
||||
lightUiThemeId,
|
||||
darkUiThemeId,
|
||||
@@ -28,21 +34,29 @@ export function resolveCurrentTerminalTheme({
|
||||
customAccent,
|
||||
}: ResolveCurrentTerminalThemeParams): TerminalTheme {
|
||||
if (followAppTerminalTheme) {
|
||||
const followedId = resolveFollowedTerminalThemeId({
|
||||
const followedId = resolveFollowAppTerminalThemeId(pendingFollowAppTerminalThemeId, {
|
||||
resolvedTheme,
|
||||
terminalThemeDarkId,
|
||||
terminalThemeLightId,
|
||||
lightUiThemeId,
|
||||
darkUiThemeId,
|
||||
fallbackThemeId: terminalThemeId,
|
||||
});
|
||||
const followed = TERMINAL_THEMES.find(t => t.id === followedId)
|
||||
const followed = getBuiltinTerminalThemeById(followedId)
|
||||
|| customThemes.find(t => t.id === followedId);
|
||||
if (followed) {
|
||||
return applyCustomAccentToTerminalTheme(followed, accentMode, customAccent);
|
||||
}
|
||||
}
|
||||
const baseTheme = TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|
||||
const manualThemeId = resolveManualTerminalThemeId({
|
||||
resolvedTheme,
|
||||
terminalThemeDarkId,
|
||||
terminalThemeLightId,
|
||||
lightUiThemeId,
|
||||
darkUiThemeId,
|
||||
fallbackThemeId: terminalThemeId,
|
||||
});
|
||||
const baseTheme = getBuiltinTerminalThemeById(manualThemeId)
|
||||
|| customThemes.find(t => t.id === manualThemeId)
|
||||
|| getBuiltinTerminalThemeById(terminalThemeId)
|
||||
|| customThemes.find(t => t.id === terminalThemeId)
|
||||
|| TERMINAL_THEMES[0];
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
getSftpCurrentPathMemoryKey,
|
||||
getSftpReopenMemoryKey,
|
||||
resolveSftpOpenLocation,
|
||||
} from "./sftpReopenLocation.ts";
|
||||
|
||||
test("first open of a terminal lands on the terminal cwd", () => {
|
||||
const location = resolveSftpOpenLocation({
|
||||
hostId: "host-1",
|
||||
connectionKey: "host-1:server-a:22:ssh::deploy",
|
||||
terminalCwd: "/home/deploy",
|
||||
remembered: null,
|
||||
});
|
||||
|
||||
assert.equal(location, "/home/deploy");
|
||||
});
|
||||
|
||||
test("reopening the same terminal restores the last browsed path", () => {
|
||||
const location = resolveSftpOpenLocation({
|
||||
hostId: "host-1",
|
||||
connectionKey: "host-1:server-a:22:ssh::deploy",
|
||||
terminalCwd: "/home/deploy",
|
||||
remembered: {
|
||||
hostId: "host-1",
|
||||
connectionKey: "host-1:server-a:22:ssh::deploy",
|
||||
path: "/home/deploy/projects/app",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(location, "/home/deploy/projects/app");
|
||||
});
|
||||
|
||||
test("remembered path for a different host falls back to terminal cwd", () => {
|
||||
const location = resolveSftpOpenLocation({
|
||||
hostId: "host-2",
|
||||
connectionKey: "host-2:server-b:22:ssh::root",
|
||||
terminalCwd: "/srv",
|
||||
remembered: {
|
||||
hostId: "host-1",
|
||||
connectionKey: "host-1:server-a:22:ssh::deploy",
|
||||
path: "/home/deploy/projects/app",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(location, "/srv");
|
||||
});
|
||||
|
||||
test("explicit upload target wins over remembered path", () => {
|
||||
const location = resolveSftpOpenLocation({
|
||||
hostId: "host-1",
|
||||
connectionKey: "host-1:server-a:22:ssh::deploy",
|
||||
terminalCwd: "/home/deploy",
|
||||
explicitTargetPath: "/tmp/upload-here",
|
||||
hasPendingUpload: true,
|
||||
remembered: {
|
||||
hostId: "host-1",
|
||||
connectionKey: "host-1:server-a:22:ssh::deploy",
|
||||
path: "/home/deploy/projects/app",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(location, "/tmp/upload-here");
|
||||
});
|
||||
|
||||
test("untargeted upload does not inherit a remembered reopen path", () => {
|
||||
const location = resolveSftpOpenLocation({
|
||||
hostId: "host-1",
|
||||
connectionKey: "host-1:server-a:22:ssh::deploy",
|
||||
terminalCwd: undefined,
|
||||
hasPendingUpload: true,
|
||||
remembered: {
|
||||
hostId: "host-1",
|
||||
connectionKey: "host-1:server-a:22:ssh::deploy",
|
||||
path: "/home/deploy/projects/app",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(location, undefined);
|
||||
});
|
||||
|
||||
test("remembered path for the same saved host but different endpoint falls back to terminal cwd", () => {
|
||||
const location = resolveSftpOpenLocation({
|
||||
hostId: "host-1",
|
||||
connectionKey: "host-1:server-a:2200:ssh::root",
|
||||
terminalCwd: "/srv/root",
|
||||
remembered: {
|
||||
hostId: "host-1",
|
||||
connectionKey: "host-1:server-a:22:ssh::deploy",
|
||||
path: "/home/deploy/projects/app",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(location, "/srv/root");
|
||||
});
|
||||
|
||||
test("workspace sftp memory is keyed by source session rather than workspace tab", () => {
|
||||
const workspaceTabId = "workspace-1";
|
||||
|
||||
assert.equal(
|
||||
getSftpReopenMemoryKey({ tabId: workspaceTabId, sourceSessionId: "session-a" }),
|
||||
"session-a",
|
||||
);
|
||||
assert.equal(
|
||||
getSftpReopenMemoryKey({ tabId: workspaceTabId, sourceSessionId: "session-b" }),
|
||||
"session-b",
|
||||
);
|
||||
assert.equal(getSftpReopenMemoryKey({ tabId: "terminal-tab-1" }), "terminal-tab-1");
|
||||
});
|
||||
|
||||
test("path changes for non-reusable sessions still use the focused terminal session", () => {
|
||||
assert.equal(
|
||||
getSftpCurrentPathMemoryKey({
|
||||
tabId: "workspace-1",
|
||||
activeTerminalSessionIdForSftp: null,
|
||||
focusedSessionId: "mosh-session-1",
|
||||
}),
|
||||
"mosh-session-1",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
export interface SftpRememberedLocation {
|
||||
hostId: string;
|
||||
connectionKey: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export function getSftpReopenMemoryKey(params: {
|
||||
tabId: string;
|
||||
sourceSessionId?: string | null;
|
||||
}): string {
|
||||
return params.sourceSessionId || params.tabId;
|
||||
}
|
||||
|
||||
export function getSftpCurrentPathMemoryKey(params: {
|
||||
tabId: string;
|
||||
activeTerminalSessionIdForSftp?: string | null;
|
||||
focusedSessionId?: string | null;
|
||||
}): string {
|
||||
return params.activeTerminalSessionIdForSftp || params.focusedSessionId || params.tabId;
|
||||
}
|
||||
|
||||
export function resolveSftpOpenLocation(params: {
|
||||
hostId: string;
|
||||
connectionKey: string;
|
||||
terminalCwd?: string;
|
||||
explicitTargetPath?: string;
|
||||
hasPendingUpload?: boolean;
|
||||
remembered?: SftpRememberedLocation | null;
|
||||
}): string | undefined {
|
||||
const { hostId, connectionKey, terminalCwd, explicitTargetPath, hasPendingUpload, remembered } = params;
|
||||
|
||||
if (explicitTargetPath) {
|
||||
return explicitTargetPath;
|
||||
}
|
||||
|
||||
if (hasPendingUpload) {
|
||||
return terminalCwd && terminalCwd.length > 0 ? terminalCwd : undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
remembered &&
|
||||
remembered.hostId === hostId &&
|
||||
remembered.connectionKey === connectionKey &&
|
||||
remembered.path
|
||||
) {
|
||||
return remembered.path;
|
||||
}
|
||||
|
||||
return terminalCwd && terminalCwd.length > 0 ? terminalCwd : undefined;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { buildSftpHostCredentials } from "./useSftpHostCredentials.ts";
|
||||
import type { Host, KnownHost, SSHKey } from "../../../domain/models.ts";
|
||||
import type { Host, Identity, KnownHost, SSHKey } from "../../../domain/models.ts";
|
||||
|
||||
const host = (overrides: Partial<Host> = {}): Host => ({
|
||||
id: "host-1",
|
||||
@@ -52,6 +52,129 @@ test("buildSftpHostCredentials rejects missing saved proxy profiles on jump host
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials rejects missing proxy identities", () => {
|
||||
assert.throws(
|
||||
() => buildSftpHostCredentials({
|
||||
host: host({
|
||||
proxyConfig: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: "missing-identity",
|
||||
},
|
||||
}),
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
}),
|
||||
/Proxy identity for "Host" is missing/,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials rejects incomplete proxy identities", () => {
|
||||
assert.throws(
|
||||
() => buildSftpHostCredentials({
|
||||
host: host({
|
||||
proxyConfig: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: "identity-1",
|
||||
},
|
||||
}),
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [{
|
||||
id: "identity-1",
|
||||
label: "Proxy login",
|
||||
username: "proxy-user",
|
||||
authMethod: "password",
|
||||
created: 1,
|
||||
}],
|
||||
}),
|
||||
/Proxy identity for "Host" is incomplete/,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials rejects proxy identities with blank usernames even when passwords are encrypted", () => {
|
||||
assert.throws(
|
||||
() => buildSftpHostCredentials({
|
||||
host: host({
|
||||
proxyConfig: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: "identity-1",
|
||||
},
|
||||
}),
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [{
|
||||
id: "identity-1",
|
||||
label: "Proxy login",
|
||||
username: " ",
|
||||
authMethod: "password",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
created: 1,
|
||||
}],
|
||||
}),
|
||||
/Proxy identity for "Host" is incomplete/,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials rejects missing proxy identities on jump hosts", () => {
|
||||
const jumpHost = host({
|
||||
id: "jump-1",
|
||||
label: "Jump",
|
||||
proxyConfig: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: "missing-identity",
|
||||
},
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => buildSftpHostCredentials({
|
||||
host: host({ hostChain: { hostIds: ["jump-1"] } }),
|
||||
hosts: [jumpHost],
|
||||
keys: [],
|
||||
identities: [],
|
||||
}),
|
||||
/Proxy identity for "Jump" is missing/,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials rejects incomplete proxy identities on jump hosts", () => {
|
||||
const jumpHost = host({
|
||||
id: "jump-1",
|
||||
label: "Jump",
|
||||
proxyConfig: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: "identity-1",
|
||||
},
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => buildSftpHostCredentials({
|
||||
host: host({ hostChain: { hostIds: ["jump-1"] } }),
|
||||
hosts: [jumpHost],
|
||||
keys: [],
|
||||
identities: [{
|
||||
id: "identity-1",
|
||||
label: "Proxy login",
|
||||
username: "",
|
||||
authMethod: "password",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
created: 1,
|
||||
}],
|
||||
}),
|
||||
/Proxy identity for "Jump" is incomplete/,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials forwards custom ProxyCommand settings", () => {
|
||||
const credentials = buildSftpHostCredentials({
|
||||
host: host({
|
||||
@@ -77,6 +200,130 @@ test("buildSftpHostCredentials forwards custom ProxyCommand settings", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials resolves proxy credentials from a selected identity", () => {
|
||||
const identity: Identity = {
|
||||
id: "identity-1",
|
||||
label: "Proxy login",
|
||||
username: "proxy-user",
|
||||
authMethod: "password",
|
||||
password: "proxy-secret",
|
||||
created: 1,
|
||||
};
|
||||
const credentials = buildSftpHostCredentials({
|
||||
host: host({
|
||||
proxyConfig: {
|
||||
type: "socks5",
|
||||
host: "proxy.example.com",
|
||||
port: 1080,
|
||||
identityId: identity.id,
|
||||
},
|
||||
}),
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [identity],
|
||||
});
|
||||
|
||||
assert.deepEqual(credentials.proxy, {
|
||||
type: "socks5",
|
||||
host: "proxy.example.com",
|
||||
port: 1080,
|
||||
username: "proxy-user",
|
||||
password: "proxy-secret",
|
||||
});
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials rejects undecryptable proxy identity passwords", () => {
|
||||
assert.throws(
|
||||
() => buildSftpHostCredentials({
|
||||
host: host({
|
||||
proxyConfig: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: "identity-1",
|
||||
},
|
||||
}),
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [{
|
||||
id: "identity-1",
|
||||
label: "Proxy login",
|
||||
username: "proxy-user",
|
||||
authMethod: "password",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
created: 1,
|
||||
}],
|
||||
}),
|
||||
/Proxy credentials cannot be decrypted/,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials resolves jump host proxy credentials from a selected identity", () => {
|
||||
const identity: Identity = {
|
||||
id: "identity-1",
|
||||
label: "Proxy login",
|
||||
username: "proxy-user",
|
||||
authMethod: "password",
|
||||
password: "proxy-secret",
|
||||
created: 1,
|
||||
};
|
||||
const jumpHost = host({
|
||||
id: "jump-1",
|
||||
label: "Jump",
|
||||
proxyConfig: {
|
||||
type: "socks5",
|
||||
host: "jump-proxy.example.com",
|
||||
port: 1080,
|
||||
identityId: identity.id,
|
||||
},
|
||||
});
|
||||
|
||||
const credentials = buildSftpHostCredentials({
|
||||
host: host({ hostChain: { hostIds: ["jump-1"] } }),
|
||||
hosts: [jumpHost],
|
||||
keys: [],
|
||||
identities: [identity],
|
||||
});
|
||||
|
||||
assert.deepEqual(credentials.jumpHosts?.[0]?.proxy, {
|
||||
type: "socks5",
|
||||
host: "jump-proxy.example.com",
|
||||
port: 1080,
|
||||
username: "proxy-user",
|
||||
password: "proxy-secret",
|
||||
});
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials rejects undecryptable jump host proxy identity passwords", () => {
|
||||
const jumpHost = host({
|
||||
id: "jump-1",
|
||||
label: "Jump",
|
||||
proxyConfig: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: "identity-1",
|
||||
},
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => buildSftpHostCredentials({
|
||||
host: host({ hostChain: { hostIds: ["jump-1"] } }),
|
||||
hosts: [jumpHost],
|
||||
keys: [],
|
||||
identities: [{
|
||||
id: "identity-1",
|
||||
label: "Proxy login",
|
||||
username: "proxy-user",
|
||||
authMethod: "password",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
created: 1,
|
||||
}],
|
||||
}),
|
||||
/Proxy credentials for jump host "Jump" cannot be decrypted/,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials passes reference keys as identity file paths", () => {
|
||||
const key: SSHKey = {
|
||||
id: "key-1",
|
||||
|
||||
@@ -3,7 +3,15 @@ import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from "../../
|
||||
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
|
||||
import { resolveBridgeKeyAuth, resolveHostAuth } from "../../../domain/sshAuth";
|
||||
import { resolveHostKeepalive } from "../../../domain/host";
|
||||
import { hasUsableProxyConfig } from "../../../domain/proxyProfiles";
|
||||
import {
|
||||
findIncompleteProxyIdentityId,
|
||||
findMissingProxyIdentityId,
|
||||
formatIncompleteProxyIdentityMessage,
|
||||
formatMissingProxyIdentityMessage,
|
||||
hasUnreadableProxyCredential,
|
||||
hasUsableProxyConfig,
|
||||
resolveProxyConfigAuth,
|
||||
} from "../../../domain/proxyProfiles";
|
||||
|
||||
// Fallback used when no global TerminalSettings are wired through (older
|
||||
// call sites or tests). Matches DEFAULT_TERMINAL_SETTINGS so behavior is
|
||||
@@ -34,19 +42,18 @@ export const buildSftpHostCredentials = ({
|
||||
if (host.proxyProfileId && !host.proxyConfig) {
|
||||
throw new Error(`Saved proxy for host "${host.label || host.hostname}" is missing. Open host settings and select a valid proxy.`);
|
||||
}
|
||||
if (findMissingProxyIdentityId(host.proxyConfig, identities)) {
|
||||
throw new Error(formatMissingProxyIdentityMessage(host.label || host.hostname));
|
||||
}
|
||||
if (findIncompleteProxyIdentityId(host.proxyConfig, identities)) {
|
||||
throw new Error(formatIncompleteProxyIdentityMessage(host.label || host.hostname));
|
||||
}
|
||||
|
||||
const resolved = resolveHostAuth({ host, keys, identities });
|
||||
const key = resolved.key || null;
|
||||
|
||||
const proxyConfig = host.proxyConfig
|
||||
? {
|
||||
type: host.proxyConfig.type,
|
||||
host: host.proxyConfig.host,
|
||||
port: host.proxyConfig.port,
|
||||
command: host.proxyConfig.command,
|
||||
username: host.proxyConfig.username,
|
||||
password: sanitizeCredentialValue(host.proxyConfig.password),
|
||||
}
|
||||
? resolveProxyConfigAuth(host.proxyConfig, identities)
|
||||
: undefined;
|
||||
let jumpHosts: NetcattyJumpHost[] | undefined;
|
||||
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
|
||||
@@ -58,6 +65,12 @@ export const buildSftpHostCredentials = ({
|
||||
if (jumpHost.proxyProfileId && !jumpHost.proxyConfig) {
|
||||
throw new Error(`Saved proxy for jump host "${jumpHost.label || jumpHost.hostname}" is missing. Open host settings and select a valid proxy.`);
|
||||
}
|
||||
if (findMissingProxyIdentityId(jumpHost.proxyConfig, identities)) {
|
||||
throw new Error(formatMissingProxyIdentityMessage(jumpHost.label || jumpHost.hostname));
|
||||
}
|
||||
if (findIncompleteProxyIdentityId(jumpHost.proxyConfig, identities)) {
|
||||
throw new Error(formatIncompleteProxyIdentityMessage(jumpHost.label || jumpHost.hostname));
|
||||
}
|
||||
return jumpHost;
|
||||
}).map((jumpHost, index) => {
|
||||
const jumpAuth = resolveHostAuth({
|
||||
@@ -80,9 +93,7 @@ export const buildSftpHostCredentials = ({
|
||||
hasUsableProxyConfig(jumpHost.proxyConfig);
|
||||
if (
|
||||
hasConfiguredJumpProxyEndpoint &&
|
||||
jumpHost.proxyConfig?.username &&
|
||||
isEncryptedCredentialPlaceholder(jumpHost.proxyConfig.password) &&
|
||||
!sanitizeCredentialValue(jumpHost.proxyConfig.password)
|
||||
hasUnreadableProxyCredential(jumpHost.proxyConfig, identities)
|
||||
) {
|
||||
throw new Error(`Proxy credentials for jump host "${jumpHost.label || jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter the proxy password.`);
|
||||
}
|
||||
@@ -110,14 +121,7 @@ export const buildSftpHostCredentials = ({
|
||||
keySource: jumpKey?.source,
|
||||
label: jumpHost.label,
|
||||
proxy: hasUsableProxyConfig(jumpHost.proxyConfig)
|
||||
? {
|
||||
type: jumpHost.proxyConfig.type,
|
||||
host: jumpHost.proxyConfig.host,
|
||||
port: jumpHost.proxyConfig.port,
|
||||
command: jumpHost.proxyConfig.command,
|
||||
username: jumpHost.proxyConfig.username,
|
||||
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
|
||||
}
|
||||
? resolveProxyConfigAuth(jumpHost.proxyConfig, identities)
|
||||
: undefined,
|
||||
identityFilePaths: jumpKeyAuth.identityFilePaths,
|
||||
keepaliveInterval: hopKeepalive.interval,
|
||||
@@ -130,7 +134,7 @@ export const buildSftpHostCredentials = ({
|
||||
});
|
||||
}
|
||||
const usesTargetProxyForFirstHop = !!proxyConfig && !jumpHosts?.[0]?.proxy;
|
||||
if (usesTargetProxyForFirstHop && host.proxyConfig?.username && isEncryptedCredentialPlaceholder(host.proxyConfig.password) && !proxyConfig?.password) {
|
||||
if (usesTargetProxyForFirstHop && hasUnreadableProxyCredential(host.proxyConfig, identities)) {
|
||||
throw new Error("Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
getSidePanelLiveSnapshot,
|
||||
sidePanelLiveStore,
|
||||
SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT,
|
||||
} from './sidePanelLiveStore.ts';
|
||||
|
||||
test('sidePanelLiveStore skips notify when snapshot is unchanged', () => {
|
||||
let notifications = 0;
|
||||
const unsubscribe = sidePanelLiveStore.subscribe(() => {
|
||||
notifications += 1;
|
||||
});
|
||||
|
||||
const snapshot = {
|
||||
...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT,
|
||||
activeTerminalCwd: '/tmp',
|
||||
};
|
||||
sidePanelLiveStore.update(snapshot);
|
||||
sidePanelLiveStore.update({ ...snapshot });
|
||||
unsubscribe();
|
||||
|
||||
assert.equal(notifications, 1);
|
||||
});
|
||||
|
||||
test('getSidePanelLiveSnapshot returns inactive snapshot when disabled', () => {
|
||||
sidePanelLiveStore.update({
|
||||
...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT,
|
||||
activeTerminalCwd: '/var/log',
|
||||
});
|
||||
|
||||
assert.equal(getSidePanelLiveSnapshot(false), SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT);
|
||||
assert.equal(getSidePanelLiveSnapshot(true).activeTerminalCwd, '/var/log');
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
|
||||
|
||||
export type SidePanelLiveSnapshot = {
|
||||
sftpActiveHost: Host | null;
|
||||
activeTerminalSessionIdForSftp: string | null;
|
||||
activeTerminalCwd: string | null;
|
||||
activeWorkspace: Workspace | undefined;
|
||||
activeTerminalSessionForSystem: TerminalSession | null;
|
||||
activeSystemSessionHost: Host | null;
|
||||
focusedHost: Host | null;
|
||||
focusedSessionId: string | null;
|
||||
historySessionId: string | null;
|
||||
resolvedPreviewTheme: TerminalTheme | null;
|
||||
previewedOrVisibleThemeId: string | undefined;
|
||||
focusedFontFamilyId: string | undefined;
|
||||
focusedFontFamilyOverridden: boolean;
|
||||
focusedFontSize: number | undefined;
|
||||
focusedFontSizeOverridden: boolean;
|
||||
focusedFontWeight: number | undefined;
|
||||
focusedFontWeightOverridden: boolean;
|
||||
focusedThemeOverridden: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_SNAPSHOT: SidePanelLiveSnapshot = {
|
||||
sftpActiveHost: null,
|
||||
activeTerminalSessionIdForSftp: null,
|
||||
activeTerminalCwd: null,
|
||||
activeWorkspace: undefined,
|
||||
activeTerminalSessionForSystem: null,
|
||||
activeSystemSessionHost: null,
|
||||
focusedHost: null,
|
||||
focusedSessionId: null,
|
||||
historySessionId: null,
|
||||
resolvedPreviewTheme: null,
|
||||
previewedOrVisibleThemeId: undefined,
|
||||
focusedFontFamilyId: undefined,
|
||||
focusedFontFamilyOverridden: false,
|
||||
focusedFontSize: undefined,
|
||||
focusedFontSizeOverridden: false,
|
||||
focusedFontWeight: undefined,
|
||||
focusedFontWeightOverridden: false,
|
||||
focusedThemeOverridden: false,
|
||||
};
|
||||
|
||||
export const SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT = EMPTY_SNAPSHOT;
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
function liveSnapshotEqual(a: SidePanelLiveSnapshot, b: SidePanelLiveSnapshot): boolean {
|
||||
return a.sftpActiveHost === b.sftpActiveHost
|
||||
&& a.activeTerminalSessionIdForSftp === b.activeTerminalSessionIdForSftp
|
||||
&& a.activeTerminalCwd === b.activeTerminalCwd
|
||||
&& a.activeWorkspace === b.activeWorkspace
|
||||
&& a.activeTerminalSessionForSystem === b.activeTerminalSessionForSystem
|
||||
&& a.activeSystemSessionHost === b.activeSystemSessionHost
|
||||
&& a.focusedHost === b.focusedHost
|
||||
&& a.focusedSessionId === b.focusedSessionId
|
||||
&& a.historySessionId === b.historySessionId
|
||||
&& a.resolvedPreviewTheme === b.resolvedPreviewTheme
|
||||
&& a.previewedOrVisibleThemeId === b.previewedOrVisibleThemeId
|
||||
&& a.focusedFontFamilyId === b.focusedFontFamilyId
|
||||
&& a.focusedFontFamilyOverridden === b.focusedFontFamilyOverridden
|
||||
&& a.focusedFontSize === b.focusedFontSize
|
||||
&& a.focusedFontSizeOverridden === b.focusedFontSizeOverridden
|
||||
&& a.focusedFontWeight === b.focusedFontWeight
|
||||
&& a.focusedFontWeightOverridden === b.focusedFontWeightOverridden
|
||||
&& a.focusedThemeOverridden === b.focusedThemeOverridden;
|
||||
}
|
||||
|
||||
class SidePanelLiveStore {
|
||||
private snapshot: SidePanelLiveSnapshot = EMPTY_SNAPSHOT;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
getSnapshot = (): SidePanelLiveSnapshot => this.snapshot;
|
||||
|
||||
subscribe = (listener: Listener): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
|
||||
update(next: SidePanelLiveSnapshot): void {
|
||||
if (liveSnapshotEqual(this.snapshot, next)) return;
|
||||
this.snapshot = next;
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const sidePanelLiveStore = new SidePanelLiveStore();
|
||||
|
||||
const noopSubscribe = () => () => {};
|
||||
|
||||
export function subscribeSidePanelLiveSnapshot(enabled: boolean, listener: Listener): () => void {
|
||||
if (!enabled) return noopSubscribe();
|
||||
return sidePanelLiveStore.subscribe(listener);
|
||||
}
|
||||
|
||||
export function getSidePanelLiveSnapshot(enabled: boolean): SidePanelLiveSnapshot {
|
||||
return enabled ? sidePanelLiveStore.getSnapshot() : SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT;
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useEffect, type MutableRefObject } from 'react';
|
||||
import { useEffect, useRef, type MutableRefObject } from 'react';
|
||||
import {
|
||||
STORAGE_KEY_AUTO_UPDATE_ENABLED,
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
STORAGE_KEY_APP_ICON_VARIANT,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
@@ -15,10 +17,12 @@ interface UseSystemSettingsEffectsParams {
|
||||
globalHotkeyEnabled: boolean;
|
||||
closeToTray: boolean;
|
||||
windowOpacity: number;
|
||||
appIconVariant: AppIconVariant;
|
||||
autoUpdateEnabled: boolean;
|
||||
persistMountedRef: MutableRefObject<boolean>;
|
||||
setHotkeyRegistrationError: (error: string | null) => void;
|
||||
setAutoUpdateEnabled: (enabled: boolean | ((prev: boolean) => boolean)) => void;
|
||||
setAppIconVariant: (variant: AppIconVariant | ((prev: AppIconVariant) => AppIconVariant)) => void;
|
||||
notifySettingsChanged: (key: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
@@ -28,12 +32,16 @@ export function useSystemSettingsEffects({
|
||||
globalHotkeyEnabled,
|
||||
closeToTray,
|
||||
windowOpacity,
|
||||
appIconVariant,
|
||||
autoUpdateEnabled,
|
||||
persistMountedRef,
|
||||
setHotkeyRegistrationError,
|
||||
setAutoUpdateEnabled,
|
||||
setAppIconVariant,
|
||||
notifySettingsChanged,
|
||||
}: UseSystemSettingsEffectsParams) {
|
||||
const appIconApplyRequestIdRef = useRef(0);
|
||||
|
||||
// Persist and sync toggle window hotkey setting
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
@@ -110,6 +118,57 @@ export function useSystemSettingsEffects({
|
||||
notifySettingsChanged(STORAGE_KEY_WINDOW_OPACITY, windowOpacity);
|
||||
}, [enabled, windowOpacity, notifySettingsChanged, persistMountedRef]);
|
||||
|
||||
// Persist and sync app icon variant
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const storedBefore = resolveAppIconVariant(
|
||||
localStorageAdapter.readString(STORAGE_KEY_APP_ICON_VARIANT) ?? '',
|
||||
);
|
||||
|
||||
localStorageAdapter.writeString(STORAGE_KEY_APP_ICON_VARIANT, appIconVariant);
|
||||
if (!persistMountedRef.current) {
|
||||
// Still apply on initial mount before cross-window notify is enabled.
|
||||
} else {
|
||||
notifySettingsChanged(STORAGE_KEY_APP_ICON_VARIANT, appIconVariant);
|
||||
}
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.setAppIconVariant) return;
|
||||
|
||||
const requestId = ++appIconApplyRequestIdRef.current;
|
||||
let cancelled = false;
|
||||
|
||||
const revertVariant = () => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_APP_ICON_VARIANT, storedBefore);
|
||||
if (appIconVariant !== storedBefore) {
|
||||
setAppIconVariant(storedBefore);
|
||||
}
|
||||
if (persistMountedRef.current) {
|
||||
notifySettingsChanged(STORAGE_KEY_APP_ICON_VARIANT, storedBefore);
|
||||
}
|
||||
};
|
||||
|
||||
void bridge.setAppIconVariant(appIconVariant)
|
||||
.then((applied) => {
|
||||
if (cancelled || requestId !== appIconApplyRequestIdRef.current) return;
|
||||
if (applied === false && storedBefore !== appIconVariant) {
|
||||
console.warn('[AppIcon] Failed to apply app icon variant:', appIconVariant);
|
||||
revertVariant();
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled || requestId !== appIconApplyRequestIdRef.current) return;
|
||||
if (storedBefore !== appIconVariant) {
|
||||
console.warn('[AppIcon] Failed to apply app icon variant:', err);
|
||||
revertVariant();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [enabled, appIconVariant, notifySettingsChanged, persistMountedRef, setAppIconVariant]);
|
||||
|
||||
// Hydrate auto-update state from the main-process preference file on mount.
|
||||
// This reconciles localStorage (renderer) with auto-update-pref.json (main)
|
||||
// in case localStorage was cleared or is stale.
|
||||
|
||||
@@ -26,7 +26,11 @@ import type {
|
||||
ProviderConfig,
|
||||
WebSearchConfig,
|
||||
} from '../../infrastructure/ai/types';
|
||||
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
|
||||
import {
|
||||
DEFAULT_COMMAND_BLOCKLIST,
|
||||
DEFAULT_COMMAND_TIMEOUT_SECONDS,
|
||||
normalizeCommandTimeoutSeconds,
|
||||
} from '../../infrastructure/ai/types';
|
||||
import { removeProviderReferences } from './aiProviderCleanup';
|
||||
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
||||
import { getAIBridge } from './aiStateSnapshots';
|
||||
@@ -66,7 +70,9 @@ export function useAISettingsState() {
|
||||
localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST]
|
||||
);
|
||||
const [commandTimeout, setCommandTimeoutRaw] = useState<number>(() =>
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60
|
||||
normalizeCommandTimeoutSeconds(
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? DEFAULT_COMMAND_TIMEOUT_SECONDS,
|
||||
)
|
||||
);
|
||||
const [maxIterations, setMaxIterationsRaw] = useState<number>(() =>
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20
|
||||
@@ -163,9 +169,10 @@ export function useAISettingsState() {
|
||||
}, []);
|
||||
|
||||
const setCommandTimeout = useCallback((value: number) => {
|
||||
setCommandTimeoutRaw(value);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT, value);
|
||||
getAIBridge()?.aiMcpSetCommandTimeout?.(value);
|
||||
const normalizedValue = normalizeCommandTimeoutSeconds(value);
|
||||
setCommandTimeoutRaw(normalizedValue);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT, normalizedValue);
|
||||
getAIBridge()?.aiMcpSetCommandTimeout?.(normalizedValue);
|
||||
}, []);
|
||||
|
||||
const setMaxIterations = useCallback((value: number) => {
|
||||
@@ -235,10 +242,11 @@ export function useAISettingsState() {
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_COMMAND_TIMEOUT: {
|
||||
const timeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60;
|
||||
const timeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? DEFAULT_COMMAND_TIMEOUT_SECONDS;
|
||||
if (!Number.isFinite(timeout)) break;
|
||||
setCommandTimeoutRaw(timeout);
|
||||
getAIBridge()?.aiMcpSetCommandTimeout?.(timeout);
|
||||
const normalizedTimeout = normalizeCommandTimeoutSeconds(timeout);
|
||||
setCommandTimeoutRaw(normalizedTimeout);
|
||||
getAIBridge()?.aiMcpSetCommandTimeout?.(normalizedTimeout);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_MAX_ITERATIONS: {
|
||||
|
||||
@@ -33,7 +33,11 @@ import type {
|
||||
AISessionScope,
|
||||
WebSearchConfig,
|
||||
} from '../../infrastructure/ai/types';
|
||||
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
|
||||
import {
|
||||
DEFAULT_COMMAND_BLOCKLIST,
|
||||
DEFAULT_COMMAND_TIMEOUT_SECONDS,
|
||||
normalizeCommandTimeoutSeconds,
|
||||
} from '../../infrastructure/ai/types';
|
||||
import {
|
||||
activateDraftView,
|
||||
clearScopeDraftState,
|
||||
@@ -107,7 +111,9 @@ export function useAIState() {
|
||||
localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST]
|
||||
);
|
||||
const [commandTimeout, setCommandTimeoutRaw] = useState<number>(() =>
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60
|
||||
normalizeCommandTimeoutSeconds(
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? DEFAULT_COMMAND_TIMEOUT_SECONDS,
|
||||
)
|
||||
);
|
||||
const [maxIterations, setMaxIterationsRaw] = useState<number>(() =>
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20
|
||||
@@ -361,11 +367,12 @@ export function useAIState() {
|
||||
}, []);
|
||||
|
||||
const setCommandTimeout = useCallback((value: number) => {
|
||||
setCommandTimeoutRaw(value);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT, value);
|
||||
const normalizedValue = normalizeCommandTimeoutSeconds(value);
|
||||
setCommandTimeoutRaw(normalizedValue);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT, normalizedValue);
|
||||
// Sync to MCP Server bridge
|
||||
const bridge = getAIBridge();
|
||||
bridge?.aiMcpSetCommandTimeout?.(value);
|
||||
bridge?.aiMcpSetCommandTimeout?.(normalizedValue);
|
||||
}, []);
|
||||
|
||||
const setMaxIterations = useCallback((value: number) => {
|
||||
@@ -438,13 +445,14 @@ export function useAIState() {
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_COMMAND_TIMEOUT: {
|
||||
const timeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60;
|
||||
const timeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? DEFAULT_COMMAND_TIMEOUT_SECONDS;
|
||||
if (!Number.isFinite(timeout)) {
|
||||
console.warn('[useAIState] Cross-window sync: AI_COMMAND_TIMEOUT is not a finite number, skipping');
|
||||
break;
|
||||
}
|
||||
setCommandTimeoutRaw(timeout);
|
||||
getAIBridge()?.aiMcpSetCommandTimeout?.(timeout);
|
||||
const normalizedTimeout = normalizeCommandTimeoutSeconds(timeout);
|
||||
setCommandTimeoutRaw(normalizedTimeout);
|
||||
getAIBridge()?.aiMcpSetCommandTimeout?.(normalizedTimeout);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_MAX_ITERATIONS: {
|
||||
@@ -539,7 +547,9 @@ export function useAIState() {
|
||||
const bridge = getAIBridge();
|
||||
const initialBlocklist = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST];
|
||||
bridge?.aiMcpSetCommandBlocklist?.(initialBlocklist);
|
||||
const initialTimeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60;
|
||||
const initialTimeout = normalizeCommandTimeoutSeconds(
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? DEFAULT_COMMAND_TIMEOUT_SECONDS,
|
||||
);
|
||||
bridge?.aiMcpSetCommandTimeout?.(initialTimeout);
|
||||
const initialMaxIter = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20;
|
||||
bridge?.aiMcpSetMaxIterations?.(initialMaxIter);
|
||||
|
||||
@@ -61,7 +61,7 @@ test("chrome layout animations wait until theme settle frames complete", () => {
|
||||
cancel();
|
||||
});
|
||||
|
||||
test("syncActiveChromeTheme refreshes top tabs when the active theme fingerprint is unchanged", () => {
|
||||
test("syncActiveChromeTheme skips surface refresh when the active theme fingerprint is unchanged", () => {
|
||||
const globalWithDocument = globalThis as typeof globalThis & { document?: Document };
|
||||
const originalDocument = globalWithDocument.document;
|
||||
const theme = TERMINAL_THEMES[0];
|
||||
@@ -83,9 +83,9 @@ test("syncActiveChromeTheme refreshes top tabs when the active theme fingerprint
|
||||
throw new Error("app theme should not be restored for an unchanged active chrome theme");
|
||||
});
|
||||
|
||||
assert.notEqual(topTabsRoot.style.getPropertyValue("--top-tabs-bg"), "");
|
||||
assert.notEqual(topTabsRoot.style.getPropertyValue("--top-tabs-active-bg"), "");
|
||||
assert.notEqual(topTabsRoot.style.getPropertyValue("--top-tabs-accent"), "");
|
||||
assert.equal(topTabsRoot.style.getPropertyValue("--top-tabs-bg"), "");
|
||||
assert.equal(topTabsRoot.style.getPropertyValue("--top-tabs-active-bg"), "");
|
||||
assert.equal(topTabsRoot.style.getPropertyValue("--top-tabs-accent"), "");
|
||||
} finally {
|
||||
if (originalDocument) {
|
||||
globalWithDocument.document = originalDocument;
|
||||
|
||||
@@ -228,11 +228,6 @@ export function syncActiveChromeTheme(
|
||||
const nextFingerprint = activeTheme ? themeFingerprint(activeTheme) : null;
|
||||
const appliedFingerprint = getAppliedChromeFingerprint();
|
||||
if (nextFingerprint === appliedFingerprint) {
|
||||
if (activeTheme) {
|
||||
refreshActiveChromeThemeSurfaces(activeTheme);
|
||||
} else {
|
||||
clearTopTabsChromeThemeVars();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -148,7 +148,7 @@ export function useAgentDiscovery(
|
||||
);
|
||||
if (!match) return ea;
|
||||
|
||||
// Check if args, SDK backend, or Claude's resolved system path differ
|
||||
// Check if args, SDK backend, or managed SDK path env differ.
|
||||
const currentArgs = JSON.stringify(ea.args || []);
|
||||
const newArgs = JSON.stringify(match.args);
|
||||
const backend = match.sdkBackend ?? match.command;
|
||||
@@ -158,9 +158,12 @@ export function useAgentDiscovery(
|
||||
const matchPath = match.binPath || match.path;
|
||||
const env = match.command === 'claude'
|
||||
? { ...(ea.env ?? {}), CLAUDE_CODE_EXECUTABLE: matchPath }
|
||||
: ea.env;
|
||||
const envChanged = match.command === 'claude'
|
||||
&& ea.env?.CLAUDE_CODE_EXECUTABLE !== matchPath;
|
||||
: match.command === 'opencode'
|
||||
? { ...(ea.env ?? {}), OPENCODE_BIN: matchPath }
|
||||
: ea.env;
|
||||
const envChanged =
|
||||
(match.command === 'claude' && ea.env?.CLAUDE_CODE_EXECUTABLE !== matchPath)
|
||||
|| (match.command === 'opencode' && ea.env?.OPENCODE_BIN !== matchPath);
|
||||
if (currentArgs !== newArgs || backendChanged || envChanged) {
|
||||
changed = true;
|
||||
const { acpCommand: _legacyCommand, acpArgs: _legacyArgs, ...rest } = ea;
|
||||
@@ -193,6 +196,8 @@ export function useAgentDiscovery(
|
||||
sdkBackend: backend,
|
||||
...(agent.command === 'claude'
|
||||
? { env: { CLAUDE_CODE_EXECUTABLE: agent.binPath || agent.path || '' } }
|
||||
: agent.command === 'opencode'
|
||||
? { env: { OPENCODE_BIN: agent.binPath || agent.path || '' } }
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -385,6 +385,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}
|
||||
|
||||
lastSyncedDataRef.current = dataHash;
|
||||
manager.setPendingLocalSync(false);
|
||||
|
||||
// Successful sync implies a successful per-provider
|
||||
// `checkProviderConflict` (which inspects remote) — equivalent
|
||||
@@ -459,6 +460,28 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
getDataHashRef.current = getDataHash;
|
||||
}, [getDataHash]);
|
||||
|
||||
const refreshPendingLocalSync = useCallback(async () => {
|
||||
if (!enabled || !sync.hasAnyConnectedProvider || !sync.isUnlocked) {
|
||||
manager.setPendingLocalSync(false);
|
||||
return;
|
||||
}
|
||||
if (!remoteCheckDoneRef.current) {
|
||||
return;
|
||||
}
|
||||
const currentHash = await getDataHashRef.current();
|
||||
const hashDecision = resolveAutoSyncHashDecision({
|
||||
currentHash,
|
||||
lastSyncedHash: lastSyncedDataRef.current,
|
||||
appliedSkipHash: skipNextSyncHashRef.current,
|
||||
});
|
||||
manager.setPendingLocalSync(hashDecision === 'sync');
|
||||
}, [enabled, sync.hasAnyConnectedProvider, sync.isUnlocked]);
|
||||
|
||||
const refreshPendingLocalSyncRef = useRef(refreshPendingLocalSync);
|
||||
useEffect(() => {
|
||||
refreshPendingLocalSyncRef.current = refreshPendingLocalSync;
|
||||
}, [refreshPendingLocalSync]);
|
||||
|
||||
// Serialize `checkRemoteVersion` invocations. Overlapping runs would
|
||||
// race on `commitRemoteInspection` + `onApplyPayload`: two merges
|
||||
// could both write-then-clear the apply-in-progress sentinel around
|
||||
@@ -713,6 +736,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// unlock/startupReady transition, and a manual sync from
|
||||
// Settings remains available as an escape hatch.
|
||||
remoteCheckDoneRef.current = true;
|
||||
await refreshPendingLocalSyncRef.current();
|
||||
}
|
||||
checkRemoteInFlightRef.current = false;
|
||||
}
|
||||
@@ -843,6 +867,21 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
bookmarksVersion,
|
||||
syncableSettingsStorageVersion,
|
||||
]);
|
||||
|
||||
// Reflect unsynced local edits in the top-bar cloud indicator.
|
||||
useEffect(() => {
|
||||
void refreshPendingLocalSync();
|
||||
}, [
|
||||
refreshPendingLocalSync,
|
||||
getDataHash,
|
||||
sync.hasAnyConnectedProvider,
|
||||
sync.isUnlocked,
|
||||
sync.isSyncing,
|
||||
config.settingsVersion,
|
||||
enabled,
|
||||
bookmarksVersion,
|
||||
syncableSettingsStorageVersion,
|
||||
]);
|
||||
|
||||
// Check remote version on startup/unlock, then retry with backoff
|
||||
// while the inspect keeps failing. Without the timer-based retry,
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface CloudSyncHook {
|
||||
remoteVersion: number;
|
||||
remoteUpdatedAt: number;
|
||||
syncHistory: SyncHistoryEntry[];
|
||||
pendingLocalSync: boolean;
|
||||
pendingBrowserAuthProvider: 'google' | 'onedrive' | null;
|
||||
|
||||
// Computed
|
||||
@@ -731,6 +732,7 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
remoteVersion: state.remoteVersion,
|
||||
remoteUpdatedAt: state.remoteUpdatedAt,
|
||||
syncHistory: state.syncHistory,
|
||||
pendingLocalSync: state.pendingLocalSync,
|
||||
pendingBrowserAuthProvider: pendingBrowserAuth?.provider ?? null,
|
||||
|
||||
// Computed
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
import { startMainWindowInputFocusRecovery } from "./useMainWindowInputFocusRecovery.ts";
|
||||
|
||||
const root = fileURLToPath(new URL("../..", import.meta.url));
|
||||
|
||||
function readProjectFile(path: string): string {
|
||||
return readFileSync(join(root, path), "utf8");
|
||||
}
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
function createEventTargetStub() {
|
||||
const listeners = new Map<string, Set<Listener>>();
|
||||
|
||||
return {
|
||||
addEventListener(eventName: string, listener: Listener) {
|
||||
const eventListeners = listeners.get(eventName) ?? new Set<Listener>();
|
||||
eventListeners.add(listener);
|
||||
listeners.set(eventName, eventListeners);
|
||||
},
|
||||
removeEventListener(eventName: string, listener: Listener) {
|
||||
listeners.get(eventName)?.delete(listener);
|
||||
},
|
||||
dispatch(eventName: string) {
|
||||
for (const listener of listeners.get(eventName) ?? []) {
|
||||
listener();
|
||||
}
|
||||
},
|
||||
listenerCount(eventName: string) {
|
||||
return listeners.get(eventName)?.size ?? 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("main-window input focus recovery ignores generic focus and visible visibility changes", () => {
|
||||
let visibilityState: DocumentVisibilityState = "visible";
|
||||
let scheduleFocusCalls = 0;
|
||||
let cancelFocusCalls = 0;
|
||||
let hiddenCalls = 0;
|
||||
let shownHandler: Listener | null = null;
|
||||
let willHideHandler: Listener | null = null;
|
||||
|
||||
const documentTarget = createEventTargetStub();
|
||||
const windowTarget = createEventTargetStub();
|
||||
|
||||
const cleanup = startMainWindowInputFocusRecovery(
|
||||
{ onPageHidden: () => { hiddenCalls += 1; } },
|
||||
{
|
||||
documentRef: {
|
||||
...documentTarget,
|
||||
get visibilityState() {
|
||||
return visibilityState;
|
||||
},
|
||||
},
|
||||
windowRef: windowTarget,
|
||||
bridge: {
|
||||
onWindowShown(cb: Listener) {
|
||||
shownHandler = cb;
|
||||
return () => { shownHandler = null; };
|
||||
},
|
||||
onWindowWillHide(cb: Listener) {
|
||||
willHideHandler = cb;
|
||||
return () => { willHideHandler = null; };
|
||||
},
|
||||
},
|
||||
scheduleFocus: () => {
|
||||
scheduleFocusCalls += 1;
|
||||
return { cancel: () => { cancelFocusCalls += 1; } };
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(windowTarget.listenerCount("focus"), 0);
|
||||
|
||||
windowTarget.dispatch("focus");
|
||||
documentTarget.dispatch("visibilitychange");
|
||||
|
||||
assert.equal(scheduleFocusCalls, 0);
|
||||
assert.equal(hiddenCalls, 0);
|
||||
|
||||
shownHandler?.();
|
||||
|
||||
assert.equal(scheduleFocusCalls, 1);
|
||||
|
||||
willHideHandler?.();
|
||||
|
||||
assert.equal(hiddenCalls, 1);
|
||||
assert.equal(cancelFocusCalls, 1);
|
||||
|
||||
shownHandler?.();
|
||||
|
||||
assert.equal(scheduleFocusCalls, 2);
|
||||
|
||||
visibilityState = "hidden";
|
||||
documentTarget.dispatch("visibilitychange");
|
||||
|
||||
assert.equal(hiddenCalls, 2);
|
||||
assert.equal(cancelFocusCalls, 2);
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("main-window input focus recovery retries an explicit show when visibility catches up", () => {
|
||||
let visibilityState: DocumentVisibilityState = "hidden";
|
||||
let scheduleFocusCalls = 0;
|
||||
let hiddenCalls = 0;
|
||||
let shownHandler: Listener | null = null;
|
||||
|
||||
const documentTarget = createEventTargetStub();
|
||||
|
||||
const cleanup = startMainWindowInputFocusRecovery(
|
||||
{ onPageHidden: () => { hiddenCalls += 1; } },
|
||||
{
|
||||
documentRef: {
|
||||
...documentTarget,
|
||||
get visibilityState() {
|
||||
return visibilityState;
|
||||
},
|
||||
},
|
||||
bridge: {
|
||||
onWindowShown(cb: Listener) {
|
||||
shownHandler = cb;
|
||||
return () => { shownHandler = null; };
|
||||
},
|
||||
},
|
||||
scheduleFocus: () => {
|
||||
scheduleFocusCalls += 1;
|
||||
return { cancel: () => undefined };
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
shownHandler?.();
|
||||
|
||||
assert.equal(scheduleFocusCalls, 0);
|
||||
|
||||
visibilityState = "visible";
|
||||
documentTarget.dispatch("visibilitychange");
|
||||
|
||||
assert.equal(scheduleFocusCalls, 1);
|
||||
assert.equal(hiddenCalls, 0);
|
||||
|
||||
documentTarget.dispatch("visibilitychange");
|
||||
|
||||
assert.equal(scheduleFocusCalls, 1);
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("useMainWindowInputFocusRecovery restores input focus only after explicit window-shown IPC", () => {
|
||||
const source = readProjectFile("application/state/useMainWindowInputFocusRecovery.ts");
|
||||
|
||||
assert.match(source, /visibilityState !== "visible"/);
|
||||
assert.match(source, /scheduleFocus\(\)/);
|
||||
assert.match(source, /onWindowShown\?\.\(\(\) => \{\s*pendingExplicitShowRecovery = true;\s*recoverFocus\(\);\s*\}\)/);
|
||||
assert.doesNotMatch(source, /window\.addEventListener\("focus"/);
|
||||
assert.doesNotMatch(source, /window\.removeEventListener\("focus"/);
|
||||
});
|
||||
|
||||
test("useMainWindowInputFocusRecovery uses visibility changes only to dismiss transient UI", () => {
|
||||
const source = readProjectFile("application/state/useMainWindowInputFocusRecovery.ts");
|
||||
const handlerStart = source.indexOf("const onVisibilityChange");
|
||||
const handlerEnd = source.indexOf('addEventListener("visibilitychange"', handlerStart);
|
||||
const visibilityHandler = source.slice(handlerStart, handlerEnd);
|
||||
|
||||
assert.match(source, /visibilityState === "hidden"/);
|
||||
assert.match(source, /addEventListener\("visibilitychange", onVisibilityChange\)/);
|
||||
assert.match(source, /onPageHidden\?\.\(\)/);
|
||||
assert.match(source, /onWindowWillHide/);
|
||||
assert.match(source, /cancelPendingFocusRecovery/);
|
||||
assert.match(visibilityHandler, /dismissTransientUi\(\)/);
|
||||
assert.doesNotMatch(visibilityHandler, /else\s*\{\s*recoverFocus\(\);\s*\}/);
|
||||
assert.doesNotMatch(visibilityHandler, /visibilityState !== "hidden"[\s\S]*recoverFocus\(\)/);
|
||||
});
|
||||
|
||||
test("scheduleWindowInputFocus skips deferred focus when the page is hidden", () => {
|
||||
const source = readProjectFile("application/state/windowInputFocus.ts");
|
||||
|
||||
assert.match(source, /document\.visibilityState !== "visible"/);
|
||||
assert.match(source, /cancelAnimationFrame/);
|
||||
assert.match(source, /clearTimeout/);
|
||||
});
|
||||
|
||||
test("AppView mounts main-window input focus recovery with overlay dismiss", () => {
|
||||
const source = readProjectFile("application/app/AppView.tsx");
|
||||
|
||||
assert.match(source, /useMainWindowInputFocusRecovery\(\{ onPageHidden: dismissTransientOverlays \}\)/);
|
||||
assert.match(source, /setIsQuickSwitcherOpen\(false\)/);
|
||||
assert.match(source, /setProtocolSelectHost\(null\)/);
|
||||
});
|
||||
|
||||
test("dropdown closes when the document becomes hidden", () => {
|
||||
const source = readProjectFile("components/ui/dropdown.tsx");
|
||||
|
||||
assert.match(source, /document\.visibilityState === "hidden"/);
|
||||
assert.match(source, /setOpen\(false\)/);
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
import {
|
||||
scheduleWindowInputFocus,
|
||||
type ScheduledWindowInputFocus,
|
||||
} from "./windowInputFocus";
|
||||
|
||||
export type MainWindowInputFocusRecoveryOptions = {
|
||||
/** Close transient overlays before the window hides (#1722). */
|
||||
onPageHidden?: () => void;
|
||||
};
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
type MainWindowInputFocusRecoveryDocument = {
|
||||
visibilityState: DocumentVisibilityState;
|
||||
addEventListener: (eventName: "visibilitychange", listener: Listener) => void;
|
||||
removeEventListener: (eventName: "visibilitychange", listener: Listener) => void;
|
||||
};
|
||||
|
||||
type MainWindowInputFocusRecoveryWindow = {
|
||||
addEventListener: (eventName: "focus", listener: Listener) => void;
|
||||
removeEventListener: (eventName: "focus", listener: Listener) => void;
|
||||
};
|
||||
|
||||
type MainWindowInputFocusRecoveryBridge = {
|
||||
onWindowShown?: (callback: Listener) => Listener;
|
||||
onWindowWillHide?: (callback: Listener) => Listener;
|
||||
};
|
||||
|
||||
export type MainWindowInputFocusRecoveryDependencies = {
|
||||
documentRef: MainWindowInputFocusRecoveryDocument;
|
||||
windowRef?: MainWindowInputFocusRecoveryWindow;
|
||||
bridge?: MainWindowInputFocusRecoveryBridge;
|
||||
scheduleFocus?: () => ScheduledWindowInputFocus;
|
||||
};
|
||||
|
||||
export function startMainWindowInputFocusRecovery(
|
||||
options: MainWindowInputFocusRecoveryOptions = {},
|
||||
dependencies: MainWindowInputFocusRecoveryDependencies = {
|
||||
documentRef: document,
|
||||
windowRef: window,
|
||||
bridge: netcattyBridge.get(),
|
||||
scheduleFocus: scheduleWindowInputFocus,
|
||||
},
|
||||
): () => void {
|
||||
const { onPageHidden } = options;
|
||||
const {
|
||||
documentRef,
|
||||
bridge,
|
||||
scheduleFocus = scheduleWindowInputFocus,
|
||||
} = dependencies;
|
||||
|
||||
let pendingFocusRecovery: ScheduledWindowInputFocus | null = null;
|
||||
let pendingExplicitShowRecovery = false;
|
||||
|
||||
const cancelPendingFocusRecovery = () => {
|
||||
pendingFocusRecovery?.cancel();
|
||||
pendingFocusRecovery = null;
|
||||
};
|
||||
|
||||
const recoverFocus = (): boolean => {
|
||||
if (documentRef.visibilityState !== "visible") return false;
|
||||
pendingExplicitShowRecovery = false;
|
||||
cancelPendingFocusRecovery();
|
||||
pendingFocusRecovery = scheduleFocus();
|
||||
return true;
|
||||
};
|
||||
|
||||
const dismissTransientUi = () => {
|
||||
pendingExplicitShowRecovery = false;
|
||||
cancelPendingFocusRecovery();
|
||||
onPageHidden?.();
|
||||
};
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
if (documentRef.visibilityState === "hidden") {
|
||||
dismissTransientUi();
|
||||
return;
|
||||
}
|
||||
if (pendingExplicitShowRecovery) {
|
||||
recoverFocus();
|
||||
}
|
||||
};
|
||||
|
||||
documentRef.addEventListener("visibilitychange", onVisibilityChange);
|
||||
|
||||
const unsubscribeShown = bridge?.onWindowShown?.(() => {
|
||||
pendingExplicitShowRecovery = true;
|
||||
recoverFocus();
|
||||
});
|
||||
const unsubscribeWillHide = bridge?.onWindowWillHide?.(() => {
|
||||
dismissTransientUi();
|
||||
});
|
||||
|
||||
return () => {
|
||||
pendingExplicitShowRecovery = false;
|
||||
cancelPendingFocusRecovery();
|
||||
documentRef.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
unsubscribeShown?.();
|
||||
unsubscribeWillHide?.();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover OS/renderer input focus when the main process explicitly shows the
|
||||
* main window (#760, #1714, #1722).
|
||||
*/
|
||||
export function useMainWindowInputFocusRecovery(
|
||||
options: MainWindowInputFocusRecoveryOptions = {},
|
||||
): void {
|
||||
const { onPageHidden } = options;
|
||||
|
||||
useEffect(() => {
|
||||
return startMainWindowInputFocusRecovery({ onPageHidden });
|
||||
}, [onPageHidden]);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const appSource = readFileSync(new URL('../../App.tsx', import.meta.url), 'utf8');
|
||||
const hookSource = readFileSync(new URL('./useManualTerminalChromeSurfaceInjection.ts', import.meta.url), 'utf8');
|
||||
const bridgeSource = readFileSync(new URL('../../components/terminalLayer/TerminalLayerTabBridge.tsx', import.meta.url), 'utf8');
|
||||
const varsSource = readFileSync(new URL('../../infrastructure/theme/terminalAppearanceVars.ts', import.meta.url), 'utf8');
|
||||
|
||||
test('manual mode injects chrome surfaces from focused session theme', () => {
|
||||
assert.match(appSource, /includeChromeSurfaces: followAppTerminalTheme/);
|
||||
assert.match(bridgeSource, /useManualTerminalChromeSurfaceInjection/);
|
||||
assert.match(bridgeSource, /!s\.followAppTerminalTheme && isTerminalLayerVisible/);
|
||||
assert.match(readFileSync(new URL('../app/AppHostTreeLayer.tsx', import.meta.url), 'utf8'), /useManualTerminalChromeSurfaceInjection/);
|
||||
assert.match(appSource, /resolveSessionAppearance=\{themeRuntime\.resolveFocusedAppearance\}/);
|
||||
assert.match(hookSource, /applyTopTabsChromeThemeVars\(theme\)/);
|
||||
assert.match(hookSource, /injectTerminalLayerChromeSurfaceVars\(theme\)/);
|
||||
assert.match(hookSource, /if \(wasEnabled\) \{[\s\S]*clearTerminalLayerChromeSurfaceVars\(\)/);
|
||||
assert.doesNotMatch(hookSource, /clearTopTabsChromeThemeVars\(\)/);
|
||||
assert.match(varsSource, /injectTerminalLayerChromeSurfaceVars/);
|
||||
assert.match(varsSource, /clearTerminalLayerChromeSurfaceVars/);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useLayoutEffect, useRef } from 'react';
|
||||
|
||||
import type { TerminalTheme } from '../../domain/models';
|
||||
import { applyTopTabsChromeThemeVars } from '../app/topTabsChromeTheme';
|
||||
import {
|
||||
clearTerminalLayerChromeSurfaceVars,
|
||||
injectTerminalLayerChromeSurfaceVars,
|
||||
} from '../../infrastructure/theme/terminalAppearanceVars';
|
||||
|
||||
/** Manual mode: side panel + host tree follow the focused session theme, not the global default. */
|
||||
export function useManualTerminalChromeSurfaceInjection(
|
||||
theme: TerminalTheme,
|
||||
enabled: boolean,
|
||||
): void {
|
||||
const prevEnabledRef = useRef(enabled);
|
||||
useLayoutEffect(() => {
|
||||
const wasEnabled = prevEnabledRef.current;
|
||||
prevEnabledRef.current = enabled;
|
||||
if (enabled) {
|
||||
injectTerminalLayerChromeSurfaceVars(theme);
|
||||
applyTopTabsChromeThemeVars(theme);
|
||||
return;
|
||||
}
|
||||
if (wasEnabled) {
|
||||
clearTerminalLayerChromeSurfaceVars();
|
||||
}
|
||||
}, [enabled, theme.id, theme]);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { findMatchEndingAfter } from './useOutputTriggers.ts';
|
||||
|
||||
test('findMatchEndingAfter skips stale overlap matches and finds current output', () => {
|
||||
const text = 'NETCATTY_TRIGGER_PROBE old\r\nprompt# NETCATTY_TRIGGER_PROBE: command not found\r\n';
|
||||
const minEndOffset = 'NETCATTY_TRIGGER_PROBE old\r\n'.length;
|
||||
const match = findMatchEndingAfter(text, 'NETCATTY_TRIGGER_PROBE', minEndOffset);
|
||||
assert.deepEqual(match, {
|
||||
value: 'NETCATTY_TRIGGER_PROBE',
|
||||
endOffset: 'NETCATTY_TRIGGER_PROBE old\r\nprompt# NETCATTY_TRIGGER_PROBE'.length,
|
||||
});
|
||||
});
|
||||
|
||||
test('findMatchEndingAfter supports regex patterns', () => {
|
||||
const text = 'old done\r\nservice ready: 200\r\n';
|
||||
const minEndOffset = 'old done\r\n'.length;
|
||||
const match = findMatchEndingAfter(text, 'ready:\\s+\\d+', minEndOffset);
|
||||
assert.deepEqual(match, {
|
||||
value: 'ready: 200',
|
||||
endOffset: 'old done\r\nservice ready: 200'.length,
|
||||
});
|
||||
});
|
||||
|
||||
test('findMatchEndingAfter returns null when matches are only stale', () => {
|
||||
const text = 'NETCATTY_TRIGGER_PROBE old\r\nnew output\r\n';
|
||||
const minEndOffset = 'NETCATTY_TRIGGER_PROBE old\r\n'.length;
|
||||
assert.equal(findMatchEndingAfter(text, 'NETCATTY_TRIGGER_PROBE', minEndOffset), null);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import type { Snippet } from '@/domain/models';
|
||||
import { snippetAppliesToOutputTrigger } from '@/domain/snippetTargets.ts';
|
||||
import { isScriptSnippet } from '@/domain/snippetScript.ts';
|
||||
import { createTerminalOutputTriggerFilter } from '@/domain/terminalOutputTriggerFilter.ts';
|
||||
import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts';
|
||||
import { getActiveScriptRunForSession } from '@/application/state/scriptAutomationCoordinator.ts';
|
||||
|
||||
type OutputTriggerContext = {
|
||||
sessionId: string;
|
||||
hostId?: string;
|
||||
snippets: Snippet[];
|
||||
onRunScript: (snippet: Snippet, sessionId: string) => void | Promise<void>;
|
||||
};
|
||||
|
||||
function isSessionScriptRunActive(sessionId: string): boolean {
|
||||
return Boolean(getActiveScriptRunForSession(sessionId));
|
||||
}
|
||||
|
||||
export function findMatchEndingAfter(text: string, pattern: string, minEndOffset: number): { value: string; endOffset: number } | null {
|
||||
const source = new RegExp(pattern);
|
||||
for (let startOffset = 0; startOffset <= text.length;) {
|
||||
const match = source.exec(text.slice(startOffset));
|
||||
if (!match || match.index === undefined) return null;
|
||||
const absoluteStart = startOffset + match.index;
|
||||
const absoluteEnd = absoluteStart + match[0].length;
|
||||
if (absoluteEnd > minEndOffset) {
|
||||
return { value: match[0], endOffset: absoluteEnd };
|
||||
}
|
||||
startOffset = Math.max(absoluteStart + 1, absoluteEnd);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function useOutputTriggers({
|
||||
sessionId,
|
||||
hostId,
|
||||
snippets,
|
||||
onRunScript,
|
||||
}: OutputTriggerContext) {
|
||||
const bufferRef = useRef('');
|
||||
const launchingRef = useRef(false);
|
||||
const lastTriggerMatchEndRef = useRef(new Map<string, number>());
|
||||
const serverOutputFilterRef = useRef(createTerminalOutputTriggerFilter());
|
||||
|
||||
const scanBuffer = useCallback((recentChunk: string) => {
|
||||
if (!recentChunk || isSessionScriptRunActive(sessionId) || launchingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = bufferRef.current;
|
||||
const overlap = 64;
|
||||
const chunkWithOverlap = text.slice(Math.max(0, text.length - recentChunk.length - overlap));
|
||||
const chunkStartInSlice = Math.max(0, chunkWithOverlap.length - recentChunk.length);
|
||||
const chunkBaseOffset = text.length - chunkWithOverlap.length;
|
||||
|
||||
for (const snippet of snippets) {
|
||||
if (isSessionScriptRunActive(sessionId) || launchingRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!isScriptSnippet(snippet) || snippet.trigger !== 'onOutput' || !snippet.triggerPattern || !snippet.id) {
|
||||
continue;
|
||||
}
|
||||
if (!snippetAppliesToOutputTrigger(snippet, hostId)) continue;
|
||||
try {
|
||||
const matched = findMatchEndingAfter(chunkWithOverlap, snippet.triggerPattern, chunkStartInSlice);
|
||||
if (!matched) {
|
||||
continue;
|
||||
}
|
||||
const matchEnd = chunkBaseOffset + matched.endOffset;
|
||||
const lastMatchEnd = lastTriggerMatchEndRef.current.get(snippet.id) ?? -1;
|
||||
if (matchEnd <= lastMatchEnd) {
|
||||
continue;
|
||||
}
|
||||
const matchedSnippetId = snippet.id;
|
||||
launchingRef.current = true;
|
||||
lastTriggerMatchEndRef.current.set(matchedSnippetId, matchEnd);
|
||||
void Promise.resolve(onRunScript(snippet, sessionId))
|
||||
.catch(() => {
|
||||
// Failed starts can retry on the next matching output chunk.
|
||||
})
|
||||
.finally(() => {
|
||||
launchingRef.current = false;
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
// ignore invalid regex
|
||||
}
|
||||
}
|
||||
}, [hostId, onRunScript, sessionId, snippets]);
|
||||
|
||||
const appendOutput = useCallback((chunk: string) => {
|
||||
if (!chunk) return;
|
||||
const { scannableText, alternateScreenActive } = serverOutputFilterRef.current.processServerChunk(chunk);
|
||||
if (!scannableText || alternateScreenActive) {
|
||||
return;
|
||||
}
|
||||
bufferRef.current = (bufferRef.current + scannableText).slice(-8192);
|
||||
scanBuffer(scannableText);
|
||||
}, [scanBuffer]);
|
||||
|
||||
const noteUserInput = useCallback((data: string) => {
|
||||
if (!data) return;
|
||||
serverOutputFilterRef.current.noteUserInput(data);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
bufferRef.current = '';
|
||||
launchingRef.current = false;
|
||||
lastTriggerMatchEndRef.current = new Map();
|
||||
serverOutputFilterRef.current.reset();
|
||||
}, [sessionId, hostId]);
|
||||
|
||||
return { appendOutput, noteUserInput };
|
||||
}
|
||||
|
||||
export function setupScriptBridgeListeners(
|
||||
getSnapshot: (sessionId: string) => ReturnType<typeof import('@/infrastructure/scripts/screenSnapshotRegistry.ts').captureScreenSnapshot>,
|
||||
) {
|
||||
const disposers: Array<() => void> = [];
|
||||
|
||||
disposers.push(
|
||||
netcattyBridge.get()?.onScriptScreenSnapshotRequest?.(({ requestId, sessionId }) => {
|
||||
const snapshot = getSnapshot(sessionId);
|
||||
void netcattyBridge.get()?.scriptScreenSnapshotResponse?.(requestId, snapshot);
|
||||
}) ?? (() => {}),
|
||||
);
|
||||
|
||||
return () => {
|
||||
disposers.forEach((dispose) => dispose());
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { ScriptRun, ScriptRunParams } from '@/types/global/netcatty-bridge-script.d.ts';
|
||||
import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts';
|
||||
|
||||
export function useScriptExecution() {
|
||||
const [runs, setRuns] = useState<ScriptRun[]>([]);
|
||||
const runsRef = useRef(runs);
|
||||
runsRef.current = runs;
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.scriptGetRuns) return undefined;
|
||||
bridge.scriptGetRuns().then(setRuns).catch(() => {});
|
||||
const dispose = bridge.onScriptRunsUpdated?.(({ runs: nextRuns }) => {
|
||||
setRuns(nextRuns);
|
||||
});
|
||||
return dispose;
|
||||
}, []);
|
||||
|
||||
const runScript = useCallback(async (params: ScriptRunParams) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.scriptRun) {
|
||||
throw new Error('Script bridge unavailable');
|
||||
}
|
||||
return bridge.scriptRun(params);
|
||||
}, []);
|
||||
|
||||
const stopRun = useCallback(async (runId: string) => {
|
||||
await netcattyBridge.get()?.scriptStop?.(runId);
|
||||
}, []);
|
||||
|
||||
const pauseRun = useCallback(async (runId: string) => {
|
||||
await netcattyBridge.get()?.scriptPause?.(runId);
|
||||
}, []);
|
||||
|
||||
const resumeRun = useCallback(async (runId: string) => {
|
||||
await netcattyBridge.get()?.scriptResume?.(runId);
|
||||
}, []);
|
||||
|
||||
const getRunsForSession = useCallback((sessionId: string) => {
|
||||
return runsRef.current.filter((run) => run.sessionId === sessionId);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
runs,
|
||||
runScript,
|
||||
stopRun,
|
||||
pauseRun,
|
||||
resumeRun,
|
||||
getRunsForSession,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts';
|
||||
import { DEFAULT_RECORDING_PROMPT_TIMEOUT_MS } from '@/domain/snippetScript.ts';
|
||||
import type { ScriptRecordingStep } from '@/types/global/netcatty-bridge-script.d.ts';
|
||||
|
||||
export function useScriptRecorder(sessionId: string | undefined) {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [elapsedMs, setElapsedMs] = useState(0);
|
||||
const startedAtRef = useRef<number | null>(null);
|
||||
const inputBufferRef = useRef('');
|
||||
const lastStepAtRef = useRef<number>(Date.now());
|
||||
const isRecordingRef = useRef(false);
|
||||
const isPausedRef = useRef(false);
|
||||
const sessionIdRef = useRef(sessionId);
|
||||
|
||||
sessionIdRef.current = sessionId;
|
||||
isRecordingRef.current = isRecording;
|
||||
isPausedRef.current = isPaused;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRecording || isPaused) return undefined;
|
||||
const timer = window.setInterval(() => {
|
||||
if (startedAtRef.current) {
|
||||
setElapsedMs(Date.now() - startedAtRef.current);
|
||||
}
|
||||
}, 250);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [isRecording, isPaused]);
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
const sid = sessionIdRef.current;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!sid || !bridge?.scriptRecordingStart) return;
|
||||
await bridge.scriptRecordingStart(sid);
|
||||
startedAtRef.current = Date.now();
|
||||
lastStepAtRef.current = Date.now();
|
||||
inputBufferRef.current = '';
|
||||
setElapsedMs(0);
|
||||
setIsPaused(false);
|
||||
isPausedRef.current = false;
|
||||
isRecordingRef.current = true;
|
||||
setIsRecording(true);
|
||||
}, []);
|
||||
|
||||
const stopRecording = useCallback(async () => {
|
||||
const sid = sessionIdRef.current;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!sid || !bridge?.scriptRecordingStop) {
|
||||
return { steps: [] as ScriptRecordingStep[], code: '' };
|
||||
}
|
||||
const result = await bridge.scriptRecordingStop(sid);
|
||||
isRecordingRef.current = false;
|
||||
isPausedRef.current = false;
|
||||
setIsRecording(false);
|
||||
setIsPaused(false);
|
||||
startedAtRef.current = null;
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
const pauseRecording = useCallback(() => {
|
||||
isPausedRef.current = true;
|
||||
setIsPaused(true);
|
||||
}, []);
|
||||
|
||||
const resumeRecording = useCallback(() => {
|
||||
isPausedRef.current = false;
|
||||
setIsPaused(false);
|
||||
}, []);
|
||||
|
||||
const appendStep = useCallback(async (step: ScriptRecordingStep) => {
|
||||
const sid = sessionIdRef.current;
|
||||
if (!sid || !isRecordingRef.current || isPausedRef.current) return;
|
||||
await netcattyBridge.get()?.scriptRecordingAppendStep?.(sid, step);
|
||||
lastStepAtRef.current = Date.now();
|
||||
}, []);
|
||||
|
||||
const recordInput = useCallback((data: string) => {
|
||||
if (!isRecordingRef.current || isPausedRef.current) return;
|
||||
inputBufferRef.current += data;
|
||||
}, []);
|
||||
|
||||
const recordBackspace = useCallback(() => {
|
||||
if (!isRecordingRef.current || isPausedRef.current) return;
|
||||
inputBufferRef.current = inputBufferRef.current.slice(0, -1);
|
||||
}, []);
|
||||
|
||||
const recordClearLine = useCallback(() => {
|
||||
if (!isRecordingRef.current || isPausedRef.current) return;
|
||||
inputBufferRef.current = '';
|
||||
}, []);
|
||||
|
||||
const recordEnter = useCallback(async (options?: { sensitive?: boolean }) => {
|
||||
const sid = sessionIdRef.current;
|
||||
if (!isRecordingRef.current || isPausedRef.current || !sid) return;
|
||||
const line = inputBufferRef.current;
|
||||
inputBufferRef.current = '';
|
||||
const now = Date.now();
|
||||
const gap = now - lastStepAtRef.current;
|
||||
if (gap > 1000) {
|
||||
await appendStep({ type: 'sleep', value: gap });
|
||||
}
|
||||
await appendStep({
|
||||
type: 'send',
|
||||
value: line,
|
||||
sensitive: options?.sensitive,
|
||||
});
|
||||
await appendStep({ type: 'waitForPrompt', timeoutMs: DEFAULT_RECORDING_PROMPT_TIMEOUT_MS });
|
||||
lastStepAtRef.current = now;
|
||||
}, [appendStep]);
|
||||
|
||||
return {
|
||||
isRecording,
|
||||
isPaused,
|
||||
elapsedMs,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
pauseRecording,
|
||||
resumeRecording,
|
||||
recordInput,
|
||||
recordBackspace,
|
||||
recordClearLine,
|
||||
recordEnter,
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { MouseEvent,useCallback,useEffect,useMemo,useRef,useState } from 'react'
|
||||
import { ConnectionLog,Host,SerialConfig,Snippet,TerminalSession,Workspace,WorkspaceViewMode } from '../../domain/models';
|
||||
import { addLogView, getLogViewTabId, removeLogView, type LogView } from './logViewState';
|
||||
import { createHostTerminalSession, createLocalTerminalSession, createSerialTerminalSession, type LocalTerminalOptions } from './sessionFactories';
|
||||
import { isScriptSnippet } from '../../domain/snippetScript.ts';
|
||||
import {
|
||||
appendPaneToWorkspaceRoot,
|
||||
collectSessionIds,
|
||||
@@ -967,9 +968,12 @@ export const useSessionState = ({
|
||||
const sessionsWithWorkspace = newSessions.map(s => ({
|
||||
...s,
|
||||
workspaceId: workspace.id,
|
||||
// Store the command to run after connection
|
||||
startupCommand: resolvedCommand,
|
||||
noAutoRun: snippet.noAutoRun,
|
||||
...(isScriptSnippet(snippet)
|
||||
? { pendingScriptId: snippet.id, pendingScript: snippet }
|
||||
: {
|
||||
startupCommand: resolvedCommand,
|
||||
noAutoRun: snippet.noAutoRun,
|
||||
}),
|
||||
}));
|
||||
|
||||
setSessions(prev => [...prev, ...sessionsWithWorkspace]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
|
||||
|
||||
import { runThemeTransition } from './themeTransition';
|
||||
import { runThemeTransition, type ThemeTransitionMode } from './themeTransition';
|
||||
import { SyncConfig, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
|
||||
import {
|
||||
STORAGE_KEY_COLOR,
|
||||
@@ -43,12 +43,15 @@ import {
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
STORAGE_KEY_APP_ICON_VARIANT,
|
||||
STORAGE_KEY_AUTO_UPDATE_ENABLED,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
@@ -62,8 +65,7 @@ import {
|
||||
shouldApplyIncomingCustomKeyBindingsRecord,
|
||||
updateCustomKeyBinding as updateCustomKeyBindingRecord,
|
||||
} from '../../domain/customKeyBindings';
|
||||
import { TERMINAL_THEME_AUTO } from '../../domain/terminalAppearance';
|
||||
import { customThemeStore, useCustomThemes } from '../state/customThemeStore';
|
||||
import { resolveGlobalTerminalAppearance, idleThemeUserIntent } from '../../domain/terminalAppearanceRuntime';
|
||||
import { DEFAULT_FONT_SIZE, TERMINAL_FONT_AUTO } from '../../infrastructure/config/fonts';
|
||||
import { getUiThemeById } from '../../infrastructure/config/uiThemes';
|
||||
import { DEFAULT_UI_FONT_ID, withWindowsEmojiFallback } from '../../infrastructure/config/uiFonts';
|
||||
@@ -116,9 +118,18 @@ import { resolveRestorePreviousSessionSetting, resolveRestoreTerminalCwdSetting
|
||||
import { sessionRestoreStorage } from './sessionRestoreStorage';
|
||||
import { useSettingsStorageSync } from './settingsStorageSync';
|
||||
import { useSettingsIpcSync } from './settingsIpcSync';
|
||||
import { resolveCurrentTerminalTheme } from './settingsTerminalTheme';
|
||||
import { TERMINAL_THEME_AUTO } from '../../domain/terminalAppearance';
|
||||
import { customThemeStore, useCustomThemes } from '../state/customThemeStore';
|
||||
import { useSystemSettingsEffects } from './systemSettingsEffects';
|
||||
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
|
||||
import { DEFAULT_APP_ICON_VARIANT } from '../../infrastructure/config/appIconVariants';
|
||||
import { applyCustomCssToDocument } from '../../lib/customCss';
|
||||
import {
|
||||
DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_ENABLED,
|
||||
DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
isTerminalSidePanelAutoOpenTab,
|
||||
type TerminalSidePanelAutoOpenTab,
|
||||
} from '../../domain/terminalSidePanelAutoOpen';
|
||||
|
||||
export const useSettingsState = (options: { enableSettingsSync?: boolean; enableSystemEffects?: boolean } = {}) => {
|
||||
const enableSettingsSync = options.enableSettingsSync !== false;
|
||||
@@ -250,6 +261,14 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
|
||||
return stored ?? DEFAULT_SHOW_HOST_TREE_SIDEBAR;
|
||||
});
|
||||
const [terminalSidePanelAutoOpen, setTerminalSidePanelAutoOpenState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN);
|
||||
return stored ?? DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_ENABLED;
|
||||
});
|
||||
const [terminalSidePanelAutoOpenTab, setTerminalSidePanelAutoOpenTabState] = useState<TerminalSidePanelAutoOpenTab>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB);
|
||||
return isTerminalSidePanelAutoOpenTab(stored) ? stored : DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB;
|
||||
});
|
||||
const [shellOnlyTabNumberShortcuts, setShellOnlyTabNumberShortcutsState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
return stored ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS;
|
||||
@@ -342,6 +361,18 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
return clampWindowOpacity(candidate);
|
||||
});
|
||||
}, []);
|
||||
const [appIconVariant, setAppIconVariantState] = useState<AppIconVariant>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_APP_ICON_VARIANT);
|
||||
return resolveAppIconVariant(stored ?? DEFAULT_APP_ICON_VARIANT);
|
||||
});
|
||||
const setAppIconVariant = useCallback((nextValue: SetStateAction<AppIconVariant>) => {
|
||||
setAppIconVariantState((prev) => {
|
||||
const candidate = typeof nextValue === 'function'
|
||||
? (nextValue as (prevState: AppIconVariant) => AppIconVariant)(prev)
|
||||
: nextValue;
|
||||
return resolveAppIconVariant(candidate);
|
||||
});
|
||||
}, []);
|
||||
const incomingTerminalSettingsSignatureRef = useRef<string | null>(null);
|
||||
const localTerminalSettingsVersionRef = useRef(0);
|
||||
const broadcastedLocalTerminalSettingsVersionRef = useRef(0);
|
||||
@@ -356,6 +387,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
// Fix 1: Mount guard — skip redundant IPC broadcasts & localStorage writes on initial mount.
|
||||
// Set to true by the LAST useEffect declaration; all persist effects see false on first render.
|
||||
const persistMountedRef = useRef(false);
|
||||
const appearanceTransitionModeRef = useRef<ThemeTransitionMode>('view');
|
||||
|
||||
const setTerminalSettings = useCallback((nextValue: SetStateAction<TerminalSettings>) => {
|
||||
setTerminalSettingsState((prev) => {
|
||||
@@ -511,6 +543,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const syncCustomCssFromStorage = useCallback(() => {
|
||||
const storedCss = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_CSS) || '';
|
||||
setCustomCSS((prev) => (prev === storedCss ? prev : storedCss));
|
||||
applyCustomCssToDocument(storedCss);
|
||||
}, []);
|
||||
|
||||
const rehydrateAllFromStorage = useCallback(() => {
|
||||
@@ -591,6 +624,14 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setShowSftpTabState(storedShowSftpTab ?? DEFAULT_SHOW_SFTP_TAB);
|
||||
const storedShowHostTreeSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
|
||||
setShowHostTreeSidebarState(storedShowHostTreeSidebar ?? DEFAULT_SHOW_HOST_TREE_SIDEBAR);
|
||||
const storedTerminalSidePanelAutoOpen = localStorageAdapter.readBoolean(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN);
|
||||
setTerminalSidePanelAutoOpenState(storedTerminalSidePanelAutoOpen ?? DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_ENABLED);
|
||||
const storedTerminalSidePanelAutoOpenTab = readStoredString(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB);
|
||||
setTerminalSidePanelAutoOpenTabState(
|
||||
isTerminalSidePanelAutoOpenTab(storedTerminalSidePanelAutoOpenTab)
|
||||
? storedTerminalSidePanelAutoOpenTab
|
||||
: DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
);
|
||||
const storedShellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
setShellOnlyTabNumberShortcutsState(storedShellOnlyTabNumberShortcuts ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
const storedDisableTerminalFontZoom = localStorageAdapter.readBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM);
|
||||
@@ -611,8 +652,10 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
useLayoutEffect(() => {
|
||||
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
|
||||
const apply = () => applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
|
||||
const transitionMode = appearanceTransitionModeRef.current;
|
||||
appearanceTransitionModeRef.current = 'instant';
|
||||
if (persistMountedRef.current) {
|
||||
runThemeTransition(apply);
|
||||
runThemeTransition(apply, { mode: transitionMode });
|
||||
} else {
|
||||
apply();
|
||||
}
|
||||
@@ -686,12 +729,15 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setIsHotkeyRecordingState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setWindowOpacity,
|
||||
setAppIconVariant,
|
||||
setAutoUpdateEnabled,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setWorkspaceFocusStyleState,
|
||||
setShowHostTreeSidebarState,
|
||||
setTerminalSidePanelAutoOpenState,
|
||||
setTerminalSidePanelAutoOpenTabState,
|
||||
setDisableTerminalFontZoomState,
|
||||
setRestorePreviousSessionState,
|
||||
setRestoreTerminalCwdState,
|
||||
@@ -723,18 +769,18 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
|
||||
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
|
||||
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
});
|
||||
|
||||
@@ -846,6 +892,21 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
notifySettingsChanged(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setTerminalSidePanelAutoOpen = useCallback((enabled: boolean) => {
|
||||
setTerminalSidePanelAutoOpenState(enabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN, enabled);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setTerminalSidePanelAutoOpenTab = useCallback((tab: TerminalSidePanelAutoOpenTab) => {
|
||||
const next = isTerminalSidePanelAutoOpenTab(tab) ? tab : DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB;
|
||||
setTerminalSidePanelAutoOpenTabState(next);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB, next);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB, next);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setShellOnlyTabNumberShortcuts = useCallback((enabled: boolean) => {
|
||||
setShellOnlyTabNumberShortcutsState(enabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, enabled);
|
||||
@@ -1032,10 +1093,12 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
globalHotkeyEnabled,
|
||||
closeToTray,
|
||||
windowOpacity,
|
||||
appIconVariant,
|
||||
autoUpdateEnabled,
|
||||
persistMountedRef,
|
||||
setHotkeyRegistrationError,
|
||||
setAutoUpdateEnabled,
|
||||
setAppIconVariant,
|
||||
notifySettingsChanged,
|
||||
});
|
||||
|
||||
@@ -1082,21 +1145,26 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
// Subscribe to custom theme changes so editing in-place triggers re-render
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
const currentTerminalTheme = useMemo(() => resolveCurrentTerminalTheme({
|
||||
terminalThemeId,
|
||||
terminalThemeDarkId,
|
||||
terminalThemeLightId,
|
||||
const settledTerminalTheme = useMemo(() => resolveGlobalTerminalAppearance({
|
||||
userIntent: idleThemeUserIntent(),
|
||||
settings: {
|
||||
terminalThemeId,
|
||||
terminalThemeDarkId,
|
||||
terminalThemeLightId,
|
||||
followAppTerminalTheme,
|
||||
resolvedTheme,
|
||||
lightUiThemeId,
|
||||
darkUiThemeId,
|
||||
accentMode,
|
||||
customAccent,
|
||||
},
|
||||
customThemes,
|
||||
followAppTerminalTheme,
|
||||
resolvedTheme,
|
||||
lightUiThemeId,
|
||||
darkUiThemeId,
|
||||
accentMode,
|
||||
customAccent,
|
||||
}), [terminalThemeId, terminalThemeDarkId, terminalThemeLightId, customThemes,
|
||||
}).theme, [terminalThemeId, terminalThemeDarkId, terminalThemeLightId, customThemes,
|
||||
followAppTerminalTheme, resolvedTheme, lightUiThemeId, darkUiThemeId,
|
||||
accentMode, customAccent]);
|
||||
|
||||
const currentTerminalTheme = settledTerminalTheme;
|
||||
|
||||
const updateTerminalSetting = useCallback(<K extends keyof TerminalSettings>(
|
||||
key: K,
|
||||
value: TerminalSettings[K]
|
||||
@@ -1176,6 +1244,10 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setShowSftpTab,
|
||||
showHostTreeSidebar,
|
||||
setShowHostTreeSidebar,
|
||||
terminalSidePanelAutoOpen,
|
||||
setTerminalSidePanelAutoOpen,
|
||||
terminalSidePanelAutoOpenTab,
|
||||
setTerminalSidePanelAutoOpenTab,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
setShellOnlyTabNumberShortcuts,
|
||||
disableTerminalFontZoom,
|
||||
@@ -1218,6 +1290,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setGlobalHotkeyEnabled,
|
||||
windowOpacity,
|
||||
setWindowOpacity,
|
||||
appIconVariant,
|
||||
setAppIconVariant,
|
||||
rehydrateAllFromStorage,
|
||||
applyAppTheme,
|
||||
workspaceFocusStyle,
|
||||
@@ -1230,7 +1304,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
|
||||
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
|
||||
]),
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
createStoredStringSyncHandlers,
|
||||
readOptionalStoredStringValue,
|
||||
readStoredStringValue,
|
||||
resolveStoredStringUpdate,
|
||||
} from "./useStoredString.ts";
|
||||
@@ -85,12 +86,15 @@ test("stored string helpers read fallback and resolve updater-style toggles", (t
|
||||
|
||||
const storageKey = "netcatty:test-mode";
|
||||
assert.equal(readStoredStringValue(storageKey, "edit", isTestMode), "edit");
|
||||
assert.equal(readOptionalStoredStringValue(storageKey, isTestMode), null);
|
||||
|
||||
env.storage.setItem(storageKey, "preview");
|
||||
assert.equal(readStoredStringValue(storageKey, "edit", isTestMode), "preview");
|
||||
assert.equal(readOptionalStoredStringValue(storageKey, isTestMode), "preview");
|
||||
|
||||
env.storage.setItem(storageKey, "invalid");
|
||||
assert.equal(readStoredStringValue(storageKey, "edit", isTestMode), "edit");
|
||||
assert.equal(readOptionalStoredStringValue(storageKey, isTestMode), null);
|
||||
|
||||
assert.equal(resolveStoredStringUpdate<TestMode>("edit", "preview"), "preview");
|
||||
assert.equal(
|
||||
|
||||
@@ -18,6 +18,15 @@ export const readStoredStringValue = <T extends string>(
|
||||
return isAllowedValue(stored) ? stored : fallback;
|
||||
};
|
||||
|
||||
export const readOptionalStoredStringValue = <T extends string>(
|
||||
storageKey: string,
|
||||
isAllowedValue: (value: string | null) => value is T,
|
||||
): T | null => {
|
||||
if (!canUseLocalStorage()) return null;
|
||||
const stored = localStorageAdapter.readString(storageKey);
|
||||
return isAllowedValue(stored) ? stored : null;
|
||||
};
|
||||
|
||||
export const resolveStoredStringUpdate = <T extends string>(
|
||||
currentValue: T,
|
||||
nextValue: T | ((currentValue: T) => T),
|
||||
|
||||
@@ -74,11 +74,28 @@ export const useTerminalBackend = () => {
|
||||
return bridge.execCommand(options);
|
||||
}, []);
|
||||
|
||||
const setupOsc7Tracking = useCallback(async (sessionId: string, command: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.setupOsc7Tracking) {
|
||||
return { success: false, error: "setupOsc7Tracking unavailable" };
|
||||
}
|
||||
return bridge.setupOsc7Tracking(sessionId, command);
|
||||
}, []);
|
||||
|
||||
const writeToSession = useCallback((sessionId: string, data: string, options?: Parameters<NonNullable<NetcattyBridge["writeToSession"]>>[2]) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.writeToSession?.(sessionId, data, options);
|
||||
}, []);
|
||||
|
||||
const interruptSession = useCallback((sessionId: string, trace?: NetcattyTerminalInterruptTrace) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.interruptSession) {
|
||||
bridge.interruptSession(sessionId, trace);
|
||||
return;
|
||||
}
|
||||
bridge?.writeToSession?.(sessionId, "\x03");
|
||||
}, []);
|
||||
|
||||
const resizeSession = useCallback((sessionId: string, cols: number, rows: number) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.resizeSession?.(sessionId, cols, rows);
|
||||
@@ -89,6 +106,11 @@ export const useTerminalBackend = () => {
|
||||
bridge?.setSessionFlowPaused?.(sessionId, paused);
|
||||
}, []);
|
||||
|
||||
const ackSessionFlow = useCallback((sessionId: string, bytes: number) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.ackSessionFlow?.(sessionId, bytes);
|
||||
}, []);
|
||||
|
||||
const closeSession = useCallback((sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.closeSession?.(sessionId);
|
||||
@@ -100,10 +122,14 @@ export const useTerminalBackend = () => {
|
||||
return bridge.setSessionEncoding(sessionId, encoding);
|
||||
}, []);
|
||||
|
||||
const onSessionData = useCallback((sessionId: string, cb: (data: string) => void) => {
|
||||
const onSessionData = useCallback((
|
||||
sessionId: string,
|
||||
cb: (data: string) => void,
|
||||
options?: Parameters<NetcattyBridge["onSessionData"]>[2],
|
||||
) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onSessionData) throw new Error("onSessionData unavailable");
|
||||
return bridge.onSessionData(sessionId, cb);
|
||||
return bridge.onSessionData(sessionId, cb, options);
|
||||
}, []);
|
||||
|
||||
const onSessionExit = useCallback((sessionId: string, cb: (evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void) => {
|
||||
@@ -324,13 +350,16 @@ export const useTerminalBackend = () => {
|
||||
cancelZmodem,
|
||||
onZmodemEvent,
|
||||
execCommand,
|
||||
setupOsc7Tracking,
|
||||
getSessionPwd,
|
||||
getSessionRemoteInfo,
|
||||
getSessionDistroInfo,
|
||||
getServerStats,
|
||||
writeToSession,
|
||||
interruptSession,
|
||||
resizeSession,
|
||||
setSessionFlowPaused,
|
||||
ackSessionFlow,
|
||||
closeSession,
|
||||
setSessionEncoding,
|
||||
onSessionData,
|
||||
@@ -373,13 +402,16 @@ export const useTerminalBackend = () => {
|
||||
cancelZmodem,
|
||||
onZmodemEvent,
|
||||
execCommand,
|
||||
setupOsc7Tracking,
|
||||
getSessionPwd,
|
||||
getSessionRemoteInfo,
|
||||
getSessionDistroInfo,
|
||||
getServerStats,
|
||||
writeToSession,
|
||||
interruptSession,
|
||||
resizeSession,
|
||||
setSessionFlowPaused,
|
||||
ackSessionFlow,
|
||||
closeSession,
|
||||
setSessionEncoding,
|
||||
onSessionData,
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { Host, TerminalTheme } from '../../domain/models';
|
||||
import {
|
||||
idleThemeUserIntent,
|
||||
pickingThemeUserIntent,
|
||||
resolveGlobalTerminalAppearance,
|
||||
resolveTerminalAppearance,
|
||||
type ResolvedAppearance,
|
||||
type TerminalAppearanceHostScope,
|
||||
type TerminalAppearanceSettings,
|
||||
type ThemeUserIntent,
|
||||
} from '../../domain/terminalAppearanceRuntime';
|
||||
import { getFollowAppTerminalThemeSelectionUpdate } from '../../domain/terminalAppearance';
|
||||
import {
|
||||
injectTerminalAppearanceVars,
|
||||
} from '../../infrastructure/theme/terminalAppearanceVars';
|
||||
|
||||
export type ThemeRuntimeSettings = TerminalAppearanceSettings & {
|
||||
customThemes: TerminalTheme[];
|
||||
setTheme: (theme: 'light' | 'dark' | 'system') => void;
|
||||
setLightUiThemeId: (id: string) => void;
|
||||
setDarkUiThemeId: (id: string) => void;
|
||||
};
|
||||
|
||||
export function useThemeRuntime(settings: ThemeRuntimeSettings) {
|
||||
const [userIntent, setUserIntent] = useState<ThemeUserIntent>(idleThemeUserIntent());
|
||||
|
||||
const appearanceSettings = useMemo((): TerminalAppearanceSettings => ({
|
||||
terminalThemeId: settings.terminalThemeId,
|
||||
terminalThemeDarkId: settings.terminalThemeDarkId,
|
||||
terminalThemeLightId: settings.terminalThemeLightId,
|
||||
followAppTerminalTheme: settings.followAppTerminalTheme,
|
||||
resolvedTheme: settings.resolvedTheme,
|
||||
lightUiThemeId: settings.lightUiThemeId,
|
||||
darkUiThemeId: settings.darkUiThemeId,
|
||||
accentMode: settings.accentMode,
|
||||
customAccent: settings.customAccent,
|
||||
}), [
|
||||
settings.terminalThemeId,
|
||||
settings.terminalThemeDarkId,
|
||||
settings.terminalThemeLightId,
|
||||
settings.followAppTerminalTheme,
|
||||
settings.resolvedTheme,
|
||||
settings.lightUiThemeId,
|
||||
settings.darkUiThemeId,
|
||||
settings.accentMode,
|
||||
settings.customAccent,
|
||||
]);
|
||||
|
||||
const globalAppearance = useMemo(() => resolveGlobalTerminalAppearance({
|
||||
userIntent,
|
||||
settings: appearanceSettings,
|
||||
customThemes: settings.customThemes,
|
||||
}), [userIntent, appearanceSettings, settings.customThemes]);
|
||||
|
||||
const resolveFocusedAppearance = useCallback((hostScope: TerminalAppearanceHostScope): ResolvedAppearance => (
|
||||
resolveTerminalAppearance({
|
||||
userIntent,
|
||||
settings: appearanceSettings,
|
||||
hostScope,
|
||||
customThemes: settings.customThemes,
|
||||
})
|
||||
), [userIntent, appearanceSettings, settings.customThemes]);
|
||||
|
||||
const applyFollowAppSettingsForPick = useCallback((themeId: string) => {
|
||||
const update = getFollowAppTerminalThemeSelectionUpdate(themeId);
|
||||
if (!update) return false;
|
||||
if (update.appTheme === 'dark') {
|
||||
settings.setDarkUiThemeId(update.uiThemeId);
|
||||
} else {
|
||||
settings.setLightUiThemeId(update.uiThemeId);
|
||||
}
|
||||
settings.setTheme(update.appTheme);
|
||||
return true;
|
||||
}, [settings]);
|
||||
|
||||
const pickTheme = useCallback((themeId: string, options?: { followApp?: boolean; scopeHostId?: string | null }) => {
|
||||
const followApp = options?.followApp ?? settings.followAppTerminalTheme;
|
||||
setUserIntent(pickingThemeUserIntent(themeId, {
|
||||
scopeHostId: followApp ? undefined : options?.scopeHostId,
|
||||
}));
|
||||
if (followApp) {
|
||||
applyFollowAppSettingsForPick(themeId);
|
||||
}
|
||||
}, [applyFollowAppSettingsForPick, settings.followAppTerminalTheme]);
|
||||
|
||||
const clearIntent = useCallback(() => {
|
||||
setUserIntent(idleThemeUserIntent());
|
||||
}, []);
|
||||
|
||||
const settleManualIntent = useCallback(() => {
|
||||
setUserIntent(idleThemeUserIntent());
|
||||
}, []);
|
||||
|
||||
return {
|
||||
userIntent,
|
||||
globalAppearance,
|
||||
resolveFocusedAppearance,
|
||||
pickTheme,
|
||||
clearIntent,
|
||||
settleManualIntent,
|
||||
currentTerminalTheme: globalAppearance.theme,
|
||||
};
|
||||
}
|
||||
|
||||
export function useTerminalAppearanceInjection(
|
||||
appearance: ResolvedAppearance,
|
||||
options?: { includeChromeSurfaces?: boolean },
|
||||
): void {
|
||||
const includeChromeSurfaces = options?.includeChromeSurfaces ?? true;
|
||||
useLayoutEffect(() => {
|
||||
injectTerminalAppearanceVars(appearance.theme, { includeChromeSurfaces });
|
||||
}, [appearance.theme.id, appearance.theme, includeChromeSurfaces]);
|
||||
}
|
||||
|
||||
export function buildHostScope(host: Host | null, isEphemeral: boolean): TerminalAppearanceHostScope {
|
||||
return { host, isEphemeral };
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export interface UseVaultAgentBridgeInput {
|
||||
terminalSettings?: Pick<TerminalSettings, 'keepaliveInterval' | 'keepaliveCountMax'>;
|
||||
resolveEffectiveHost: (host: Host) => Host;
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
updateSnippets: (snippets: Snippet[]) => void;
|
||||
customGroups: string[];
|
||||
updateCustomGroups: (groups: string[]) => void;
|
||||
notes: VaultNote[];
|
||||
@@ -27,6 +28,7 @@ export interface UseVaultAgentBridgeInput {
|
||||
type VaultAgentSnapshot = {
|
||||
hosts: Host[];
|
||||
notes: VaultNote[];
|
||||
snippets: Snippet[];
|
||||
customGroups: string[];
|
||||
};
|
||||
|
||||
@@ -37,27 +39,32 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
|
||||
const vaultSnapshotRef = useRef<VaultAgentSnapshot>({
|
||||
hosts: input.hosts,
|
||||
notes: input.notes,
|
||||
snippets: input.snippets,
|
||||
customGroups: input.customGroups,
|
||||
});
|
||||
const lastSyncedVaultInputRef = useRef({
|
||||
hosts: input.hosts,
|
||||
notes: input.notes,
|
||||
snippets: input.snippets,
|
||||
customGroups: input.customGroups,
|
||||
});
|
||||
|
||||
if (
|
||||
input.hosts !== lastSyncedVaultInputRef.current.hosts
|
||||
|| input.notes !== lastSyncedVaultInputRef.current.notes
|
||||
|| input.snippets !== lastSyncedVaultInputRef.current.snippets
|
||||
|| input.customGroups !== lastSyncedVaultInputRef.current.customGroups
|
||||
) {
|
||||
vaultSnapshotRef.current = {
|
||||
hosts: input.hosts,
|
||||
notes: input.notes,
|
||||
snippets: input.snippets,
|
||||
customGroups: input.customGroups,
|
||||
};
|
||||
lastSyncedVaultInputRef.current = {
|
||||
hosts: input.hosts,
|
||||
notes: input.notes,
|
||||
snippets: input.snippets,
|
||||
customGroups: input.customGroups,
|
||||
};
|
||||
}
|
||||
@@ -69,7 +76,7 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
|
||||
getHosts: () => vaultSnapshotRef.current.hosts,
|
||||
getNotes: () => vaultSnapshotRef.current.notes,
|
||||
getCustomGroups: () => vaultSnapshotRef.current.customGroups,
|
||||
snippets: current.snippets,
|
||||
snippets: vaultSnapshotRef.current.snippets,
|
||||
portForwardingRules: current.portForwardingRules,
|
||||
keys: current.keys,
|
||||
identities: current.identities,
|
||||
@@ -94,6 +101,10 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
|
||||
vaultSnapshotRef.current.notes = notes;
|
||||
current.updateNotes(notes);
|
||||
},
|
||||
updateSnippets: (nextSnippets) => {
|
||||
vaultSnapshotRef.current.snippets = nextSnippets;
|
||||
current.updateSnippets(nextSnippets);
|
||||
},
|
||||
startTunnel: current.startTunnel,
|
||||
stopTunnel: current.stopTunnel,
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "../../infrastructure/config/defaultData";
|
||||
import {
|
||||
STORAGE_KEY_CONNECTION_LOGS,
|
||||
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA,
|
||||
STORAGE_KEY_GROUP_CONFIGS,
|
||||
STORAGE_KEY_GROUPS,
|
||||
STORAGE_KEY_HOSTS,
|
||||
@@ -39,10 +40,18 @@ import {
|
||||
STORAGE_KEY_SNIPPETS,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
} from "../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import { localStorageAdapter, LOCAL_STORAGE_ADAPTER_CHANGED_EVENT } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import { mergeGlobalHistoryOnAppend, sanitizeGlobalHistoryEntries } from "../../domain/globalHistory";
|
||||
import {
|
||||
buildTerminalDataMapFromLogs,
|
||||
mergeConnectionLogsFromStorage,
|
||||
mergeTerminalDataIntoLogs,
|
||||
mergeTerminalDataMapsForStorage,
|
||||
type ConnectionLogTerminalDataMap,
|
||||
} from "../../domain/connectionLogTerminalData";
|
||||
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
|
||||
import { loadSanitizedShellHistory } from "./shellHistoryPersistence";
|
||||
import { setVaultInitialized } from "./vaultInitStore";
|
||||
import {
|
||||
decryptGroupConfigs,
|
||||
decryptHosts,
|
||||
@@ -148,6 +157,43 @@ const readLegacyLineTimestampsEnabled = (): boolean => {
|
||||
return stored?.showLineTimestamps === true;
|
||||
};
|
||||
|
||||
const readConnectionLogTerminalDataMap = (): ConnectionLogTerminalDataMap =>
|
||||
localStorageAdapter.read<ConnectionLogTerminalDataMap>(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA) ?? {};
|
||||
|
||||
const readPersistedConnectionLogIds = (): Set<string> => {
|
||||
const stored = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS) ?? [];
|
||||
return new Set(stored.map((log) => log.id));
|
||||
};
|
||||
|
||||
const buildPersistedLogIds = (
|
||||
logs: ConnectionLog[],
|
||||
usePersistedLogIdsFromDisk: boolean,
|
||||
): Set<string> => {
|
||||
const ids = new Set(logs.map((log) => log.id));
|
||||
if (usePersistedLogIdsFromDisk) {
|
||||
for (const id of readPersistedConnectionLogIds()) {
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
};
|
||||
|
||||
const writeConnectionLogTerminalDataMap = (
|
||||
logs: ConnectionLog[],
|
||||
localMaps: ConnectionLogTerminalDataMap[],
|
||||
usePersistedLogIdsFromDisk: boolean,
|
||||
): boolean => {
|
||||
const existing = readConnectionLogTerminalDataMap();
|
||||
const pruned = mergeTerminalDataMapsForStorage(
|
||||
logs,
|
||||
existing,
|
||||
localMaps,
|
||||
buildPersistedLogIds(logs, usePersistedLogIdsFromDisk),
|
||||
);
|
||||
if (JSON.stringify(existing) === JSON.stringify(pruned)) return true;
|
||||
return localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA, pruned);
|
||||
};
|
||||
|
||||
export const useVaultState = () => {
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
@@ -183,6 +229,87 @@ export const useVaultState = () => {
|
||||
const identitiesReadSeq = useRef(0);
|
||||
const proxyProfilesReadSeq = useRef(0);
|
||||
const groupConfigsReadSeq = useRef(0);
|
||||
const connectionLogTerminalDataRef = useRef<ConnectionLogTerminalDataMap>({});
|
||||
|
||||
const syncConnectionLogTerminalDataMap = useCallback((
|
||||
logs: ConnectionLog[],
|
||||
options?: { usePersistedLogIdsFromDisk?: boolean },
|
||||
) => {
|
||||
const usePersistedLogIdsFromDisk = options?.usePersistedLogIdsFromDisk ?? false;
|
||||
const localMaps = [
|
||||
connectionLogTerminalDataRef.current,
|
||||
buildTerminalDataMapFromLogs(logs),
|
||||
];
|
||||
const existing = readConnectionLogTerminalDataMap();
|
||||
const merged = mergeTerminalDataMapsForStorage(
|
||||
logs,
|
||||
existing,
|
||||
localMaps,
|
||||
buildPersistedLogIds(logs, usePersistedLogIdsFromDisk),
|
||||
);
|
||||
const persisted = writeConnectionLogTerminalDataMap(
|
||||
logs,
|
||||
localMaps,
|
||||
usePersistedLogIdsFromDisk,
|
||||
);
|
||||
if (persisted) {
|
||||
connectionLogTerminalDataRef.current = merged;
|
||||
} else {
|
||||
connectionLogTerminalDataRef.current = {
|
||||
...connectionLogTerminalDataRef.current,
|
||||
...buildTerminalDataMapFromLogs(logs),
|
||||
};
|
||||
}
|
||||
return { map: connectionLogTerminalDataRef.current, persisted };
|
||||
}, []);
|
||||
|
||||
const persistConnectionLogState = useCallback((
|
||||
logs: ConnectionLog[],
|
||||
options?: { pruneMainBlob?: boolean },
|
||||
) => {
|
||||
const unsavedTerminalDataPending = logs.some(
|
||||
(log) => !log.saved && log.terminalData !== undefined,
|
||||
);
|
||||
|
||||
let mainPersisted = true;
|
||||
if (options?.pruneMainBlob) {
|
||||
const prunedMain = pruneConnectionLogsForStorage(logs);
|
||||
mainPersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, prunedMain);
|
||||
if (!mainPersisted && unsavedTerminalDataPending) {
|
||||
mainPersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, logs);
|
||||
}
|
||||
}
|
||||
|
||||
let sidePersisted = true;
|
||||
if (mainPersisted || !options?.pruneMainBlob) {
|
||||
({ persisted: sidePersisted } = syncConnectionLogTerminalDataMap(logs, {
|
||||
usePersistedLogIdsFromDisk: Boolean(options?.pruneMainBlob && mainPersisted),
|
||||
}));
|
||||
}
|
||||
|
||||
if (!options?.pruneMainBlob) {
|
||||
mainPersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, logs);
|
||||
}
|
||||
|
||||
if (!mainPersisted && unsavedTerminalDataPending) {
|
||||
console.warn(
|
||||
"[useVaultState] Failed to persist connection log terminal replay data to localStorage.",
|
||||
);
|
||||
} else if (!sidePersisted && unsavedTerminalDataPending && options?.pruneMainBlob && mainPersisted) {
|
||||
console.warn(
|
||||
"[useVaultState] Failed to persist connection log terminal replay data side store.",
|
||||
);
|
||||
}
|
||||
}, [syncConnectionLogTerminalDataMap]);
|
||||
|
||||
const applyConnectionLogsFromStorage = useCallback((
|
||||
prev: ConnectionLog[],
|
||||
storedLogs: ConnectionLog[],
|
||||
terminalDataMap: ConnectionLogTerminalDataMap,
|
||||
) => {
|
||||
connectionLogTerminalDataRef.current = terminalDataMap;
|
||||
return mergeConnectionLogsFromStorage(prev, storedLogs, terminalDataMap);
|
||||
}, []);
|
||||
|
||||
const updateHosts = useCallback((data: Host[]) => {
|
||||
const cleaned = normalizeVaultOrder(data.map(sanitizeHost));
|
||||
@@ -403,12 +530,12 @@ export const useVaultState = () => {
|
||||
const final = [...updated, ...savedLogs].sort(
|
||||
(a, b) => b.startTime - a.startTime
|
||||
);
|
||||
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, pruneConnectionLogsForStorage(final));
|
||||
persistConnectionLogState(final, { pruneMainBlob: true });
|
||||
return final;
|
||||
});
|
||||
return newLog.id;
|
||||
},
|
||||
[]
|
||||
[persistConnectionLogState],
|
||||
);
|
||||
|
||||
const updateConnectionLog = useCallback(
|
||||
@@ -417,11 +544,11 @@ export const useVaultState = () => {
|
||||
const updated = prev.map((log) =>
|
||||
log.id === id ? { ...log, ...updates } : log
|
||||
);
|
||||
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, pruneConnectionLogsForStorage(updated));
|
||||
persistConnectionLogState(updated, { pruneMainBlob: true });
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
[]
|
||||
[persistConnectionLogState],
|
||||
);
|
||||
|
||||
const toggleConnectionLogSaved = useCallback((id: string) => {
|
||||
@@ -429,26 +556,29 @@ export const useVaultState = () => {
|
||||
const updated = prev.map((log) =>
|
||||
log.id === id ? { ...log, saved: !log.saved } : log
|
||||
);
|
||||
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, updated);
|
||||
persistConnectionLogState(updated, { pruneMainBlob: false });
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
}, [persistConnectionLogState]);
|
||||
|
||||
const deleteConnectionLog = useCallback((id: string) => {
|
||||
setConnectionLogs((prev) => {
|
||||
const updated = prev.filter((log) => log.id !== id);
|
||||
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, updated);
|
||||
const map = { ...connectionLogTerminalDataRef.current };
|
||||
delete map[id];
|
||||
connectionLogTerminalDataRef.current = map;
|
||||
persistConnectionLogState(updated, { pruneMainBlob: true });
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
}, [persistConnectionLogState]);
|
||||
|
||||
const clearUnsavedConnectionLogs = useCallback(() => {
|
||||
setConnectionLogs((prev) => {
|
||||
const saved = prev.filter((log) => log.saved);
|
||||
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, pruneConnectionLogsForStorage(saved));
|
||||
persistConnectionLogState(saved, { pruneMainBlob: true });
|
||||
return saved;
|
||||
});
|
||||
}, []);
|
||||
}, [persistConnectionLogState]);
|
||||
|
||||
// Convert a known host to a managed host
|
||||
const convertKnownHostToHost = useCallback((knownHost: KnownHost): Host => {
|
||||
@@ -644,7 +774,11 @@ export const useVaultState = () => {
|
||||
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
|
||||
STORAGE_KEY_CONNECTION_LOGS,
|
||||
);
|
||||
if (savedConnectionLogs) setConnectionLogs(savedConnectionLogs);
|
||||
const terminalDataMap = readConnectionLogTerminalDataMap();
|
||||
connectionLogTerminalDataRef.current = terminalDataMap;
|
||||
if (savedConnectionLogs) {
|
||||
setConnectionLogs(mergeTerminalDataIntoLogs(savedConnectionLogs, terminalDataMap));
|
||||
}
|
||||
|
||||
// Load managed sources
|
||||
const savedManagedSources = localStorageAdapter.read<ManagedSource[]>(
|
||||
@@ -668,6 +802,7 @@ export const useVaultState = () => {
|
||||
}
|
||||
} finally {
|
||||
setIsInitialized(true);
|
||||
setVaultInitialized(true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -787,7 +922,16 @@ export const useVaultState = () => {
|
||||
|
||||
if (key === STORAGE_KEY_CONNECTION_LOGS) {
|
||||
const next = safeParse<ConnectionLog[]>(event.newValue) ?? [];
|
||||
setConnectionLogs(next);
|
||||
setConnectionLogs((prev) =>
|
||||
applyConnectionLogsFromStorage(prev, next, connectionLogTerminalDataRef.current),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA) {
|
||||
const next = safeParse<ConnectionLogTerminalDataMap>(event.newValue) ?? {};
|
||||
connectionLogTerminalDataRef.current = next;
|
||||
setConnectionLogs((prev) => mergeConnectionLogsFromStorage(prev, prev, next));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -810,9 +954,29 @@ export const useVaultState = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleLocalStorageAdapterChanged = (event: Event) => {
|
||||
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
|
||||
if (key === STORAGE_KEY_CONNECTION_LOGS) {
|
||||
const next = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS) ?? [];
|
||||
setConnectionLogs((prev) =>
|
||||
applyConnectionLogsFromStorage(prev, next, connectionLogTerminalDataRef.current),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (key === STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA) {
|
||||
const next = readConnectionLogTerminalDataMap();
|
||||
connectionLogTerminalDataRef.current = next;
|
||||
setConnectionLogs((prev) => mergeConnectionLogsFromStorage(prev, prev, next));
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("storage", handleStorage);
|
||||
return () => window.removeEventListener("storage", handleStorage);
|
||||
}, []);
|
||||
window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleLocalStorageAdapterChanged);
|
||||
return () => {
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleLocalStorageAdapterChanged);
|
||||
};
|
||||
}, [applyConnectionLogsFromStorage]);
|
||||
|
||||
const updateHostLastConnected = useCallback((hostId: string) => {
|
||||
setHosts((prev) => {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
let vaultInitialized = false;
|
||||
|
||||
export function isVaultInitialized(): boolean {
|
||||
return vaultInitialized;
|
||||
}
|
||||
|
||||
export function setVaultInitialized(value: boolean): void {
|
||||
vaultInitialized = value;
|
||||
}
|
||||
@@ -9,17 +9,49 @@ export const requestWindowInputFocus = (): void => {
|
||||
}
|
||||
};
|
||||
|
||||
export const scheduleWindowInputFocus = (): void => {
|
||||
const scheduleFrame: (callback: () => void) => unknown =
|
||||
export type ScheduledWindowInputFocus = {
|
||||
cancel: () => void;
|
||||
};
|
||||
|
||||
export const scheduleWindowInputFocus = (): ScheduledWindowInputFocus => {
|
||||
let cancelled = false;
|
||||
let frameId: number | undefined;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const cancel = () => {
|
||||
cancelled = true;
|
||||
if (frameId !== undefined && typeof cancelAnimationFrame === "function") {
|
||||
cancelAnimationFrame(frameId);
|
||||
frameId = undefined;
|
||||
}
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const runIfVisible = () => {
|
||||
if (cancelled || document.visibilityState !== "visible") return;
|
||||
requestWindowInputFocus();
|
||||
};
|
||||
|
||||
const scheduleFrame: (callback: FrameRequestCallback) => number =
|
||||
typeof requestAnimationFrame === "function"
|
||||
? requestAnimationFrame
|
||||
: (callback) => {
|
||||
callback();
|
||||
return undefined;
|
||||
callback(0);
|
||||
return 0;
|
||||
};
|
||||
|
||||
scheduleFrame(() => {
|
||||
requestWindowInputFocus();
|
||||
setTimeout(requestWindowInputFocus, 50);
|
||||
frameId = scheduleFrame(() => {
|
||||
frameId = undefined;
|
||||
runIfVisible();
|
||||
if (cancelled) return;
|
||||
timeoutId = setTimeout(() => {
|
||||
timeoutId = undefined;
|
||||
runIfVisible();
|
||||
}, 50);
|
||||
});
|
||||
|
||||
return { cancel };
|
||||
};
|
||||
@@ -181,12 +181,29 @@ test("terminal selection AI preference is syncable for auto-sync detection", ()
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal side panel auto-open settings are syncable for auto-sync detection", () => {
|
||||
assert.ok(
|
||||
(SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(
|
||||
storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
(SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(
|
||||
storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
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");
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB, "scripts");
|
||||
|
||||
const payload = buildSyncPayload(vault([]));
|
||||
|
||||
assert.equal(payload.settings?.showHostTreeSidebar, false);
|
||||
assert.equal(payload.settings?.terminalSidePanelAutoOpen, true);
|
||||
assert.equal(payload.settings?.terminalSidePanelAutoOpenTab, "scripts");
|
||||
});
|
||||
|
||||
test("buildSyncPayload excludes externalAgents (device-local OS-bound config)", () => {
|
||||
@@ -378,6 +395,8 @@ test("applySyncPayload restores host tree sidebar visibility setting", async ()
|
||||
customGroups: [],
|
||||
settings: {
|
||||
showHostTreeSidebar: false,
|
||||
terminalSidePanelAutoOpen: true,
|
||||
terminalSidePanelAutoOpenTab: "scripts",
|
||||
},
|
||||
syncedAt: 1,
|
||||
} as SyncPayload;
|
||||
@@ -385,6 +404,8 @@ test("applySyncPayload restores host tree sidebar visibility setting", async ()
|
||||
await applySyncPayload(payload, { importVaultData: () => {} });
|
||||
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR), "false");
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN), "true");
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB), "scripts");
|
||||
});
|
||||
|
||||
test("applySyncPayload dispatches a same-window AI-state-changed event so the open chat panel rehydrates", async () => {
|
||||
|
||||
@@ -53,6 +53,8 @@ import {
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
|
||||
STORAGE_KEY_EDITOR_WORD_WRAP,
|
||||
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
|
||||
@@ -88,6 +90,7 @@ import {
|
||||
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
import { isTerminalSidePanelAutoOpenTab } from '../domain/terminalSidePanelAutoOpen';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input types
|
||||
@@ -227,6 +230,8 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
STORAGE_KEY_CUSTOM_THEMES,
|
||||
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
|
||||
STORAGE_KEY_EDITOR_WORD_WRAP,
|
||||
@@ -386,6 +391,12 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (termFont) settings.terminalFontFamily = termFont;
|
||||
const termSize = localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE);
|
||||
if (termSize != null) settings.terminalFontSize = termSize;
|
||||
const terminalSidePanelAutoOpen = localStorageAdapter.readBoolean(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN);
|
||||
if (terminalSidePanelAutoOpen != null) settings.terminalSidePanelAutoOpen = terminalSidePanelAutoOpen;
|
||||
const terminalSidePanelAutoOpenTab = localStorageAdapter.readString(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB);
|
||||
if (isTerminalSidePanelAutoOpenTab(terminalSidePanelAutoOpenTab)) {
|
||||
settings.terminalSidePanelAutoOpenTab = terminalSidePanelAutoOpenTab;
|
||||
}
|
||||
|
||||
// Terminal settings (syncable subset only)
|
||||
const termSettingsRaw = localStorageAdapter.readString(STORAGE_KEY_TERM_SETTINGS);
|
||||
@@ -571,6 +582,12 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
|
||||
if (settings.terminalThemeLight != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_LIGHT, settings.terminalThemeLight);
|
||||
if (settings.terminalFontFamily != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, settings.terminalFontFamily);
|
||||
if (settings.terminalFontSize != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_SIZE, String(settings.terminalFontSize));
|
||||
if (settings.terminalSidePanelAutoOpen != null) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN, settings.terminalSidePanelAutoOpen);
|
||||
}
|
||||
if (isTerminalSidePanelAutoOpenTab(settings.terminalSidePanelAutoOpenTab)) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB, settings.terminalSidePanelAutoOpenTab);
|
||||
}
|
||||
|
||||
// Terminal settings — merge with existing to preserve platform-specific keys
|
||||
if (settings.terminalSettings) {
|
||||
|
||||
@@ -47,6 +47,7 @@ interface AIChatPanelContentProps {
|
||||
handleDeleteSession: (event: React.MouseEvent, sessionId: string) => void;
|
||||
messages: ChatMessage[];
|
||||
isStreaming: boolean;
|
||||
activeCompaction?: import('./ai/hooks/useAgentCompactionUi').ActiveCompactionUi | null;
|
||||
inputValue: string;
|
||||
setInputValue: (value: string) => void;
|
||||
handleSend: () => void;
|
||||
@@ -100,6 +101,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
handleDeleteSession,
|
||||
messages,
|
||||
isStreaming,
|
||||
activeCompaction = null,
|
||||
inputValue,
|
||||
setInputValue,
|
||||
handleSend,
|
||||
@@ -210,6 +212,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
activeSessionId={activeSessionId}
|
||||
activeCompaction={activeCompaction}
|
||||
notes={notes}
|
||||
hosts={hosts}
|
||||
onOpenVaultNote={onOpenVaultNote}
|
||||
|
||||
@@ -182,3 +182,15 @@ test('AI side panel re-renders when retained content becomes visible again', ()
|
||||
{ ...hiddenProps, isVisible: true },
|
||||
), false);
|
||||
});
|
||||
|
||||
test('AI side panel re-renders when command timeout changes', () => {
|
||||
const props = baseProps({
|
||||
isVisible: true,
|
||||
commandTimeout: 60,
|
||||
});
|
||||
|
||||
assert.equal(aiChatSidePanelPropsAreEqual(
|
||||
props,
|
||||
{ ...props, commandTimeout: 86_400 },
|
||||
), false);
|
||||
});
|
||||
+186
-39
@@ -10,6 +10,7 @@ import type {
|
||||
AgentModelPreset,
|
||||
AISessionScope,
|
||||
DiscoveredAgent,
|
||||
ExternalAgentConfig,
|
||||
} from '../infrastructure/ai/types';
|
||||
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
|
||||
import { getAgentModelPresets } from '../infrastructure/ai/types';
|
||||
@@ -54,11 +55,14 @@ import { useAIPermissionGrantsState } from '../application/state/useAIPermission
|
||||
import { useConversationExport } from './ai/hooks/useConversationExport';
|
||||
import type { AIChatSidePanelProps } from './AIChatSidePanel.types';
|
||||
import {
|
||||
buildSdkRuntimeModelCacheKey,
|
||||
sdkRuntimeModelCache,
|
||||
generateId,
|
||||
normalizeSdkRuntimeModelPresets,
|
||||
shouldAdoptSdkCurrentModel,
|
||||
shouldLoadSdkRuntimeModels,
|
||||
shouldUseStoredAgentModel,
|
||||
type SdkRuntimeModelCatalog,
|
||||
} from './AIChatSidePanelHelpers';
|
||||
import { AIChatPanelContent } from './AIChatPanelContent';
|
||||
import {
|
||||
@@ -74,6 +78,13 @@ type UserSkillsStatusResult = { ok: boolean; skills?: Array<{
|
||||
status: 'ready' | 'warning';
|
||||
}> } | null;
|
||||
type UserSkillsStatusLoadResult = UserSkillsStatusResult | undefined;
|
||||
type SdkRuntimeModelTarget = {
|
||||
agentId: string;
|
||||
cacheKey: string;
|
||||
sdkBackend: string;
|
||||
agentEnv?: Record<string, string>;
|
||||
agentCommand?: string;
|
||||
};
|
||||
|
||||
const USER_SKILLS_STATUS_CACHE_TTL_MS = 60_000;
|
||||
let userSkillsStatusCache: {
|
||||
@@ -252,6 +263,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
globalPermissionMode,
|
||||
setGlobalPermissionMode,
|
||||
commandBlocklist,
|
||||
commandTimeout,
|
||||
maxIterations = 20,
|
||||
webSearchConfig,
|
||||
quickMessages = [],
|
||||
@@ -264,8 +276,10 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
isVisible = true,
|
||||
notes = [],
|
||||
hosts = [],
|
||||
snippets = [],
|
||||
onOpenVaultNote,
|
||||
onOpenVaultHost,
|
||||
onOpenVaultSnippet,
|
||||
onOpenVaultSection,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
@@ -288,6 +302,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
sendToCattyAgent,
|
||||
sendToExternalAgent,
|
||||
reportStreamError,
|
||||
activeCompaction,
|
||||
} = useAIChatStreaming({
|
||||
maxIterations,
|
||||
addMessageToSession,
|
||||
@@ -653,55 +668,178 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
const agentModelMapRef = useRef(agentModelMap);
|
||||
agentModelMapRef.current = agentModelMap;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
const sdkBackend = getExternalAgentSdkBackend(currentAgentConfig);
|
||||
if (!sdkBackend) return;
|
||||
if (!shouldLoadSdkRuntimeModels(currentAgentConfig) && !isCodexManagedAgent) return;
|
||||
|
||||
const bridge = getNetcattyBridge();
|
||||
if (!bridge?.aiSdkAgentListModels) return;
|
||||
|
||||
let cancelled = false;
|
||||
void bridge.aiSdkAgentListModels(
|
||||
const buildExternalAgentRuntimeModelTarget = useCallback((agent: ExternalAgentConfig | undefined): SdkRuntimeModelTarget | null => {
|
||||
if (!agent) return null;
|
||||
const sdkBackend = getExternalAgentSdkBackend(agent);
|
||||
if (!sdkBackend) return null;
|
||||
return {
|
||||
agentId: agent.id,
|
||||
cacheKey: buildSdkRuntimeModelCacheKey(agent),
|
||||
sdkBackend,
|
||||
undefined,
|
||||
undefined,
|
||||
`models_${currentAgentId}`,
|
||||
currentAgentConfig.env,
|
||||
getManualAgentCommand(currentAgentConfig),
|
||||
).then((result) => {
|
||||
if (cancelled || !result?.ok || !Array.isArray(result.models)) return;
|
||||
const runtimePresets = normalizeSdkRuntimeModelPresets(result.models, result.currentModelId);
|
||||
const storedModelId = agentModelMapRef.current[currentAgentId];
|
||||
if (runtimePresets.length === 0) {
|
||||
setRuntimeAgentModelPresets((prev) => {
|
||||
if (!(currentAgentId in prev)) return prev;
|
||||
const { [currentAgentId]: _removed, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
if (shouldAdoptSdkCurrentModel(result.currentModelId, storedModelId, runtimePresets)) {
|
||||
setAgentModel(currentAgentId, result.currentModelId!);
|
||||
}
|
||||
return;
|
||||
}
|
||||
agentEnv: agent.env,
|
||||
agentCommand: getManualAgentCommand(agent),
|
||||
};
|
||||
}, []);
|
||||
|
||||
const buildDiscoveredAgentRuntimeModelTarget = useCallback((agent: DiscoveredAgent): SdkRuntimeModelTarget | null => {
|
||||
const sdkBackend = agent.sdkBackend ?? agent.command;
|
||||
if (sdkBackend !== 'opencode') return null;
|
||||
const command = agent.binPath || agent.path || agent.command;
|
||||
const agentId = `discovered_${agent.command}`;
|
||||
return {
|
||||
agentId,
|
||||
cacheKey: buildSdkRuntimeModelCacheKey({
|
||||
id: agentId,
|
||||
command,
|
||||
sdkBackend,
|
||||
env: command ? { OPENCODE_BIN: command } : undefined,
|
||||
}),
|
||||
sdkBackend,
|
||||
agentCommand: command,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const applySdkRuntimeModelCatalog = useCallback((
|
||||
agentId: string,
|
||||
catalog: SdkRuntimeModelCatalog,
|
||||
options: { adoptCurrentModel?: boolean } = {},
|
||||
) => {
|
||||
const runtimePresets = normalizeSdkRuntimeModelPresets(catalog.models, catalog.currentModelId);
|
||||
const storedModelId = agentModelMapRef.current[agentId];
|
||||
if (runtimePresets.length === 0) {
|
||||
setRuntimeAgentModelPresets((prev) => {
|
||||
if (!(agentId in prev)) return prev;
|
||||
const { [agentId]: _removed, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
} else {
|
||||
setRuntimeAgentModelPresets((prev) => ({
|
||||
...prev,
|
||||
[currentAgentId]: runtimePresets,
|
||||
[agentId]: runtimePresets,
|
||||
}));
|
||||
if (shouldAdoptSdkCurrentModel(result.currentModelId, storedModelId, runtimePresets)) {
|
||||
setAgentModel(currentAgentId, result.currentModelId);
|
||||
}
|
||||
}).catch((err) => {
|
||||
if (!cancelled) {
|
||||
}
|
||||
|
||||
if (
|
||||
options.adoptCurrentModel
|
||||
&& catalog.currentModelId
|
||||
&& shouldAdoptSdkCurrentModel(catalog.currentModelId, storedModelId, runtimePresets)
|
||||
) {
|
||||
setAgentModel(agentId, catalog.currentModelId);
|
||||
}
|
||||
}, [setAgentModel]);
|
||||
|
||||
const loadSdkRuntimeModelCatalog = useCallback((
|
||||
target: SdkRuntimeModelTarget,
|
||||
options: { force?: boolean; logErrors?: boolean } = {},
|
||||
): Promise<SdkRuntimeModelCatalog | null> => {
|
||||
const bridge = getNetcattyBridge();
|
||||
if (!bridge?.aiSdkAgentListModels) return Promise.resolve(null);
|
||||
|
||||
return sdkRuntimeModelCache.refresh(
|
||||
target.cacheKey,
|
||||
async () => {
|
||||
const result = await bridge.aiSdkAgentListModels!(
|
||||
target.sdkBackend,
|
||||
undefined,
|
||||
undefined,
|
||||
`models_${target.agentId}`,
|
||||
target.agentEnv,
|
||||
target.agentCommand,
|
||||
);
|
||||
if (!result?.ok || !Array.isArray(result.models)) {
|
||||
throw new Error(result?.error || 'Failed to load SDK agent models');
|
||||
}
|
||||
return {
|
||||
currentModelId: result.currentModelId ?? null,
|
||||
models: result.models,
|
||||
};
|
||||
},
|
||||
{ force: options.force },
|
||||
).catch((err) => {
|
||||
if (options.logErrors !== false) {
|
||||
console.warn('[AIChatSidePanel] Failed to load SDK agent models:', err);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
if (!currentAgentConfig) return;
|
||||
if (!shouldLoadSdkRuntimeModels(currentAgentConfig) && !isCodexManagedAgent) return;
|
||||
|
||||
const target = buildExternalAgentRuntimeModelTarget(currentAgentConfig);
|
||||
if (!target) return;
|
||||
|
||||
const cached = sdkRuntimeModelCache.read(target.cacheKey);
|
||||
if (cached) {
|
||||
applySdkRuntimeModelCatalog(target.agentId, cached);
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
void loadSdkRuntimeModelCatalog(target, {
|
||||
force: target.sdkBackend === 'opencode',
|
||||
}).then((catalog) => {
|
||||
if (cancelled || !catalog) return;
|
||||
applySdkRuntimeModelCatalog(target.agentId, catalog, { adoptCurrentModel: true });
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isVisible, currentAgentConfig, currentAgentId, isCodexManagedAgent, setAgentModel]);
|
||||
}, [
|
||||
isVisible,
|
||||
currentAgentConfig,
|
||||
isCodexManagedAgent,
|
||||
buildExternalAgentRuntimeModelTarget,
|
||||
loadSdkRuntimeModelCatalog,
|
||||
applySdkRuntimeModelCatalog,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
const targets = new Map<string, SdkRuntimeModelTarget>();
|
||||
const configuredTargetCacheKeys = new Set<string>();
|
||||
|
||||
for (const agent of externalAgents) {
|
||||
if (!agent.enabled || getExternalAgentSdkBackend(agent) !== 'opencode') continue;
|
||||
const target = buildExternalAgentRuntimeModelTarget(agent);
|
||||
if (target) {
|
||||
targets.set(target.cacheKey, target);
|
||||
configuredTargetCacheKeys.add(target.cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
for (const agent of discoveredAgents) {
|
||||
const target = buildDiscoveredAgentRuntimeModelTarget(agent);
|
||||
if (target) targets.set(target.cacheKey, target);
|
||||
}
|
||||
|
||||
if (targets.size === 0) return;
|
||||
|
||||
let cancelled = false;
|
||||
for (const target of targets.values()) {
|
||||
void loadSdkRuntimeModelCatalog(target, { logErrors: false })
|
||||
.then((catalog) => {
|
||||
if (cancelled || !catalog) return;
|
||||
if (configuredTargetCacheKeys.has(target.cacheKey)) {
|
||||
applySdkRuntimeModelCatalog(target.agentId, catalog);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
isVisible,
|
||||
externalAgents,
|
||||
discoveredAgents,
|
||||
buildExternalAgentRuntimeModelTarget,
|
||||
buildDiscoveredAgentRuntimeModelTarget,
|
||||
loadSdkRuntimeModelCatalog,
|
||||
applySdkRuntimeModelCatalog,
|
||||
]);
|
||||
|
||||
const hasCodexCustomConfig = codexCustomConfigResolved && isCodexManagedAgent;
|
||||
|
||||
@@ -966,6 +1104,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
scopeLabel,
|
||||
globalPermissionMode,
|
||||
commandBlocklist,
|
||||
commandTimeout,
|
||||
terminalSessions,
|
||||
webSearchConfig,
|
||||
getExecutorContext: () => buildExecutorContextForScope(toolScope),
|
||||
@@ -986,7 +1125,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
setStreamingForScope,
|
||||
sendToExternalAgent, sendToCattyAgent, reportStreamError, autoTitleSession, t,
|
||||
abortControllersRef, terminalSessions, defaultTargetSession, providers, selectedAgentModel, updateSessionExternalSessionId,
|
||||
scopeType, scopeTargetId, scopeHostIds, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, buildExecutorContextForScope,
|
||||
scopeType, scopeTargetId, scopeHostIds, scopeLabel, globalPermissionMode, commandBlocklist, commandTimeout, webSearchConfig, buildExecutorContextForScope,
|
||||
toolIntegrationMode,
|
||||
clearScopeDraft, showScopeSessionView, setActiveSessionId,
|
||||
]);
|
||||
@@ -1113,6 +1252,9 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
handleDeleteSession={handleDeleteSession}
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
activeCompaction={
|
||||
activeCompaction?.sessionId === activeSessionId ? activeCompaction : null
|
||||
}
|
||||
inputValue={inputValue}
|
||||
setInputValue={setInputValue}
|
||||
handleSend={handleSend}
|
||||
@@ -1140,8 +1282,10 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
setGlobalPermissionMode={setGlobalPermissionMode}
|
||||
notes={notes}
|
||||
hosts={hosts}
|
||||
snippets={snippets}
|
||||
onOpenVaultNote={onOpenVaultNote}
|
||||
onOpenVaultHost={onOpenVaultHost}
|
||||
onOpenVaultSnippet={onOpenVaultSnippet}
|
||||
onOpenVaultSection={onOpenVaultSection}
|
||||
/>
|
||||
</React.Profiler>
|
||||
@@ -1183,6 +1327,7 @@ const AI_CHAT_SIDE_PANEL_AI_STATE_KEYS = [
|
||||
'globalPermissionMode',
|
||||
'setGlobalPermissionMode',
|
||||
'commandBlocklist',
|
||||
'commandTimeout',
|
||||
'maxIterations',
|
||||
'webSearchConfig',
|
||||
'quickMessages',
|
||||
@@ -1210,8 +1355,10 @@ export function aiChatSidePanelPropsAreEqual(
|
||||
if (prev.resolveExecutorContext !== next.resolveExecutorContext) return false;
|
||||
if (prev.notes !== next.notes) return false;
|
||||
if (prev.hosts !== next.hosts) return false;
|
||||
if (prev.snippets !== next.snippets) return false;
|
||||
if (prev.onOpenVaultNote !== next.onOpenVaultNote) return false;
|
||||
if (prev.onOpenVaultHost !== next.onOpenVaultHost) return false;
|
||||
if (prev.onOpenVaultSnippet !== next.onOpenVaultSnippet) return false;
|
||||
if (prev.onOpenVaultSection !== next.onOpenVaultSection) return false;
|
||||
|
||||
for (const key of AI_CHAT_SIDE_PANEL_AI_STATE_KEYS) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
} from '../infrastructure/ai/types';
|
||||
import type { AIQuickMessage } from '../infrastructure/ai/quickMessages';
|
||||
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
|
||||
import type { Host, VaultNote } from '../types';
|
||||
import type { Host, Snippet, VaultNote } from '../types';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Props
|
||||
@@ -69,6 +69,7 @@ export interface AIChatSidePanelProps {
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
setGlobalPermissionMode?: (mode: AIPermissionMode) => void;
|
||||
commandBlocklist?: string[];
|
||||
commandTimeout?: number;
|
||||
maxIterations?: number;
|
||||
|
||||
// Web search
|
||||
@@ -108,9 +109,11 @@ export interface AIChatSidePanelProps {
|
||||
// Vault artifact navigation (from AI chat tool results)
|
||||
notes?: VaultNote[];
|
||||
hosts?: Host[];
|
||||
snippets?: Snippet[];
|
||||
onOpenVaultNote?: (noteId: string) => void;
|
||||
onOpenVaultHost?: (hostId: string) => void;
|
||||
onOpenVaultSection?: (section: 'notes' | 'hosts') => void;
|
||||
onOpenVaultSnippet?: (snippetId: string) => void;
|
||||
onOpenVaultSection?: (section: 'notes' | 'hosts' | 'snippets') => void;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
createSdkRuntimeModelCache,
|
||||
modelPresetsContainId,
|
||||
normalizeSdkRuntimeModelPresets,
|
||||
shouldAdoptSdkCurrentModel,
|
||||
@@ -85,3 +86,116 @@ test('shouldUseStoredAgentModel trusts SDK defaults when no runtime list is retu
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('SDK runtime model cache coalesces concurrent refreshes', async () => {
|
||||
const cache = createSdkRuntimeModelCache();
|
||||
let calls = 0;
|
||||
let resolveLoad!: (value: { currentModelId: string | null; models: AgentModelPreset[] }) => void;
|
||||
const load = () => {
|
||||
calls += 1;
|
||||
return new Promise<{ currentModelId: string | null; models: AgentModelPreset[] }>((resolve) => {
|
||||
resolveLoad = resolve;
|
||||
});
|
||||
};
|
||||
|
||||
const first = cache.refresh('opencode:/opt/bin/opencode', load);
|
||||
const second = cache.refresh('opencode:/opt/bin/opencode', load);
|
||||
|
||||
assert.equal(first, second);
|
||||
assert.equal(calls, 1);
|
||||
|
||||
resolveLoad({
|
||||
currentModelId: 'openai/gpt-5.1',
|
||||
models: [{ id: 'openai/gpt-5.1', name: 'GPT-5.1' }],
|
||||
});
|
||||
assert.deepEqual(await first, {
|
||||
currentModelId: 'openai/gpt-5.1',
|
||||
models: [{ id: 'openai/gpt-5.1', name: 'GPT-5.1' }],
|
||||
});
|
||||
});
|
||||
|
||||
test('SDK runtime model cache serves cached models while background refresh updates them', async () => {
|
||||
let now = 1_000;
|
||||
const cache = createSdkRuntimeModelCache({ now: () => now });
|
||||
|
||||
await cache.refresh('opencode:/opt/bin/opencode', async () => ({
|
||||
currentModelId: 'openai/gpt-5.1',
|
||||
models: [{ id: 'openai/gpt-5.1', name: 'GPT-5.1' }],
|
||||
}));
|
||||
|
||||
assert.deepEqual(cache.read('opencode:/opt/bin/opencode')?.models, [
|
||||
{ id: 'openai/gpt-5.1', name: 'GPT-5.1' },
|
||||
]);
|
||||
|
||||
now = 2_000;
|
||||
const refresh = cache.refresh(
|
||||
'opencode:/opt/bin/opencode',
|
||||
async () => ({
|
||||
currentModelId: 'anthropic/claude-sonnet-4-6',
|
||||
models: [{ id: 'anthropic/claude-sonnet-4-6', name: 'Claude Sonnet 4.6' }],
|
||||
}),
|
||||
{ force: true },
|
||||
);
|
||||
|
||||
assert.deepEqual(cache.read('opencode:/opt/bin/opencode')?.models, [
|
||||
{ id: 'openai/gpt-5.1', name: 'GPT-5.1' },
|
||||
]);
|
||||
|
||||
await refresh;
|
||||
assert.deepEqual(cache.read('opencode:/opt/bin/opencode')?.models, [
|
||||
{ id: 'anthropic/claude-sonnet-4-6', name: 'Claude Sonnet 4.6' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('SDK runtime model cache keeps the previous catalog when a refresh fails', async () => {
|
||||
const cache = createSdkRuntimeModelCache();
|
||||
|
||||
await cache.refresh('opencode:/opt/bin/opencode', async () => ({
|
||||
currentModelId: 'openai/gpt-5.1',
|
||||
models: [{ id: 'openai/gpt-5.1', name: 'GPT-5.1' }],
|
||||
}));
|
||||
|
||||
await assert.rejects(
|
||||
cache.refresh(
|
||||
'opencode:/opt/bin/opencode',
|
||||
async () => {
|
||||
throw new Error('OpenCode unavailable');
|
||||
},
|
||||
{ force: true },
|
||||
),
|
||||
/OpenCode unavailable/,
|
||||
);
|
||||
|
||||
assert.deepEqual(cache.read('opencode:/opt/bin/opencode')?.models, [
|
||||
{ id: 'openai/gpt-5.1', name: 'GPT-5.1' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('SDK runtime model cache ignores degraded empty catalogs', async () => {
|
||||
const cache = createSdkRuntimeModelCache();
|
||||
|
||||
await cache.refresh('opencode:/opt/bin/opencode', async () => ({
|
||||
currentModelId: 'openai/gpt-5.1',
|
||||
models: [{ id: 'openai/gpt-5.1', name: 'GPT-5.1' }],
|
||||
}));
|
||||
|
||||
const refreshed = await cache.refresh(
|
||||
'opencode:/opt/bin/opencode',
|
||||
async () => ({ currentModelId: null, models: [] }),
|
||||
{ force: true },
|
||||
);
|
||||
|
||||
assert.deepEqual(refreshed.models, [
|
||||
{ id: 'openai/gpt-5.1', name: 'GPT-5.1' },
|
||||
]);
|
||||
assert.deepEqual(cache.read('opencode:/opt/bin/opencode')?.models, [
|
||||
{ id: 'openai/gpt-5.1', name: 'GPT-5.1' },
|
||||
]);
|
||||
|
||||
const emptyCache = createSdkRuntimeModelCache();
|
||||
await emptyCache.refresh('opencode:/usr/bin/opencode', async () => ({
|
||||
currentModelId: null,
|
||||
models: [],
|
||||
}));
|
||||
assert.equal(emptyCache.read('opencode:/usr/bin/opencode'), null);
|
||||
});
|
||||
@@ -1,6 +1,102 @@
|
||||
import type { AgentModelPreset, ExternalAgentConfig } from '../infrastructure/ai/types';
|
||||
import { getExternalAgentSdkBackend } from '../infrastructure/ai/managedAgents';
|
||||
|
||||
export type SdkRuntimeModelCatalog = {
|
||||
currentModelId: string | null;
|
||||
models: AgentModelPreset[];
|
||||
};
|
||||
|
||||
export type SdkRuntimeModelCacheEntry = SdkRuntimeModelCatalog & {
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
type SdkRuntimeModelCacheOptions = {
|
||||
ttlMs?: number;
|
||||
now?: () => number;
|
||||
};
|
||||
|
||||
type SdkRuntimeModelRefreshOptions = {
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
const SDK_RUNTIME_MODEL_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const MODEL_CACHE_ENV_HINTS = ['CLAUDE_CODE_EXECUTABLE', 'CODEBUDDY_CODE_PATH', 'OPENCODE_BIN'] as const;
|
||||
|
||||
function cloneCatalog(catalog: SdkRuntimeModelCatalog): SdkRuntimeModelCatalog {
|
||||
return {
|
||||
currentModelId: catalog.currentModelId ?? null,
|
||||
models: [...catalog.models],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSdkRuntimeModelCatalog(catalog: SdkRuntimeModelCatalog): SdkRuntimeModelCatalog {
|
||||
return {
|
||||
currentModelId: catalog.currentModelId ?? null,
|
||||
models: Array.isArray(catalog.models)
|
||||
? catalog.models.filter((model): model is AgentModelPreset => Boolean(model?.id))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSdkRuntimeModelCacheKey(agent: {
|
||||
id: string;
|
||||
command?: string;
|
||||
sdkBackend?: string;
|
||||
acpCommand?: string;
|
||||
env?: Record<string, string>;
|
||||
}): string {
|
||||
const sdkBackend = agent.sdkBackend || agent.acpCommand || '';
|
||||
const envHints = MODEL_CACHE_ENV_HINTS.map((key) => `${key}=${agent.env?.[key] ?? ''}`);
|
||||
return [agent.id, sdkBackend, agent.command ?? '', ...envHints].join('\u0000');
|
||||
}
|
||||
|
||||
export function createSdkRuntimeModelCache(options: SdkRuntimeModelCacheOptions = {}) {
|
||||
const ttlMs = options.ttlMs ?? SDK_RUNTIME_MODEL_CACHE_TTL_MS;
|
||||
const now = options.now ?? (() => Date.now());
|
||||
const entries = new Map<string, SdkRuntimeModelCacheEntry>();
|
||||
const inFlight = new Map<string, Promise<SdkRuntimeModelCatalog>>();
|
||||
|
||||
return {
|
||||
read(key: string): SdkRuntimeModelCacheEntry | null {
|
||||
const entry = entries.get(key);
|
||||
return entry ? { ...cloneCatalog(entry), updatedAt: entry.updatedAt } : null;
|
||||
},
|
||||
refresh(
|
||||
key: string,
|
||||
load: () => Promise<SdkRuntimeModelCatalog>,
|
||||
refreshOptions: SdkRuntimeModelRefreshOptions = {},
|
||||
): Promise<SdkRuntimeModelCatalog> {
|
||||
const cached = entries.get(key);
|
||||
if (!refreshOptions.force && cached && now() - cached.updatedAt < ttlMs) {
|
||||
return Promise.resolve(cloneCatalog(cached));
|
||||
}
|
||||
|
||||
const existing = inFlight.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const promise = Promise.resolve(load())
|
||||
.then((catalog) => {
|
||||
const normalized = normalizeSdkRuntimeModelCatalog(catalog);
|
||||
if (normalized.models.length === 0 && !normalized.currentModelId) {
|
||||
return cached ? cloneCatalog(cached) : cloneCatalog(normalized);
|
||||
}
|
||||
entries.set(key, { ...cloneCatalog(normalized), updatedAt: now() });
|
||||
return cloneCatalog(normalized);
|
||||
})
|
||||
.finally(() => {
|
||||
if (inFlight.get(key) === promise) {
|
||||
inFlight.delete(key);
|
||||
}
|
||||
});
|
||||
|
||||
inFlight.set(key, promise);
|
||||
return promise;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const sdkRuntimeModelCache = createSdkRuntimeModelCache();
|
||||
|
||||
export function modelPresetMatchesId(preset: AgentModelPreset, modelId: string): boolean {
|
||||
if (preset.thinkingLevels?.length) {
|
||||
return preset.thinkingLevels.some((level) => `${preset.id}/${level}` === modelId);
|
||||
|
||||
@@ -35,6 +35,19 @@ test("DistroAvatar keeps serial hosts on the USB icon", () => {
|
||||
assert.doesNotMatch(markup, /background-color:#2563EB/i);
|
||||
});
|
||||
|
||||
test("DistroAvatar xs size uses compact corners without appearing circular", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DistroAvatar
|
||||
host={{ ...baseHost, distro: "ubuntu" }}
|
||||
fallback="U"
|
||||
size="xs"
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /h-4 w-4 rounded-\[4px\]/);
|
||||
assert.doesNotMatch(markup, /rounded-full/);
|
||||
});
|
||||
|
||||
test("DistroAvatar tree size uses compact host icon corners", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DistroAvatar
|
||||
@@ -44,8 +57,7 @@ test("DistroAvatar tree size uses compact host icon corners", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /h-6 w-6 rounded/);
|
||||
assert.doesNotMatch(markup, /rounded-md/);
|
||||
assert.match(markup, /h-6 w-6 rounded-\[6px\]/);
|
||||
});
|
||||
|
||||
test("DistroAvatar keeps distro icon and applies custom palette color when icon mode is automatic", () => {
|
||||
|
||||
+22
-12
@@ -93,14 +93,24 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
const [errored, setErrored] = React.useState(false);
|
||||
const bg = DISTRO_COLORS[distro] || DISTRO_COLORS.default;
|
||||
|
||||
// Size variants — rounded rects (same corner style as SessionTabIcon in TopTabItems)
|
||||
// Corner radii ~27% of edge (matches lg rounded-xl on 44px) — avoids circles on small sizes
|
||||
const radiusClasses = {
|
||||
xs: "rounded-[4px]",
|
||||
sm: "rounded-[5px]",
|
||||
md: "rounded-lg",
|
||||
tree: "rounded-[6px]",
|
||||
log: "rounded-[10px]",
|
||||
lg: "rounded-xl",
|
||||
} as const;
|
||||
|
||||
// Size variants — rounded rects aligned with vault host list (lg = rounded-xl)
|
||||
const sizeClasses = {
|
||||
xs: "h-4 w-4 rounded",
|
||||
sm: "h-5 w-5 rounded",
|
||||
md: "h-8 w-8 rounded",
|
||||
tree: "h-6 w-6 rounded",
|
||||
log: "h-9 w-9 rounded-xl",
|
||||
lg: "h-11 w-11 rounded-xl",
|
||||
xs: "h-4 w-4",
|
||||
sm: "h-5 w-5",
|
||||
md: "h-8 w-8",
|
||||
tree: "h-6 w-6",
|
||||
log: "h-9 w-9",
|
||||
lg: "h-11 w-11",
|
||||
};
|
||||
const iconSizes = {
|
||||
xs: "h-2.5 w-2.5",
|
||||
@@ -111,7 +121,7 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
lg: "h-5 w-5",
|
||||
};
|
||||
|
||||
const containerClass = sizeClasses[size];
|
||||
const containerClass = cn(sizeClasses[size], radiusClasses[size]);
|
||||
const iconSize = iconSizes[size];
|
||||
|
||||
// Show USB icon for serial hosts
|
||||
@@ -119,7 +129,7 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded flex items-center justify-center bg-amber-600 text-white dark:bg-amber-400 dark:text-slate-950",
|
||||
"shrink-0 flex items-center justify-center bg-amber-600 text-white dark:bg-amber-400 dark:text-slate-950",
|
||||
containerClass,
|
||||
className,
|
||||
)}
|
||||
@@ -135,7 +145,7 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded flex items-center justify-center text-white",
|
||||
"shrink-0 flex items-center justify-center text-white",
|
||||
containerClass,
|
||||
className,
|
||||
)}
|
||||
@@ -150,7 +160,7 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded flex items-center justify-center overflow-hidden",
|
||||
"shrink-0 flex items-center justify-center overflow-hidden",
|
||||
containerClass,
|
||||
!customColor && bg,
|
||||
className,
|
||||
@@ -170,7 +180,7 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded flex items-center justify-center bg-primary text-primary-foreground",
|
||||
"shrink-0 flex items-center justify-center bg-primary text-primary-foreground",
|
||||
containerClass,
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { hasGroupTelnetFields } from "./GroupDetailsPanel.tsx";
|
||||
|
||||
test("GroupDetailsPanel treats cleared telnet credentials as explicit settings", () => {
|
||||
assert.equal(hasGroupTelnetFields({ telnetUsername: "" }), true);
|
||||
assert.equal(hasGroupTelnetFields({ telnetPassword: "" }), true);
|
||||
assert.equal(hasGroupTelnetFields({}), false);
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Eye,
|
||||
EyeOff,
|
||||
@@ -6,6 +7,7 @@ import {
|
||||
MoreHorizontal,
|
||||
Palette,
|
||||
Plus,
|
||||
Router,
|
||||
Settings2,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
@@ -16,8 +18,7 @@ import { resolveGroupDefaults, resolveGroupTerminalThemeId } from "../domain/gro
|
||||
import {
|
||||
formatProxyConfigEndpoint,
|
||||
formatProxyConfigType,
|
||||
isCompleteProxyConfig,
|
||||
normalizeManualProxyConfig,
|
||||
updateProxyConfigField,
|
||||
} from "../domain/proxyProfiles";
|
||||
import {
|
||||
EnvVar,
|
||||
@@ -34,24 +35,36 @@ import {
|
||||
EnvVarsPanel,
|
||||
HostDetailsSection,
|
||||
HostDetailsSettingRow,
|
||||
HostDetailsOverrideReset,
|
||||
ProxyPanel,
|
||||
} from "./host-details";
|
||||
import {
|
||||
AsidePanel,
|
||||
AsidePanelContent,
|
||||
type AsidePanelLayout,
|
||||
type AsidePanelResizeProps,
|
||||
} from "./ui/aside-panel";
|
||||
import { Button } from "./ui/button";
|
||||
import { Combobox } from "./ui/combobox";
|
||||
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
|
||||
import { Input } from "./ui/input";
|
||||
import { Switch } from "./ui/switch";
|
||||
import { TerminalFontSelect } from "./settings/TerminalFontSelect";
|
||||
import { useAvailableFonts } from "../application/state/fontStore";
|
||||
import { toast } from "./ui/toast";
|
||||
import { GroupSshSettingsSection } from "./GroupSshSettingsSection";
|
||||
import { prepareProxyConfigForSave } from "./HostDetailsPanel.helpers";
|
||||
|
||||
type SubPanel = "none" | "proxy" | "chain" | "env-vars" | "theme-select";
|
||||
|
||||
const ToggleRow: React.FC<{ label: string; hint?: React.ReactNode; enabled: boolean; onToggle: () => void }> = ({ label, hint, enabled, onToggle }) => {
|
||||
return (
|
||||
<HostDetailsSettingRow label={label} hint={hint}>
|
||||
<Switch checked={enabled} onCheckedChange={() => onToggle()} />
|
||||
</HostDetailsSettingRow>
|
||||
);
|
||||
};
|
||||
|
||||
interface GroupDetailsPanelProps {
|
||||
groupPath: string;
|
||||
config: GroupConfig | undefined;
|
||||
@@ -68,11 +81,19 @@ interface GroupDetailsPanelProps {
|
||||
layout?: AsidePanelLayout;
|
||||
}
|
||||
|
||||
const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
type GroupDetailsPanelPropsWithResize = GroupDetailsPanelProps & AsidePanelResizeProps;
|
||||
|
||||
export const hasGroupTelnetFields = (c: Partial<GroupConfig>): boolean =>
|
||||
c.telnetPort !== undefined ||
|
||||
c.telnetUsername !== undefined ||
|
||||
c.telnetPassword !== undefined ||
|
||||
c.telnetEnabled === true;
|
||||
|
||||
const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
groupPath,
|
||||
config,
|
||||
availableKeys,
|
||||
identities: _identities,
|
||||
identities,
|
||||
proxyProfiles = [],
|
||||
allHosts,
|
||||
groups,
|
||||
@@ -82,8 +103,16 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
onSave,
|
||||
onCancel,
|
||||
layout = "overlay",
|
||||
resizable,
|
||||
persistWidthStorageKey,
|
||||
resizeAriaLabel,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const asideResizeProps = {
|
||||
resizable,
|
||||
persistWidthStorageKey,
|
||||
resizeAriaLabel,
|
||||
};
|
||||
const availableFonts = useAvailableFonts();
|
||||
|
||||
const originalName = groupPath.includes("/")
|
||||
@@ -104,17 +133,16 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
const hasSshFields = (c: Partial<GroupConfig>) =>
|
||||
c.protocol === 'ssh' ||
|
||||
c.port !== undefined || !!c.username || !!c.password || !!c.identityFileId ||
|
||||
c.deviceType !== undefined ||
|
||||
c.agentForwarding !== undefined || c.authMethod !== undefined || !!c.identityId ||
|
||||
!!c.proxyProfileId || !!c.proxyConfig || !!c.hostChain || !!c.startupCommand || c.legacyAlgorithms !== undefined || c.skipEcdsaHostKey !== undefined || c.algorithms !== undefined || c.backspaceBehavior !== undefined ||
|
||||
(c.environmentVariables && c.environmentVariables.length > 0) ||
|
||||
c.moshEnabled !== undefined || !!c.moshServerPath ||
|
||||
c.etEnabled !== undefined || c.etPort !== undefined ||
|
||||
(c.identityFilePaths && c.identityFilePaths.length > 0);
|
||||
const hasTelnetFields = (c: Partial<GroupConfig>) =>
|
||||
c.telnetPort !== undefined || !!c.telnetUsername || !!c.telnetPassword || c.telnetEnabled === true;
|
||||
|
||||
const [sshEnabled, setSshEnabled] = useState(() => hasSshFields(config || {}));
|
||||
const [telnetEnabled, setTelnetEnabled] = useState(() => hasTelnetFields(config || {}));
|
||||
const [telnetEnabled, setTelnetEnabled] = useState(() => hasGroupTelnetFields(config || {}));
|
||||
|
||||
// Sub-panel state
|
||||
const [activeSubPanel, setActiveSubPanel] = useState<SubPanel>("none");
|
||||
@@ -164,6 +192,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
delete next.identityId;
|
||||
delete next.identityFileId;
|
||||
delete next.identityFilePaths;
|
||||
delete next.deviceType;
|
||||
delete next.agentForwarding;
|
||||
delete next.startupCommand;
|
||||
delete next.legacyAlgorithms;
|
||||
@@ -198,18 +227,12 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
|
||||
// Proxy helpers
|
||||
const updateProxyConfig = useCallback(
|
||||
(field: keyof ProxyConfig, value: string | number) => {
|
||||
(field: keyof ProxyConfig, value: ProxyConfig[keyof ProxyConfig]) => {
|
||||
setForm((prev) => {
|
||||
const { proxyProfileId: _proxyProfileId, ...rest } = prev;
|
||||
return {
|
||||
...rest,
|
||||
proxyConfig: {
|
||||
type: prev.proxyConfig?.type || "http",
|
||||
host: prev.proxyConfig?.host || "",
|
||||
port: prev.proxyConfig?.port || 8080,
|
||||
...prev.proxyConfig,
|
||||
[field]: value,
|
||||
},
|
||||
proxyConfig: updateProxyConfigField(prev.proxyConfig, field, value),
|
||||
};
|
||||
});
|
||||
},
|
||||
@@ -342,6 +365,11 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
if (!parentGroup || groupConfigs.length === 0) return false;
|
||||
return !!resolveGroupDefaults(parentGroup, groupConfigs).skipEcdsaHostKey;
|
||||
}, [groupConfigs, parentGroup]);
|
||||
const inheritedDeviceType = useMemo(() => {
|
||||
if (!parentGroup || groupConfigs.length === 0) return undefined;
|
||||
return resolveGroupDefaults(parentGroup, groupConfigs).deviceType;
|
||||
}, [groupConfigs, parentGroup]);
|
||||
const effectiveDeviceType = form.deviceType ?? inheritedDeviceType;
|
||||
const effectiveThemeId = form.themeOverride === false
|
||||
? inheritedThemeId
|
||||
: (form.theme || inheritedThemeId);
|
||||
@@ -355,19 +383,31 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
setNameError(t("vault.groups.errors.invalidChars"));
|
||||
return;
|
||||
}
|
||||
const normalizedProxyConfig = normalizeManualProxyConfig(form.proxyConfig);
|
||||
if (normalizedProxyConfig && !isCompleteProxyConfig(normalizedProxyConfig)) {
|
||||
toast.error(
|
||||
normalizedProxyConfig.host ? t("proxyProfiles.error.port") : t("hostDetails.proxyPanel.error.required"),
|
||||
);
|
||||
setActiveSubPanel("proxy");
|
||||
return;
|
||||
}
|
||||
if (sshEnabled && hasMissingProxyProfile) {
|
||||
toast.error(t("hostDetails.proxyPanel.missingSaved"));
|
||||
const proxySave = sshEnabled
|
||||
? prepareProxyConfigForSave({
|
||||
proxyConfig: form.proxyConfig,
|
||||
proxyProfileId: form.proxyProfileId,
|
||||
proxyProfiles,
|
||||
identities,
|
||||
})
|
||||
: null;
|
||||
if (proxySave?.error) {
|
||||
const messageKey = proxySave.error === "port"
|
||||
? "proxyProfiles.error.port"
|
||||
: proxySave.error === "required"
|
||||
? "hostDetails.proxyPanel.error.required"
|
||||
: proxySave.error === "missingSaved"
|
||||
? "hostDetails.proxyPanel.missingSaved"
|
||||
: proxySave.error === "missingIdentity"
|
||||
? "hostDetails.proxyPanel.missingIdentity"
|
||||
: proxySave.error === "incompleteIdentity"
|
||||
? "hostDetails.proxyPanel.incompleteIdentity"
|
||||
: "hostDetails.proxyPanel.unreadableIdentity";
|
||||
toast.error(t(messageKey));
|
||||
setActiveSubPanel("proxy");
|
||||
return;
|
||||
}
|
||||
const normalizedProxyConfig = proxySave?.normalizedProxyConfig;
|
||||
setNameError(null);
|
||||
|
||||
const newPath = parentGroup
|
||||
@@ -387,6 +427,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
...(form.identityId !== undefined && { identityId: form.identityId }),
|
||||
...(form.identityFileId !== undefined && { identityFileId: form.identityFileId }),
|
||||
...(form.identityFilePaths !== undefined && { identityFilePaths: form.identityFilePaths }),
|
||||
...(form.deviceType !== undefined && { deviceType: form.deviceType }),
|
||||
...(form.agentForwarding !== undefined && { agentForwarding: form.agentForwarding }),
|
||||
...(form.startupCommand !== undefined && { startupCommand: form.startupCommand }),
|
||||
...(form.legacyAlgorithms !== undefined && { legacyAlgorithms: form.legacyAlgorithms }),
|
||||
@@ -437,6 +478,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
<ProxyPanel
|
||||
proxyConfig={form.proxyConfig}
|
||||
proxyProfiles={proxyProfiles}
|
||||
identities={identities}
|
||||
selectedProxyProfileId={form.proxyProfileId}
|
||||
onUpdateProxy={updateProxyConfig}
|
||||
onSelectProxyProfile={selectProxyProfile}
|
||||
@@ -444,6 +486,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
onCancel={onCancel}
|
||||
layout={layout}
|
||||
{...asideResizeProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -462,6 +505,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
onCancel={onCancel}
|
||||
layout={layout}
|
||||
{...asideResizeProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -490,6 +534,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
onCancel={onCancel}
|
||||
layout={layout}
|
||||
{...asideResizeProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -511,6 +556,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
showBackButton={true}
|
||||
layout={layout}
|
||||
{...asideResizeProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -529,6 +575,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
dataSection="group-details-panel"
|
||||
title={t("vault.groups.details")}
|
||||
layout={layout}
|
||||
{...asideResizeProps}
|
||||
actions={
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -594,6 +641,28 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
chainedHosts={chainedHosts}
|
||||
/>
|
||||
|
||||
{sshEnabled && (!form.protocol || form.protocol === "ssh") && !form.moshEnabled && !form.etEnabled && (
|
||||
<HostDetailsSection
|
||||
icon={<Router size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.deviceType")}
|
||||
>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.deviceType")}
|
||||
hint={t("hostDetails.deviceType.desc")}
|
||||
enabled={effectiveDeviceType === "network"}
|
||||
onToggle={() => update("deviceType", effectiveDeviceType === "network" ? "general" : "network")}
|
||||
/>
|
||||
{effectiveDeviceType === "network" && (
|
||||
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
|
||||
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 break-words">
|
||||
{t("hostDetails.deviceType.warning")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</HostDetailsSection>
|
||||
)}
|
||||
|
||||
{/* Telnet Section (if enabled) */}
|
||||
{telnetEnabled && (
|
||||
<HostDetailsSection
|
||||
@@ -642,7 +711,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
<Input
|
||||
placeholder={t("hostDetails.username.placeholder")}
|
||||
value={form.telnetUsername || ""}
|
||||
onChange={(e) => update("telnetUsername", e.target.value || undefined)}
|
||||
onChange={(e) => update("telnetUsername", e.target.value)}
|
||||
className="h-10"
|
||||
/>
|
||||
<div className="relative">
|
||||
@@ -650,7 +719,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
placeholder={t("hostDetails.password.placeholder")}
|
||||
type={showTelnetPassword ? "text" : "password"}
|
||||
value={form.telnetPassword || ""}
|
||||
onChange={(e) => update("telnetPassword", e.target.value || undefined)}
|
||||
onChange={(e) => update("telnetPassword", e.target.value)}
|
||||
className="h-10 pr-10"
|
||||
/>
|
||||
<button
|
||||
@@ -684,79 +753,76 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
title={t("vault.groups.details.appearance")}
|
||||
>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-3 p-2 rounded-lg bg-secondary/50 hover:bg-secondary transition-colors text-left"
|
||||
onClick={() => setActiveSubPanel("theme-select")}
|
||||
>
|
||||
<div
|
||||
className="w-12 h-8 rounded-md border border-border/60 flex items-center justify-center text-[6px] font-mono overflow-hidden"
|
||||
style={{
|
||||
backgroundColor:
|
||||
customThemeStore.getThemeById(effectiveThemeId)?.colors.background || "#100F0F",
|
||||
color:
|
||||
customThemeStore.getThemeById(effectiveThemeId)?.colors.foreground || "#CECDC3",
|
||||
}}
|
||||
<div className="flex w-full items-center gap-1 rounded-lg bg-secondary/50 p-2 transition-colors hover:bg-secondary">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-3 text-left"
|
||||
onClick={() => setActiveSubPanel("theme-select")}
|
||||
>
|
||||
<div className="p-0.5">
|
||||
<div
|
||||
style={{
|
||||
color: customThemeStore.getThemeById(effectiveThemeId)?.colors.green,
|
||||
}}
|
||||
>
|
||||
$
|
||||
<div
|
||||
className="w-12 h-8 rounded-md border border-border/60 flex items-center justify-center text-[6px] font-mono overflow-hidden shrink-0"
|
||||
style={{
|
||||
backgroundColor:
|
||||
customThemeStore.getThemeById(effectiveThemeId)?.colors.background || "#100F0F",
|
||||
color:
|
||||
customThemeStore.getThemeById(effectiveThemeId)?.colors.foreground || "#CECDC3",
|
||||
}}
|
||||
>
|
||||
<div className="p-0.5">
|
||||
<div
|
||||
style={{
|
||||
color: customThemeStore.getThemeById(effectiveThemeId)?.colors.green,
|
||||
}}
|
||||
>
|
||||
$
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm flex-1">
|
||||
{customThemeStore.getThemeById(effectiveThemeId)?.name || "Flexoki Dark"}
|
||||
</span>
|
||||
</button>
|
||||
{hasActiveThemeOverride && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-primary"
|
||||
onClick={() =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
theme: undefined,
|
||||
themeOverride: false,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{t("common.useGlobal")}
|
||||
</Button>
|
||||
)}
|
||||
<span className="text-sm flex-1 truncate">
|
||||
{customThemeStore.getThemeById(effectiveThemeId)?.name || "Flexoki Dark"}
|
||||
</span>
|
||||
</button>
|
||||
{hasActiveThemeOverride && (
|
||||
<HostDetailsOverrideReset
|
||||
label={t("common.useGlobal")}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
theme: undefined,
|
||||
themeOverride: false,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TerminalFontSelect
|
||||
value={form.fontFamily || availableFonts[0]?.id || ""}
|
||||
fonts={availableFonts}
|
||||
onChange={(id) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
fontFamily: id,
|
||||
fontFamilyOverride: true,
|
||||
}));
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
{form.fontFamilyOverride && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-primary"
|
||||
onClick={() =>
|
||||
<div className="flex items-center gap-2">
|
||||
<TerminalFontSelect
|
||||
value={form.fontFamily || availableFonts[0]?.id || ""}
|
||||
fonts={availableFonts}
|
||||
onChange={(id) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
fontFamily: undefined,
|
||||
fontFamilyOverride: false,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{t("common.useGlobal")}
|
||||
</Button>
|
||||
)}
|
||||
fontFamily: id,
|
||||
fontFamilyOverride: true,
|
||||
}));
|
||||
}}
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
{form.fontFamilyOverride && (
|
||||
<HostDetailsOverrideReset
|
||||
label={t("common.useGlobal")}
|
||||
onClick={() =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
fontFamily: undefined,
|
||||
fontFamilyOverride: false,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Font Size */}
|
||||
<HostDetailsSettingRow label="Font Size">
|
||||
|
||||
@@ -6,7 +6,7 @@ import { MAX_FONT_SIZE, MIN_FONT_SIZE } from "../infrastructure/config/fonts";
|
||||
import { AlgorithmOverridesPanel } from "./host-details/AlgorithmOverridesPanel";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { HostDetailsSection, HostDetailsSettingRow } from "./host-details";
|
||||
import { HostDetailsSection, HostDetailsSettingRow, HostDetailsOverrideReset } from "./host-details";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
|
||||
import { Input } from "./ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||
@@ -46,7 +46,11 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
proxySummaryTooltip,
|
||||
clearProxyConfig,
|
||||
groupDefaults,
|
||||
}) => (
|
||||
}) => {
|
||||
const inheritedDeviceType = effectiveGroupDefaults?.deviceType;
|
||||
const effectiveDeviceType = form.deviceType ?? inheritedDeviceType;
|
||||
|
||||
return (
|
||||
<>
|
||||
<HostDetailsSection
|
||||
icon={<Palette size={14} className="text-muted-foreground" />}
|
||||
@@ -54,110 +58,110 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
>
|
||||
|
||||
{/* SSH Theme Selection */}
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-3 p-2 rounded-lg bg-secondary/50 hover:bg-secondary transition-colors text-left"
|
||||
onClick={() => setActiveSubPanel("theme-select")}
|
||||
>
|
||||
<div
|
||||
className="w-12 h-8 rounded-md border border-border/60 flex items-center justify-center text-[6px] font-mono overflow-hidden"
|
||||
style={{
|
||||
backgroundColor:
|
||||
customThemeStore.getThemeById(effectiveThemeId)?.colors.background || "#100F0F",
|
||||
color:
|
||||
customThemeStore.getThemeById(effectiveThemeId)?.colors.foreground || "#CECDC3",
|
||||
}}
|
||||
<div className="flex w-full items-center gap-1 rounded-lg bg-secondary/50 p-2 transition-colors hover:bg-secondary">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-3 text-left"
|
||||
onClick={() => setActiveSubPanel("theme-select")}
|
||||
>
|
||||
<div className="p-0.5">
|
||||
<div
|
||||
style={{
|
||||
color: customThemeStore.getThemeById(effectiveThemeId)?.colors.green,
|
||||
}}
|
||||
>
|
||||
$
|
||||
<div
|
||||
className="w-12 h-8 rounded-md border border-border/60 flex items-center justify-center text-[6px] font-mono overflow-hidden shrink-0"
|
||||
style={{
|
||||
backgroundColor:
|
||||
customThemeStore.getThemeById(effectiveThemeId)?.colors.background || "#100F0F",
|
||||
color:
|
||||
customThemeStore.getThemeById(effectiveThemeId)?.colors.foreground || "#CECDC3",
|
||||
}}
|
||||
>
|
||||
<div className="p-0.5">
|
||||
<div
|
||||
style={{
|
||||
color: customThemeStore.getThemeById(effectiveThemeId)?.colors.green,
|
||||
}}
|
||||
>
|
||||
$
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm flex-1">
|
||||
{customThemeStore.getThemeById(effectiveThemeId)?.name || "Flexoki Dark"}
|
||||
</span>
|
||||
</button>
|
||||
{hasEffectiveThemeOverride && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-primary"
|
||||
onClick={() => setForm((prev) => clearHostThemeOverride(prev))}
|
||||
>
|
||||
{t("common.useGlobal")}
|
||||
</Button>
|
||||
)}
|
||||
<span className="text-sm flex-1 truncate">
|
||||
{customThemeStore.getThemeById(effectiveThemeId)?.name || "Flexoki Dark"}
|
||||
</span>
|
||||
</button>
|
||||
{hasEffectiveThemeOverride && (
|
||||
<HostDetailsOverrideReset
|
||||
label={t("common.useGlobal")}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setForm((prev) => clearHostThemeOverride(prev));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Font Size */}
|
||||
<HostDetailsSettingRow label="Font Size">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (effectiveFontSize > MIN_FONT_SIZE) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
fontSize: effectiveFontSize - 1,
|
||||
fontSizeOverride: true,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
disabled={effectiveFontSize <= MIN_FONT_SIZE}
|
||||
className="h-8 w-8 px-0"
|
||||
>
|
||||
-
|
||||
</Button>
|
||||
<Input
|
||||
type="number"
|
||||
min={MIN_FONT_SIZE}
|
||||
max={MAX_FONT_SIZE}
|
||||
value={effectiveFontSize}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
if (val >= MIN_FONT_SIZE && val <= MAX_FONT_SIZE) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
fontSize: val,
|
||||
fontSizeOverride: true,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
className="h-8 w-16 text-center"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">pt</span>
|
||||
{hasEffectiveFontSizeOverride && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-primary"
|
||||
onClick={() => setForm((prev) => clearHostFontSizeOverride(prev))}
|
||||
onClick={() => {
|
||||
if (effectiveFontSize > MIN_FONT_SIZE) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
fontSize: effectiveFontSize - 1,
|
||||
fontSizeOverride: true,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
disabled={effectiveFontSize <= MIN_FONT_SIZE}
|
||||
className="h-8 w-8 px-0"
|
||||
>
|
||||
{t("common.useGlobal")}
|
||||
-
|
||||
</Button>
|
||||
<Input
|
||||
type="number"
|
||||
min={MIN_FONT_SIZE}
|
||||
max={MAX_FONT_SIZE}
|
||||
value={effectiveFontSize}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
if (val >= MIN_FONT_SIZE && val <= MAX_FONT_SIZE) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
fontSize: val,
|
||||
fontSizeOverride: true,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
className="h-8 w-16 text-center"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">pt</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (effectiveFontSize < MAX_FONT_SIZE) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
fontSize: effectiveFontSize + 1,
|
||||
fontSizeOverride: true,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
disabled={effectiveFontSize >= MAX_FONT_SIZE}
|
||||
className="h-8 w-8 px-0"
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
</div>
|
||||
{hasEffectiveFontSizeOverride && (
|
||||
<HostDetailsOverrideReset
|
||||
size="sm"
|
||||
label={t("common.useGlobal")}
|
||||
onClick={() => setForm((prev) => clearHostFontSizeOverride(prev))}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (effectiveFontSize < MAX_FONT_SIZE) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
fontSize: effectiveFontSize + 1,
|
||||
fontSizeOverride: true,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
disabled={effectiveFontSize >= MAX_FONT_SIZE}
|
||||
className="h-8 w-8 px-0"
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
</div>
|
||||
</HostDetailsSettingRow>
|
||||
</HostDetailsSection>
|
||||
@@ -176,7 +180,6 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
...prev,
|
||||
moshEnabled: true,
|
||||
etEnabled: false,
|
||||
deviceType: prev.deviceType === 'network' ? undefined : prev.deviceType,
|
||||
x11Forwarding: undefined,
|
||||
}));
|
||||
} else {
|
||||
@@ -200,7 +203,6 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
...prev,
|
||||
etEnabled: true,
|
||||
moshEnabled: false,
|
||||
deviceType: prev.deviceType === 'network' ? undefined : prev.deviceType,
|
||||
x11Forwarding: undefined,
|
||||
}));
|
||||
} else {
|
||||
@@ -276,10 +278,10 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
<ToggleRow
|
||||
label={t("hostDetails.deviceType")}
|
||||
hint={t("hostDetails.deviceType.desc")}
|
||||
enabled={form.deviceType === 'network'}
|
||||
onToggle={() => update("deviceType", form.deviceType === 'network' ? undefined : 'network')}
|
||||
enabled={effectiveDeviceType === 'network'}
|
||||
onToggle={() => update("deviceType", effectiveDeviceType === 'network' ? 'general' : 'network')}
|
||||
/>
|
||||
{form.deviceType === 'network' && (
|
||||
{effectiveDeviceType === 'network' && (
|
||||
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
|
||||
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 break-words">
|
||||
@@ -627,4 +629,5 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
/>
|
||||
</HostDetailsSection>
|
||||
</>
|
||||
);
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from "react";
|
||||
import { ChevronDown, Eye, EyeOff, FileKey, FolderLock, FolderOpen, Key, KeyRound, MapPin, Plus, Shield, Trash2, User, X } from "lucide-react";
|
||||
import { ChevronDown, Eye, EyeOff, FileKey, FolderLock, FolderOpen, Key, KeyRound, MapPin, Plus, Shapes, Shield, Trash2, User, X } from "lucide-react";
|
||||
import type { Host } from "../types";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import { HostIconPicker } from "./HostIconPicker";
|
||||
import { Button } from "./ui/button";
|
||||
import { Combobox } from "./ui/combobox";
|
||||
@@ -51,23 +50,12 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
|
||||
icon={<MapPin size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.address")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<DistroAvatar
|
||||
host={form as Host}
|
||||
fallback={
|
||||
form.label?.slice(0, 2).toUpperCase() ||
|
||||
form.hostname?.slice(0, 2).toUpperCase() ||
|
||||
"H"
|
||||
}
|
||||
className="h-10 w-10"
|
||||
/>
|
||||
<Input
|
||||
placeholder={t("hostDetails.hostname.placeholder")}
|
||||
value={form.hostname}
|
||||
onChange={(e) => update("hostname", e.target.value)}
|
||||
className="h-10 flex-1"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
placeholder={t("hostDetails.hostname.placeholder")}
|
||||
value={form.hostname}
|
||||
onChange={(e) => update("hostname", e.target.value)}
|
||||
className="h-10 w-full"
|
||||
/>
|
||||
</HostDetailsSection>
|
||||
|
||||
<HostDetailsSection
|
||||
@@ -630,11 +618,12 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
|
||||
</HostDetailsSection>
|
||||
|
||||
<HostDetailsSection
|
||||
icon={<DistroAvatar host={form as Host} fallback="H" size="sm" />}
|
||||
icon={<Shapes size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.icon.sectionTitle")}
|
||||
hint={t("hostDetails.icon.desc")}
|
||||
>
|
||||
<HostIconPicker
|
||||
previewHost={form as Host}
|
||||
distroMode={form.distroMode}
|
||||
manualDistro={form.manualDistro}
|
||||
effectiveDistro={effectiveFormDistro}
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import type { GroupConfig } from "../domain/models";
|
||||
import type { Host } from "../types";
|
||||
import { LINUX_DISTRO_OPTIONS, NETWORK_DEVICE_OPTIONS } from "../domain/host";
|
||||
import {
|
||||
findProxyProfile,
|
||||
hasIncompleteProxyIdentity,
|
||||
hasMissingProxyIdentity,
|
||||
hasUnreadableProxyCredential,
|
||||
isCompleteProxyConfig,
|
||||
normalizeManualProxyConfig,
|
||||
} from "../domain/proxyProfiles";
|
||||
import type { Host, Identity, ProxyConfig, ProxyProfile } from "../types";
|
||||
import {
|
||||
LINUX_DISTRO_OPTIONS,
|
||||
NETWORK_DEVICE_OPTIONS,
|
||||
POSIX_PLATFORM_OPTIONS,
|
||||
} from "../domain/host";
|
||||
|
||||
export const parseOptionalPortInput = (value: string): number | undefined =>
|
||||
value ? Number(value) : undefined;
|
||||
@@ -74,7 +86,74 @@ export const resolveDetailsTelnetPassword = (
|
||||
? groupDefaults.telnetPassword
|
||||
: host.password ?? groupDefaults?.password ?? "";
|
||||
|
||||
export const prepareTelnetCredentialsForSave = (host: Host): Host =>
|
||||
host.telnetIdentityId
|
||||
? {
|
||||
...host,
|
||||
telnetUsername: undefined,
|
||||
telnetPassword: undefined,
|
||||
}
|
||||
: host;
|
||||
|
||||
export const LINUX_DISTRO_OPTION_IDS = [
|
||||
...LINUX_DISTRO_OPTIONS,
|
||||
...POSIX_PLATFORM_OPTIONS,
|
||||
...NETWORK_DEVICE_OPTIONS,
|
||||
];
|
||||
|
||||
export type ProxySaveValidationError =
|
||||
| "required"
|
||||
| "port"
|
||||
| "missingSaved"
|
||||
| "missingIdentity"
|
||||
| "incompleteIdentity"
|
||||
| "unreadableIdentity";
|
||||
|
||||
export const validateProxyConfigForSave = ({
|
||||
proxyConfig,
|
||||
proxyProfileId,
|
||||
proxyProfiles,
|
||||
identities,
|
||||
}: {
|
||||
proxyConfig?: ProxyConfig;
|
||||
proxyProfileId?: string;
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
identities?: Identity[];
|
||||
}): ProxySaveValidationError | null => {
|
||||
const selectedProxyProfile = findProxyProfile(proxyProfileId, proxyProfiles ?? []);
|
||||
if (proxyProfileId && !selectedProxyProfile) return "missingSaved";
|
||||
|
||||
const normalizedProxyConfig = normalizeManualProxyConfig(proxyConfig);
|
||||
if (normalizedProxyConfig && !isCompleteProxyConfig(normalizedProxyConfig)) {
|
||||
return normalizedProxyConfig.host ? "port" : "required";
|
||||
}
|
||||
|
||||
const effectiveProxyConfig = selectedProxyProfile?.config ?? proxyConfig;
|
||||
if (hasMissingProxyIdentity(effectiveProxyConfig, identities ?? [])) {
|
||||
return "missingIdentity";
|
||||
}
|
||||
if (hasIncompleteProxyIdentity(effectiveProxyConfig, identities ?? [])) {
|
||||
return "incompleteIdentity";
|
||||
}
|
||||
if (hasUnreadableProxyCredential(effectiveProxyConfig, identities ?? [])) {
|
||||
return "unreadableIdentity";
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const prepareProxyConfigForSave = (params: {
|
||||
proxyConfig?: ProxyConfig;
|
||||
proxyProfileId?: string;
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
identities?: Identity[];
|
||||
}): {
|
||||
error?: ProxySaveValidationError;
|
||||
normalizedProxyConfig?: ProxyConfig;
|
||||
} => {
|
||||
const error = validateProxyConfigForSave(params);
|
||||
if (error) return { error };
|
||||
return {
|
||||
normalizedProxyConfig: normalizeManualProxyConfig(params.proxyConfig),
|
||||
};
|
||||
};
|
||||
@@ -4,11 +4,14 @@ import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
|
||||
import type { Host } from "../types.ts";
|
||||
import type { Host, Identity } from "../types.ts";
|
||||
import HostDetailsPanel, { parseOptionalPortInput } from "./HostDetailsPanel.tsx";
|
||||
import {
|
||||
prepareProxyConfigForSave,
|
||||
prepareTelnetCredentialsForSave,
|
||||
resolvePrimaryProtocolSavePort,
|
||||
resolvePrimaryProtocolSwitchPort,
|
||||
validateProxyConfigForSave,
|
||||
} from "./HostDetailsPanel.helpers.ts";
|
||||
import { TooltipProvider } from "./ui/tooltip.tsx";
|
||||
|
||||
@@ -26,7 +29,10 @@ const hostWithMissingProxyProfile: Host = {
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
const renderHostDetails = (initialData: Host = hostWithMissingProxyProfile) =>
|
||||
const renderHostDetails = (
|
||||
initialData: Host = hostWithMissingProxyProfile,
|
||||
options: { identities?: Identity[] } = {},
|
||||
) =>
|
||||
renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
@@ -37,7 +43,7 @@ const renderHostDetails = (initialData: Host = hostWithMissingProxyProfile) =>
|
||||
React.createElement(HostDetailsPanel, {
|
||||
initialData,
|
||||
availableKeys: [],
|
||||
identities: [],
|
||||
identities: options.identities ?? [],
|
||||
proxyProfiles: [],
|
||||
groups: [],
|
||||
managedSources: [],
|
||||
@@ -108,6 +114,60 @@ test("HostDetailsPanel keeps explicitly cleared telnet credentials empty", () =>
|
||||
assert.doesNotMatch(markup, /placeholder="Telnet Password"[^>]*value="ssh-password"/);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel shows a selected telnet identity instead of manual telnet fields", () => {
|
||||
const markup = renderHostDetails(
|
||||
{
|
||||
...hostWithMissingProxyProfile,
|
||||
protocol: "telnet",
|
||||
telnetEnabled: true,
|
||||
identityId: "ssh-identity",
|
||||
telnetIdentityId: "telnet-identity",
|
||||
telnetUsername: undefined,
|
||||
telnetPassword: undefined,
|
||||
proxyProfileId: undefined,
|
||||
},
|
||||
{
|
||||
identities: [{
|
||||
id: "ssh-identity",
|
||||
label: "SSH login",
|
||||
username: "ssh-user",
|
||||
authMethod: "password",
|
||||
password: "ssh-password",
|
||||
created: 1,
|
||||
}, {
|
||||
id: "telnet-identity",
|
||||
label: "Telnet login",
|
||||
username: "telnet-user",
|
||||
authMethod: "password",
|
||||
password: "telnet-password",
|
||||
created: 2,
|
||||
}],
|
||||
},
|
||||
);
|
||||
|
||||
assert.match(markup, /telnet-user - Telnet login/);
|
||||
assert.doesNotMatch(markup, /placeholder="Telnet Username"/);
|
||||
assert.doesNotMatch(markup, /placeholder="Telnet Password"/);
|
||||
});
|
||||
|
||||
test("prepareTelnetCredentialsForSave preserves selected telnet identity and clears manual telnet fields", () => {
|
||||
const saved = prepareTelnetCredentialsForSave({
|
||||
...hostWithMissingProxyProfile,
|
||||
protocol: "telnet",
|
||||
telnetEnabled: true,
|
||||
identityId: "ssh-identity",
|
||||
telnetIdentityId: "telnet-identity",
|
||||
telnetUsername: "stale-telnet-user",
|
||||
telnetPassword: "stale-telnet-password",
|
||||
proxyProfileId: undefined,
|
||||
});
|
||||
|
||||
assert.equal(saved.identityId, "ssh-identity");
|
||||
assert.equal(saved.telnetIdentityId, "telnet-identity");
|
||||
assert.equal(saved.telnetUsername, undefined);
|
||||
assert.equal(saved.telnetPassword, undefined);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel disables save when hostname is whitespace only", () => {
|
||||
const markup = renderHostDetails({
|
||||
...hostWithMissingProxyProfile,
|
||||
@@ -275,6 +335,138 @@ test("parseOptionalPortInput clears empty port values", () => {
|
||||
assert.equal(parseOptionalPortInput("2325"), 2325);
|
||||
});
|
||||
|
||||
test("validateProxyConfigForSave rejects missing proxy identities", () => {
|
||||
assert.equal(
|
||||
validateProxyConfigForSave({
|
||||
proxyConfig: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: "missing-identity",
|
||||
},
|
||||
identities: [],
|
||||
}),
|
||||
"missingIdentity",
|
||||
);
|
||||
});
|
||||
|
||||
test("validateProxyConfigForSave rejects missing identities inside saved proxy profiles", () => {
|
||||
assert.equal(
|
||||
validateProxyConfigForSave({
|
||||
proxyProfileId: "profile-1",
|
||||
proxyProfiles: [{
|
||||
id: "profile-1",
|
||||
label: "Office Proxy",
|
||||
config: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: "missing-identity",
|
||||
},
|
||||
createdAt: 1,
|
||||
}],
|
||||
identities: [],
|
||||
}),
|
||||
"missingIdentity",
|
||||
);
|
||||
});
|
||||
|
||||
test("validateProxyConfigForSave rejects incomplete proxy identities", () => {
|
||||
assert.equal(
|
||||
validateProxyConfigForSave({
|
||||
proxyConfig: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: "identity-1",
|
||||
},
|
||||
identities: [{
|
||||
id: "identity-1",
|
||||
label: "Proxy login",
|
||||
username: "proxy-user",
|
||||
authMethod: "password",
|
||||
created: 1,
|
||||
}],
|
||||
}),
|
||||
"incompleteIdentity",
|
||||
);
|
||||
});
|
||||
|
||||
test("validateProxyConfigForSave rejects unreadable proxy identity passwords", () => {
|
||||
assert.equal(
|
||||
validateProxyConfigForSave({
|
||||
proxyConfig: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: "identity-1",
|
||||
},
|
||||
identities: [{
|
||||
id: "identity-1",
|
||||
label: "Proxy login",
|
||||
username: "proxy-user",
|
||||
authMethod: "password",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
created: 1,
|
||||
}],
|
||||
}),
|
||||
"unreadableIdentity",
|
||||
);
|
||||
});
|
||||
|
||||
test("prepareProxyConfigForSave returns a normalized proxy config for save handlers", () => {
|
||||
assert.deepEqual(
|
||||
prepareProxyConfigForSave({
|
||||
proxyConfig: {
|
||||
type: "http",
|
||||
host: " proxy.example.com ",
|
||||
port: "3128" as never,
|
||||
command: "cloudflared access ssh --hostname %h --token secret",
|
||||
username: " proxy-user ",
|
||||
password: "proxy-secret",
|
||||
},
|
||||
identities: [],
|
||||
}),
|
||||
{
|
||||
normalizedProxyConfig: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
username: "proxy-user",
|
||||
password: "proxy-secret",
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("prepareProxyConfigForSave returns identity errors before save handlers build output", () => {
|
||||
assert.deepEqual(
|
||||
prepareProxyConfigForSave({
|
||||
proxyProfileId: "profile-1",
|
||||
proxyProfiles: [{
|
||||
id: "profile-1",
|
||||
label: "Office Proxy",
|
||||
config: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: "identity-1",
|
||||
},
|
||||
createdAt: 1,
|
||||
}],
|
||||
identities: [{
|
||||
id: "identity-1",
|
||||
label: "Proxy login",
|
||||
username: "",
|
||||
authMethod: "password",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
created: 1,
|
||||
}],
|
||||
}),
|
||||
{ error: "incompleteIdentity" },
|
||||
);
|
||||
});
|
||||
|
||||
test("resolvePrimaryProtocolSwitchPort only migrates opposite protocol defaults", () => {
|
||||
assert.equal(resolvePrimaryProtocolSwitchPort(22, "telnet", false, false), 23);
|
||||
assert.equal(resolvePrimaryProtocolSwitchPort(23, "ssh", false, false), 22);
|
||||
@@ -326,3 +518,34 @@ test("HostDetailsPanel shows color and icon controls in the connection settings"
|
||||
assert.match(markup, /Blue/);
|
||||
assert.match(markup, /IP or Hostname/);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel keeps empty notes collapsed below connection settings", () => {
|
||||
const markup = renderHostDetails({
|
||||
...hostWithMissingProxyProfile,
|
||||
proxyProfileId: undefined,
|
||||
notes: "",
|
||||
});
|
||||
|
||||
assert.match(markup, /aria-label="Show notes editor"/);
|
||||
assert.doesNotMatch(markup, /placeholder="Hardware, project, customer, region, role\.\.\."/);
|
||||
assert.ok(
|
||||
markup.indexOf("IP or Hostname") < markup.indexOf("Notes"),
|
||||
"expected notes to render after connection settings",
|
||||
);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel expands notes when the host already has notes", () => {
|
||||
const markup = renderHostDetails({
|
||||
...hostWithMissingProxyProfile,
|
||||
proxyProfileId: undefined,
|
||||
notes: "Runs nightly backups",
|
||||
});
|
||||
|
||||
assert.match(markup, /aria-label="Hide notes editor"/);
|
||||
assert.match(markup, /Runs nightly backups/);
|
||||
assert.match(markup, /placeholder="Hardware, project, customer, region, role\.\.\."/);
|
||||
assert.ok(
|
||||
markup.indexOf("IP or Hostname") < markup.indexOf("Notes"),
|
||||
"expected notes to render after connection settings",
|
||||
);
|
||||
});
|
||||
+229
-69
@@ -20,8 +20,7 @@ import {
|
||||
import {
|
||||
formatProxyConfigEndpoint,
|
||||
formatProxyConfigType,
|
||||
isCompleteProxyConfig,
|
||||
normalizeManualProxyConfig,
|
||||
updateProxyConfigField,
|
||||
} from "../domain/proxyProfiles";
|
||||
import { customThemeStore } from "../application/state/customThemeStore";
|
||||
import {
|
||||
@@ -30,7 +29,7 @@ import {
|
||||
resolveHostTerminalFontSize,
|
||||
resolveHostTerminalThemeId,
|
||||
} from "../domain/terminalAppearance";
|
||||
import { EnvVar, GroupConfig, Host, Identity, ManagedSource, ProxyConfig, ProxyProfile, SSHKey } from "../types";
|
||||
import { EnvVar, GroupConfig, Host, Identity, ManagedSource, ProxyConfig, ProxyProfile, Snippet, SSHKey } from "../types";
|
||||
import { DISTRO_COLORS, DISTRO_LOGOS } from "./DistroAvatar";
|
||||
import ThemeSelectPanel from "./ThemeSelectPanel";
|
||||
import {
|
||||
@@ -38,6 +37,7 @@ import {
|
||||
AsidePanelContent,
|
||||
AsidePanelFooter,
|
||||
type AsidePanelLayout,
|
||||
type AsidePanelResizeProps,
|
||||
} from "./ui/aside-panel";
|
||||
import { HostDetailsAdvancedSections } from "./HostDetailsAdvancedSections";
|
||||
import { HostDetailsConnectionSections } from "./HostDetailsConnectionSections";
|
||||
@@ -49,6 +49,8 @@ import {
|
||||
resolveDetailsTelnetUsername,
|
||||
resolvePrimaryProtocolSavePort,
|
||||
resolvePrimaryProtocolSwitchPort,
|
||||
prepareTelnetCredentialsForSave,
|
||||
prepareProxyConfigForSave,
|
||||
} from "./HostDetailsPanel.helpers";
|
||||
export { parseOptionalPortInput } from "./HostDetailsPanel.helpers";
|
||||
import { Button } from "./ui/button";
|
||||
@@ -65,6 +67,10 @@ import {
|
||||
ProxyPanel,
|
||||
} from "./host-details";
|
||||
import { HostNotesEditor } from "./host/HostNotesEditor";
|
||||
import { HostDetailsScriptsSection } from "./host/HostDetailsScriptsSection";
|
||||
import { ensureHostConnectScriptIds, getHostConnectScriptIds, prepareSnippetForHostConnectQueue } from "@/domain/hostConnectScripts.ts";
|
||||
import { isScriptSnippet } from "@/domain/snippetScript.ts";
|
||||
import { unlinkHostFromScripts } from "@/domain/snippetTargets.ts";
|
||||
|
||||
type CredentialType = "sshid" | "key" | "certificate" | "localKeyFile" | null;
|
||||
type SubPanel =
|
||||
@@ -96,9 +102,13 @@ interface HostDetailsPanelProps {
|
||||
groupConfigs?: GroupConfig[];
|
||||
layout?: AsidePanelLayout;
|
||||
onImportKey?: (draft: Partial<SSHKey>) => SSHKey;
|
||||
snippets?: Snippet[];
|
||||
onSnippetsChange?: (snippets: Snippet[]) => void;
|
||||
}
|
||||
|
||||
const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
type HostDetailsPanelPropsWithResize = HostDetailsPanelProps & AsidePanelResizeProps;
|
||||
|
||||
const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
initialData,
|
||||
availableKeys,
|
||||
identities,
|
||||
@@ -118,8 +128,18 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
groupConfigs = [],
|
||||
layout = "overlay",
|
||||
onImportKey,
|
||||
snippets = [],
|
||||
onSnippetsChange,
|
||||
resizable,
|
||||
persistWidthStorageKey,
|
||||
resizeAriaLabel,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const asideResizeProps = {
|
||||
resizable,
|
||||
persistWidthStorageKey,
|
||||
resizeAriaLabel,
|
||||
};
|
||||
const { checkSshAgent } = useApplicationBackend();
|
||||
const [form, setForm] = useState<Host>(
|
||||
() =>
|
||||
@@ -152,6 +172,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showTelnetPassword, setShowTelnetPassword] = useState(false);
|
||||
const [showAlgorithmOverrides, setShowAlgorithmOverrides] = useState(false);
|
||||
const [showNotesEditor, setShowNotesEditor] = useState(() => Boolean(initialData?.notes?.trim()));
|
||||
|
||||
const [newKeyFilePath, setNewKeyFilePath] = useState("");
|
||||
const [pendingReferenceKeyPath, setPendingReferenceKeyPath] = useState<string | null>(null);
|
||||
@@ -175,15 +196,39 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
|
||||
const [groupInputValue, setGroupInputValue] = useState(form.group || "");
|
||||
|
||||
const initialHostId = initialData?.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
setForm(normalizePrimaryTelnetState(initialData));
|
||||
setGroupInputValue(initialData.group || "");
|
||||
setPendingReferenceKeyPath(null);
|
||||
setShowPassword(false);
|
||||
setShowTelnetPassword(false);
|
||||
}
|
||||
}, [initialData]);
|
||||
if (!initialData) return;
|
||||
const normalized = normalizePrimaryTelnetState(initialData);
|
||||
setForm(
|
||||
snippets.length > 0
|
||||
? ensureHostConnectScriptIds(normalized, snippets)
|
||||
: normalized,
|
||||
);
|
||||
setGroupInputValue(initialData.group || "");
|
||||
setPendingReferenceKeyPath(null);
|
||||
setShowPassword(false);
|
||||
setShowTelnetPassword(false);
|
||||
setShowNotesEditor(Boolean(normalized.notes?.trim()));
|
||||
// Reset only when opening a different host — not when snippets list updates.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialHostId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialData || snippets.length === 0) return;
|
||||
setForm((prev) => {
|
||||
if (prev.id !== initialData.id) return prev;
|
||||
const synced = ensureHostConnectScriptIds(prev, snippets);
|
||||
if (
|
||||
synced.connectScriptIds === prev.connectScriptIds
|
||||
&& synced.loginScriptId === prev.loginScriptId
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return synced;
|
||||
});
|
||||
}, [initialData, snippets]);
|
||||
|
||||
const update = <K extends keyof Host>(key: K, value: Host[K]) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
@@ -283,18 +328,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
}, []);
|
||||
|
||||
const updateProxyConfig = useCallback(
|
||||
(field: keyof ProxyConfig, value: string | number) => {
|
||||
(field: keyof ProxyConfig, value: ProxyConfig[keyof ProxyConfig]) => {
|
||||
setForm((prev) => {
|
||||
const { proxyProfileId: _proxyProfileId, ...rest } = prev;
|
||||
return {
|
||||
...rest,
|
||||
proxyConfig: {
|
||||
type: prev.proxyConfig?.type || "http",
|
||||
host: prev.proxyConfig?.host || "",
|
||||
port: prev.proxyConfig?.port || 8080,
|
||||
...prev.proxyConfig,
|
||||
[field]: value,
|
||||
},
|
||||
proxyConfig: updateProxyConfigField(prev.proxyConfig, field, value),
|
||||
} as Host;
|
||||
});
|
||||
},
|
||||
@@ -364,19 +403,29 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
const handleSubmit = () => {
|
||||
const hostname = form.hostname.trim();
|
||||
if (!hostname) return;
|
||||
const normalizedProxyConfig = normalizeManualProxyConfig(form.proxyConfig);
|
||||
if (normalizedProxyConfig && !isCompleteProxyConfig(normalizedProxyConfig)) {
|
||||
toast.error(
|
||||
normalizedProxyConfig.host ? t("proxyProfiles.error.port") : t("hostDetails.proxyPanel.error.required"),
|
||||
);
|
||||
setActiveSubPanel("proxy");
|
||||
return;
|
||||
}
|
||||
if (hasMissingProxyProfile) {
|
||||
toast.error(t("hostDetails.proxyPanel.missingSaved"));
|
||||
const proxySave = prepareProxyConfigForSave({
|
||||
proxyConfig: form.proxyConfig,
|
||||
proxyProfileId: form.proxyProfileId,
|
||||
proxyProfiles,
|
||||
identities,
|
||||
});
|
||||
if (proxySave.error) {
|
||||
const messageKey = proxySave.error === "port"
|
||||
? "proxyProfiles.error.port"
|
||||
: proxySave.error === "required"
|
||||
? "hostDetails.proxyPanel.error.required"
|
||||
: proxySave.error === "missingSaved"
|
||||
? "hostDetails.proxyPanel.missingSaved"
|
||||
: proxySave.error === "missingIdentity"
|
||||
? "hostDetails.proxyPanel.missingIdentity"
|
||||
: proxySave.error === "incompleteIdentity"
|
||||
? "hostDetails.proxyPanel.incompleteIdentity"
|
||||
: "hostDetails.proxyPanel.unreadableIdentity";
|
||||
toast.error(t(messageKey));
|
||||
setActiveSubPanel("proxy");
|
||||
return;
|
||||
}
|
||||
const normalizedProxyConfig = proxySave.normalizedProxyConfig;
|
||||
let finalLabel = form.label?.trim() || hostname;
|
||||
const finalGroup = groupInputValue.trim() || form.group || "";
|
||||
|
||||
@@ -418,7 +467,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
password: form.savePassword === false ? undefined : form.password,
|
||||
managedSourceId: finalManagedSourceId,
|
||||
};
|
||||
cleaned = normalizePrimaryTelnetState(cleaned);
|
||||
cleaned = prepareTelnetCredentialsForSave(normalizePrimaryTelnetState(cleaned));
|
||||
if (
|
||||
onImportKey &&
|
||||
pendingReferenceKeyPath &&
|
||||
@@ -464,6 +513,38 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
if ((cleaned.protocol && cleaned.protocol !== "ssh") || cleaned.moshEnabled || cleaned.etEnabled) {
|
||||
delete cleaned.x11Forwarding;
|
||||
}
|
||||
if (onSnippetsChange && initialData) {
|
||||
const hostId = cleaned.id;
|
||||
const savedQueueIds = initialData.connectScriptIds ?? getHostConnectScriptIds(initialData, snippets);
|
||||
const finalQueueIds = cleaned.connectScriptIds ?? getHostConnectScriptIds(cleaned, snippets);
|
||||
const savedSet = new Set(savedQueueIds);
|
||||
const finalSet = new Set(finalQueueIds);
|
||||
let nextSnippets = snippets;
|
||||
let changed = false;
|
||||
|
||||
for (const scriptId of finalQueueIds) {
|
||||
if (!savedSet.has(scriptId)) {
|
||||
nextSnippets = nextSnippets.map((item) => (
|
||||
item.id === scriptId && isScriptSnippet(item)
|
||||
? prepareSnippetForHostConnectQueue(item, hostId)
|
||||
: item
|
||||
));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
for (const scriptId of savedQueueIds) {
|
||||
if (!finalSet.has(scriptId)) {
|
||||
const unlinked = unlinkHostFromScripts(nextSnippets, hostId, scriptId);
|
||||
if (unlinked !== nextSnippets) {
|
||||
nextSnippets = unlinked;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
onSnippetsChange(nextSnippets);
|
||||
}
|
||||
}
|
||||
onSave(cleaned);
|
||||
};
|
||||
|
||||
@@ -518,6 +599,21 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
return identities.find((i) => i.id === form.identityId);
|
||||
}, [form.identityId, identities]);
|
||||
|
||||
const selectedTelnetIdentity = useMemo(() => {
|
||||
if (!form.telnetIdentityId) return undefined;
|
||||
return identities.find((i) => i.id === form.telnetIdentityId);
|
||||
}, [form.telnetIdentityId, identities]);
|
||||
|
||||
const telnetIdentityOptions: ComboboxOption[] = useMemo(
|
||||
() =>
|
||||
identities.map((identity) => ({
|
||||
value: identity.id,
|
||||
label: identity.label,
|
||||
sublabel: identity.username,
|
||||
})),
|
||||
[identities],
|
||||
);
|
||||
|
||||
const filteredIdentitySuggestions = useMemo(() => {
|
||||
if (selectedIdentity) return [];
|
||||
const q = (form.username || "").toLowerCase().trim();
|
||||
@@ -563,6 +659,19 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
setIdentitySuggestionsOpen(false);
|
||||
}, []);
|
||||
|
||||
const updateTelnetIdentity = useCallback((identityId: string) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
telnetIdentityId: identityId || undefined,
|
||||
...(identityId
|
||||
? {
|
||||
telnetUsername: undefined,
|
||||
telnetPassword: undefined,
|
||||
}
|
||||
: {}),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
if (activeSubPanel === "create-group") {
|
||||
return (
|
||||
<CreateGroupPanel
|
||||
@@ -575,6 +684,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
onCancel={onCancel}
|
||||
layout={layout}
|
||||
{...asideResizeProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -584,6 +694,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
<ProxyPanel
|
||||
proxyConfig={form.proxyConfig}
|
||||
proxyProfiles={proxyProfiles}
|
||||
identities={identities}
|
||||
selectedProxyProfileId={form.proxyProfileId}
|
||||
onUpdateProxy={updateProxyConfig}
|
||||
onSelectProxyProfile={selectProxyProfile}
|
||||
@@ -591,6 +702,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
onCancel={onCancel}
|
||||
layout={layout}
|
||||
{...asideResizeProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -609,6 +721,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
onCancel={onCancel}
|
||||
layout={layout}
|
||||
{...asideResizeProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -637,6 +750,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
onCancel={onCancel}
|
||||
layout={layout}
|
||||
{...asideResizeProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -658,6 +772,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
showBackButton={true}
|
||||
layout={layout}
|
||||
{...asideResizeProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -694,6 +809,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
showBackButton={true}
|
||||
layout={layout}
|
||||
{...asideResizeProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -705,6 +821,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
width="w-[420px]"
|
||||
layout={layout}
|
||||
dataSection="host-details-panel"
|
||||
{...asideResizeProps}
|
||||
title={
|
||||
initialData ? t("hostDetails.title.details") : t("hostDetails.title.new")
|
||||
}
|
||||
@@ -784,19 +901,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</div>
|
||||
</HostDetailsSection>
|
||||
|
||||
<HostDetailsSection
|
||||
icon={<FileText size={14} className="text-muted-foreground shrink-0" />}
|
||||
title={t("hostDetails.notes.label")}
|
||||
hint={t("hostDetails.notes.help")}
|
||||
>
|
||||
<HostNotesEditor
|
||||
panelKey={form.id}
|
||||
value={form.notes ?? ""}
|
||||
onChange={(notes) => update("notes", notes)}
|
||||
showHeader={false}
|
||||
/>
|
||||
</HostDetailsSection>
|
||||
|
||||
<HostDetailsConnectionSections
|
||||
t={t}
|
||||
form={form}
|
||||
@@ -828,6 +932,42 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
getDistroOptionLabel={getDistroOptionLabel}
|
||||
/>
|
||||
|
||||
{onSnippetsChange ? (
|
||||
<HostDetailsScriptsSection
|
||||
host={form}
|
||||
onHostChange={setForm}
|
||||
snippets={snippets}
|
||||
t={t}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<HostDetailsSection
|
||||
icon={<FileText size={14} className="text-muted-foreground shrink-0" />}
|
||||
title={t("hostDetails.notes.label")}
|
||||
hint={t("hostDetails.notes.help")}
|
||||
action={
|
||||
<Switch
|
||||
checked={showNotesEditor}
|
||||
onCheckedChange={setShowNotesEditor}
|
||||
aria-label={
|
||||
showNotesEditor
|
||||
? t("hostDetails.notes.toggle.hide")
|
||||
: t("hostDetails.notes.toggle.show")
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{showNotesEditor ? (
|
||||
<HostNotesEditor
|
||||
panelKey={form.id}
|
||||
value={form.notes ?? ""}
|
||||
onChange={(notes) => update("notes", notes)}
|
||||
showHeader={false}
|
||||
defaultTab="edit"
|
||||
/>
|
||||
) : null}
|
||||
</HostDetailsSection>
|
||||
|
||||
<HostDetailsAdvancedSections
|
||||
t={t}
|
||||
form={form}
|
||||
@@ -908,32 +1048,52 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</div>
|
||||
|
||||
<p className="text-xs font-semibold">{t("hostDetails.telnet.credentials")}</p>
|
||||
<Input
|
||||
placeholder={t("hostDetails.telnet.username")}
|
||||
value={effectiveTelnetUsername}
|
||||
onChange={(e) =>
|
||||
update("telnetUsername" as keyof Host, e.target.value)
|
||||
}
|
||||
className="h-10"
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder={t("hostDetails.telnet.password")}
|
||||
type={showTelnetPassword ? "text" : "password"}
|
||||
value={effectiveTelnetPassword}
|
||||
onChange={(e) =>
|
||||
update("telnetPassword" as keyof Host, e.target.value)
|
||||
}
|
||||
className="h-10 pr-10"
|
||||
{identities.length > 0 && (
|
||||
<Combobox
|
||||
options={telnetIdentityOptions}
|
||||
value={form.telnetIdentityId || ""}
|
||||
onValueChange={updateTelnetIdentity}
|
||||
placeholder={t("hostDetails.telnet.identity.placeholder")}
|
||||
emptyText={t("common.noResultsFound")}
|
||||
className="w-full"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTelnetPassword(!showTelnetPassword)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showTelnetPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{form.telnetIdentityId ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{selectedTelnetIdentity
|
||||
? `${selectedTelnetIdentity.username} - ${selectedTelnetIdentity.label}`
|
||||
: t("hostDetails.identity.missing")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
placeholder={t("hostDetails.telnet.username")}
|
||||
value={effectiveTelnetUsername}
|
||||
onChange={(e) =>
|
||||
update("telnetUsername" as keyof Host, e.target.value)
|
||||
}
|
||||
className="h-10"
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder={t("hostDetails.telnet.password")}
|
||||
type={showTelnetPassword ? "text" : "password"}
|
||||
value={effectiveTelnetPassword}
|
||||
onChange={(e) =>
|
||||
update("telnetPassword" as keyof Host, e.target.value)
|
||||
}
|
||||
className="h-10 pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTelnetPassword(!showTelnetPassword)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showTelnetPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Input
|
||||
placeholder={groupDefaults?.charset || t("hostDetails.charset.placeholder")}
|
||||
|
||||
@@ -10,8 +10,10 @@ import {
|
||||
isHostIconId,
|
||||
resolveHostIconDefaultColorHex,
|
||||
} from "../domain/hostIcon";
|
||||
import type { Host } from "../domain/models";
|
||||
import type { HostIconColorId, HostIconColorMode, HostIconId, HostIconMode } from "../domain/models";
|
||||
import { cn } from "../lib/utils";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import { renderHostIconGlyph } from "./hostIconRenderer";
|
||||
import { Input } from "./ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||
@@ -24,6 +26,7 @@ type DistroOption = {
|
||||
};
|
||||
|
||||
type HostIconPickerProps = {
|
||||
previewHost?: Host;
|
||||
distroMode?: "auto" | "manual";
|
||||
manualDistro?: string;
|
||||
effectiveDistro?: string;
|
||||
@@ -94,6 +97,7 @@ const colorHexForId = (colorId?: HostIconColorId) =>
|
||||
HOST_ICON_COLORS.find((color) => color.id === colorId)?.hex || HOST_ICON_COLORS[0].hex;
|
||||
|
||||
export const HostIconPicker: React.FC<HostIconPickerProps> = ({
|
||||
previewHost,
|
||||
distroMode,
|
||||
manualDistro,
|
||||
effectiveDistro,
|
||||
@@ -205,6 +209,23 @@ export const HostIconPicker: React.FC<HostIconPickerProps> = ({
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{previewHost ? (
|
||||
<div
|
||||
className="flex items-center justify-center rounded-xl border border-border/60 bg-secondary/20 py-5"
|
||||
aria-label={t("hostDetails.icon.preview")}
|
||||
>
|
||||
<DistroAvatar
|
||||
host={previewHost}
|
||||
fallback={
|
||||
previewHost.label?.slice(0, 2).toUpperCase()
|
||||
|| previewHost.hostname?.slice(0, 2).toUpperCase()
|
||||
|| "H"
|
||||
}
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.icon.source")}</span>
|
||||
|
||||
@@ -457,9 +457,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
<div className="mr-2 h-4 w-4 flex-shrink-0" />
|
||||
)}
|
||||
icon={(
|
||||
<div className="mr-2 flex-shrink-0">
|
||||
<DistroAvatar host={host} fallback={(host.os || "L")[0].toUpperCase()} size="tree" />
|
||||
</div>
|
||||
<DistroAvatar host={host} fallback={(host.os || "L")[0].toUpperCase()} size="tree" />
|
||||
)}
|
||||
content={(
|
||||
<div className="min-w-0 flex-1 leading-tight">
|
||||
|
||||
@@ -123,6 +123,57 @@ interface HostItemProps {
|
||||
onConvertToHost: (knownHost: KnownHost) => void;
|
||||
}
|
||||
|
||||
const knownHostActionButtonClass =
|
||||
"h-8 w-8 shrink-0 opacity-0 transition-opacity group-hover:opacity-100";
|
||||
|
||||
const KnownHostItemActions: React.FC<{
|
||||
knownHost: KnownHost;
|
||||
converted: boolean;
|
||||
onDelete: (id: string) => void;
|
||||
onConvertToHost: (knownHost: KnownHost) => void;
|
||||
}> = ({ knownHost, converted, onDelete, onConvertToHost }) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{!converted && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={knownHostActionButtonClass}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onConvertToHost(knownHost);
|
||||
}}
|
||||
>
|
||||
<ArrowRight size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("action.convertToHost")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(knownHostActionButtonClass, "text-destructive hover:text-destructive hover:bg-destructive/10")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(knownHost.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("action.remove")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const HostItem = React.memo<HostItemProps>(
|
||||
({ knownHost, converted, viewMode, reorderProps, onDelete, onConvertToHost }) => {
|
||||
const { t } = useI18n();
|
||||
@@ -141,39 +192,6 @@ const HostItem = React.memo<HostItemProps>(
|
||||
reorderProps?.className,
|
||||
)}
|
||||
>
|
||||
{/* Quick action buttons on hover */}
|
||||
<div className="absolute top-1 right-1 flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{!converted && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-primary/20 text-primary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onConvertToHost(knownHost);
|
||||
}}
|
||||
>
|
||||
<ArrowRight size={12} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("action.convertToHost")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-destructive/20 text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(knownHost.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("action.remove")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
<VaultEntityIcon
|
||||
className={vaultPrimaryIconClass}
|
||||
@@ -184,6 +202,12 @@ const HostItem = React.memo<HostItemProps>(
|
||||
{knownHost.hostname}
|
||||
</span>
|
||||
</div>
|
||||
<KnownHostItemActions
|
||||
knownHost={knownHost}
|
||||
converted={converted}
|
||||
onDelete={onDelete}
|
||||
onConvertToHost={onConvertToHost}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
@@ -226,26 +250,12 @@ const HostItem = React.memo<HostItemProps>(
|
||||
{knownHost.hostname}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{!converted && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onConvertToHost(knownHost);
|
||||
}}
|
||||
>
|
||||
<ArrowRight size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("action.convertToHost")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<KnownHostItemActions
|
||||
knownHost={knownHost}
|
||||
converted={converted}
|
||||
onDelete={onDelete}
|
||||
onConvertToHost={onConvertToHost}
|
||||
/>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
|
||||
@@ -4,7 +4,7 @@ import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
|
||||
import type { ProxyProfile } from "../types.ts";
|
||||
import type { Identity, ProxyProfile } from "../types.ts";
|
||||
import { ProxyPanel } from "./host-details/ProxyPanel.tsx";
|
||||
|
||||
const proxyProfile: ProxyProfile = {
|
||||
@@ -18,6 +18,15 @@ const proxyProfile: ProxyProfile = {
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
const proxyIdentity: Identity = {
|
||||
id: "identity-1",
|
||||
label: "Proxy login",
|
||||
username: "proxy-user",
|
||||
authMethod: "password",
|
||||
password: "proxy-secret",
|
||||
created: 1,
|
||||
};
|
||||
|
||||
const renderPanel = (props: Partial<React.ComponentProps<typeof ProxyPanel>> = {}) =>
|
||||
renderToStaticMarkup(
|
||||
React.createElement(
|
||||
@@ -26,6 +35,7 @@ const renderPanel = (props: Partial<React.ComponentProps<typeof ProxyPanel>> = {
|
||||
React.createElement(ProxyPanel, {
|
||||
proxyConfig: undefined,
|
||||
proxyProfiles: [],
|
||||
identities: [],
|
||||
selectedProxyProfileId: undefined,
|
||||
onUpdateProxy: () => {},
|
||||
onSelectProxyProfile: () => {},
|
||||
@@ -102,6 +112,79 @@ test("ProxyPanel disables saving invalid manual proxy ports", () => {
|
||||
assert.match(markup, /disabled=""/);
|
||||
});
|
||||
|
||||
test("ProxyPanel shows a clear missing state for stale proxy identities", () => {
|
||||
const markup = renderPanel({
|
||||
proxyConfig: {
|
||||
type: "http",
|
||||
host: "manual-proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: "missing-identity",
|
||||
},
|
||||
identities: [proxyIdentity],
|
||||
});
|
||||
|
||||
assert.match(markup, /Missing keychain identity/);
|
||||
assert.match(markup, /Username/);
|
||||
assert.match(markup, /disabled=""/);
|
||||
});
|
||||
|
||||
test("ProxyPanel warns when a saved proxy profile has a stale identity", () => {
|
||||
const markup = renderPanel({
|
||||
proxyProfiles: [{
|
||||
...proxyProfile,
|
||||
config: {
|
||||
type: "http",
|
||||
host: "office-proxy.example.com",
|
||||
port: 8080,
|
||||
identityId: "missing-identity",
|
||||
},
|
||||
}],
|
||||
selectedProxyProfileId: proxyProfile.id,
|
||||
identities: [proxyIdentity],
|
||||
});
|
||||
|
||||
assert.match(markup, /Missing keychain identity/);
|
||||
assert.match(markup, /disabled=""/);
|
||||
});
|
||||
|
||||
test("ProxyPanel warns when a saved proxy profile has an incomplete identity", () => {
|
||||
const markup = renderPanel({
|
||||
proxyProfiles: [{
|
||||
...proxyProfile,
|
||||
config: {
|
||||
type: "http",
|
||||
host: "office-proxy.example.com",
|
||||
port: 8080,
|
||||
identityId: proxyIdentity.id,
|
||||
},
|
||||
}],
|
||||
selectedProxyProfileId: proxyProfile.id,
|
||||
identities: [{ ...proxyIdentity, password: undefined }],
|
||||
});
|
||||
|
||||
assert.match(markup, /Proxy identity needs a username and password/);
|
||||
assert.match(markup, /disabled=""/);
|
||||
});
|
||||
|
||||
test("ProxyPanel warns when a saved proxy profile has an unreadable identity password", () => {
|
||||
const markup = renderPanel({
|
||||
proxyProfiles: [{
|
||||
...proxyProfile,
|
||||
config: {
|
||||
type: "http",
|
||||
host: "office-proxy.example.com",
|
||||
port: 8080,
|
||||
identityId: proxyIdentity.id,
|
||||
},
|
||||
}],
|
||||
selectedProxyProfileId: proxyProfile.id,
|
||||
identities: [{ ...proxyIdentity, password: "enc:v1:djEwAAAA" }],
|
||||
});
|
||||
|
||||
assert.match(markup, /Proxy identity password cannot be read/);
|
||||
assert.match(markup, /disabled=""/);
|
||||
});
|
||||
|
||||
test("ProxyPanel supports custom ProxyCommand settings", () => {
|
||||
const markup = renderPanel({
|
||||
proxyConfig: {
|
||||
@@ -127,3 +210,19 @@ test("ProxyPanel uses a dropdown for proxy type selection", () => {
|
||||
assert.match(markup, /role="combobox"/);
|
||||
assert.match(markup, /aria-label="Type"/);
|
||||
});
|
||||
|
||||
test("ProxyPanel offers keychain identities for HTTP and SOCKS5 proxy credentials", () => {
|
||||
const markup = renderPanel({
|
||||
proxyConfig: {
|
||||
type: "socks5",
|
||||
host: "manual-proxy.example.com",
|
||||
port: 1080,
|
||||
identityId: proxyIdentity.id,
|
||||
},
|
||||
identities: [proxyIdentity],
|
||||
});
|
||||
|
||||
assert.match(markup, /Keychain identity/);
|
||||
assert.match(markup, /Proxy login/);
|
||||
assert.match(markup, /proxy-user/);
|
||||
});
|
||||
Loaded 100 of 533 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user