mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-07 08:31:00 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e661169b60 | ||
|
|
95f39f11b7 | ||
|
|
642905043c | ||
|
|
3943b4869f | ||
|
|
ee8687af15 | ||
|
|
bf723e7348 | ||
|
|
15baefaa57 | ||
|
|
f0f858447e | ||
|
|
83bbf9c73d | ||
|
|
7ee5888795 | ||
|
|
1349df9c0c | ||
|
|
4259be6f57 | ||
|
|
24665eb532 | ||
|
|
7faa0db866 | ||
|
|
56a86b48bf | ||
|
|
53328ca50d | ||
|
|
a0abcc3131 | ||
|
|
8083aece66 | ||
|
|
6893b986ca | ||
|
|
599c8ff200 | ||
|
|
cd6578ba25 | ||
|
|
12835ab0e5 | ||
|
|
0913c6188f | ||
|
|
9ef868f7ed | ||
|
|
20b613d1bb | ||
|
|
12d7d6c730 | ||
|
|
07e837b21c | ||
|
|
825bd722d1 | ||
|
|
51f5fd9088 | ||
|
|
d9ed716b01 | ||
|
|
c1ebedcc1f | ||
|
|
7c707bf3b0 | ||
|
|
59f70af0c3 | ||
|
|
a15f677727 | ||
|
|
2cfbf235f7 | ||
|
|
3446749573 | ||
|
|
b709417e23 | ||
|
|
d79353942b | ||
|
|
0ceec3d45c | ||
|
|
fc287f9038 | ||
|
|
d071827a1e | ||
|
|
6b114f55eb | ||
|
|
3235f11870 | ||
|
|
6fad3f0b76 | ||
|
|
278f528deb | ||
|
|
b6da5e60c9 | ||
|
|
355488f4d0 | ||
|
|
61a2473a7c | ||
|
|
5e6fc5f721 | ||
|
|
b17835bf58 | ||
|
|
977c5c56e1 | ||
|
|
06ec9d6b1d | ||
|
|
a7c35f2813 | ||
|
|
0c01238a39 | ||
|
|
46d730f2fa | ||
|
|
c7de55007b | ||
|
|
4caa37c10d | ||
|
|
6feef21841 | ||
|
|
99003f9716 | ||
|
|
9180e6abb6 | ||
|
|
8b5135b008 | ||
|
|
306a91d1e3 | ||
|
|
4fa3eff976 | ||
|
|
db89462e00 | ||
|
|
f26c057128 | ||
|
|
7ca3974bf8 | ||
|
|
58bef9344a | ||
|
|
12a513606b | ||
|
|
89da4674cd | ||
|
|
3a540a5730 | ||
|
|
bd3491a3e4 | ||
|
|
8322a2aeb8 | ||
|
|
7ac21c3c33 | ||
|
|
5cd6149981 | ||
|
|
947cca5199 | ||
|
|
4108538dbb | ||
|
|
3beb909a09 | ||
|
|
4b874410f9 | ||
|
|
f237cfb4ab | ||
|
|
aa37b353f7 | ||
|
|
3d134abe94 | ||
|
|
218bf2183a | ||
|
|
a7cc2cbee3 | ||
|
|
c79f485270 | ||
|
|
8424471ff6 | ||
|
|
46262d18f3 | ||
|
|
6554f7bc8f | ||
|
|
010e2f356e | ||
|
|
d184819b61 | ||
|
|
f53d359d38 | ||
|
|
c6bdcffb89 | ||
|
|
369251099f | ||
|
|
65c3df2ff4 | ||
|
|
44a84b5046 | ||
|
|
0ea8269a78 | ||
|
|
d6b00df333 | ||
|
|
8e165a7101 | ||
|
|
8deb52842b | ||
|
|
a82e69865f | ||
|
|
6e4a1cf2a2 | ||
|
|
194691038c | ||
|
|
28762b3a73 | ||
|
|
a493fbfea8 | ||
|
|
6304608114 | ||
|
|
e4c7484181 | ||
|
|
11d5c269d1 | ||
|
|
8741d683ba | ||
|
|
affe197f26 | ||
|
|
40dd3d1a6a | ||
|
|
9b397053ca | ||
|
|
0380eef924 | ||
|
|
475e896cf0 | ||
|
|
256dbca4ba | ||
|
|
541e627702 | ||
|
|
96f0a9e559 | ||
|
|
dea182c38a | ||
|
|
77856252cf | ||
|
|
cff74b6e49 | ||
|
|
94464da004 | ||
|
|
055fa4bb97 | ||
|
|
d8ac9b081b | ||
|
|
b74b5a442a | ||
|
|
6f074d620e | ||
|
|
e2f8ca9c00 | ||
|
|
d1cb3e100b | ||
|
|
1e939c9b31 | ||
|
|
9bf846978d | ||
|
|
e897a3a30a | ||
|
|
a238a5ac2f | ||
|
|
b2c02559f1 | ||
|
|
f95cf73ca4 | ||
|
|
018067032e | ||
|
|
1d07879c11 | ||
|
|
eb1a39dbc2 | ||
|
|
982569532c | ||
|
|
554d1991df | ||
|
|
feb6f60db2 | ||
|
|
96101c0c73 | ||
|
|
fe297dbc55 | ||
|
|
e3580a45e5 | ||
|
|
df359f6a9a | ||
|
|
7bdad5e701 | ||
|
|
39f8bf194a | ||
|
|
36295e63ed | ||
|
|
257cda56e3 | ||
|
|
7587090bcd | ||
|
|
eba27d94b6 | ||
|
|
3289c0deca | ||
|
|
d1de0d39e8 | ||
|
|
25a2141bb3 | ||
|
|
b9c164aeb6 | ||
|
|
5afdbdf8fc | ||
|
|
14da682578 | ||
|
|
7008262762 | ||
|
|
7c2e1ef238 | ||
|
|
8eb3ee44b4 | ||
|
|
0b5c48282a | ||
|
|
45a757c0e0 | ||
|
|
b480eabb3c | ||
|
|
7477f5c47a | ||
|
|
4364522bf1 | ||
|
|
4d3fa2d225 | ||
|
|
cde2fa716c | ||
|
|
43063e1744 | ||
|
|
5b83b47845 | ||
|
|
a815e7d2e5 | ||
|
|
9674cd8ae6 | ||
|
|
d9334a8e73 | ||
|
|
ba9d9c1e58 | ||
|
|
73e615cb64 | ||
|
|
93079bb576 | ||
|
|
52bc48f73a | ||
|
|
46755465f9 | ||
|
|
04906a80ff | ||
|
|
193b2b73c9 | ||
|
|
b32310b073 | ||
|
|
5a27dbe955 | ||
|
|
2cbcebf45a | ||
|
|
644d55a0c0 | ||
|
|
1bb20026a2 | ||
|
|
932dd4f191 | ||
|
|
ecadc1fc2d | ||
|
|
79ccf47655 | ||
|
|
6ef0a4ad6b | ||
|
|
88142d2a92 | ||
|
|
f5c3302329 | ||
|
|
bb02f8e162 | ||
|
|
d57dd664a2 | ||
|
|
74ec6678bb | ||
|
|
b9e88cd99d | ||
|
|
32afade4f9 | ||
|
|
66de2db912 | ||
|
|
b08ddc73b4 | ||
|
|
dccd872122 | ||
|
|
1e568011f8 | ||
|
|
0a38da8867 | ||
|
|
5e739f8293 | ||
|
|
6f64245d10 | ||
|
|
2318eca54e | ||
|
|
ed8a04c84e | ||
|
|
d48ca65a1e | ||
|
|
285fcd55a9 | ||
|
|
05b713ab18 | ||
|
|
293b15f67a | ||
|
|
83aec35f2f | ||
|
|
910ef72205 | ||
|
|
550a37b379 | ||
|
|
2b396c14e3 | ||
|
|
36724a3abd | ||
|
|
4459aa4ef3 | ||
|
|
64a6986d01 | ||
|
|
a301ecb2ca | ||
|
|
f16429e30f | ||
|
|
46b9bf6ccb | ||
|
|
17c8f11194 | ||
|
|
4d1a7ea55a | ||
|
|
babe06a944 | ||
|
|
9e31d53bdd | ||
|
|
ea24841939 | ||
|
|
bf9f557e42 | ||
|
|
106e748a9b | ||
|
|
3d669fffed | ||
|
|
94fff62f9b | ||
|
|
324253f23a | ||
|
|
e9a2e44a91 | ||
|
|
7b4f046001 | ||
|
|
1a3560a19f | ||
|
|
3b525300e0 | ||
|
|
08ff49d3f5 | ||
|
|
f5c4271a07 | ||
|
|
74d41b43b6 | ||
|
|
3408bba303 | ||
|
|
5e00e998a8 | ||
|
|
3847f0cda0 | ||
|
|
1ebcd017bd | ||
|
|
9013a7e312 | ||
|
|
afefbd953f | ||
|
|
535b141b23 | ||
|
|
b21e44b65f | ||
|
|
b42be379e3 | ||
|
|
b2f0a3bea3 | ||
|
|
df3745d185 | ||
|
|
f85bb3f9b2 | ||
|
|
566f3e3c32 | ||
|
|
58eb91fb23 | ||
|
|
36267717ac | ||
|
|
5e323f1f8f | ||
|
|
c0efc9d5c1 | ||
|
|
61188ab8e2 | ||
|
|
ae209d37c1 | ||
|
|
a5b0efba75 | ||
|
|
5adb64e40e | ||
|
|
41fea1028d | ||
|
|
5a90a4331b | ||
|
|
881f3b1a34 | ||
|
|
8be5865b76 | ||
|
|
685d1cb41a | ||
|
|
14fe1e3ecb | ||
|
|
636f4d7037 | ||
|
|
c92ad2f601 | ||
|
|
8a876fd67d | ||
|
|
d39cd60863 | ||
|
|
602ca92476 | ||
|
|
f413035295 | ||
|
|
bfd3fb4dad | ||
|
|
733e19a6f6 | ||
|
|
85b552e1a6 | ||
|
|
068730c53c | ||
|
|
c9d84c7ce3 | ||
|
|
d558aea7de | ||
|
|
e211eec693 | ||
|
|
6b1277d3e1 | ||
|
|
35bf38be70 | ||
|
|
555c00406e | ||
|
|
e67012654a | ||
|
|
ecdb1d17cd | ||
|
|
a5578b5e60 | ||
|
|
fb4641878f | ||
|
|
7d6f30f51f | ||
|
|
9869b645b1 | ||
|
|
037b85bd66 | ||
|
|
ba784b8b35 | ||
|
|
eae760db3f | ||
|
|
4b5993cad6 | ||
|
|
6af62aa093 | ||
|
|
61e8de4270 | ||
|
|
27dce4e427 | ||
|
|
8b53fb1c7b | ||
|
|
6c1661dc3c | ||
|
|
3662b45121 | ||
|
|
437253179e | ||
|
|
d85f4edbbb | ||
|
|
96c9ccaaa0 | ||
|
|
3203ed7a19 | ||
|
|
846d8246a3 | ||
|
|
26a04b22d3 | ||
|
|
f5f55ffc2e | ||
|
|
0792ce1415 | ||
|
|
eca23a2691 | ||
|
|
aa1781577b | ||
|
|
409d293faa | ||
|
|
39fea86f13 | ||
|
|
ce5d1d0e5a | ||
|
|
7ac29366ae | ||
|
|
4860581525 | ||
|
|
d9156349e1 | ||
|
|
983b0b2f1d | ||
|
|
a552c14cbd | ||
|
|
3f5787ceb1 | ||
|
|
e4ec2363d0 | ||
|
|
84b71910ee | ||
|
|
371217832b | ||
|
|
afb514b472 | ||
|
|
e14dc22bba | ||
|
|
6b7c12c23c | ||
|
|
222b3869dd | ||
|
|
56af2d3840 | ||
|
|
1695470089 | ||
|
|
d4b5f799cb |
No files matched your search
@@ -34,7 +34,7 @@ body:
|
||||
attributes:
|
||||
label: How did you install Netcatty?
|
||||
options:
|
||||
- GitHub Release (.dmg / .exe / .AppImage / .deb)
|
||||
- GitHub Release (.dmg / .exe / .AppImage / .deb / .rpm / .pacman)
|
||||
- Homebrew
|
||||
- Built from source (npm run dev / pack)
|
||||
- Other
|
||||
|
||||
@@ -50,6 +50,7 @@ const baseUrl = `https://github.com/${repo}/releases/download/${tag}`;
|
||||
// - AppImage: x64 -> x86_64, arm64 -> arm64
|
||||
// - deb: x64 -> amd64, arm64 -> arm64
|
||||
// - rpm: x64 -> x86_64, arm64 -> aarch64
|
||||
// - pacman: x64 -> x64, arm64 -> aarch64
|
||||
const files = {
|
||||
mac: {
|
||||
arm64: `Netcatty-${version}-mac-arm64.dmg`,
|
||||
@@ -70,6 +71,10 @@ const files = {
|
||||
rpm: {
|
||||
x64: `Netcatty-${version}-linux-x86_64.rpm`,
|
||||
arm64: `Netcatty-${version}-linux-aarch64.rpm`
|
||||
},
|
||||
pacman: {
|
||||
x64: `Netcatty-${version}-linux-x64.pacman`,
|
||||
arm64: `Netcatty-${version}-linux-aarch64.pacman`
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -88,7 +93,9 @@ const badges = {
|
||||
deb_x64: `[](${baseUrl}/${files.linux.deb.x64})`,
|
||||
deb_arm64: `[](${baseUrl}/${files.linux.deb.arm64})`,
|
||||
rpm_x64: `[](${baseUrl}/${files.linux.rpm.x64})`,
|
||||
rpm_arm64: `[](${baseUrl}/${files.linux.rpm.arm64})`
|
||||
rpm_arm64: `[](${baseUrl}/${files.linux.rpm.arm64})`,
|
||||
pacman_x64: `[](${baseUrl}/${files.linux.pacman.x64})`,
|
||||
pacman_arm64: `[](${baseUrl}/${files.linux.pacman.arm64})`
|
||||
}
|
||||
};
|
||||
|
||||
@@ -99,7 +106,7 @@ const content = `
|
||||
| :--- | :--- |
|
||||
| **Windows** | ${badges.win.setup_x64} |
|
||||
| **macOS** | ${badges.mac.apple_silicon} ${badges.mac.intel} |
|
||||
| **Linux** | ${badges.linux.appimage_x64} ${badges.linux.deb_x64} ${badges.linux.rpm_x64} <br> ${badges.linux.appimage_arm64} ${badges.linux.deb_arm64} ${badges.linux.rpm_arm64} |
|
||||
| **Linux** | ${badges.linux.appimage_x64} ${badges.linux.deb_x64} ${badges.linux.rpm_x64} ${badges.linux.pacman_x64} <br> ${badges.linux.appimage_arm64} ${badges.linux.deb_arm64} ${badges.linux.rpm_arm64} ${badges.linux.pacman_arm64} |
|
||||
`;
|
||||
|
||||
fs.writeFileSync('release_notes.md', content);
|
||||
|
||||
@@ -348,6 +348,7 @@ jobs:
|
||||
release/*.AppImage
|
||||
release/*.deb
|
||||
release/*.rpm
|
||||
release/*.pacman
|
||||
release/*.tar.gz
|
||||
release/*.yml
|
||||
release/*.blockmap
|
||||
@@ -410,6 +411,9 @@ jobs:
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Install pacman packaging dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y libarchive-tools
|
||||
|
||||
- name: Set version
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -457,6 +461,7 @@ jobs:
|
||||
release/*.AppImage
|
||||
release/*.deb
|
||||
release/*.rpm
|
||||
release/*.pacman
|
||||
release/*.yml
|
||||
release/*.blockmap
|
||||
if-no-files-found: ignore
|
||||
@@ -510,6 +515,7 @@ jobs:
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y curl build-essential python3 git libfuse2 file rpm \
|
||||
libarchive-tools \
|
||||
libglib2.0-0 libgtk-3-0 libnss3 libxss1 libxtst6 libasound2 \
|
||||
libatk-bridge2.0-0 libdrm2 libgbm1 libx11-xcb1 libxcb-dri3-0
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||
@@ -568,6 +574,7 @@ jobs:
|
||||
release/*.AppImage
|
||||
release/*.deb
|
||||
release/*.rpm
|
||||
release/*.pacman
|
||||
release/*.yml
|
||||
release/*.blockmap
|
||||
if-no-files-found: ignore
|
||||
@@ -673,6 +680,7 @@ jobs:
|
||||
artifacts/*.AppImage
|
||||
artifacts/*.deb
|
||||
artifacts/*.rpm
|
||||
artifacts/*.pacman
|
||||
artifacts/*.yml
|
||||
artifacts/*.blockmap
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -27,6 +27,11 @@ jobs:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install shell test dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y fish
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
|
||||
+11
@@ -8,6 +8,7 @@ pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
@@ -41,8 +42,18 @@ coverage
|
||||
# Codex
|
||||
/.codex/
|
||||
|
||||
# Qoder
|
||||
.qoder
|
||||
|
||||
# Workbuddy
|
||||
.workbuddy
|
||||
|
||||
# Codebuddy
|
||||
.codebuddy
|
||||
|
||||
# AI / Superpowers generated docs (local only)
|
||||
/docs/superpowers/
|
||||
/.superpowers/
|
||||
|
||||
# Dev-only electron-updater test config (not for production)
|
||||
/dev-app-update.yml
|
||||
|
||||
@@ -24,10 +24,13 @@ import { matchesKeyBinding } from './domain/models';
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from './domain/groupConfig';
|
||||
import { upsertKnownHost } from './domain/knownHosts';
|
||||
import { materializeHostProxyProfile } from './domain/proxyProfiles';
|
||||
import { buildSshDeepLinkConnectionHost, buildSshDeepLinkHostDraft, findSshDeepLinkHost, parseSshDeepLink } from './domain/sshDeepLink';
|
||||
import { resolveHostAuth } from './domain/sshAuth';
|
||||
import { isEncryptedCredentialPlaceholder } from './domain/credentials';
|
||||
import {
|
||||
getFollowAppTerminalThemeSelectionUpdate,
|
||||
mergeTerminalHostUpdate,
|
||||
type TerminalHostUpdate,
|
||||
} from './domain/terminalAppearance';
|
||||
import { selectConnectionLogForTerminalDataCapture } from './domain/connectionLog';
|
||||
import { collectSessionIds } from './domain/workspace';
|
||||
@@ -35,7 +38,7 @@ 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 { useCustomThemes } from './application/state/customThemeStore';
|
||||
import { customThemeStore, useCustomThemes } from './application/state/customThemeStore';
|
||||
import type { SyncPayload } from './domain/sync';
|
||||
import { applySyncPayload, buildLocalVaultPayload, hasMeaningfulSyncData } from './application/syncPayload';
|
||||
import {
|
||||
@@ -56,6 +59,7 @@ import { VaultSection } from './components/VaultView';
|
||||
import { KeyboardInteractiveRequest } from './components/KeyboardInteractiveModal';
|
||||
import { PassphraseRequest } from './components/PassphraseModal';
|
||||
import { classifyLocalShellType } from './lib/localShell';
|
||||
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';
|
||||
@@ -99,11 +103,13 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>(null);
|
||||
// Navigation state for VaultView sections
|
||||
const [navigateToSection, setNavigateToSection] = useState<VaultSection | null>(null);
|
||||
const [deepLinkHostDraft, setDeepLinkHostDraft] = useState<Host | null>(null);
|
||||
// Keyboard-interactive authentication queue (2FA/MFA) - queue-based to handle multiple concurrent sessions
|
||||
const [keyboardInteractiveQueue, setKeyboardInteractiveQueue] = useState<KeyboardInteractiveRequest[]>([]);
|
||||
// Passphrase request queue for encrypted SSH keys
|
||||
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
|
||||
const [pendingNewWindowSession, setPendingNewWindowSession] = useState<OpenSessionInNewWindowPayload | null>(null);
|
||||
const isPeerSessionWindow = typeof window !== 'undefined' && window.location.hash.startsWith('#/session-window');
|
||||
|
||||
const {
|
||||
theme,
|
||||
@@ -113,6 +119,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
customAccent,
|
||||
terminalThemeId,
|
||||
setTerminalThemeId,
|
||||
setTerminalThemeDarkId,
|
||||
setTerminalThemeLightId,
|
||||
followAppTerminalTheme,
|
||||
currentTerminalTheme,
|
||||
terminalFontFamilyId,
|
||||
@@ -138,7 +146,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
sessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
sessionLogsTimestampsEnabled,
|
||||
reapplyCurrentTheme,
|
||||
applyAppTheme,
|
||||
workspaceFocusStyle,
|
||||
} = settings;
|
||||
|
||||
@@ -162,6 +170,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
snippets,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
notes,
|
||||
noteGroups,
|
||||
knownHosts,
|
||||
shellHistory,
|
||||
connectionLogs,
|
||||
@@ -173,6 +183,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
updateProxyProfiles,
|
||||
updateSnippets,
|
||||
updateSnippetPackages,
|
||||
updateNotes,
|
||||
updateNoteGroups,
|
||||
updateCustomGroups,
|
||||
updateKnownHosts,
|
||||
updateManagedSources,
|
||||
@@ -193,7 +205,6 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const keysRef = useRef(keys);
|
||||
keysRef.current = keys;
|
||||
const knownHostsRef = useRef(knownHosts);
|
||||
knownHostsRef.current = knownHosts;
|
||||
// Bridge the gap while useVaultState hydrates: its async init awaits
|
||||
// hosts/keys/identities/proxyProfiles decryption before reading knownHosts,
|
||||
// so the state is briefly [] at boot even when localStorage has entries.
|
||||
@@ -204,6 +215,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
() => getEffectiveKnownHosts(knownHosts) ?? [],
|
||||
[knownHosts],
|
||||
);
|
||||
knownHostsRef.current = effectiveKnownHosts;
|
||||
|
||||
const {
|
||||
sessions,
|
||||
@@ -215,6 +227,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
sessionRenameValue,
|
||||
setSessionRenameValue,
|
||||
startSessionRename,
|
||||
renameSessionInline,
|
||||
submitSessionRename,
|
||||
resetSessionRename,
|
||||
workspaceRenameTarget,
|
||||
@@ -229,9 +242,12 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
closeSession,
|
||||
closeWorkspace,
|
||||
updateSessionStatus,
|
||||
updateSessionFontSize,
|
||||
clearSessionFontSizeOverride,
|
||||
createWorkspaceWithHosts,
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
removeSessionFromWorkspace,
|
||||
appendHostToWorkspace,
|
||||
appendLocalTerminalToWorkspace,
|
||||
createWorkspaceFromTargets,
|
||||
@@ -244,6 +260,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
runSnippet,
|
||||
orphanSessions,
|
||||
orderedTabs,
|
||||
getOrderedWorkTabs,
|
||||
reorderTabs,
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
@@ -252,7 +269,10 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
closeLogView,
|
||||
copySession,
|
||||
createSessionFromCloneSource,
|
||||
} = useSessionState();
|
||||
updateSessionRestoreCwd,
|
||||
updateSessionDynamicTitle,
|
||||
updateSessionCodingCliProvider,
|
||||
} = useSessionState({ persistSessionRestore: !isPeerSessionWindow });
|
||||
|
||||
const handleRunSnippet = useCallback(
|
||||
async (snippet: Snippet, targetHosts: Host[]) => {
|
||||
@@ -267,12 +287,11 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const isMacClient = typeof navigator !== 'undefined' && /Mac|Macintosh/.test(navigator.userAgent);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Immersive Mode — derive UI chrome colors from the active terminal's theme
|
||||
// Active tab lookup maps
|
||||
// ---------------------------------------------------------------------------
|
||||
const customThemes = useCustomThemes();
|
||||
const editorTabs = useEditorTabs();
|
||||
|
||||
// Resolve the effective TerminalTheme for the currently focused terminal tab
|
||||
const hostById = useMemo(
|
||||
() => new Map(hosts.map((host) => [host.id, host])),
|
||||
[hosts],
|
||||
@@ -291,8 +310,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
() => new Map([...customThemes, ...TERMINAL_THEMES].map((theme) => [theme.id, theme])),
|
||||
[customThemes],
|
||||
);
|
||||
// activeTabId-derived chrome (immersive theme, window title, sftp guard) is
|
||||
// owned by <AppActiveTabChrome/> so switching tabs does not re-render App.
|
||||
// activeTabId-derived chrome (window title, sftp guard) is owned by
|
||||
// <AppActiveTabChrome/> so switching tabs does not re-render App.
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -301,7 +320,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
if (!payload?.sourceSession) return;
|
||||
setPendingNewWindowSession(payload);
|
||||
});
|
||||
}, []);
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVaultInitialized || !pendingNewWindowSession?.sourceSession) return;
|
||||
@@ -350,6 +369,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
snippets,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
notes,
|
||||
noteGroups,
|
||||
knownHosts: getEffectiveKnownHosts(knownHosts),
|
||||
groupConfigs,
|
||||
},
|
||||
@@ -363,6 +384,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
keys,
|
||||
proxyProfiles,
|
||||
knownHosts,
|
||||
noteGroups,
|
||||
notes,
|
||||
portForwardingRulesForSync,
|
||||
snippetPackages,
|
||||
snippets,
|
||||
@@ -398,7 +421,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// hydrated enough to be backed up (or the user genuinely stays empty,
|
||||
// in which case the effect continues to no-op).
|
||||
useEffect(() => {
|
||||
if (!isVaultInitialized || versionBackupAttemptedRef.current) return;
|
||||
if (isPeerSessionWindow || !isVaultInitialized || versionBackupAttemptedRef.current) return;
|
||||
const payload = buildCurrentSyncPayloadRef.current();
|
||||
if (!hasMeaningfulSyncData(payload)) return;
|
||||
versionBackupAttemptedRef.current = true;
|
||||
@@ -422,7 +445,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isVaultInitialized, hosts, keys, identities, proxyProfiles, snippets, customGroups, snippetPackages, knownHosts]);
|
||||
}, [isPeerSessionWindow, isVaultInitialized, hosts, keys, identities, proxyProfiles, snippets, customGroups, snippetPackages, notes, noteGroups, knownHosts]);
|
||||
|
||||
// Memoized "apply a remote payload safely" callback. Stable identity
|
||||
// across renders so useAutoSync's `syncNow` useCallback doesn't rebuild
|
||||
@@ -452,6 +475,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
// Auto-sync hook for cloud sync
|
||||
const { syncNow: handleSyncNow, emptyVaultConflict, resolveEmptyVaultConflict } = useAutoSync({
|
||||
enabled: !isPeerSessionWindow,
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
@@ -459,6 +483,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
snippets,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
notes,
|
||||
noteGroups,
|
||||
portForwardingRules: portForwardingRulesForSync,
|
||||
groupConfigs,
|
||||
settingsVersion: settings.settingsVersion,
|
||||
@@ -478,6 +504,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
// Update check hook - checks for new versions on startup
|
||||
const { updateState, dismissUpdate, installUpdate } = useUpdateCheck({
|
||||
enabled: !isPeerSessionWindow,
|
||||
// Install blocked because an editor has unsaved changes (#1215). The main
|
||||
// process broadcasts this; show an actionable toast telling the user to save
|
||||
// and click "Restart Now" again.
|
||||
@@ -492,9 +519,10 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const _handleGlobalHotkeyKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleGlobalHotkeyKeyDownImpl(() => ({ HOTKEY_DEBUG, closeTabKeyStr, e, executeHotkeyAction, hotkeyScheme, keyBindings, matchesKeyBinding }), e); });
|
||||
const _handleEscapeKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleEscapeKeyDownImpl(() => ({ e, isQuickSwitcherOpen, setIsQuickSwitcherOpen }), e); });
|
||||
|
||||
useAppStartupEffects({ dismissUpdate, groupConfigs, hosts, identities, installUpdate, isVaultInitialized, keys, openSettingsWindow, portForwardingRules, proxyProfiles, sessions, setKeyboardInteractiveQueue, t, terminalSettings, updateState, workspaces });
|
||||
useAppStartupEffects({ dismissUpdate, enabled: !isPeerSessionWindow, groupConfigs, hosts, identities, installUpdate, isVaultInitialized, keys, openSettingsWindow, portForwardingRules, proxyProfiles, sessions, setKeyboardInteractiveQueue, t, terminalSettings, updateState, workspaces });
|
||||
|
||||
useEffect(() => {
|
||||
if (isPeerSessionWindow) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onTrayFocusSession || !bridge?.onTrayTogglePortForward) return;
|
||||
|
||||
@@ -509,9 +537,10 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
unsubscribeFocus?.();
|
||||
unsubscribeToggle?.();
|
||||
};
|
||||
}, []);
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPeerSessionWindow) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onTrayPanelJumpToSession || !bridge?.onTrayPanelConnectToHost) return;
|
||||
|
||||
@@ -525,7 +554,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
unsubscribeJump?.();
|
||||
unsubscribeConnect?.();
|
||||
};
|
||||
}, []);
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
// Handle keyboard-interactive submit
|
||||
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[], savePassword?: string) => { return handleKeyboardInteractiveSubmitImpl(() => ({ hosts, keyboardInteractiveQueue, netcattyBridge, requestId, responses, savePassword, sessions, setKeyboardInteractiveQueue, updateHosts }), requestId, responses, savePassword); }, [keyboardInteractiveQueue, sessions, hosts, updateHosts]);
|
||||
@@ -666,6 +695,12 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
const toggleScriptsSidePanelRef = useRef<(() => void) | null>(null);
|
||||
const toggleSidePanelRef = useRef<(() => void) | null>(null);
|
||||
const openNoteRequestIdRef = useRef(0);
|
||||
const [openNoteRequest, setOpenNoteRequest] = useState<{
|
||||
tabId: string;
|
||||
noteId: 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.
|
||||
const handleOpenSettingsRef = useRef<() => void>(() => {});
|
||||
@@ -697,12 +732,25 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
const closeTabsInFlightRef = useRef(false);
|
||||
|
||||
const editorTabTopIds = useMemo(
|
||||
() => editorTabs.map((tab) => toEditorTabId(tab.id)),
|
||||
[editorTabs],
|
||||
);
|
||||
|
||||
// 顶层标签顺序需要包含编辑器标签,供顶部标签和编辑器邻居计算使用。
|
||||
const orderedTabsWithEditors = useMemo(
|
||||
() => [...orderedTabs, ...editorTabs.map((tab) => toEditorTabId(tab.id))],
|
||||
[orderedTabs, editorTabs],
|
||||
() => getOrderedWorkTabs(editorTabTopIds),
|
||||
[editorTabTopIds, getOrderedWorkTabs],
|
||||
);
|
||||
|
||||
const reorderWorkTabs = useCallback((
|
||||
draggedId: string,
|
||||
targetId: string,
|
||||
position: 'before' | 'after' = 'before',
|
||||
) => {
|
||||
reorderTabs(draggedId, targetId, position, editorTabTopIds);
|
||||
}, [editorTabTopIds, reorderTabs]);
|
||||
|
||||
// Close many tabs at once with a single batched busy-shell confirmation.
|
||||
// Used by the "Close all / Close others / Close to the right" context-menu
|
||||
// actions on tabs (#748).
|
||||
@@ -712,7 +760,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
);
|
||||
|
||||
// Shared hotkey action handler - used by both global handler and terminal callback
|
||||
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => { return executeHotkeyActionImpl(() => ({ IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, action, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, e, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces }), action, e); }, [orderedTabs, editorTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, settings, confirmIfBusyLocalTerminal]);
|
||||
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => { return executeHotkeyActionImpl(() => ({ IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, action, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, e, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, workspaces }), action, e); }, [orderedTabs, editorTabs, sessions, workspaces, isQuickSwitcherOpen, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, toggleWorkspaceViewMode, settings, confirmIfBusyLocalTerminal]);
|
||||
|
||||
const handleWindowCommandCloseRequest = useCallback(async () => {
|
||||
const openDialogs = Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"][data-state="open"]'));
|
||||
@@ -778,15 +826,18 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
const quickResults = useMemo(() => {
|
||||
if (!isQuickSwitcherOpen) return [];
|
||||
const term = quickSearch.trim().toLowerCase();
|
||||
const filtered = term
|
||||
? hosts.filter(h =>
|
||||
h.label.toLowerCase().includes(term) ||
|
||||
h.hostname.toLowerCase().includes(term) ||
|
||||
(h.group || '').toLowerCase().includes(term)
|
||||
)
|
||||
: hosts;
|
||||
return filtered;
|
||||
const term = quickSearch.trim();
|
||||
if (!term) return hosts;
|
||||
return hosts
|
||||
.map((host) => ({ host, match: getHostSearchMatch(term, host) }))
|
||||
.filter((entry) => entry.match.matched)
|
||||
.sort((left, right) => {
|
||||
if (left.match.score !== right.match.score) {
|
||||
return right.match.score - left.match.score;
|
||||
}
|
||||
return left.host.label.localeCompare(right.host.label);
|
||||
})
|
||||
.map((entry) => entry.host);
|
||||
}, [quickSearch, hosts, isQuickSwitcherOpen]);
|
||||
|
||||
const handleDeleteHost = useCallback((hostId: string) => {
|
||||
@@ -848,6 +899,59 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// Wrapper to connect to host with logging
|
||||
const handleConnectToHost = useCallback((host: Host) => { return handleConnectToHostImpl(() => ({ addConnectionLog, connectToHost, host, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef }), host); }, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys]);
|
||||
|
||||
const _handleSshDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
const rawUrl = payload?.url || '';
|
||||
const target = parseSshDeepLink(rawUrl);
|
||||
if (!target) {
|
||||
toast.warning(t('deepLink.ssh.invalid'));
|
||||
return;
|
||||
}
|
||||
|
||||
const effectiveHosts = hosts.map((host) => {
|
||||
const effectiveHost = resolveEffectiveHost(host);
|
||||
const resolvedAuth = resolveHostAuth({ host: effectiveHost, keys, identities });
|
||||
return {
|
||||
...effectiveHost,
|
||||
username: resolvedAuth.username || effectiveHost.username,
|
||||
};
|
||||
});
|
||||
const matchedEffectiveHost = findSshDeepLinkHost(effectiveHosts, target);
|
||||
if (matchedEffectiveHost) {
|
||||
const originalHost = hosts.find((host) => host.id === matchedEffectiveHost.id) ?? matchedEffectiveHost;
|
||||
handleConnectToHost(buildSshDeepLinkConnectionHost(originalHost));
|
||||
return;
|
||||
}
|
||||
|
||||
setDeepLinkHostDraft(buildSshDeepLinkHostDraft(target, {
|
||||
id: crypto.randomUUID(),
|
||||
now: Date.now(),
|
||||
}));
|
||||
setNavigateToSection('hosts');
|
||||
setActiveTabId('vault');
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isPeerSessionWindow) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onSshDeepLink) return;
|
||||
return bridge.onSshDeepLink((payload) => {
|
||||
_handleSshDeepLink(payload);
|
||||
});
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
const handleOpenHostFromVaultNote = useCallback((host: Host, source?: { noteId?: string }) => {
|
||||
const tabId = handleConnectToHost(host);
|
||||
if (source?.noteId && typeof tabId === 'string' && tabId) {
|
||||
openNoteRequestIdRef.current += 1;
|
||||
setOpenNoteRequest({
|
||||
tabId,
|
||||
noteId: source.noteId,
|
||||
requestId: openNoteRequestIdRef.current,
|
||||
});
|
||||
}
|
||||
return tabId;
|
||||
}, [handleConnectToHost]);
|
||||
|
||||
// Wrap updateSessionStatus to track lastConnectedAt on successful connection
|
||||
const handleSessionStatusChange = useCallback((sessionId: string, status: TerminalSession['status']) => {
|
||||
updateSessionStatus(sessionId, status);
|
||||
@@ -859,7 +963,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}
|
||||
}, [updateSessionStatus, updateHostLastConnected]);
|
||||
|
||||
const handleUpdateHostFromTerminal = useCallback((host: Host) => {
|
||||
const handleUpdateHostFromTerminal = useCallback((host: TerminalHostUpdate) => {
|
||||
updateHosts(hosts.map((h) => (
|
||||
h.id === host.id ? mergeTerminalHostUpdate(h, host) : h
|
||||
)));
|
||||
@@ -898,6 +1002,19 @@ 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);
|
||||
}
|
||||
if (update.terminalThemeLightId) {
|
||||
setTerminalThemeLightId(update.terminalThemeLightId);
|
||||
}
|
||||
setTheme(update.appTheme);
|
||||
}, [setTerminalThemeDarkId, setTerminalThemeLightId, setTheme]);
|
||||
|
||||
const handleOpenQuickSwitcher = useCallback(() => {
|
||||
setIsQuickSwitcherOpen(true);
|
||||
}, []);
|
||||
@@ -959,26 +1076,30 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
<AppActiveTabChrome
|
||||
showSftpTab={settings.showSftpTab}
|
||||
setActiveTabId={setActiveTabId}
|
||||
applyAppTheme={applyAppTheme}
|
||||
hostById={hostById}
|
||||
sessionById={sessionById}
|
||||
workspaceById={workspaceById}
|
||||
themeById={themeById}
|
||||
workspaceById={workspaceById}
|
||||
currentTerminalTheme={currentTerminalTheme}
|
||||
followAppTerminalTheme={followAppTerminalTheme}
|
||||
accentMode={accentMode}
|
||||
customAccent={customAccent}
|
||||
reapplyCurrentTheme={reapplyCurrentTheme}
|
||||
editorTabs={editorTabs}
|
||||
logViews={logViews}
|
||||
t={t}
|
||||
/>
|
||||
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, 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, openLogView, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, 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, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleOpenHostFromVaultNote, 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, 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, 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, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AppWithProviders() {
|
||||
const settings = useSettingsState();
|
||||
const isPeerSessionWindow = typeof window !== 'undefined' && window.location.hash.startsWith('#/session-window');
|
||||
const settings = useSettingsState({
|
||||
enableSettingsSync: !isPeerSessionWindow,
|
||||
enableSystemEffects: !isPeerSessionWindow,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { handleConnectToHostImpl } from './app/AppHandlers.ts';
|
||||
import type { Host } from '../types';
|
||||
|
||||
const baseHost: Host = {
|
||||
id: 'host-1',
|
||||
label: '10.2.0.32',
|
||||
hostname: '10.2.0.32',
|
||||
username: 'root',
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
protocol: 'ssh',
|
||||
};
|
||||
|
||||
test('connect host handler returns the created terminal tab id', () => {
|
||||
const logs: unknown[] = [];
|
||||
const connectedHosts: Host[] = [];
|
||||
const result = handleConnectToHostImpl(
|
||||
() => ({
|
||||
addConnectionLog: (entry: unknown) => logs.push(entry),
|
||||
connectToHost: (host: Host) => {
|
||||
connectedHosts.push(host);
|
||||
return 'session-from-connect';
|
||||
},
|
||||
identities: [],
|
||||
keys: [],
|
||||
resolveEffectiveHost: (host: Host) => host,
|
||||
resolveHostAuth: () => ({ username: 'root' }),
|
||||
systemInfoRef: { current: { username: 'local-user', hostname: 'local-host' } },
|
||||
}),
|
||||
baseHost,
|
||||
);
|
||||
|
||||
assert.equal(result, 'session-from-connect');
|
||||
assert.equal(connectedHosts.length, 1);
|
||||
assert.equal(logs.length, 1);
|
||||
});
|
||||
|
||||
test('connect serial host handler returns the created terminal tab id', () => {
|
||||
const serialHost: Host = {
|
||||
...baseHost,
|
||||
id: 'serial-1',
|
||||
label: '',
|
||||
hostname: '/dev/tty.usbserial',
|
||||
protocol: 'serial',
|
||||
};
|
||||
|
||||
const result = handleConnectToHostImpl(
|
||||
() => ({
|
||||
addConnectionLog: () => {},
|
||||
connectToHost: () => 'serial-session',
|
||||
identities: [],
|
||||
keys: [],
|
||||
resolveEffectiveHost: (host: Host) => host,
|
||||
resolveHostAuth: () => ({ username: 'root' }),
|
||||
systemInfoRef: { current: { username: 'local-user', hostname: 'local-host' } },
|
||||
}),
|
||||
serialHost,
|
||||
);
|
||||
|
||||
assert.equal(result, 'serial-session');
|
||||
});
|
||||
@@ -1,10 +1,19 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { handleGlobalHotkeyKeyDownImpl } from './app/AppHandlers.ts';
|
||||
import { executeHotkeyActionImpl, getLogHostVisualSnapshot, handleGlobalHotkeyKeyDownImpl } from './app/AppHandlers.ts';
|
||||
import { matchesKeyBinding } from '../domain/models.ts';
|
||||
import { DEFAULT_KEY_BINDINGS } from '../domain/models/keyBindings.ts';
|
||||
|
||||
class FakeInputHTMLElement {
|
||||
tagName = 'INPUT';
|
||||
isContentEditable = false;
|
||||
|
||||
closest(): FakeInputHTMLElement | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeHTMLElement {
|
||||
tagName = 'TEXTAREA';
|
||||
isContentEditable = false;
|
||||
@@ -68,3 +77,120 @@ test('global hotkey handler lets terminal font size shortcuts reach xterm', () =
|
||||
assert.equal(prevented, false);
|
||||
assert.equal(stopped, false);
|
||||
});
|
||||
|
||||
test('global hotkey handler routes quick switch through focused search inputs', () => {
|
||||
const target = new FakeInputHTMLElement();
|
||||
const handledActions: string[] = [];
|
||||
const event = {
|
||||
key: 'j',
|
||||
code: 'KeyJ',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
target,
|
||||
composedPath: () => [target],
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
} as unknown as KeyboardEvent;
|
||||
|
||||
handleGlobalHotkeyKeyDownImpl(
|
||||
() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: 'Ctrl + W',
|
||||
executeHotkeyAction: (action: string) => {
|
||||
handledActions.push(action);
|
||||
},
|
||||
hotkeyScheme: 'pc',
|
||||
keyBindings: DEFAULT_KEY_BINDINGS,
|
||||
matchesKeyBinding,
|
||||
}),
|
||||
event,
|
||||
);
|
||||
|
||||
assert.deepEqual(handledActions, ['quickSwitch']);
|
||||
});
|
||||
|
||||
test('quick switch hotkey toggles the quick switcher open state', () => {
|
||||
let isQuickSwitcherOpen = false;
|
||||
const setIsQuickSwitcherOpen = (next: boolean) => {
|
||||
isQuickSwitcherOpen = next;
|
||||
};
|
||||
const noop = () => {};
|
||||
const baseCtx = {
|
||||
IS_DEV: false,
|
||||
MOVE_FOCUS_DEBOUNCE_MS: 0,
|
||||
activeTabStore: { getActiveTabId: () => 'vault' },
|
||||
addConnectionLogRef: { current: noop },
|
||||
closeSession: noop,
|
||||
closeTabInFlightRef: { current: false },
|
||||
closeWorkspace: noop,
|
||||
collectSessionIds: () => [],
|
||||
confirmIfBusyLocalTerminal: async () => true,
|
||||
createLocalTerminalWithCurrentShell: noop,
|
||||
editorTabs: [],
|
||||
fromEditorTabId: () => null,
|
||||
handleOpenSettingsRef: { current: noop },
|
||||
handleRequestCloseEditorTabRef: { current: noop },
|
||||
isEditorTabId: () => false,
|
||||
isQuickSwitcherOpen,
|
||||
lastMoveFocusTimeRef: { current: 0 },
|
||||
moveFocusInWorkspace: noop,
|
||||
orderedTabs: [],
|
||||
resolveCloseIntent: () => ({ kind: 'noop' }),
|
||||
resolveSnippetsShortcutIntent: () => ({ kind: 'noop' }),
|
||||
sessions: [],
|
||||
setActiveTabId: noop,
|
||||
setAddToWorkspaceDialog: noop,
|
||||
setIsQuickSwitcherOpen,
|
||||
setNavigateToSection: noop,
|
||||
settings: { showSftpTab: true, shellOnlyTabNumberShortcuts: false },
|
||||
splitSessionWithCurrentShell: noop,
|
||||
systemInfoRef: { current: { username: 'user', hostname: 'host' } },
|
||||
toEditorTabId: (id: string) => `editor:${id}`,
|
||||
toggleBroadcast: noop,
|
||||
toggleScriptsSidePanelRef: { current: noop },
|
||||
toggleSidePanelRef: { current: noop },
|
||||
workspaces: [],
|
||||
};
|
||||
|
||||
const event = {
|
||||
key: 'j',
|
||||
code: 'KeyJ',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
} as KeyboardEvent;
|
||||
|
||||
executeHotkeyActionImpl(() => baseCtx, 'quickSwitch', event);
|
||||
assert.equal(isQuickSwitcherOpen, true);
|
||||
|
||||
executeHotkeyActionImpl(() => ({ ...baseCtx, isQuickSwitcherOpen: true }), 'quickSwitch', event);
|
||||
assert.equal(isQuickSwitcherOpen, false);
|
||||
});
|
||||
|
||||
test('connection log host snapshot includes custom host icon fields', () => {
|
||||
assert.deepEqual(
|
||||
getLogHostVisualSnapshot({
|
||||
id: 'host-1',
|
||||
label: 'Database',
|
||||
hostname: 'db.example.com',
|
||||
username: 'root',
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
distro: 'ubuntu',
|
||||
iconMode: 'custom',
|
||||
iconId: 'database',
|
||||
iconColor: 'blue',
|
||||
}),
|
||||
{
|
||||
hostOs: 'linux',
|
||||
hostDistro: 'ubuntu',
|
||||
hostIconMode: 'custom',
|
||||
hostIconId: 'database',
|
||||
hostIconColorMode: 'manual',
|
||||
hostIconColor: 'blue',
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -5,14 +5,10 @@ import {
|
||||
isEditorTabId,
|
||||
useActiveTabId,
|
||||
} from '../state/activeTabStore';
|
||||
import { setImmersiveActive } from '../state/immersiveStore';
|
||||
import { useImmersiveMode } from '../state/useImmersiveMode';
|
||||
import { updateActiveChromeThemeDeps } from '../state/activeChromeThemeSync';
|
||||
import { useActiveChromeTheme } from '../state/useActiveChromeTheme';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import {
|
||||
applyCustomAccentToTerminalTheme,
|
||||
resolveHostTerminalThemeId,
|
||||
} from '../../domain/terminalAppearance';
|
||||
import { collectSessionIds } from '../../domain/workspace';
|
||||
import { resolveActiveChromeTheme } from './activeChromeTheme';
|
||||
import type {
|
||||
Host,
|
||||
TerminalSession,
|
||||
@@ -25,15 +21,15 @@ import type { EditorTab } from '../state/editorTabStore';
|
||||
interface AppActiveTabChromeProps {
|
||||
showSftpTab: boolean;
|
||||
setActiveTabId: (id: string) => void;
|
||||
applyAppTheme: () => void;
|
||||
hostById: Map<string, Host>;
|
||||
sessionById: Map<string, TerminalSession>;
|
||||
workspaceById: Map<string, Workspace>;
|
||||
themeById: Map<string, TerminalTheme>;
|
||||
workspaceById: Map<string, Workspace>;
|
||||
currentTerminalTheme: TerminalTheme;
|
||||
followAppTerminalTheme: boolean;
|
||||
accentMode: 'theme' | 'custom';
|
||||
customAccent: string;
|
||||
reapplyCurrentTheme: () => void;
|
||||
editorTabs: readonly EditorTab[];
|
||||
logViews: readonly LogView[];
|
||||
t: (key: string) => string;
|
||||
@@ -41,27 +37,24 @@ interface AppActiveTabChromeProps {
|
||||
|
||||
/**
|
||||
* Owns the `activeTabId` subscription and the purely side-effectful "chrome"
|
||||
* work derived from it: immersive-mode theming, window title, and the
|
||||
* SFTP-tab guard. Extracted out of <App> so that switching top tabs only
|
||||
* work derived from it: window title and the SFTP-tab guard.
|
||||
* Extracted out of <App> so that switching top tabs only
|
||||
* re-renders this null-rendering component (and the self-subscribing leaves)
|
||||
* instead of forcing the entire App tree (which holds all vault/session/
|
||||
* settings state and rebuilds the giant AppView ctx) to re-render.
|
||||
*
|
||||
* Renders nothing; publishes "immersive active" to immersiveStore so AppView
|
||||
* and TopTabs can read it without re-rendering App.
|
||||
*/
|
||||
export function AppActiveTabChrome({
|
||||
showSftpTab,
|
||||
setActiveTabId,
|
||||
applyAppTheme,
|
||||
hostById,
|
||||
sessionById,
|
||||
workspaceById,
|
||||
themeById,
|
||||
workspaceById,
|
||||
currentTerminalTheme,
|
||||
followAppTerminalTheme,
|
||||
accentMode,
|
||||
customAccent,
|
||||
reapplyCurrentTheme,
|
||||
editorTabs,
|
||||
logViews,
|
||||
t,
|
||||
@@ -74,55 +67,43 @@ export function AppActiveTabChrome({
|
||||
}
|
||||
}, [showSftpTab, activeTabId, setActiveTabId]);
|
||||
|
||||
const activeTerminalTheme = useMemo<TerminalTheme | null>(() => {
|
||||
if (activeTabId === 'vault' || activeTabId === 'sftp') return null;
|
||||
const chromeThemeDeps = useMemo(() => ({
|
||||
accentMode,
|
||||
applyAppTheme,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
editorTabs,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
logViews,
|
||||
sessionById,
|
||||
themeById,
|
||||
workspaceById,
|
||||
}), [
|
||||
accentMode,
|
||||
applyAppTheme,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
editorTabs,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
logViews,
|
||||
sessionById,
|
||||
themeById,
|
||||
workspaceById,
|
||||
]);
|
||||
|
||||
const resolveTheme = (s: TerminalSession): TerminalTheme => {
|
||||
let baseTheme: TerminalTheme;
|
||||
if (followAppTerminalTheme) {
|
||||
baseTheme = currentTerminalTheme;
|
||||
} else {
|
||||
const host = hostById.get(s.hostId) ?? null;
|
||||
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
||||
baseTheme = themeById.get(themeId) || currentTerminalTheme;
|
||||
}
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
};
|
||||
updateActiveChromeThemeDeps(chromeThemeDeps);
|
||||
|
||||
const workspace = workspaceById.get(activeTabId);
|
||||
if (workspace) {
|
||||
if (workspace.viewMode === 'focus') {
|
||||
const wsSessionIds = collectSessionIds(workspace.root);
|
||||
const focused = (workspace.focusedSessionId
|
||||
? sessionById.get(workspace.focusedSessionId)
|
||||
: null)
|
||||
?? wsSessionIds.map((id) => sessionById.get(id)).find(Boolean);
|
||||
return focused ? resolveTheme(focused) : null;
|
||||
}
|
||||
const sessionIds = collectSessionIds(workspace.root);
|
||||
const wsSessions = sessionIds
|
||||
.map((id) => sessionById.get(id))
|
||||
.filter(Boolean) as TerminalSession[];
|
||||
if (wsSessions.length === 0) return null;
|
||||
const firstTheme = resolveTheme(wsSessions[0]);
|
||||
const allSame = wsSessions.every((s) => resolveTheme(s).id === firstTheme.id);
|
||||
return allSame ? firstTheme : null;
|
||||
}
|
||||
|
||||
const session = sessionById.get(activeTabId);
|
||||
if (!session) return null;
|
||||
return resolveTheme(session);
|
||||
}, [accentMode, activeTabId, currentTerminalTheme, customAccent, followAppTerminalTheme, hostById, sessionById, themeById, workspaceById]);
|
||||
|
||||
useImmersiveMode({
|
||||
const activeChromeTheme = useMemo(() => resolveActiveChromeTheme({
|
||||
...chromeThemeDeps,
|
||||
activeTabId,
|
||||
activeTerminalTheme,
|
||||
restoreOriginalTheme: reapplyCurrentTheme,
|
||||
});
|
||||
}), [chromeThemeDeps, activeTabId]);
|
||||
|
||||
useEffect(() => {
|
||||
setImmersiveActive(activeTerminalTheme !== null);
|
||||
}, [activeTerminalTheme]);
|
||||
useActiveChromeTheme({
|
||||
activeTheme: activeChromeTheme,
|
||||
applyAppTheme,
|
||||
});
|
||||
|
||||
const editorTabFileNameCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("../../App.tsx", 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\)/);
|
||||
});
|
||||
@@ -2,11 +2,27 @@
|
||||
import type React from 'react';
|
||||
import type { Host, HostProtocol } from '../../types';
|
||||
import type { PassphraseRequest } from '../../components/PassphraseModal';
|
||||
import { getEffectiveHostDistro } from '../../domain/host';
|
||||
import { sanitizeHostIconFields } from '../../domain/hostIcon';
|
||||
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
|
||||
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
|
||||
|
||||
type AppContextGetter = () => Record<string, any>;
|
||||
const TERMINAL_PASSTHROUGH_ACTIONS = getTerminalPassthroughActions();
|
||||
|
||||
export const getLogHostVisualSnapshot = (host: Host) => {
|
||||
const icon = sanitizeHostIconFields(host);
|
||||
return {
|
||||
hostOs: host.os,
|
||||
hostDistro: getEffectiveHostDistro(host) || undefined,
|
||||
hostIconMode: icon.iconMode,
|
||||
hostIconId: icon.iconId,
|
||||
...(icon.iconColorMode ? { hostIconColorMode: icon.iconColorMode } : {}),
|
||||
...(icon.iconColor ? { hostIconColor: icon.iconColor } : {}),
|
||||
...(icon.iconColorCustom ? { hostIconColorCustom: icon.iconColorCustom } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
export function handleTrayJumpToSessionImpl(getCtx: AppContextGetter, sessionId: string) {
|
||||
const { sessions, setActiveTabId, setWorkspaceFocusedSession } = getCtx();
|
||||
{
|
||||
@@ -65,12 +81,13 @@ export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: str
|
||||
hostname: host.hostname,
|
||||
username,
|
||||
protocol: 'serial',
|
||||
...getLogHostVisualSnapshot(effectiveHost),
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: localHost,
|
||||
saved: false,
|
||||
});
|
||||
return;
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
const protocol = effectiveHost.etEnabled ? 'et' : effectiveHost.moshEnabled ? 'mosh' : (effectiveHost.protocol || 'ssh');
|
||||
@@ -83,11 +100,13 @@ export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: str
|
||||
hostname: host.hostname,
|
||||
username: resolvedAuth.username || 'root',
|
||||
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh' | 'et',
|
||||
...getLogHostVisualSnapshot(effectiveHost),
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: localHost,
|
||||
saved: false,
|
||||
});
|
||||
return sessionId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +142,11 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
|
||||
target instanceof HTMLElement &&
|
||||
!!target.closest?.(".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport");
|
||||
|
||||
if ((isFormElement || isMonacoElement) && !isXtermInput && e.key !== 'Escape') {
|
||||
const quickSwitchBinding = keyBindings.find((binding) => binding.action === 'quickSwitch');
|
||||
const quickSwitchKeyStr = quickSwitchBinding ? (isMac ? quickSwitchBinding.mac : quickSwitchBinding.pc) : null;
|
||||
const isQuickSwitchHotkey = quickSwitchKeyStr ? matchesKeyBinding(e, quickSwitchKeyStr, isMac) : false;
|
||||
|
||||
if ((isFormElement || isMonacoElement) && !isXtermInput && e.key !== 'Escape' && !isQuickSwitchHotkey) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -427,7 +450,7 @@ export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: st
|
||||
}
|
||||
|
||||
export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string, e: KeyboardEvent) {
|
||||
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces } = getCtx();
|
||||
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, workspaces } = getCtx();
|
||||
{
|
||||
// Build complete tab list: vault + (sftp when visible) + sessions/workspaces + editor tabs.
|
||||
// Hiding the SFTP tab must also remove it from keyboard cycling so nextTab
|
||||
@@ -436,13 +459,19 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
const allTabs = settings.showSftpTab
|
||||
? ['vault', 'sftp', ...orderedTabs, ...editorTabs.map((t) => toEditorTabId(t.id))]
|
||||
: ['vault', ...orderedTabs, ...editorTabs.map((t) => toEditorTabId(t.id))];
|
||||
const numberShortcutTabs = buildNumberShortcutTabTargets({
|
||||
showSftpTab: settings.showSftpTab ?? true,
|
||||
shellOnlyTabNumberShortcuts: settings.shellOnlyTabNumberShortcuts ?? false,
|
||||
orderedTabs,
|
||||
editorTabIds: editorTabs.map((t) => toEditorTabId(t.id)),
|
||||
});
|
||||
switch (action) {
|
||||
case 'switchToTab': {
|
||||
// Get the number key pressed (1-9)
|
||||
const num = parseInt(e.key, 10);
|
||||
if (num >= 1 && num <= 9) {
|
||||
if (num <= allTabs.length) {
|
||||
setActiveTabId(allTabs[num - 1]);
|
||||
if (num <= numberShortcutTabs.length) {
|
||||
setActiveTabId(numberShortcutTabs[num - 1]);
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -520,6 +549,40 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
|
||||
break;
|
||||
}
|
||||
case 'closeSession': {
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
if (!currentId || currentId === 'vault' || currentId === 'sftp') break;
|
||||
if (closeTabInFlightRef.current) break;
|
||||
|
||||
const session = sessions.find((s) => s.id === currentId) ?? null;
|
||||
const workspace = workspaces.find((w) => w.id === currentId) ?? null;
|
||||
|
||||
closeTabInFlightRef.current = true;
|
||||
(async () => {
|
||||
try {
|
||||
// If active tab is a workspace, close the focused session (pane)
|
||||
if (workspace) {
|
||||
// Validate focusedSessionId is still valid — it can become stale
|
||||
// if the previously focused session was already closed
|
||||
const aliveIds = collectSessionIds(workspace.root);
|
||||
const focusedId = aliveIds.includes(workspace.focusedSessionId)
|
||||
? workspace.focusedSessionId
|
||||
: aliveIds[0];
|
||||
if (focusedId) {
|
||||
const ok = await confirmIfBusyLocalTerminal([focusedId]);
|
||||
if (ok) closeSession(focusedId);
|
||||
}
|
||||
} else if (session) {
|
||||
// Standalone session tab — close the session
|
||||
const ok = await confirmIfBusyLocalTerminal([session.id]);
|
||||
if (ok) closeSession(session.id);
|
||||
}
|
||||
} finally {
|
||||
closeTabInFlightRef.current = false;
|
||||
}
|
||||
})();
|
||||
break;
|
||||
}
|
||||
case 'newTab':
|
||||
case 'openLocal':
|
||||
// Add connection log for local terminal
|
||||
@@ -545,6 +608,8 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
}
|
||||
break;
|
||||
case 'quickSwitch':
|
||||
setIsQuickSwitcherOpen(!isQuickSwitcherOpen);
|
||||
break;
|
||||
case 'commandPalette':
|
||||
setIsQuickSwitcherOpen(true);
|
||||
break;
|
||||
@@ -623,6 +688,15 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'togglePaneZoom': {
|
||||
// Toggle workspace between split and focus (zoom) mode
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
const activeWs = workspaces.find(w => w.id === currentId);
|
||||
if (activeWs) {
|
||||
toggleWorkspaceViewMode(activeWs.id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'moveFocus': {
|
||||
// Debounce to prevent double-triggering when focus switches between terminals
|
||||
const now = Date.now();
|
||||
@@ -708,12 +782,13 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host) {
|
||||
hostname: host.hostname,
|
||||
username: username,
|
||||
protocol: 'serial',
|
||||
...getLogHostVisualSnapshot(effectiveHost),
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: localHost,
|
||||
saved: false,
|
||||
});
|
||||
return;
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
const protocol = effectiveHost.etEnabled ? 'et' : effectiveHost.moshEnabled ? 'mosh' : (effectiveHost.protocol || 'ssh');
|
||||
@@ -726,11 +801,13 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host) {
|
||||
hostname: host.hostname,
|
||||
username: resolvedAuth.username || 'root',
|
||||
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh' | 'et',
|
||||
...getLogHostVisualSnapshot(effectiveHost),
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: localHost,
|
||||
saved: false,
|
||||
});
|
||||
return sessionId;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const storage = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
removeItem: (key: string) => storage.delete(key),
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
getAppHostTreeLayerStyle,
|
||||
} = await import('./AppHostTreeLayer');
|
||||
const hostTreeLayerSource = readFileSync(new URL('./AppHostTreeLayer.tsx', import.meta.url), 'utf8');
|
||||
|
||||
test('shared host tree layer is visible above work tabs', () => {
|
||||
assert.deepEqual(getAppHostTreeLayerStyle(true), {
|
||||
visibility: 'visible',
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 30,
|
||||
});
|
||||
});
|
||||
|
||||
test('shared host tree layer is hidden behind root pages', () => {
|
||||
assert.deepEqual(getAppHostTreeLayerStyle(false), {
|
||||
visibility: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('shared host tree does not force open when entering a work tab surface', () => {
|
||||
assert.doesNotMatch(hostTreeLayerSource, /setIsOpen\(true\)/);
|
||||
assert.doesNotMatch(hostTreeLayerSource, /shouldAutoOpenHostTreeOnSurfaceChange/);
|
||||
});
|
||||
|
||||
test('host tree layer hides immediately when leaving work tab surfaces', () => {
|
||||
assert.match(hostTreeLayerSource, /getAppHostTreeLayerStyle\(surfaceVisible\)/);
|
||||
assert.doesNotMatch(hostTreeLayerSource, /layerVisible/);
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { useActiveTabId } from '../state/activeTabStore';
|
||||
import type { EditorTab } from '../state/editorTabStore';
|
||||
import type { LogView } from '../state/logViewState';
|
||||
import { TerminalHostTreeSidebar } from '../../components/terminalLayer/TerminalHostTreeSidebar';
|
||||
import type { GroupConfig, Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
|
||||
import {
|
||||
isHostTreeWorkTabSurface,
|
||||
resolveWorkTabActiveHostId,
|
||||
resolveWorkTabHostTreeTheme,
|
||||
} from './workTabSurface';
|
||||
|
||||
interface AppHostTreeLayerProps {
|
||||
enabled: boolean;
|
||||
hosts: Host[];
|
||||
customGroups: string[];
|
||||
groupConfigs: GroupConfig[];
|
||||
sessions: TerminalSession[];
|
||||
workspaces: Workspace[];
|
||||
editorTabs: readonly EditorTab[];
|
||||
logViews: readonly LogView[];
|
||||
orderedTabs: readonly string[];
|
||||
accentMode: 'theme' | 'custom';
|
||||
currentTerminalTheme: TerminalTheme;
|
||||
customAccent: string;
|
||||
followAppTerminalTheme: boolean;
|
||||
hostById: ReadonlyMap<string, Host>;
|
||||
themeById: ReadonlyMap<string, TerminalTheme>;
|
||||
onConnect: (host: Host) => void;
|
||||
onCreateLocalTerminal?: () => void;
|
||||
}
|
||||
|
||||
export function getAppHostTreeLayerStyle(surfaceVisible: boolean): React.CSSProperties {
|
||||
return {
|
||||
visibility: surfaceVisible ? 'visible' : 'hidden',
|
||||
pointerEvents: surfaceVisible ? 'auto' : 'none',
|
||||
zIndex: surfaceVisible ? 30 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
|
||||
enabled,
|
||||
hosts,
|
||||
customGroups,
|
||||
groupConfigs,
|
||||
sessions,
|
||||
workspaces,
|
||||
editorTabs,
|
||||
logViews,
|
||||
orderedTabs,
|
||||
accentMode,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
themeById,
|
||||
onConnect,
|
||||
onCreateLocalTerminal,
|
||||
}) => {
|
||||
const activeTabId = useActiveTabId();
|
||||
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 surfaceVisible = isHostTreeWorkTabSurface({
|
||||
enabled,
|
||||
activeTabId,
|
||||
logViewIds,
|
||||
orderedTabs,
|
||||
sessionIds,
|
||||
workspaceIds,
|
||||
});
|
||||
|
||||
const activeHostId = useMemo(() => resolveWorkTabActiveHostId({
|
||||
activeTabId,
|
||||
editorTabs,
|
||||
sessions,
|
||||
workspaces,
|
||||
}), [activeTabId, editorTabs, sessions, workspaces]);
|
||||
|
||||
const hostTreeTheme = useMemo(() => resolveWorkTabHostTreeTheme({
|
||||
activeHostId,
|
||||
accentMode,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
themeById,
|
||||
}), [
|
||||
activeHostId,
|
||||
accentMode,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
themeById,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 flex min-h-0"
|
||||
data-section="app-host-tree-layer"
|
||||
style={getAppHostTreeLayerStyle(surfaceVisible)}
|
||||
>
|
||||
<TerminalHostTreeSidebar
|
||||
enabled={enabled}
|
||||
surfaceVisible={surfaceVisible}
|
||||
hosts={hosts}
|
||||
customGroups={customGroups}
|
||||
groupConfigs={groupConfigs}
|
||||
resolvedPreviewTheme={hostTreeTheme}
|
||||
activeHostId={activeHostId}
|
||||
onConnect={onConnect}
|
||||
onCreateLocalTerminal={onCreateLocalTerminal}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const storage = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
removeItem: (key: string) => storage.delete(key),
|
||||
},
|
||||
});
|
||||
|
||||
const { getLogViewWrapperStyle, shouldRenderTerminalLayerMount } = await import('./AppMounts.tsx');
|
||||
const activeTabChromeSource = readFileSync(new URL('./AppActiveTabChrome.tsx', import.meta.url), 'utf8');
|
||||
const appViewSource = readFileSync(new URL('./AppView.tsx', import.meta.url), 'utf8');
|
||||
const appMountsSource = readFileSync(new URL('./AppMounts.tsx', import.meta.url), 'utf8');
|
||||
const globalCssSource = readFileSync(new URL('../../index.css', import.meta.url), 'utf8');
|
||||
|
||||
test('visible log view leaves room for the terminal host sidebar', () => {
|
||||
assert.deepEqual(getLogViewWrapperStyle(true, 220), {
|
||||
left: 220,
|
||||
});
|
||||
});
|
||||
|
||||
test('hidden log view remains hidden while preserving host sidebar offset', () => {
|
||||
assert.deepEqual(getLogViewWrapperStyle(false, 220), {
|
||||
visibility: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
position: 'absolute',
|
||||
zIndex: -1,
|
||||
left: 220,
|
||||
});
|
||||
});
|
||||
|
||||
test('terminal layer renders only after terminal content is visible or mounted', () => {
|
||||
assert.equal(shouldRenderTerminalLayerMount(true, false), true);
|
||||
assert.equal(shouldRenderTerminalLayerMount(false, true), true);
|
||||
assert.equal(shouldRenderTerminalLayerMount(false, false), false);
|
||||
});
|
||||
|
||||
test('inactive app surfaces suppress background color transitions', () => {
|
||||
assert.match(appMountsSource, /data-inactive-app-surface=\{isActive \? undefined : "true"\}/);
|
||||
assert.match(appMountsSource, /data-inactive-app-surface=\{isVisible \? undefined : "true"\}/);
|
||||
assert.match(globalCssSource, /\[data-inactive-app-surface\][\s\S]*transition: none !important;/);
|
||||
});
|
||||
|
||||
test('vault activation suppresses inherited text color transitions', () => {
|
||||
assert.match(appMountsSource, /data-app-surface-transition-suppressed/);
|
||||
assert.match(appMountsSource, /setSuppressActiveTransition\(false\)/);
|
||||
assert.match(globalCssSource, /\[data-app-surface-transition-suppressed\][\s\S]*transition: none !important;/);
|
||||
});
|
||||
|
||||
test('vault surface carries app theme vars while terminal chrome is active', () => {
|
||||
assert.match(appMountsSource, /appThemeStyle\?: React\.CSSProperties/);
|
||||
assert.match(appMountsSource, /style=\{\{ \.\.\.appThemeStyle, \.\.\.containerStyle \}\}/);
|
||||
assert.match(appViewSource, /buildAppThemeCssVars\(tokens, accentMode, customAccent\)/);
|
||||
assert.match(appViewSource, /<VaultViewContainer appThemeStyle=\{appThemeStyle\}>/);
|
||||
});
|
||||
|
||||
test('active tab chrome keeps removed theme side effects unmounted', () => {
|
||||
const removedThemeHook = ['use', 'Im', 'mersive', 'Mode'].join('');
|
||||
const removedThemeStoreSetter = ['set', 'Im', 'mersive', 'Active'].join('');
|
||||
assert.equal(activeTabChromeSource.includes(removedThemeHook), false);
|
||||
assert.equal(activeTabChromeSource.includes(removedThemeStoreSetter), false);
|
||||
});
|
||||
+115
-27
@@ -1,20 +1,56 @@
|
||||
import React, { Suspense, lazy, useEffect, useState } from 'react';
|
||||
import { useActiveTabId, useIsSftpActive, useIsTerminalLayerVisible, useIsVaultActive } from '../state/activeTabStore';
|
||||
import React, { Suspense, lazy, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useActiveTabId, useIsSftpActive, useIsVaultActive } from '../state/activeTabStore';
|
||||
import { useTerminalHostTreeLayoutWidth } from '../state/terminalHostTreeStore';
|
||||
import { isTerminalContentTabSurface } from './workTabSurface';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { ConnectionLog, TerminalTheme } from '../../types';
|
||||
import { LazyLoadBoundary } from '../../components/ui/lazy-load-boundary';
|
||||
import type { LogView as LogViewType } from '../state/logViewState';
|
||||
import type { SftpView as SftpViewComponent } from '../../components/SftpView';
|
||||
import type { TerminalLayer as TerminalLayerComponent } from '../../components/TerminalLayer';
|
||||
|
||||
// Visibility container for VaultView - isolates isActive subscription
|
||||
export const VaultViewContainer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
export const VaultViewContainer: React.FC<{
|
||||
children: React.ReactNode;
|
||||
appThemeStyle?: React.CSSProperties;
|
||||
}> = ({ children, appThemeStyle }) => {
|
||||
const isActive = useIsVaultActive();
|
||||
const wasActiveRef = useRef(isActive);
|
||||
const [suppressActiveTransition, setSuppressActiveTransition] = useState(false);
|
||||
const isActivating = isActive && !wasActiveRef.current;
|
||||
const shouldSuppressTransition = isActivating || suppressActiveTransition;
|
||||
const containerStyle: React.CSSProperties = isActive
|
||||
? {}
|
||||
: { visibility: 'hidden', pointerEvents: 'none', position: 'absolute', zIndex: -1 };
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const wasActive = wasActiveRef.current;
|
||||
wasActiveRef.current = isActive;
|
||||
if (!isActive || wasActive) return;
|
||||
|
||||
setSuppressActiveTransition(true);
|
||||
const view = window;
|
||||
let firstFrame = 0;
|
||||
let secondFrame = 0;
|
||||
firstFrame = view.requestAnimationFrame(() => {
|
||||
secondFrame = view.requestAnimationFrame(() => {
|
||||
setSuppressActiveTransition(false);
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
view.cancelAnimationFrame(firstFrame);
|
||||
view.cancelAnimationFrame(secondFrame);
|
||||
};
|
||||
}, [isActive]);
|
||||
|
||||
return (
|
||||
<div className={cn("absolute inset-0", isActive ? "z-20" : "")} style={containerStyle}>
|
||||
<div
|
||||
className={cn("absolute inset-0", isActive ? "z-20" : "")}
|
||||
data-inactive-app-surface={isActive ? undefined : "true"}
|
||||
data-app-surface-transition-suppressed={shouldSuppressTransition ? "true" : undefined}
|
||||
style={{ ...appThemeStyle, ...containerStyle }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -29,27 +65,43 @@ interface LogViewWrapperProps {
|
||||
onUpdateLog: (logId: string, updates: Partial<ConnectionLog>) => void;
|
||||
}
|
||||
|
||||
export function getLogViewWrapperStyle(
|
||||
isVisible: boolean,
|
||||
hostTreeLayoutWidth: number,
|
||||
): React.CSSProperties {
|
||||
const baseStyle = {
|
||||
left: hostTreeLayoutWidth,
|
||||
};
|
||||
return isVisible
|
||||
? baseStyle
|
||||
: { visibility: 'hidden', pointerEvents: 'none', position: 'absolute', zIndex: -1, ...baseStyle };
|
||||
}
|
||||
|
||||
export const LogViewWrapper: React.FC<LogViewWrapperProps> = ({ logView, defaultTerminalTheme, defaultFontSize, onClose, onUpdateLog }) => {
|
||||
const activeTabId = useActiveTabId();
|
||||
const isVisible = activeTabId === logView.id;
|
||||
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
|
||||
|
||||
// Use same pattern as VaultViewContainer for visibility
|
||||
const containerStyle: React.CSSProperties = isVisible
|
||||
? {}
|
||||
: { visibility: 'hidden', pointerEvents: 'none', position: 'absolute', zIndex: -1 };
|
||||
const containerStyle = getLogViewWrapperStyle(isVisible, hostTreeLayoutWidth);
|
||||
|
||||
return (
|
||||
<div className={cn("absolute inset-0", isVisible ? "z-20" : "")} style={containerStyle}>
|
||||
<Suspense fallback={null}>
|
||||
<LazyLogView
|
||||
log={logView.log}
|
||||
defaultTerminalTheme={defaultTerminalTheme}
|
||||
defaultFontSize={defaultFontSize}
|
||||
isVisible={isVisible}
|
||||
onClose={onClose}
|
||||
onUpdateLog={onUpdateLog}
|
||||
/>
|
||||
</Suspense>
|
||||
<div
|
||||
className={cn("absolute inset-0", isVisible ? "z-20" : "")}
|
||||
data-inactive-app-surface={isVisible ? undefined : "true"}
|
||||
style={containerStyle}
|
||||
>
|
||||
<LazyLoadBoundary name="Log view" resetKey={logView.id}>
|
||||
<Suspense fallback={<LogViewFallback />}>
|
||||
<LazyLogView
|
||||
log={logView.log}
|
||||
defaultTerminalTheme={defaultTerminalTheme}
|
||||
defaultFontSize={defaultFontSize}
|
||||
isVisible={isVisible}
|
||||
onClose={onClose}
|
||||
onUpdateLog={onUpdateLog}
|
||||
/>
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -67,6 +119,31 @@ const LazyTerminalLayer = lazy(() =>
|
||||
type SftpViewProps = React.ComponentProps<typeof SftpViewComponent>;
|
||||
type TerminalLayerProps = React.ComponentProps<typeof TerminalLayerComponent>;
|
||||
|
||||
const LogViewFallback = () => (
|
||||
<div className="netcatty-lazy-fade-in h-full min-h-0 bg-background" aria-hidden="true" />
|
||||
);
|
||||
|
||||
const SftpViewFallback = ({ visible }: { visible: boolean }) => {
|
||||
if (!visible) return null;
|
||||
return (
|
||||
<div className="netcatty-lazy-fade-in absolute inset-0 z-20 bg-background" aria-hidden="true" />
|
||||
);
|
||||
};
|
||||
|
||||
const TerminalLayerFallback = ({ visible }: { visible: boolean }) => {
|
||||
if (!visible) return null;
|
||||
return (
|
||||
<div className="netcatty-lazy-fade-in absolute inset-0 z-20 bg-background" aria-hidden="true" />
|
||||
);
|
||||
};
|
||||
|
||||
export function shouldRenderTerminalLayerMount(
|
||||
isVisible: boolean,
|
||||
shouldMount: boolean,
|
||||
): boolean {
|
||||
return isVisible || shouldMount;
|
||||
}
|
||||
|
||||
export const SftpViewMount: React.FC<SftpViewProps> = (props) => {
|
||||
const isActive = useIsSftpActive();
|
||||
const [shouldMount, setShouldMount] = useState(isActive);
|
||||
@@ -78,14 +155,23 @@ export const SftpViewMount: React.FC<SftpViewProps> = (props) => {
|
||||
if (!shouldMount) return null;
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LazySftpView {...props} />
|
||||
</Suspense>
|
||||
<LazyLoadBoundary name="SFTP" resetKey={isActive ? "active" : "idle"}>
|
||||
<Suspense fallback={<SftpViewFallback visible={isActive} />}>
|
||||
<LazySftpView {...props} />
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
export const TerminalLayerMount: React.FC<TerminalLayerProps> = (props) => {
|
||||
const isVisible = useIsTerminalLayerVisible(props.draggingSessionId);
|
||||
const activeTabId = useActiveTabId();
|
||||
const sessionIds = useMemo(() => new Set(props.sessions.map((session) => session.id)), [props.sessions]);
|
||||
const workspaceIds = useMemo(() => new Set(props.workspaces.map((workspace) => workspace.id)), [props.workspaces]);
|
||||
const isVisible = isTerminalContentTabSurface({
|
||||
activeTabId,
|
||||
sessionIds,
|
||||
workspaceIds,
|
||||
}) || !!props.draggingSessionId;
|
||||
const [shouldMount, setShouldMount] = useState(isVisible);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -107,13 +193,15 @@ export const TerminalLayerMount: React.FC<TerminalLayerProps> = (props) => {
|
||||
return () => window.clearTimeout(id);
|
||||
}, [shouldMount]);
|
||||
|
||||
const shouldRender = shouldMount || isVisible;
|
||||
const shouldRender = shouldRenderTerminalLayerMount(isVisible, shouldMount);
|
||||
|
||||
if (!shouldRender) return null;
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LazyTerminalLayer {...props} />
|
||||
</Suspense>
|
||||
<LazyLoadBoundary name="Terminal" resetKey={activeTabId}>
|
||||
<Suspense fallback={<TerminalLayerFallback visible={isVisible} />}>
|
||||
<LazyTerminalLayer {...props} />
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
);
|
||||
};
|
||||
+168
-79
@@ -1,25 +1,27 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React, { Suspense, lazy } from 'react';
|
||||
import React, { Suspense, lazy, useMemo } from 'react';
|
||||
import { AlertTriangle, Download, Trash2 } from 'lucide-react';
|
||||
import { activeTabStore, toEditorTabId } from '../state/activeTabStore';
|
||||
import { useImmersiveActive } from '../state/immersiveStore';
|
||||
import { activeTabStore, toEditorTabId, useIsEditorTabActive } from '../state/activeTabStore';
|
||||
import { editorTabStore } from '../state/editorTabStore';
|
||||
import { releaseEditorTabSaveCoordinator, saveEditorTab } from '../state/editorTabSave';
|
||||
import { useTerminalHostTreeLayoutWidth } from '../state/terminalHostTreeStore';
|
||||
import { TopTabs } from '../../components/TopTabs';
|
||||
import { VaultView } from '../../components/VaultView';
|
||||
import { QuickAddSnippetDialog } from '../../components/QuickAddSnippetDialog';
|
||||
import { AddToWorkspaceDialog } from '../../components/workspace/AddToWorkspaceDialog';
|
||||
import { KeyboardInteractiveModal } from '../../components/KeyboardInteractiveModal';
|
||||
import { PassphraseModal } from '../../components/PassphraseModal';
|
||||
import { TextEditorTabView } from '../../components/editor/TextEditorTabView';
|
||||
import { UnsavedChangesProvider } from '../../components/editor/UnsavedChangesDialog';
|
||||
import { SnippetExecutionProvider } from '../../components/SnippetExecutionProvider';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../../components/ui/dialog';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Label } from '../../components/ui/label';
|
||||
import { LazyLoadBoundary } from '../../components/ui/lazy-load-boundary';
|
||||
import { toast } from '../../components/ui/toast';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { AppHostTreeLayer } from './AppHostTreeLayer';
|
||||
import { getUiThemeById } from '../../infrastructure/config/uiThemes';
|
||||
import { buildAppThemeCssVars } from '../state/settingsStateDefaults';
|
||||
|
||||
const LazyProtocolSelectDialog = lazy(() => import('../../components/ProtocolSelectDialog'));
|
||||
const LazyQuickSwitcher = lazy(() =>
|
||||
@@ -28,6 +30,25 @@ const LazyQuickSwitcher = lazy(() =>
|
||||
const LazyCreateWorkspaceDialog = lazy(() =>
|
||||
import('../../components/CreateWorkspaceDialog').then((m) => ({ default: m.CreateWorkspaceDialog })),
|
||||
);
|
||||
const LazyTextEditorTabView = lazy(() =>
|
||||
import('../../components/editor/TextEditorTabView').then((m) => ({ default: m.TextEditorTabView })),
|
||||
);
|
||||
|
||||
const TextEditorTabFallback = ({ tabId }: { tabId: string }) => {
|
||||
const isVisible = useIsEditorTabActive(tabId);
|
||||
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...(isVisible ? null : { pointerEvents: 'none', visibility: 'hidden' }),
|
||||
zIndex: 20,
|
||||
left: hostTreeLayoutWidth,
|
||||
}}
|
||||
className="netcatty-lazy-fade-in absolute top-0 right-0 bottom-0 min-h-0 flex flex-col bg-background"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type AppViewContext = Record<string, any>;
|
||||
|
||||
@@ -36,31 +57,36 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
|
||||
clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, closeWorkspace, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell,
|
||||
connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent,
|
||||
customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
|
||||
customGroups, currentTerminalTheme, deepLinkHostDraft, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
|
||||
followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost,
|
||||
handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
|
||||
handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect,
|
||||
handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
|
||||
handleOpenHostFromVaultNote, 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, openLogView, orderedTabsWithEditors, orphanSessions,
|
||||
passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderTabs, reorderWorkspaceSessions, resetSessionRename,
|
||||
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
|
||||
passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename,
|
||||
resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
||||
sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen,
|
||||
setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId,
|
||||
sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen,
|
||||
setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, 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,
|
||||
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById,
|
||||
toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog,
|
||||
updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources,
|
||||
updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces,
|
||||
updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces,
|
||||
VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper,
|
||||
} = ctx;
|
||||
|
||||
// Immersive flag from store (not ctx) so toggling it doesn't re-render <App>.
|
||||
// Note: we intentionally do NOT subscribe to the active tab id here — editor
|
||||
// tab visibility self-subscribes inside TextEditorTabView — so plain tab
|
||||
// switches don't re-render AppView/App at all.
|
||||
const isImmersive = useImmersiveActive();
|
||||
const appThemeStyle = useMemo(() => {
|
||||
const tokens = getUiThemeById(
|
||||
resolvedTheme,
|
||||
resolvedTheme === 'dark' ? settings.darkUiThemeId : settings.lightUiThemeId,
|
||||
).tokens;
|
||||
return {
|
||||
...buildAppThemeCssVars(tokens, accentMode, customAccent),
|
||||
colorScheme: resolvedTheme,
|
||||
} as React.CSSProperties;
|
||||
}, [accentMode, customAccent, resolvedTheme, settings.darkUiThemeId, settings.lightUiThemeId]);
|
||||
|
||||
return (
|
||||
<SnippetExecutionProvider>
|
||||
@@ -113,10 +139,9 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-screen text-foreground font-sans netcatty-shell", isImmersive && "immersive-transition")} onContextMenu={handleRootContextMenu}>
|
||||
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" onContextMenu={handleRootContextMenu}>
|
||||
<TopTabs
|
||||
theme={resolvedTheme}
|
||||
followAppTerminalTheme={followAppTerminalTheme}
|
||||
hosts={hosts}
|
||||
sessions={sessions}
|
||||
orphanSessions={orphanSessions}
|
||||
@@ -139,18 +164,39 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
windowOpacity={settings.windowOpacity}
|
||||
setWindowOpacity={settings.setWindowOpacity}
|
||||
onSyncNow={handleSyncNowManual}
|
||||
isImmersiveActive={isImmersive}
|
||||
onStartSessionDrag={setDraggingSessionId}
|
||||
onEndSessionDrag={handleEndSessionDrag}
|
||||
onReorderTabs={reorderTabs}
|
||||
onReorderTabs={reorderWorkTabs}
|
||||
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
|
||||
showSftpTab={settings.showSftpTab}
|
||||
showHostTreeSidebar={settings.showHostTreeSidebar}
|
||||
editorTabs={editorTabs}
|
||||
onRequestCloseEditorTab={handleRequestCloseEditorTab}
|
||||
hostById={hostById}
|
||||
/>
|
||||
|
||||
<div className="flex-1 relative min-h-0">
|
||||
<VaultViewContainer>
|
||||
<AppHostTreeLayer
|
||||
enabled={settings.showHostTreeSidebar}
|
||||
hosts={hosts}
|
||||
customGroups={customGroups}
|
||||
groupConfigs={groupConfigs}
|
||||
sessions={sessions}
|
||||
workspaces={workspaces}
|
||||
editorTabs={editorTabs}
|
||||
logViews={logViews}
|
||||
orderedTabs={orderedTabsWithEditors}
|
||||
accentMode={accentMode}
|
||||
currentTerminalTheme={currentTerminalTheme}
|
||||
customAccent={customAccent}
|
||||
followAppTerminalTheme={followAppTerminalTheme}
|
||||
hostById={hostById}
|
||||
themeById={themeById}
|
||||
onConnect={handleConnectToHost}
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
/>
|
||||
|
||||
<VaultViewContainer appThemeStyle={appThemeStyle}>
|
||||
<VaultView
|
||||
hosts={hosts}
|
||||
keys={keys}
|
||||
@@ -158,6 +204,8 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
proxyProfiles={proxyProfiles}
|
||||
snippets={snippets}
|
||||
snippetPackages={snippetPackages}
|
||||
notes={notes}
|
||||
noteGroups={noteGroups}
|
||||
customGroups={customGroups}
|
||||
knownHosts={effectiveKnownHosts}
|
||||
shellHistory={shellHistory}
|
||||
@@ -174,6 +222,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
onConnectSerial={handleConnectSerial}
|
||||
onDeleteHost={handleDeleteHost}
|
||||
onConnect={handleConnectToHost}
|
||||
onOpenHostFromNote={handleOpenHostFromVaultNote}
|
||||
groupConfigs={groupConfigs}
|
||||
onUpdateGroupConfigs={updateGroupConfigs}
|
||||
onUpdateHosts={updateHosts}
|
||||
@@ -183,6 +232,8 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
onUpdateProxyProfiles={updateProxyProfiles}
|
||||
onUpdateSnippets={updateSnippets}
|
||||
onUpdateSnippetPackages={updateSnippetPackages}
|
||||
onUpdateNotes={updateNotes}
|
||||
onUpdateNoteGroups={updateNoteGroups}
|
||||
onUpdateCustomGroups={updateCustomGroups}
|
||||
onUpdateKnownHosts={updateKnownHosts}
|
||||
onUpdateManagedSources={updateManagedSources}
|
||||
@@ -199,6 +250,8 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
showOnlyUngroupedHostsInRoot={settings.showOnlyUngroupedHostsInRoot}
|
||||
navigateToSection={navigateToSection}
|
||||
onNavigateToSectionHandled={() => setNavigateToSection(null)}
|
||||
deepLinkHostDraft={deepLinkHostDraft}
|
||||
onDeepLinkHostDraftHandled={() => setDeepLinkHostDraft(null)}
|
||||
terminalSettings={terminalSettings}
|
||||
/>
|
||||
</VaultViewContainer>
|
||||
@@ -207,9 +260,11 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
hosts={hosts}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
knownHosts={effectiveKnownHosts}
|
||||
proxyProfiles={proxyProfiles}
|
||||
groupConfigs={groupConfigs}
|
||||
updateHosts={updateHosts}
|
||||
onAddKnownHost={handleAddKnownHost}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
||||
sftpAutoSync={sftpAutoSync}
|
||||
@@ -231,11 +286,14 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
identities={identities}
|
||||
snippets={snippets}
|
||||
snippetPackages={snippetPackages}
|
||||
notes={notes}
|
||||
noteGroups={noteGroups}
|
||||
sessions={sessions}
|
||||
workspaces={workspaces}
|
||||
knownHosts={effectiveKnownHosts}
|
||||
draggingSessionId={draggingSessionId}
|
||||
terminalTheme={currentTerminalTheme}
|
||||
terminalThemeId={terminalThemeId}
|
||||
followAppTerminalTheme={followAppTerminalTheme}
|
||||
accentMode={accentMode}
|
||||
customAccent={customAccent}
|
||||
@@ -243,11 +301,19 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
terminalFontFamilyId={terminalFontFamilyId}
|
||||
fontSize={terminalFontSize}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
disableTerminalFontZoom={settings.disableTerminalFontZoom}
|
||||
restoreTerminalCwd={settings.restoreTerminalCwd}
|
||||
keyBindings={keyBindings}
|
||||
onHotkeyAction={handleHotkeyAction}
|
||||
onUpdateTerminalThemeId={setTerminalThemeId}
|
||||
onUpdateFollowAppTerminalThemeId={handleFollowAppTerminalThemeChange}
|
||||
onUpdateTerminalFontFamilyId={setTerminalFontFamilyId}
|
||||
onUpdateTerminalFontSize={setTerminalFontSize}
|
||||
onUpdateSessionFontSize={updateSessionFontSize}
|
||||
onUpdateSessionRestoreCwd={updateSessionRestoreCwd}
|
||||
onUpdateSessionDynamicTitle={updateSessionDynamicTitle}
|
||||
onUpdateSessionCodingCliProvider={updateSessionCodingCliProvider}
|
||||
onClearSessionFontSizeOverride={clearSessionFontSizeOverride}
|
||||
onUpdateTerminalFontWeight={(w) => updateTerminalSetting('fontWeight', w)}
|
||||
onCloseSession={closeSession}
|
||||
onUpdateSessionStatus={handleSessionStatusChange}
|
||||
@@ -257,6 +323,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
onCommandExecuted={(command, hostId, hostLabel, sessionId) => {
|
||||
addShellHistoryEntry({ command, hostId, hostLabel, sessionId });
|
||||
}}
|
||||
shellHistory={shellHistory}
|
||||
onTerminalDataCapture={handleTerminalDataCapture}
|
||||
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
|
||||
onAddSessionToWorkspace={addSessionToWorkspace}
|
||||
@@ -268,12 +335,20 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
|
||||
onSetWorkspaceFocusedSession={setWorkspaceFocusedSession}
|
||||
onReorderWorkspaceSessions={reorderWorkspaceSessions}
|
||||
onReorderTabs={reorderWorkTabs}
|
||||
onCopySession={copySessionWithCurrentShell}
|
||||
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
|
||||
onSplitSession={splitSessionWithCurrentShell}
|
||||
onConnectToHost={handleConnectToHost}
|
||||
openNoteRequest={openNoteRequest}
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
isBroadcastEnabled={isBroadcastEnabled}
|
||||
onToggleBroadcast={toggleBroadcast}
|
||||
updateHosts={updateHosts}
|
||||
updateSnippets={updateSnippets}
|
||||
updateSnippetPackages={updateSnippetPackages}
|
||||
updateNotes={updateNotes}
|
||||
updateNoteGroups={updateNoteGroups}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
||||
sftpAutoSync={sftpAutoSync}
|
||||
@@ -289,8 +364,12 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
sessionLogsFormat={sessionLogsFormat}
|
||||
sessionLogsTimestampsEnabled={sessionLogsTimestampsEnabled}
|
||||
sshDebugLogsEnabled={sshDebugLogsEnabled}
|
||||
showHostTreeSidebar={settings.showHostTreeSidebar}
|
||||
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
|
||||
toggleSidePanelRef={toggleSidePanelRef}
|
||||
onStartSessionRename={startSessionRename}
|
||||
onSubmitSessionRename={submitSessionRename}
|
||||
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
|
||||
/>
|
||||
|
||||
{/* Log Views - readonly terminal replays */}
|
||||
@@ -311,14 +390,17 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
|
||||
{/* Editor Tabs — kept mounted for Monaco instance persistence; visibility toggled via CSS */}
|
||||
{editorTabs.map((tab) => (
|
||||
<TextEditorTabView
|
||||
key={tab.id}
|
||||
tabId={tab.id}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
hostById={hostById}
|
||||
onRequestClose={(id) => handleRequestCloseEditorTabRef.current(id)}
|
||||
/>
|
||||
<LazyLoadBoundary key={tab.id} name="Editor" resetKey={tab.id}>
|
||||
<Suspense fallback={<TextEditorTabFallback tabId={tab.id} />}>
|
||||
<LazyTextEditorTabView
|
||||
tabId={tab.id}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
hostById={hostById}
|
||||
onRequestClose={(id) => handleRequestCloseEditorTabRef.current(id)}
|
||||
/>
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -382,38 +464,40 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
)}
|
||||
|
||||
{isQuickSwitcherOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<LazyQuickSwitcher
|
||||
isOpen={isQuickSwitcherOpen}
|
||||
query={quickSearch}
|
||||
results={quickResults}
|
||||
sessions={sessions}
|
||||
workspaces={workspaces}
|
||||
showSftpTab={settings.showSftpTab}
|
||||
onQueryChange={setQuickSearch}
|
||||
onSelect={handleHostConnectWithProtocolCheck}
|
||||
onSelectTab={(tabId) => {
|
||||
setActiveTabId(tabId);
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
}}
|
||||
onCreateLocalTerminal={(shell) => {
|
||||
handleCreateLocalTerminal(shell);
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
}}
|
||||
onCreateWorkspace={() => {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
setAddToWorkspaceDialog({ mode: 'create' });
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
}}
|
||||
keyBindings={keyBindings}
|
||||
/>
|
||||
</Suspense>
|
||||
<LazyLoadBoundary name="Quick switcher" resetKey={quickSearch}>
|
||||
<Suspense fallback={null}>
|
||||
<LazyQuickSwitcher
|
||||
isOpen={isQuickSwitcherOpen}
|
||||
query={quickSearch}
|
||||
results={quickResults}
|
||||
sessions={sessions}
|
||||
workspaces={workspaces}
|
||||
showSftpTab={settings.showSftpTab}
|
||||
onQueryChange={setQuickSearch}
|
||||
onSelect={handleHostConnectWithProtocolCheck}
|
||||
onSelectTab={(tabId) => {
|
||||
setActiveTabId(tabId);
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
}}
|
||||
onCreateLocalTerminal={(shell) => {
|
||||
handleCreateLocalTerminal(shell);
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
}}
|
||||
onCreateWorkspace={() => {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
setAddToWorkspaceDialog({ mode: 'create' });
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
}}
|
||||
keyBindings={keyBindings}
|
||||
/>
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
)}
|
||||
|
||||
<Dialog open={!!sessionRenameTarget} onOpenChange={(open) => {
|
||||
@@ -471,25 +555,29 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
</Dialog>
|
||||
|
||||
{isCreateWorkspaceOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<LazyCreateWorkspaceDialog
|
||||
isOpen={isCreateWorkspaceOpen}
|
||||
onClose={() => setIsCreateWorkspaceOpen(false)}
|
||||
hosts={hosts}
|
||||
onCreate={createWorkspaceWithHosts}
|
||||
/>
|
||||
</Suspense>
|
||||
<LazyLoadBoundary name="Create workspace" resetKey="create-workspace">
|
||||
<Suspense fallback={null}>
|
||||
<LazyCreateWorkspaceDialog
|
||||
isOpen={isCreateWorkspaceOpen}
|
||||
onClose={() => setIsCreateWorkspaceOpen(false)}
|
||||
hosts={hosts}
|
||||
onCreate={createWorkspaceWithHosts}
|
||||
/>
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
)}
|
||||
|
||||
{/* Protocol Select Dialog for QuickSwitcher */}
|
||||
{protocolSelectHost && (
|
||||
<Suspense fallback={null}>
|
||||
<LazyProtocolSelectDialog
|
||||
host={protocolSelectHost}
|
||||
onSelect={handleProtocolSelect}
|
||||
onCancel={() => setProtocolSelectHost(null)}
|
||||
/>
|
||||
</Suspense>
|
||||
<LazyLoadBoundary name="Protocol selector" resetKey={protocolSelectHost.id}>
|
||||
<Suspense fallback={null}>
|
||||
<LazyProtocolSelectDialog
|
||||
host={protocolSelectHost}
|
||||
onSelect={handleProtocolSelect}
|
||||
onCancel={() => setProtocolSelectHost(null)}
|
||||
/>
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
)}
|
||||
|
||||
{/* Global Keyboard-Interactive Authentication Modal (2FA/MFA) - processes queue */}
|
||||
@@ -537,6 +625,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
hosts: emptyVaultConflict.hostCount,
|
||||
keys: emptyVaultConflict.keyCount,
|
||||
snippets: emptyVaultConflict.snippetCount,
|
||||
notes: emptyVaultConflict.noteCount,
|
||||
proxyProfiles: emptyVaultConflict.proxyProfileCount,
|
||||
})}</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { toEditorTabId } from "../state/activeTabStore.ts";
|
||||
import type { EditorTab } from "../state/editorTabStore.ts";
|
||||
import type { LogView } from "../state/logViewState.ts";
|
||||
import { isActiveChromeThemeResolvable, resolveActiveChromeTheme } from "./activeChromeTheme.ts";
|
||||
import type { Host, TerminalSession, TerminalTheme, Workspace } from "../../types";
|
||||
|
||||
const theme = (id: string, type: "dark" | "light" = "dark"): TerminalTheme => ({
|
||||
id,
|
||||
name: id,
|
||||
type,
|
||||
colors: {
|
||||
background: type === "dark" ? "#111111" : "#eeeeee",
|
||||
foreground: type === "dark" ? "#eeeeee" : "#111111",
|
||||
cursor: "#22aaff",
|
||||
},
|
||||
});
|
||||
|
||||
const currentTheme = theme("current");
|
||||
const hostTheme = theme("host-theme");
|
||||
const logTheme = theme("log-theme", "light");
|
||||
|
||||
const baseInput = {
|
||||
accentMode: "theme" as const,
|
||||
currentTerminalTheme: currentTheme,
|
||||
customAccent: "221.2 83.2% 53.3%",
|
||||
editorTabs: [],
|
||||
followAppTerminalTheme: false,
|
||||
hostById: new Map<string, Host>(),
|
||||
logViews: [],
|
||||
sessionById: new Map<string, TerminalSession>(),
|
||||
themeById: new Map([
|
||||
[currentTheme.id, currentTheme],
|
||||
[hostTheme.id, hostTheme],
|
||||
[logTheme.id, logTheme],
|
||||
]),
|
||||
workspaceById: new Map<string, Workspace>(),
|
||||
};
|
||||
|
||||
test("editor tabs use the owning host terminal theme when follow-app terminal theme is off", () => {
|
||||
const editorTab = {
|
||||
id: "editor-1",
|
||||
hostId: "host-1",
|
||||
sessionId: "sftp-1",
|
||||
};
|
||||
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
activeTabId: toEditorTabId(editorTab.id),
|
||||
editorTabs: [editorTab as unknown as EditorTab],
|
||||
hostById: new Map([
|
||||
["host-1", { id: "host-1", theme: hostTheme.id } as unknown as Host],
|
||||
]),
|
||||
});
|
||||
|
||||
assert.equal(resolved?.id, hostTheme.id);
|
||||
});
|
||||
|
||||
test("editor tabs use the followed terminal theme when follow-app terminal theme is on", () => {
|
||||
const editorTab = {
|
||||
id: "editor-1",
|
||||
hostId: "host-1",
|
||||
sessionId: "sftp-1",
|
||||
};
|
||||
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
activeTabId: toEditorTabId(editorTab.id),
|
||||
editorTabs: [editorTab as unknown as EditorTab],
|
||||
followAppTerminalTheme: true,
|
||||
hostById: new Map([
|
||||
["host-1", { id: "host-1", theme: hostTheme.id } as unknown as Host],
|
||||
]),
|
||||
});
|
||||
|
||||
assert.equal(resolved?.id, currentTheme.id);
|
||||
});
|
||||
|
||||
test("log tabs use the saved log theme when available", () => {
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
activeTabId: "log-1",
|
||||
logViews: [{
|
||||
id: "log-1",
|
||||
connectionLogId: "1",
|
||||
log: { id: "1", themeId: logTheme.id },
|
||||
} as unknown as LogView],
|
||||
});
|
||||
|
||||
assert.equal(resolved?.id, logTheme.id);
|
||||
});
|
||||
|
||||
test("root pages use the normal application theme", () => {
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
activeTabId: "vault",
|
||||
});
|
||||
|
||||
assert.equal(resolved, null);
|
||||
});
|
||||
|
||||
test("chrome theme sync waits until a newly opened session is present in deps", () => {
|
||||
assert.equal(
|
||||
isActiveChromeThemeResolvable({
|
||||
activeTabId: "session-new",
|
||||
editorTabs: [],
|
||||
logViews: [],
|
||||
sessionById: new Map(),
|
||||
workspaceById: new Map(),
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
isActiveChromeThemeResolvable({
|
||||
activeTabId: "session-new",
|
||||
editorTabs: [],
|
||||
logViews: [],
|
||||
sessionById: new Map([["session-new", { id: "session-new" } as TerminalSession]]),
|
||||
workspaceById: new Map(),
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { fromEditorTabId, isEditorTabId } from "../state/activeTabStore";
|
||||
|
||||
export type ResolveActiveChromeThemeInput = {
|
||||
accentMode: "theme" | "custom";
|
||||
activeTabId: string;
|
||||
currentTerminalTheme: TerminalTheme;
|
||||
customAccent: string;
|
||||
editorTabs: readonly EditorTab[];
|
||||
followAppTerminalTheme: boolean;
|
||||
hostById: Map<string, Host>;
|
||||
logViews: readonly LogView[];
|
||||
sessionById: Map<string, TerminalSession>;
|
||||
themeById: Map<string, TerminalTheme>;
|
||||
workspaceById: Map<string, Workspace>;
|
||||
};
|
||||
|
||||
export function isActiveChromeThemeResolvable({
|
||||
activeTabId,
|
||||
editorTabs,
|
||||
logViews,
|
||||
sessionById,
|
||||
workspaceById,
|
||||
}: Pick<
|
||||
ResolveActiveChromeThemeInput,
|
||||
"activeTabId" | "editorTabs" | "logViews" | "sessionById" | "workspaceById"
|
||||
>): boolean {
|
||||
if (activeTabId === "vault" || activeTabId === "sftp") return true;
|
||||
if (isEditorTabId(activeTabId)) {
|
||||
return editorTabs.some((tab) => tab.id === fromEditorTabId(activeTabId));
|
||||
}
|
||||
if (logViews.some((item) => item.id === activeTabId)) return true;
|
||||
if (workspaceById.has(activeTabId)) return true;
|
||||
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,
|
||||
activeTabId,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
editorTabs,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
logViews,
|
||||
sessionById,
|
||||
themeById,
|
||||
workspaceById,
|
||||
}: ResolveActiveChromeThemeInput): TerminalTheme | null {
|
||||
if (activeTabId === "vault" || activeTabId === "sftp") return null;
|
||||
|
||||
const resolveHostTheme = (hostId: string): TerminalTheme => {
|
||||
if (followAppTerminalTheme) return currentTerminalTheme;
|
||||
const host = hostById.get(hostId) ?? null;
|
||||
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
||||
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
};
|
||||
|
||||
const resolveSessionTheme = (session: TerminalSession): TerminalTheme => resolveHostTheme(session.hostId);
|
||||
|
||||
if (isEditorTabId(activeTabId)) {
|
||||
const editorTabId = fromEditorTabId(activeTabId);
|
||||
const editorTab = editorTabs.find((tab) => tab.id === editorTabId);
|
||||
if (!editorTab) return null;
|
||||
return resolveHostTheme(editorTab.hostId);
|
||||
}
|
||||
|
||||
const logView = logViews.find((item) => item.id === activeTabId);
|
||||
if (logView) {
|
||||
const explicitThemeId = logView.log.themeId;
|
||||
return explicitThemeId ? themeById.get(explicitThemeId) ?? currentTerminalTheme : currentTerminalTheme;
|
||||
}
|
||||
|
||||
const workspace = workspaceById.get(activeTabId);
|
||||
if (workspace) {
|
||||
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);
|
||||
return focusedSession ? resolveSessionTheme(focusedSession) : null;
|
||||
}
|
||||
|
||||
const workspaceSessions = collectSessionIds(workspace.root)
|
||||
.map((id) => sessionById.get(id))
|
||||
.filter(Boolean) as TerminalSession[];
|
||||
if (workspaceSessions.length === 0) return null;
|
||||
|
||||
const firstTheme = resolveSessionTheme(workspaceSessions[0]);
|
||||
const allSame = workspaceSessions.every((session) => resolveSessionTheme(session).id === firstTheme.id);
|
||||
return allSame ? firstTheme : null;
|
||||
}
|
||||
|
||||
const session = sessionById.get(activeTabId);
|
||||
return session ? resolveSessionTheme(session) : null;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { shouldQueueKeyboardInteractiveRequest } from "./useAppStartupEffects.ts";
|
||||
|
||||
const sessions = [{ id: "terminal-1" }, { id: "terminal-2" }];
|
||||
|
||||
test("terminal-scoped keyboard-interactive requests are limited to owned sessions", () => {
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest({ scope: "terminal", sessionId: "terminal-1" }, sessions),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest({ scope: "terminal", sessionId: "foreign-terminal" }, sessions),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("external keyboard-interactive requests are not filtered by terminal session ids", () => {
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest({ scope: "external", sessionId: "sftp-conn-1" }, sessions),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest({ scope: "external", sessionId: "tunnel-1" }, sessions),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("disabled peer windows still queue sender-targeted external keyboard-interactive requests", () => {
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest({ scope: "external", sessionId: "sftp-conn-1" }, sessions),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("disabled peer windows can still queue owned terminal keyboard-interactive requests", () => {
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest({ scope: "terminal", sessionId: "terminal-1" }, sessions),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("legacy unscoped keyboard-interactive requests remain visible", () => {
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest({ sessionId: "legacy-conn" }, sessions),
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildNumberShortcutTabTargets } from './tabShortcutTargets.ts';
|
||||
|
||||
test('number shortcut tabs include vault and sftp by default', () => {
|
||||
assert.deepEqual(
|
||||
buildNumberShortcutTabTargets({
|
||||
showSftpTab: true,
|
||||
shellOnlyTabNumberShortcuts: false,
|
||||
orderedTabs: ['session-1', 'workspace-1'],
|
||||
editorTabIds: ['editor:file-1'],
|
||||
}),
|
||||
['vault', 'sftp', 'session-1', 'workspace-1', 'editor:file-1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('number shortcut tabs skip vault and sftp when shell-only mode is enabled', () => {
|
||||
assert.deepEqual(
|
||||
buildNumberShortcutTabTargets({
|
||||
showSftpTab: true,
|
||||
shellOnlyTabNumberShortcuts: true,
|
||||
orderedTabs: ['session-1', 'workspace-1'],
|
||||
editorTabIds: ['editor:file-1'],
|
||||
}),
|
||||
['session-1', 'workspace-1', 'editor:file-1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('hidden sftp tab is omitted from default number shortcut targets', () => {
|
||||
assert.deepEqual(
|
||||
buildNumberShortcutTabTargets({
|
||||
showSftpTab: false,
|
||||
shellOnlyTabNumberShortcuts: false,
|
||||
orderedTabs: ['session-1'],
|
||||
editorTabIds: [],
|
||||
}),
|
||||
['vault', 'session-1'],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
/** Tab ids targeted by Cmd/Ctrl+[1...9] number shortcuts. */
|
||||
export function buildNumberShortcutTabTargets(params: {
|
||||
showSftpTab: boolean;
|
||||
shellOnlyTabNumberShortcuts: boolean;
|
||||
orderedTabs: readonly string[];
|
||||
editorTabIds: readonly string[];
|
||||
}): string[] {
|
||||
const workTabs = [...params.orderedTabs, ...params.editorTabIds];
|
||||
if (params.shellOnlyTabNumberShortcuts) {
|
||||
return workTabs;
|
||||
}
|
||||
const pinnedTabs = params.showSftpTab ? ['vault', 'sftp'] : ['vault'];
|
||||
return [...pinnedTabs, ...workTabs];
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
test("active chrome theme applies top tab vars and clears them before vault restore transition", () => {
|
||||
const chromeThemeSource = readFileSync(new URL("../state/useActiveChromeTheme.ts", import.meta.url), "utf8");
|
||||
const syncSource = readFileSync(new URL("../state/activeChromeThemeSync.ts", import.meta.url), "utf8");
|
||||
const effectsSource = readFileSync(new URL("../../components/terminalLayer/useTerminalLayerEffects.ts", import.meta.url), "utf8");
|
||||
|
||||
assert.match(chromeThemeSource, /applyTopTabsChromeThemeVars\(theme\)/);
|
||||
assert.match(chromeThemeSource, /resolveReadableForegroundForHsl\(cursor\)/);
|
||||
const restoreBlock = chromeThemeSource.match(
|
||||
/clearTopTabsChromeThemeVars\(\);\s*runThemeTransition\(\(\) => \{\s*removeActiveChromeTheme\(\);/,
|
||||
)?.[0] ?? "";
|
||||
assert.notEqual(restoreBlock, "", "top tab vars must clear before the vault restore transition starts");
|
||||
assert.match(syncSource, /activeTabId === 'vault' \|\| activeTabId === 'sftp'\)[\s\S]*clearTopTabsChromeThemeVars\(\)/);
|
||||
assert.match(effectsSource, /if \(!isTerminalLayerVisible\) \{[\s\S]*clearTopTabsPreviewVars\(\)/);
|
||||
});
|
||||
|
||||
test("top tabs chrome theme keeps accent foreground in sync", () => {
|
||||
const source = readFileSync(new URL("./topTabsChromeTheme.ts", import.meta.url), "utf8");
|
||||
const supportSource = readFileSync(new URL("../../components/terminalLayer/TerminalLayerSupport.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /--primary-foreground/);
|
||||
assert.match(source, /--accent-foreground/);
|
||||
assert.match(source, /resolveReadableForegroundForHsl\(accent\)/);
|
||||
assert.match(supportSource, /removeStylePropertyIfSet\(tabsRoot, '--primary-foreground'\)/);
|
||||
assert.match(supportSource, /removeStylePropertyIfSet\(tabsRoot, '--accent-foreground'\)/);
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { TerminalTheme } from '../../types';
|
||||
import { resolveReadableForegroundForHsl } from '../../domain/colorContrast';
|
||||
|
||||
function hexToHslToken(hex: string): string {
|
||||
const normalized = hex.startsWith('#') ? hex : `#${hex}`;
|
||||
const r = parseInt(normalized.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(normalized.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(normalized.slice(5, 7), 16) / 255;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
const l = (max + min) / 2;
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r:
|
||||
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||
break;
|
||||
case g:
|
||||
h = ((b - r) / d + 2) / 6;
|
||||
break;
|
||||
default:
|
||||
h = ((r - g) / d + 4) / 6;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return `${Math.round(h * 3600) / 10} ${Math.round(s * 1000) / 10}% ${Math.round(l * 1000) / 10}%`;
|
||||
}
|
||||
|
||||
function adjustLightnessToken(hsl: string, delta: number): string {
|
||||
const parts = hsl.split(/\s+/);
|
||||
const newL = Math.max(0, Math.min(100, parseFloat(parts[2]) + delta));
|
||||
return `${parts[0]} ${parts[1]} ${Math.round(newL * 10) / 10}%`;
|
||||
}
|
||||
|
||||
function adjustSaturationToken(hsl: string, factor: number): string {
|
||||
const parts = hsl.split(/\s+/);
|
||||
const newS = Math.max(0, Math.min(100, parseFloat(parts[1]) * factor));
|
||||
return `${parts[0]} ${Math.round(newS * 10) / 10}% ${parts[2]}`;
|
||||
}
|
||||
|
||||
const setStylePropertyIfChanged = (element: HTMLElement, property: string, value: string) => {
|
||||
if (element.style.getPropertyValue(property) === value) return;
|
||||
element.style.setProperty(property, value);
|
||||
};
|
||||
|
||||
const removeStylePropertyIfSet = (element: HTMLElement, property: string) => {
|
||||
if (!element.style.getPropertyValue(property)) return;
|
||||
element.style.removeProperty(property);
|
||||
};
|
||||
|
||||
const TOP_TABS_THEME_PROPERTIES = [
|
||||
'--top-tabs-bg',
|
||||
'--top-tabs-fg',
|
||||
'--top-tabs-muted',
|
||||
'--top-tabs-active-bg',
|
||||
'--top-tabs-accent',
|
||||
'--background',
|
||||
'--foreground',
|
||||
'--accent',
|
||||
'--accent-foreground',
|
||||
'--primary',
|
||||
'--primary-foreground',
|
||||
'--secondary',
|
||||
'--border',
|
||||
'--muted-foreground',
|
||||
] as const;
|
||||
|
||||
export function clearTopTabsChromeThemeVars(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
|
||||
if (!tabsRoot) return;
|
||||
for (const property of TOP_TABS_THEME_PROPERTIES) {
|
||||
removeStylePropertyIfSet(tabsRoot, property);
|
||||
}
|
||||
}
|
||||
|
||||
export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
|
||||
if (!tabsRoot) return;
|
||||
|
||||
const bg = hexToHslToken(theme.colors.background);
|
||||
const fg = hexToHslToken(theme.colors.foreground);
|
||||
const accent = hexToHslToken(theme.colors.cursor);
|
||||
const accentForeground = resolveReadableForegroundForHsl(accent);
|
||||
const isDark = theme.type === 'dark';
|
||||
const secondary = adjustLightnessToken(bg, isDark ? 6 : -5);
|
||||
const border = adjustLightnessToken(bg, isDark ? 12 : -10);
|
||||
const mutedFg = adjustSaturationToken(adjustLightnessToken(fg, isDark ? -20 : 20), 0.5);
|
||||
|
||||
setStylePropertyIfChanged(tabsRoot, '--background', bg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--foreground', fg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--accent', accent);
|
||||
setStylePropertyIfChanged(tabsRoot, '--accent-foreground', accentForeground);
|
||||
setStylePropertyIfChanged(tabsRoot, '--primary', accent);
|
||||
setStylePropertyIfChanged(tabsRoot, '--primary-foreground', accentForeground);
|
||||
setStylePropertyIfChanged(tabsRoot, '--secondary', secondary);
|
||||
setStylePropertyIfChanged(tabsRoot, '--border', border);
|
||||
setStylePropertyIfChanged(tabsRoot, '--muted-foreground', mutedFg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-bg', 'hsl(var(--secondary))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-fg', 'hsl(var(--foreground))');
|
||||
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))');
|
||||
}
|
||||
|
||||
export function hasActiveChromeThemeDataset(): boolean {
|
||||
if (typeof document === 'undefined') return false;
|
||||
return Boolean(document.documentElement.dataset.activeChromeTheme);
|
||||
}
|
||||
@@ -8,14 +8,35 @@ import { toast } from '../../components/ui/toast';
|
||||
|
||||
type StartupEffectsContext = Record<string, any>;
|
||||
|
||||
type KeyboardInteractiveScope = "terminal" | "external";
|
||||
type KeyboardInteractiveRequestLike = {
|
||||
scope?: KeyboardInteractiveScope;
|
||||
sessionId?: string;
|
||||
};
|
||||
type SessionIdLike = { id: string };
|
||||
|
||||
export function shouldQueueKeyboardInteractiveRequest(
|
||||
request: KeyboardInteractiveRequestLike,
|
||||
sessions: SessionIdLike[],
|
||||
): boolean {
|
||||
if (request.scope !== "terminal") return true;
|
||||
if (!request.sessionId) return false;
|
||||
return sessions.some((session) => session.id === request.sessionId);
|
||||
}
|
||||
|
||||
export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
const {dismissUpdate, groupConfigs, hosts, identities,
|
||||
const {dismissUpdate, enabled = true, groupConfigs, hosts, identities,
|
||||
installUpdate, isVaultInitialized, keys, openSettingsWindow, portForwardingRules, proxyProfiles, sessions, setKeyboardInteractiveQueue,
|
||||
t, terminalSettings, updateState, workspaces,
|
||||
} = ctx;
|
||||
const sessionsRef = useRef(sessions);
|
||||
useEffect(() => {
|
||||
sessionsRef.current = sessions;
|
||||
}, [sessions]);
|
||||
|
||||
// Show toast notification when update is available (only when auto-download is idle)
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
// Skip "update available" toast if auto-download has already started or completed
|
||||
if (updateState.autoDownloadStatus !== 'idle') return;
|
||||
// Don't show automatic notification when auto-update is disabled
|
||||
@@ -41,12 +62,13 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [updateState.hasUpdate, updateState.latestRelease, updateState.autoDownloadStatus, t, openSettingsWindow, dismissUpdate]);
|
||||
}, [enabled, updateState.hasUpdate, updateState.latestRelease, updateState.autoDownloadStatus, t, openSettingsWindow, dismissUpdate]);
|
||||
|
||||
// Track previous autoDownloadStatus so toast effects fire only on actual transitions,
|
||||
// not when unrelated deps (installUpdate, openSettingsWindow) change their reference.
|
||||
const prevAutoDownloadStatusRef = useRef(updateState.autoDownloadStatus);
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const prev = prevAutoDownloadStatusRef.current;
|
||||
prevAutoDownloadStatusRef.current = updateState.autoDownloadStatus;
|
||||
if (prev === updateState.autoDownloadStatus) return;
|
||||
@@ -72,10 +94,11 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [updateState.autoDownloadStatus, updateState.latestRelease?.version, t, installUpdate, openSettingsWindow]);
|
||||
}, [enabled, updateState.autoDownloadStatus, updateState.latestRelease?.version, t, installUpdate, openSettingsWindow]);
|
||||
|
||||
// Auto-start port forwarding rules on app launch
|
||||
usePortForwardingAutoStart({
|
||||
enabled,
|
||||
isVaultInitialized,
|
||||
hosts,
|
||||
keys,
|
||||
@@ -87,6 +110,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
|
||||
// Sync tray menu data + handle tray actions
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.updateTrayMenuData) return;
|
||||
|
||||
@@ -116,7 +140,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [sessions, portForwardingRules, workspaces]);
|
||||
}, [enabled, sessions, portForwardingRules, workspaces]);
|
||||
|
||||
// Quit guard: block app exit while any editor tab has unsaved changes.
|
||||
// Main process sends "app:query-dirty-editors"; we respond with the result.
|
||||
@@ -146,7 +170,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
}
|
||||
});
|
||||
return unsub;
|
||||
}, [t]);
|
||||
}, [enabled, t]);
|
||||
|
||||
// Keyboard-interactive authentication (2FA/MFA) event listener
|
||||
useEffect(() => {
|
||||
@@ -154,6 +178,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
if (!bridge?.onKeyboardInteractive) return;
|
||||
|
||||
const unsubscribe = bridge.onKeyboardInteractive((request) => {
|
||||
if (!shouldQueueKeyboardInteractiveRequest(request, sessionsRef.current)) return;
|
||||
console.log('[App] Keyboard-interactive request received:', request);
|
||||
// Add to queue instead of replacing - supports multiple concurrent sessions
|
||||
setKeyboardInteractiveQueue(prev => [...prev, {
|
||||
@@ -170,7 +195,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [setKeyboardInteractiveQueue]);
|
||||
}, [enabled, setKeyboardInteractiveQueue]);
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildOrderedWorkTabIds,
|
||||
isHostTreeWorkTabSurface,
|
||||
isRootPageTabId,
|
||||
isTerminalContentTabSurface,
|
||||
reorderWorkTabIds,
|
||||
resolveWorkTabActiveHostId,
|
||||
resolveWorkTabHostTreeTheme,
|
||||
} from './workTabSurface';
|
||||
import type { EditorTab } from '../state/editorTabStore';
|
||||
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
|
||||
|
||||
const makeTheme = (id: string, type: TerminalTheme['type'], background: string): TerminalTheme => ({
|
||||
id,
|
||||
name: id,
|
||||
type,
|
||||
colors: {
|
||||
background,
|
||||
foreground: type === 'dark' ? '#ffffff' : '#000000',
|
||||
cursor: '#888888',
|
||||
selection: '#555555',
|
||||
black: '#000000',
|
||||
red: '#ff0000',
|
||||
green: '#00ff00',
|
||||
yellow: '#ffff00',
|
||||
blue: '#0000ff',
|
||||
magenta: '#ff00ff',
|
||||
cyan: '#00ffff',
|
||||
white: '#ffffff',
|
||||
brightBlack: '#444444',
|
||||
brightRed: '#ff5555',
|
||||
brightGreen: '#55ff55',
|
||||
brightYellow: '#ffff55',
|
||||
brightBlue: '#5555ff',
|
||||
brightMagenta: '#ff55ff',
|
||||
brightCyan: '#55ffff',
|
||||
brightWhite: '#ffffff',
|
||||
},
|
||||
});
|
||||
|
||||
test('work tab order keeps custom positions and appends new tabs', () => {
|
||||
assert.deepEqual(
|
||||
buildOrderedWorkTabIds(['log-1', 'session-1'], ['session-1', 'workspace-1', 'log-1', 'editor:file-1']),
|
||||
['log-1', 'session-1', 'workspace-1', 'editor:file-1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('work tab order removes duplicate ids before rendering', () => {
|
||||
assert.deepEqual(
|
||||
buildOrderedWorkTabIds(
|
||||
['session-2', 'session-1', 'session-2', 'session-1'],
|
||||
['session-1', 'session-2', 'session-3', 'session-3'],
|
||||
),
|
||||
['session-2', 'session-1', 'session-3'],
|
||||
);
|
||||
});
|
||||
|
||||
test('work tab order reorders with newly materialized tabs', () => {
|
||||
assert.deepEqual(
|
||||
reorderWorkTabIds(
|
||||
['session-1', 'session-2', 'session-3'],
|
||||
['session-1', 'session-2', 'session-3'],
|
||||
'session-1',
|
||||
'session-3',
|
||||
'after',
|
||||
),
|
||||
['session-2', 'session-3', 'session-1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('root pages are not work tab surfaces', () => {
|
||||
assert.equal(isRootPageTabId('vault'), true);
|
||||
assert.equal(isRootPageTabId('sftp'), true);
|
||||
assert.equal(isRootPageTabId('session-1'), false);
|
||||
});
|
||||
|
||||
test('shared host tree is visible for editor, log, session, and workspace tabs', () => {
|
||||
const sessionIds = new Set(['session-1']);
|
||||
const workspaceIds = new Set(['workspace-1']);
|
||||
const logViewIds = new Set(['log-1']);
|
||||
const orderedTabs = ['session-1', 'workspace-1', 'editor:file-1', 'log-1'];
|
||||
|
||||
for (const activeTabId of orderedTabs) {
|
||||
assert.equal(isHostTreeWorkTabSurface({
|
||||
enabled: true,
|
||||
activeTabId,
|
||||
logViewIds,
|
||||
orderedTabs,
|
||||
sessionIds,
|
||||
workspaceIds,
|
||||
}), true);
|
||||
}
|
||||
});
|
||||
|
||||
test('shared host tree recognizes active log view before tab ordering catches up', () => {
|
||||
assert.equal(isHostTreeWorkTabSurface({
|
||||
enabled: true,
|
||||
activeTabId: 'log-1',
|
||||
logViewIds: new Set(['log-1']),
|
||||
orderedTabs: [],
|
||||
sessionIds: new Set(),
|
||||
workspaceIds: new Set(),
|
||||
}), true);
|
||||
});
|
||||
|
||||
test('terminal content surface is limited to sessions and workspaces', () => {
|
||||
const sessionIds = new Set(['session-1']);
|
||||
const workspaceIds = new Set(['workspace-1']);
|
||||
|
||||
assert.equal(isTerminalContentTabSurface({ activeTabId: 'session-1', sessionIds, workspaceIds }), true);
|
||||
assert.equal(isTerminalContentTabSurface({ activeTabId: 'workspace-1', sessionIds, workspaceIds }), true);
|
||||
assert.equal(isTerminalContentTabSurface({ activeTabId: 'editor:file-1', sessionIds, workspaceIds }), false);
|
||||
assert.equal(isTerminalContentTabSurface({ activeTabId: 'log-1', sessionIds, workspaceIds }), false);
|
||||
});
|
||||
|
||||
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' },
|
||||
] as TerminalSession[];
|
||||
const workspaces = [
|
||||
{ id: 'workspace-1', focusedSessionId: 'session-2' },
|
||||
] as Workspace[];
|
||||
const editorTabs = [
|
||||
{ id: 'file-1', hostId: 'host-3' },
|
||||
] as EditorTab[];
|
||||
|
||||
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'session-1', sessions, workspaces, editorTabs }), 'host-1');
|
||||
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'workspace-1', sessions, workspaces, editorTabs }), 'host-2');
|
||||
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'editor:file-1', sessions, workspaces, editorTabs }), 'host-3');
|
||||
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'log-1', sessions, workspaces, editorTabs }), null);
|
||||
});
|
||||
|
||||
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');
|
||||
const host = {
|
||||
id: 'host-1',
|
||||
label: 'Host',
|
||||
hostname: 'host.local',
|
||||
username: 'root',
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
theme: hostTheme.id,
|
||||
themeOverride: true,
|
||||
} as Host;
|
||||
|
||||
const resolved = resolveWorkTabHostTreeTheme({
|
||||
activeHostId: host.id,
|
||||
accentMode: 'theme',
|
||||
currentTerminalTheme: currentTheme,
|
||||
customAccent: '#8b5cf6',
|
||||
followAppTerminalTheme: false,
|
||||
hostById: new Map([[host.id, host]]),
|
||||
themeById: new Map([[currentTheme.id, currentTheme], [hostTheme.id, hostTheme]]),
|
||||
});
|
||||
|
||||
assert.equal(resolved.id, hostTheme.id);
|
||||
});
|
||||
|
||||
test('shared host tree uses the followed terminal theme when follow-app terminal theme is on', () => {
|
||||
const currentTheme = makeTheme('app-light', 'light', '#ffffff');
|
||||
const hostTheme = makeTheme('host-dark', 'dark', '#050505');
|
||||
const host = {
|
||||
id: 'host-1',
|
||||
label: 'Host',
|
||||
hostname: 'host.local',
|
||||
username: 'root',
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
theme: hostTheme.id,
|
||||
themeOverride: true,
|
||||
} as Host;
|
||||
|
||||
const resolved = resolveWorkTabHostTreeTheme({
|
||||
activeHostId: host.id,
|
||||
accentMode: 'theme',
|
||||
currentTerminalTheme: currentTheme,
|
||||
customAccent: '#8b5cf6',
|
||||
followAppTerminalTheme: true,
|
||||
hostById: new Map([[host.id, host]]),
|
||||
themeById: new Map([[currentTheme.id, currentTheme], [hostTheme.id, hostTheme]]),
|
||||
});
|
||||
|
||||
assert.equal(resolved.id, currentTheme.id);
|
||||
});
|
||||
|
||||
test('shared host tree falls back to the current terminal theme without an active host', () => {
|
||||
const currentTheme = makeTheme('app-dark', 'dark', '#111111');
|
||||
|
||||
const resolved = resolveWorkTabHostTreeTheme({
|
||||
activeHostId: null,
|
||||
accentMode: 'theme',
|
||||
currentTerminalTheme: currentTheme,
|
||||
customAccent: '#8b5cf6',
|
||||
followAppTerminalTheme: false,
|
||||
hostById: new Map(),
|
||||
themeById: new Map([[currentTheme.id, currentTheme]]),
|
||||
});
|
||||
|
||||
assert.equal(resolved.id, currentTheme.id);
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
fromEditorTabId,
|
||||
isEditorTabId,
|
||||
} from '../state/activeTabStore';
|
||||
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from '../../domain/terminalAppearance';
|
||||
import type { EditorTab } from '../state/editorTabStore';
|
||||
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
|
||||
|
||||
function uniqueTabIds(tabIds: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const uniqueIds: string[] = [];
|
||||
for (const tabId of tabIds) {
|
||||
if (!tabId || seen.has(tabId)) continue;
|
||||
seen.add(tabId);
|
||||
uniqueIds.push(tabId);
|
||||
}
|
||||
return uniqueIds;
|
||||
}
|
||||
|
||||
export function isRootPageTabId(activeTabId: string): boolean {
|
||||
return activeTabId === 'vault' || activeTabId === 'sftp';
|
||||
}
|
||||
|
||||
export function buildOrderedWorkTabIds(
|
||||
tabOrder: readonly string[],
|
||||
allTabIds: readonly string[],
|
||||
): string[] {
|
||||
const uniqueAllTabIds = uniqueTabIds(allTabIds);
|
||||
const allTabIdSet = new Set(uniqueAllTabIds);
|
||||
const orderedIds = uniqueTabIds(tabOrder.filter((id) => allTabIdSet.has(id)));
|
||||
const orderedIdSet = new Set(orderedIds);
|
||||
const newIds = uniqueAllTabIds.filter((id) => !orderedIdSet.has(id));
|
||||
return [...orderedIds, ...newIds];
|
||||
}
|
||||
|
||||
export function reorderWorkTabIds(
|
||||
tabOrder: readonly string[],
|
||||
allTabIds: readonly string[],
|
||||
draggedId: string,
|
||||
targetId: string,
|
||||
position: 'before' | 'after' = 'before',
|
||||
): string[] {
|
||||
if (draggedId === targetId) return buildOrderedWorkTabIds(tabOrder, allTabIds);
|
||||
|
||||
const currentOrder = buildOrderedWorkTabIds(tabOrder, allTabIds);
|
||||
const draggedIndex = currentOrder.indexOf(draggedId);
|
||||
const targetIndex = currentOrder.indexOf(targetId);
|
||||
if (draggedIndex === -1 || targetIndex === -1) return [...tabOrder];
|
||||
|
||||
currentOrder.splice(draggedIndex, 1);
|
||||
|
||||
let nextTargetIndex = targetIndex;
|
||||
if (draggedIndex < targetIndex) {
|
||||
nextTargetIndex -= 1;
|
||||
}
|
||||
if (position === 'after') {
|
||||
nextTargetIndex += 1;
|
||||
}
|
||||
|
||||
currentOrder.splice(nextTargetIndex, 0, draggedId);
|
||||
return currentOrder;
|
||||
}
|
||||
|
||||
export function isHostTreeWorkTabSurface({
|
||||
enabled,
|
||||
activeTabId,
|
||||
logViewIds = new Set(),
|
||||
orderedTabs,
|
||||
sessionIds,
|
||||
workspaceIds,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
activeTabId: string;
|
||||
logViewIds?: ReadonlySet<string>;
|
||||
orderedTabs: readonly string[];
|
||||
sessionIds: ReadonlySet<string>;
|
||||
workspaceIds: ReadonlySet<string>;
|
||||
}): boolean {
|
||||
if (!enabled) return false;
|
||||
if (isRootPageTabId(activeTabId)) return false;
|
||||
return orderedTabs.includes(activeTabId)
|
||||
|| isEditorTabId(activeTabId)
|
||||
|| logViewIds.has(activeTabId)
|
||||
|| sessionIds.has(activeTabId)
|
||||
|| workspaceIds.has(activeTabId);
|
||||
}
|
||||
|
||||
export function isTerminalContentTabSurface({
|
||||
activeTabId,
|
||||
sessionIds,
|
||||
workspaceIds,
|
||||
}: {
|
||||
activeTabId: string;
|
||||
sessionIds: ReadonlySet<string>;
|
||||
workspaceIds: ReadonlySet<string>;
|
||||
}): boolean {
|
||||
return sessionIds.has(activeTabId) || workspaceIds.has(activeTabId);
|
||||
}
|
||||
|
||||
export function resolveWorkTabActiveHostId({
|
||||
activeTabId,
|
||||
editorTabs,
|
||||
sessions,
|
||||
workspaces,
|
||||
}: {
|
||||
activeTabId: string;
|
||||
editorTabs: readonly EditorTab[];
|
||||
sessions: readonly TerminalSession[];
|
||||
workspaces: readonly Workspace[];
|
||||
}): string | null {
|
||||
if (isEditorTabId(activeTabId)) {
|
||||
const editorId = fromEditorTabId(activeTabId);
|
||||
return editorTabs.find((tab) => tab.id === editorId)?.hostId ?? null;
|
||||
}
|
||||
|
||||
const activeSession = sessions.find((session) => session.id === activeTabId);
|
||||
if (activeSession) return activeSession.hostId ?? null;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function resolveWorkTabHostTreeTheme({
|
||||
activeHostId,
|
||||
accentMode,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
themeById,
|
||||
}: {
|
||||
activeHostId: string | null;
|
||||
accentMode: 'theme' | 'custom';
|
||||
currentTerminalTheme: TerminalTheme;
|
||||
customAccent: string;
|
||||
followAppTerminalTheme: boolean;
|
||||
hostById: ReadonlyMap<string, Host>;
|
||||
themeById: ReadonlyMap<string, TerminalTheme>;
|
||||
}): TerminalTheme {
|
||||
if (!activeHostId || followAppTerminalTheme) return currentTerminalTheme;
|
||||
|
||||
const host = hostById.get(activeHostId) ?? null;
|
||||
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
||||
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
}
|
||||
@@ -14,11 +14,19 @@ const I18nContext = createContext<I18nContextValue | null>(null);
|
||||
|
||||
const interpolate = (template: string, values?: InterpolationValues): string => {
|
||||
if (!values) return template;
|
||||
return template.replace(/\{(\w+)\}/g, (_match, key: string) => {
|
||||
const replaceDoubleBraceToken = (match: string, key: string) => {
|
||||
const v = values[key];
|
||||
if (v === null || v === undefined) return match;
|
||||
return String(v);
|
||||
};
|
||||
const replaceSingleBraceToken = (_match: string, key: string) => {
|
||||
const v = values[key];
|
||||
if (v === null || v === undefined) return '';
|
||||
return String(v);
|
||||
});
|
||||
};
|
||||
return template
|
||||
.replace(/\{\{(\w+)\}\}/g, replaceDoubleBraceToken)
|
||||
.replace(/\{(\w+)\}/g, replaceSingleBraceToken);
|
||||
};
|
||||
|
||||
const resolveMessage = (resolvedLocale: string, key: string): string | undefined => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { enCoreMessages } from './en/core';
|
||||
import { enVaultMessages } from './en/vault';
|
||||
import { enTerminalMessages } from './en/terminal';
|
||||
import { enAiMessages } from './en/ai';
|
||||
import { enSystemManagerMessages } from './en/systemManager';
|
||||
|
||||
export type { Messages } from './types';
|
||||
|
||||
@@ -11,6 +12,7 @@ const en: Messages = {
|
||||
...enVaultMessages,
|
||||
...enTerminalMessages,
|
||||
...enAiMessages,
|
||||
...enSystemManagerMessages,
|
||||
};
|
||||
|
||||
export default en;
|
||||
@@ -3,9 +3,11 @@ import type { Messages } from '../types';
|
||||
export const enAiMessages: Messages = {
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent Settings',
|
||||
'ai.chat.preparing': 'Preparing…',
|
||||
'ai.title': 'AI',
|
||||
'ai.description': 'Configure AI providers, agents, and safety settings',
|
||||
'ai.providers': 'Providers',
|
||||
'ai.agents': 'Agents',
|
||||
'ai.providers.empty': 'No providers configured. Add a provider to get started.',
|
||||
'ai.providers.add': 'Add Provider',
|
||||
'ai.providers.active': 'Active',
|
||||
@@ -69,6 +71,7 @@ export const enAiMessages: Messages = {
|
||||
'ai.codex.notFoundHint': 'Could not find codex in PATH. Install it or specify the executable path below.',
|
||||
'ai.codex.customPathPlaceholder': 'e.g. /usr/local/bin/codex',
|
||||
'ai.codex.check': 'Check',
|
||||
'ai.codex.resetPath': 'Reset',
|
||||
'ai.codex.openLogin': 'Open Login',
|
||||
'ai.codex.logout': 'Logout',
|
||||
'ai.codex.connectChatGPT': 'Connect ChatGPT',
|
||||
@@ -94,6 +97,7 @@ export const enAiMessages: Messages = {
|
||||
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
|
||||
'ai.claude.envVars.hint': 'One KEY=VALUE per line, passed to the Claude agent. Stored locally in plaintext — for API keys / credentials, prefer the config directory above (a `claude` login).',
|
||||
'ai.claude.check': 'Check',
|
||||
'ai.claude.resetPath': 'Reset',
|
||||
|
||||
// AI GitHub Copilot CLI
|
||||
'ai.copilot.title': 'GitHub Copilot CLI',
|
||||
@@ -105,6 +109,68 @@ export const enAiMessages: Messages = {
|
||||
'ai.copilot.notFoundHint': 'Could not find copilot in PATH. Install it or specify the executable path below.',
|
||||
'ai.copilot.customPathPlaceholder': 'e.g. /usr/local/bin/copilot',
|
||||
'ai.copilot.check': 'Check',
|
||||
'ai.copilot.resetPath': 'Reset',
|
||||
|
||||
// AI Cursor SDK
|
||||
'ai.cursor.title': 'Cursor',
|
||||
'ai.cursor.description': 'Uses the Cursor SDK.',
|
||||
'ai.cursor.detecting': 'Detecting...',
|
||||
'ai.cursor.detected': 'Available',
|
||||
'ai.cursor.notFound': 'Unavailable',
|
||||
'ai.cursor.path': 'Runtime:',
|
||||
'ai.cursor.notFoundHint': 'Enter an API key to enable Cursor.',
|
||||
'ai.cursor.notInstalledHint': 'Cursor SDK was not detected.',
|
||||
'ai.cursor.installStatus': 'Cursor SDK',
|
||||
'ai.cursor.installed': 'Detected',
|
||||
'ai.cursor.notInstalled': 'Not detected',
|
||||
'ai.cursor.apiKeyStatus': 'API Key',
|
||||
'ai.cursor.apiKeyConfigured': 'Configured',
|
||||
'ai.cursor.apiKeyMissing': 'Missing',
|
||||
'ai.cursor.apiKeyFromEnv': 'From environment',
|
||||
'ai.cursor.apiKey': 'API Key',
|
||||
'ai.cursor.apiKeyPlaceholder': 'Enter Cursor API key',
|
||||
'ai.cursor.apiKeyPlaceholder.env': 'Using CURSOR_API_KEY; enter a key to override',
|
||||
'ai.cursor.apiKeyEnvHint': 'Cursor can use CURSOR_API_KEY from your shell. Save a key here only if you want Netcatty to override it.',
|
||||
'ai.cursor.apiKeyOverrideHint': 'Netcatty will use the saved key here before CURSOR_API_KEY.',
|
||||
'ai.cursor.saveApiKey': 'Save',
|
||||
'ai.cursor.saved': 'Saved',
|
||||
'ai.cursor.showApiKey': 'Show API key',
|
||||
'ai.cursor.hideApiKey': 'Hide API key',
|
||||
'ai.cursor.customPathPlaceholder': 'e.g. /usr/local/bin/cursor',
|
||||
'ai.cursor.check': 'Check',
|
||||
|
||||
// AI CodeBuddy Code
|
||||
'ai.codebuddy.title': 'CodeBuddy Code',
|
||||
'ai.codebuddy.description': 'Uses CodeBuddy Code via the official Agent SDK (`@tencent-ai/agent-sdk`). Once detected, it can be selected as an external coding agent.',
|
||||
'ai.codebuddy.detecting': 'Detecting...',
|
||||
'ai.codebuddy.detected': 'Detected',
|
||||
'ai.codebuddy.notFound': 'Not found',
|
||||
'ai.codebuddy.path': 'Path:',
|
||||
'ai.codebuddy.notFoundHint': 'Could not find codebuddy in PATH. Install it or specify the executable path below.',
|
||||
'ai.codebuddy.customPathPlaceholder': 'e.g. /usr/local/bin/codebuddy',
|
||||
'ai.codebuddy.check': 'Check',
|
||||
'ai.codebuddy.resetPath': 'Reset',
|
||||
'ai.codebuddy.configSection': 'Authentication & config (optional)',
|
||||
'ai.codebuddy.internetEnv': 'Internet Environment',
|
||||
'ai.codebuddy.internetEnv.default': 'Default (overseas)',
|
||||
'ai.codebuddy.internetEnv.internal': 'Internal',
|
||||
'ai.codebuddy.internetEnv.ioa': 'IOA',
|
||||
'ai.codebuddy.internetEnv.hint': 'Sets CODEBUDDY_INTERNET_ENVIRONMENT — choose Internal or IOA for restricted network environments.',
|
||||
'ai.codebuddy.envVars': 'Environment variables',
|
||||
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
|
||||
'ai.codebuddy.envVars.hint': 'One KEY=VALUE per line, passed to the CodeBuddy agent. Set CODEBUDDY_API_KEY or CODEBUDDY_AUTH_TOKEN here for authentication. Stored locally in plaintext.',
|
||||
|
||||
// AI OpenCode
|
||||
'ai.opencode.title': 'OpenCode',
|
||||
'ai.opencode.description': 'Uses OpenCode via the official SDK. Configure providers and keys in OpenCode, then select it as an external coding agent.',
|
||||
'ai.opencode.detecting': 'Detecting...',
|
||||
'ai.opencode.detected': 'Detected',
|
||||
'ai.opencode.notFound': 'Not found',
|
||||
'ai.opencode.path': 'Path:',
|
||||
'ai.opencode.notFoundHint': 'Could not find opencode in PATH. Install it or specify the executable path below.',
|
||||
'ai.opencode.customPathPlaceholder': 'e.g. /usr/local/bin/opencode',
|
||||
'ai.opencode.check': 'Check',
|
||||
'ai.opencode.resetPath': 'Reset',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': 'Default Agent',
|
||||
@@ -127,6 +193,29 @@ export const enAiMessages: Messages = {
|
||||
'ai.userSkills.status.ready': 'Ready',
|
||||
'ai.userSkills.status.warning': 'Warning',
|
||||
|
||||
// AI Quick Messages
|
||||
'ai.quickMessages.title': 'Quick Messages',
|
||||
'ai.quickMessages.description': 'Create reusable prompts you can insert from the AI chat with / or the quick-message button. Unlike user skills, quick messages fill the composer with text.',
|
||||
'ai.quickMessages.add': 'Add Quick Message',
|
||||
'ai.quickMessages.createTitle': 'New Quick Message',
|
||||
'ai.quickMessages.editTitle': 'Edit Quick Message',
|
||||
'ai.quickMessages.name': 'Name',
|
||||
'ai.quickMessages.name.placeholder': 'e.g. Check disk space',
|
||||
'ai.quickMessages.slug': 'Command',
|
||||
'ai.quickMessages.slug.placeholder': 'disk-check',
|
||||
'ai.quickMessages.descriptionField': 'Description (optional)',
|
||||
'ai.quickMessages.descriptionField.placeholder': 'Short hint about what this prompt does',
|
||||
'ai.quickMessages.content': 'Message content',
|
||||
'ai.quickMessages.content.placeholder': 'Full prompt text to insert when selected...',
|
||||
'ai.quickMessages.empty': 'No quick messages yet. Add a few prompts you use often.',
|
||||
'ai.quickMessages.confirmDelete': 'Delete quick message "{name}"?',
|
||||
'ai.quickMessages.error.nameRequired': 'Name is required.',
|
||||
'ai.quickMessages.error.invalidSlug': 'Command may only contain lowercase letters, numbers, and hyphens.',
|
||||
'ai.quickMessages.error.contentRequired': 'Message content is required.',
|
||||
'ai.quickMessages.error.slugTaken': 'This command is already used by another quick message.',
|
||||
'ai.quickMessages.error.slugConflictsWithSkill': 'This command conflicts with user skill "/{slug}". Choose another.',
|
||||
'ai.quickMessages.error.maxItems': 'You can save at most {max} quick messages.',
|
||||
|
||||
// AI Chat
|
||||
'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.',
|
||||
@@ -175,6 +264,7 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.newChat': 'New Chat',
|
||||
'ai.chat.allSessions': 'All Sessions',
|
||||
'ai.chat.loadEarlierMessages': 'Load earlier messages ({n} more)',
|
||||
'ai.chat.usedTools': 'Tools used: {n}',
|
||||
'ai.chat.loadMoreSessions': 'Load more sessions ({n} more)',
|
||||
'ai.chat.noSessions': 'No previous sessions',
|
||||
'ai.chat.retryHint': 'You can retry by sending your message again.',
|
||||
@@ -185,6 +275,18 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.menuImage': 'Image',
|
||||
'ai.chat.menuMentionHost': 'Mention Host',
|
||||
'ai.chat.menuUserSkills': 'User Skills',
|
||||
'ai.chat.menuSlashCommands': 'Slash Commands',
|
||||
'ai.chat.slashCommands': 'Slash commands',
|
||||
'ai.chat.slashQuickMessages': 'Quick messages',
|
||||
'ai.chat.slashUserSkills': 'User skills',
|
||||
'ai.chat.quickMessages': 'Slash commands',
|
||||
'ai.chat.slashNoResults': 'No matching commands',
|
||||
'ai.chat.slashEmptyHint': 'Add prompts in Settings → AI → Quick Messages.',
|
||||
|
||||
// AI Chat Shortcuts
|
||||
'ai.chatShortcuts.title': 'Chat Shortcuts',
|
||||
'ai.chatShortcuts.selectionAction': 'Show Add to Conversation when selecting terminal text',
|
||||
'ai.chatShortcuts.selectionAction.description': 'Show a small AI button next to selected terminal text.',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.',
|
||||
@@ -228,7 +330,9 @@ export const enAiMessages: Messages = {
|
||||
'terminal.layer.switchToSplitView': 'Switch to Split View',
|
||||
'terminal.layer.sftp': 'SFTP',
|
||||
'terminal.layer.scripts': 'Scripts',
|
||||
'terminal.layer.history': 'History',
|
||||
'terminal.layer.theme': 'Theme',
|
||||
'terminal.layer.notes': 'Notes',
|
||||
'terminal.layer.aiChat': 'AI Chat',
|
||||
'terminal.layer.movePanelLeft': 'Move panel to left',
|
||||
'terminal.layer.movePanelRight': 'Move panel to right',
|
||||
@@ -243,6 +347,13 @@ export const enAiMessages: Messages = {
|
||||
'terminal.layer.hostTree.collapse': 'Collapse host list',
|
||||
'terminal.layer.hostTree.expand': 'Expand host list',
|
||||
'terminal.layer.hostTree.empty': 'No hosts found',
|
||||
'terminal.layer.hostTree.details.host': 'Host',
|
||||
'terminal.layer.hostTree.details.user': 'User',
|
||||
'terminal.layer.hostTree.details.port': 'Port',
|
||||
'terminal.layer.hostTree.details.protocol': 'Protocol',
|
||||
'terminal.layer.hostTree.details.group': 'Group',
|
||||
'terminal.layer.hostTree.details.tags': 'Tags',
|
||||
'terminal.layer.hostTree.details.lastConnected': 'Last connected',
|
||||
'topTabs.openQuickSwitcher': 'Open quick switcher',
|
||||
'topTabs.moreTabs': 'More tabs',
|
||||
'topTabs.aiAssistant': 'AI Assistant',
|
||||
|
||||
@@ -42,6 +42,7 @@ export const enCoreMessages: Messages = {
|
||||
'common.more': 'More',
|
||||
'common.selectAHost': 'Select a host',
|
||||
'common.selectAHostPlaceholder': 'Select a host...',
|
||||
'sort.manual': 'Manual order',
|
||||
'sort.az': 'A-z',
|
||||
'sort.za': 'Z-a',
|
||||
'sort.newest': 'Newest to oldest',
|
||||
@@ -143,6 +144,17 @@ export const enCoreMessages: Messages = {
|
||||
'settings.update.autoUpdateEnabled': 'Automatic Updates',
|
||||
'settings.update.autoUpdateEnabledDesc': 'Automatically check and download updates when available.',
|
||||
|
||||
// Settings > Session Restore
|
||||
'settings.sessionRestore.title': 'Session Restore',
|
||||
'settings.sessionRestore.restorePreviousSession': 'Restore previous tabs and workspaces',
|
||||
'settings.sessionRestore.restorePreviousSessionDesc': 'Reopen terminal tabs and workspace layouts from the last app session.',
|
||||
'settings.sessionRestore.restoreTerminalCwd': 'Restore terminal working directory',
|
||||
'settings.sessionRestore.restoreTerminalCwdDesc': 'When reconnecting a restored terminal, try to return to its last known directory without blocking the connection.',
|
||||
'terminal.restore.placeholder.title': 'Restored session',
|
||||
'terminal.restore.placeholder.desc': 'This terminal is disconnected. Reconnect when you are ready to start a new shell for this tab.',
|
||||
'terminal.restore.placeholder.reconnect': 'Reconnect',
|
||||
'terminal.restore.cwdLog': 'Restoring working directory: {cwd}',
|
||||
|
||||
// Settings > Session Logs
|
||||
'settings.sessionLogs.title': 'Session Logs',
|
||||
'settings.sessionLogs.description': 'Configure session log export and auto-save settings.',
|
||||
@@ -225,6 +237,8 @@ export const enCoreMessages: Messages = {
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'When enabled, the root host list only shows hosts without a group. Open a group from the sidebar to see grouped hosts.',
|
||||
'settings.vault.showSftpTab': 'Show SFTP tab',
|
||||
'settings.vault.showSftpTabDesc': 'Display the standalone SFTP view in the top tab bar. When hidden, use the in-session SFTP side panel instead.',
|
||||
'settings.vault.showHostTreeSidebar': 'Show host list sidebar',
|
||||
'settings.vault.showHostTreeSidebarDesc': 'Display the host list sidebar and its top-bar toggle on terminal and editor tabs.',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': 'Update Available',
|
||||
@@ -264,9 +278,9 @@ export const enCoreMessages: Messages = {
|
||||
'settings.appearance.themeColor.dark': 'Dark palette',
|
||||
'settings.appearance.customCss': 'Custom CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'Add custom CSS to personalize the app appearance. Changes apply immediately. Major UI regions expose a [data-section="..."] attribute you can target — e.g. 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-mode terminal list), terminal-host-tree-sidebar, terminal-host-tree-sidebar-content, terminal-host-tree-sidebar-row, terminal-side-panel (SFTP/Scripts/Theme/AI panel, available while open), 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.',
|
||||
'Add custom CSS to personalize the app appearance. Changes apply immediately. Major UI regions expose a [data-section="..."] attribute you can target — e.g. 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-mode terminal list), terminal-host-tree-sidebar, terminal-host-tree-sidebar-content, terminal-host-tree-sidebar-row, terminal-side-panel (SFTP/Scripts/Theme/AI panel, available while open), 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':
|
||||
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Border around the SFTP / side panel (does not linger after closing) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Change the whole side panel background, not only the top tabs */\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/* Style selected SFTP file rows */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Thicker split dividers */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Highlight the focused split pane */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Or use Settings → Terminal → Workspace Focus Indicator → Border on focused pane */',
|
||||
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Hide the host-list toggle in the top tab bar */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Hide the plus button that opens the quick switcher */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Border around the SFTP / side panel (does not linger after closing) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Change the whole side panel background, not only the top tabs */\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/* Style selected SFTP file rows */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Thicker split dividers */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Highlight the focused split pane */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Or use Settings → Terminal → Workspace Focus Indicator → Border on focused pane */',
|
||||
'settings.appearance.language': 'Language',
|
||||
'settings.appearance.language.desc': 'Choose the UI language',
|
||||
'settings.appearance.uiFont': 'Interface Font',
|
||||
@@ -309,8 +323,19 @@ export const enCoreMessages: Messages = {
|
||||
'settings.terminal.font.size.desc': 'Terminal text size',
|
||||
'settings.terminal.font.weight': 'Font weight',
|
||||
'settings.terminal.font.weight.desc': 'Weight for regular text (100-900)',
|
||||
'settings.terminal.font.weight.thin': 'Thin',
|
||||
'settings.terminal.font.weight.extraLight': 'Extra Light',
|
||||
'settings.terminal.font.weight.light': 'Light',
|
||||
'settings.terminal.font.weight.normal': 'Normal',
|
||||
'settings.terminal.font.weight.medium': 'Medium',
|
||||
'settings.terminal.font.weight.semiBold': 'Semi Bold',
|
||||
'settings.terminal.font.weight.bold': 'Bold',
|
||||
'settings.terminal.font.weight.extraBold': 'Extra Bold',
|
||||
'settings.terminal.font.weight.black': 'Black',
|
||||
'settings.terminal.font.weightBold': 'Bold font weight',
|
||||
'settings.terminal.font.weightBold.desc': 'Weight for bold text (100-900)',
|
||||
'settings.terminal.font.smoothing': 'Font smoothing',
|
||||
'settings.terminal.font.smoothing.desc': 'Use native macOS font anti-aliasing',
|
||||
'settings.terminal.font.linePadding': 'Line padding',
|
||||
'settings.terminal.font.linePadding.desc': 'Additional space between lines (0-10)',
|
||||
'settings.terminal.font.emulationType': 'Terminal emulation type',
|
||||
@@ -338,6 +363,11 @@ export const enCoreMessages: Messages = {
|
||||
'settings.terminal.behavior.middleClickPaste': 'Middle-click paste',
|
||||
'settings.terminal.behavior.middleClickPaste.desc':
|
||||
'Paste clipboard content on middle-click',
|
||||
'settings.terminal.behavior.middleClick': 'Middle-click behavior',
|
||||
'settings.terminal.behavior.middleClick.desc': 'Action when middle-clicking in terminal',
|
||||
'settings.terminal.behavior.middleClick.menu': 'Show menu',
|
||||
'settings.terminal.behavior.middleClick.paste': 'Paste',
|
||||
'settings.terminal.behavior.middleClick.disabled': 'Do nothing',
|
||||
'settings.terminal.behavior.bracketedPaste': 'Bracketed paste mode',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'Wrap pasted text with escape sequences so the shell can distinguish paste from typed input. Disable if you see ^[[200~ artifacts.',
|
||||
@@ -429,6 +459,15 @@ export const enCoreMessages: Messages = {
|
||||
'settings.terminal.connection.x11Display.desc': 'Optional local display address for X11 forwarding. Leave empty to use the system default.',
|
||||
'settings.terminal.connection.x11Display.placeholder': 'Auto (:0 or DISPLAY)',
|
||||
'settings.terminal.section.serverStats': 'Server Stats (Linux)',
|
||||
'settings.terminal.section.systemManager': 'System Manager',
|
||||
'settings.terminal.systemManager.processRefreshInterval': 'Process list refresh',
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': 'How often to refresh the process list in the system manager side panel.',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux session refresh',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'How often to refresh the tmux session list.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker container list refresh',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'How often to refresh the Docker container list.',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker stats refresh',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'How often to refresh Docker container CPU/memory/network stats.',
|
||||
'settings.terminal.serverStats.show': 'Show Server Stats',
|
||||
'settings.terminal.serverStats.show.desc': 'Display CPU, memory, and disk usage in the terminal statusbar (Linux servers only).',
|
||||
'settings.terminal.serverStats.refreshInterval': 'Refresh Interval',
|
||||
@@ -440,8 +479,18 @@ export const enCoreMessages: Messages = {
|
||||
'settings.terminal.rendering.renderer': 'Renderer',
|
||||
'settings.terminal.rendering.renderer.desc': 'Choose the terminal rendering technology. Auto will use DOM on low-memory devices. Changes take effect on new terminal sessions.',
|
||||
'settings.terminal.rendering.auto': 'Auto',
|
||||
'settings.terminal.rendering.lineTimestamps': 'Prefix output with timestamps',
|
||||
'settings.terminal.rendering.lineTimestamps.desc': 'Insert local time before terminal output lines. The timestamp becomes part of the visible terminal content.',
|
||||
'settings.terminal.rendering.hibernateHiddenTabs': 'Hibernate hidden tabs',
|
||||
'settings.terminal.rendering.hibernateHiddenTabs.desc': 'Dispose the terminal renderer for off-screen tabs to save memory while keeping the SSH session connected. Skipped during file transfers.',
|
||||
'settings.terminal.rendering.hibernateHiddenTabsDelay': 'Hibernate delay',
|
||||
'settings.terminal.rendering.hibernateHiddenTabsDelay.desc': 'How long a tab must stay off-screen before its renderer is released (5–600 seconds).',
|
||||
'settings.terminal.rendering.hibernateSkipAltScreen': 'Skip hibernate on full-screen apps',
|
||||
'settings.terminal.rendering.hibernateSkipAltScreen.desc': 'Keep the terminal renderer alive while vim, htop, or agent TUIs own the alternate screen buffer.',
|
||||
'settings.terminal.rendering.hibernateKeepRendererCount': 'Keep hidden renderers',
|
||||
'settings.terminal.rendering.hibernateKeepRendererCount.desc': 'How many off-screen tabs keep their renderer alive (WebGL suspended) before full hibernate.',
|
||||
'settings.terminal.rendering.hibernateReplayChunkBytes': 'Wake replay chunk size',
|
||||
'settings.terminal.rendering.hibernateReplayChunkBytes.desc': 'Bytes replayed per animation frame when restoring a hibernated terminal (4–64 KB).',
|
||||
'settings.terminal.rendering.hibernatePreferWasmSerialize': 'Prefer WASM serialize',
|
||||
'settings.terminal.rendering.hibernatePreferWasmSerialize.desc': 'Use WASM terminal serialization when available; falls back to the JS serializer.',
|
||||
|
||||
// Settings > Terminal > Workspace Focus Indicator
|
||||
'settings.terminal.section.workspaceFocus': 'Workspace Focus Indicator',
|
||||
@@ -466,6 +515,10 @@ export const enCoreMessages: Messages = {
|
||||
'settings.shortcuts.scheme.disabled': 'Disabled',
|
||||
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
|
||||
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
|
||||
'settings.shortcuts.disableTerminalFontZoom.label': 'Disable terminal zoom',
|
||||
'settings.shortcuts.disableTerminalFontZoom.desc': 'Turn off terminal font zoom shortcuts, including Cmd/Ctrl + mouse wheel.',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Number keys skip pinned tabs',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'When enabled, Cmd/Ctrl+[1...9] switches only work tabs (terminals, workspaces, editors), not the pinned Vault or SFTP tabs.',
|
||||
'settings.shortcuts.section.custom': 'Custom Shortcuts',
|
||||
'settings.shortcuts.resetAll': 'Reset All',
|
||||
'settings.shortcuts.recording': 'Press keys...',
|
||||
@@ -533,7 +586,7 @@ export const enCoreMessages: Messages = {
|
||||
'sync.autoSync.emptyVaultConflict.restoreDesc': 'Recommended — recover your hosts, keys, and snippets from the cloud backup',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmpty': 'Keep Empty',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': 'Start fresh with an empty vault',
|
||||
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} hosts, {keys} keys, {snippets} snippets, {proxyProfiles} proxies',
|
||||
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} hosts, {keys} keys, {snippets} snippets, {notes} notes, {proxyProfiles} proxies',
|
||||
'sync.autoSync.emptyVaultManual': 'Cannot sync: the local vault is empty. Restore from a local backup or enable Force Push in the sync panel first.',
|
||||
|
||||
'sync.blocked.title': 'Sync paused',
|
||||
@@ -570,6 +623,7 @@ export const enCoreMessages: Messages = {
|
||||
'vault.nav.proxies': 'Proxies',
|
||||
'vault.nav.portForwarding': 'Port Forwarding',
|
||||
'vault.nav.snippets': 'Snippets',
|
||||
'vault.nav.notes': 'Notes',
|
||||
'vault.nav.knownHosts': 'Known Hosts',
|
||||
'vault.nav.logs': 'Logs',
|
||||
|
||||
@@ -667,7 +721,9 @@ export const enCoreMessages: Messages = {
|
||||
'vault.hosts.connectSelected': 'Connect ({count})',
|
||||
'vault.hosts.connectMultiple.success': 'Connecting {count} hosts',
|
||||
'vault.hosts.moveToGroup.success': 'Moved {host} to {group}',
|
||||
'vault.hosts.errors.nameRequired': 'Host name is required.',
|
||||
'vault.hosts.empty.title': 'Set up your hosts',
|
||||
'vault.hosts.empty.desc': 'Save hosts to quickly connect to your servers, VMs, and containers.',
|
||||
'deepLink.ssh.invalid': 'Invalid ssh:// link',
|
||||
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enSystemManagerMessages: Messages = {
|
||||
'terminal.layer.system': 'System',
|
||||
|
||||
'systemManager.noSession': 'No active terminal session.',
|
||||
'systemManager.notConnected': 'Connect to a host to manage processes and services.',
|
||||
'systemManager.empty': 'No data available.',
|
||||
'systemManager.tabs.processes': 'Processes',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.popup.loading': 'Opening terminal…',
|
||||
'systemManager.popup.startupFailed': 'The startup command did not complete successfully. Check that the target is still available and try again.',
|
||||
|
||||
'systemManager.errors.loadProcesses': 'Failed to load processes',
|
||||
'systemManager.errors.loadTmux': 'Failed to load tmux sessions',
|
||||
'systemManager.errors.loadTmuxWindows': 'Failed to load tmux windows',
|
||||
'systemManager.errors.loadTmuxPanes': 'Failed to load tmux panes',
|
||||
'systemManager.errors.loadTmuxClients': 'Failed to load tmux clients',
|
||||
'systemManager.errors.actionFailed': 'Action failed',
|
||||
'systemManager.errors.loadDocker': 'Failed to load containers',
|
||||
'systemManager.errors.loadDockerStats': 'Failed to load container stats',
|
||||
'systemManager.errors.loadDockerImages': 'Failed to load images',
|
||||
'systemManager.errors.sshChannelUnavailable': 'The server refused to open a new execution channel. Try again later, or reconnect this host.',
|
||||
|
||||
'systemManager.processes.search': 'Search processes…',
|
||||
'systemManager.processes.command': 'Command',
|
||||
'systemManager.processes.user': 'User',
|
||||
'systemManager.processes.term': 'Terminate',
|
||||
'systemManager.processes.kill': 'Kill',
|
||||
'systemManager.processes.stop': 'Stop (SIGSTOP)',
|
||||
'systemManager.processes.cont': 'Continue (SIGCONT)',
|
||||
'systemManager.processes.hup': 'Hang up (SIGHUP)',
|
||||
'systemManager.processes.renice': 'Renice',
|
||||
'systemManager.processes.renicePrompt': 'Nice value (-20 to 19)',
|
||||
'systemManager.processes.reniceInvalid': 'Nice value must be between -20 and 19',
|
||||
'systemManager.processes.confirmKill': 'Send SIGKILL to process {{pid}}?',
|
||||
'systemManager.processes.confirmSignal': 'Send SIG{{signal}} to process {{pid}}?',
|
||||
'systemManager.processes.filter.all': 'All',
|
||||
'systemManager.processes.filter.running': 'Running',
|
||||
'systemManager.processes.ppid': 'Parent PID',
|
||||
'systemManager.processes.rss': 'RSS',
|
||||
'systemManager.processes.vsz': 'Virtual size',
|
||||
'systemManager.processes.elapsed': 'Elapsed',
|
||||
'systemManager.processes.stat': 'State',
|
||||
'systemManager.processes.meta': '{{count}} process(es)',
|
||||
'systemManager.processes.loading': 'Loading processes…',
|
||||
'systemManager.processes.loadingMore': 'Loading more processes…',
|
||||
'systemManager.processes.state.running': 'Running',
|
||||
'systemManager.processes.state.sleeping': 'Sleeping',
|
||||
'systemManager.processes.state.stopped': 'Stopped',
|
||||
'systemManager.processes.state.zombie': 'Zombie',
|
||||
'systemManager.processes.sort.cpu': 'CPU',
|
||||
'systemManager.processes.sort.mem': 'MEM',
|
||||
'systemManager.processes.sort.pid': 'PID',
|
||||
'systemManager.processes.sort.command': 'Command',
|
||||
'systemManager.processes.sort.user': 'User',
|
||||
|
||||
'systemManager.common.dismiss': 'Dismiss',
|
||||
'systemManager.common.checkingAvailability': 'Checking availability…',
|
||||
'systemManager.common.loading': 'Loading…',
|
||||
'systemManager.common.loadingDetails': 'Loading details…',
|
||||
'systemManager.common.loadingStats': 'Loading stats…',
|
||||
|
||||
'systemManager.tmux.new': 'New',
|
||||
'systemManager.tmux.search': 'Search sessions…',
|
||||
'systemManager.tmux.newSessionTitle': 'New tmux session',
|
||||
'systemManager.tmux.newSessionDesc': 'Name the session and optionally run a script on start.',
|
||||
'systemManager.tmux.newSessionTabCustom': 'Custom command',
|
||||
'systemManager.tmux.newSessionTabSnippet': 'From snippet',
|
||||
'systemManager.tmux.pickSnippet': 'From snippets',
|
||||
'systemManager.tmux.pickSnippetEmpty': 'No snippets yet — add some in the Scripts panel or Vault.',
|
||||
'systemManager.tmux.selectedSnippet': 'Using snippet: {{label}}',
|
||||
'systemManager.tmux.newSessionName': 'Session name',
|
||||
'systemManager.tmux.newSessionCommand': 'Start command',
|
||||
'systemManager.tmux.newSessionCommandPlaceholder': 'e.g. htop or npm run dev (optional)',
|
||||
'systemManager.tmux.newSessionCommandHint': 'Leave empty for a default shell session.',
|
||||
'systemManager.tmux.creating': 'Creating…',
|
||||
'systemManager.tmux.newSessionPlaceholder': 'my-session',
|
||||
'systemManager.tmux.newSessionRequired': 'Enter a session name first',
|
||||
'systemManager.tmux.empty': 'No tmux sessions',
|
||||
'systemManager.tmux.attach': 'Attach',
|
||||
'systemManager.tmux.attached': 'Attached',
|
||||
'systemManager.tmux.detached': 'Detached',
|
||||
'systemManager.tmux.windows': '{{count}} window(s)',
|
||||
'systemManager.tmux.created': 'Created',
|
||||
'systemManager.tmux.activity': 'Activity',
|
||||
'systemManager.tmux.rename': 'Rename',
|
||||
'systemManager.tmux.detach': 'Detach all',
|
||||
'systemManager.tmux.killSession': 'Kill session',
|
||||
'systemManager.tmux.killServer': 'Kill server',
|
||||
'systemManager.tmux.loadingDetails': 'Loading details…',
|
||||
'systemManager.tmux.clients': 'Attached clients',
|
||||
'systemManager.tmux.windowList': 'Windows',
|
||||
'systemManager.tmux.newWindow': 'New window',
|
||||
'systemManager.tmux.newWindowPlaceholder': 'Window name (optional)',
|
||||
'systemManager.tmux.noWindows': 'No windows',
|
||||
'systemManager.tmux.unavailable': 'tmux is not available on this host',
|
||||
'systemManager.docker.unavailable': 'Docker is not available on this host',
|
||||
'systemManager.tmux.windowsMismatch': 'Session reports {{count}} window(s) but list-windows returned none',
|
||||
'systemManager.tmux.lastCommand': 'last command: {{command}}',
|
||||
'systemManager.tmux.noPanes': 'No panes',
|
||||
'systemManager.tmux.panes': '{{count}} pane(s)',
|
||||
'systemManager.tmux.active': 'active',
|
||||
'systemManager.tmux.unnamedWindow': 'Unnamed window',
|
||||
'systemManager.tmux.unnamedPane': 'Unnamed pane',
|
||||
'systemManager.tmux.attachWindow': 'Attach to window',
|
||||
'systemManager.tmux.selectWindow': 'Select window',
|
||||
'systemManager.tmux.killWindow': 'Kill window',
|
||||
'systemManager.tmux.killPane': 'Kill pane',
|
||||
'systemManager.tmux.splitHorizontal': 'Split horizontal',
|
||||
'systemManager.tmux.splitVertical': 'Split vertical',
|
||||
'systemManager.tmux.sendKeys': 'Send keys',
|
||||
'systemManager.tmux.sendKeysTo': 'Send keys to window {{window}} pane {{pane}}',
|
||||
'systemManager.tmux.sendKeysPlaceholder': 'Command or text…',
|
||||
'systemManager.tmux.renameSessionPrompt': 'Rename session',
|
||||
'systemManager.tmux.renameWindowPrompt': 'Rename window',
|
||||
'systemManager.tmux.windowName': 'Window name',
|
||||
'systemManager.tmux.confirmKillSession': 'Kill tmux session "{{name}}"?',
|
||||
'systemManager.tmux.confirmDetachSession': 'Detach all clients from "{{name}}"?',
|
||||
'systemManager.tmux.confirmKillWindow': 'Kill window "{{name}}"?',
|
||||
'systemManager.tmux.confirmKillPane': 'Kill pane #{{index}}?',
|
||||
'systemManager.tmux.confirmKillServer': 'Kill tmux server? All sessions will be terminated.',
|
||||
'systemManager.tmux.meta': '{{count}} session(s)',
|
||||
|
||||
'systemManager.docker.title': 'Containers',
|
||||
'systemManager.docker.subTabs.containers': 'Containers',
|
||||
'systemManager.docker.subTabs.images': 'Images',
|
||||
'systemManager.docker.empty': 'No containers found',
|
||||
'systemManager.docker.imagesEmpty': 'No images found',
|
||||
'systemManager.docker.search': 'Search containers…',
|
||||
'systemManager.docker.searchImages': 'Search images…',
|
||||
'systemManager.docker.filter.all': 'All',
|
||||
'systemManager.docker.filter.running': 'Running',
|
||||
'systemManager.docker.filter.stopped': 'Stopped',
|
||||
'systemManager.docker.filter.paused': 'Paused',
|
||||
'systemManager.docker.shell': 'Shell',
|
||||
'systemManager.docker.logs': 'Logs',
|
||||
'systemManager.docker.details': 'Details',
|
||||
'systemManager.docker.inspect': 'Inspect',
|
||||
'systemManager.docker.imageInspect': 'Image inspect',
|
||||
'systemManager.docker.confirmRemove': 'Remove this container?',
|
||||
'systemManager.docker.confirmKill': 'Force kill this container?',
|
||||
'systemManager.docker.confirmRemoveImage': 'Remove image "{{name}}"?',
|
||||
'systemManager.docker.confirmPrune': 'Remove dangling images?',
|
||||
'systemManager.docker.confirmPruneAll': 'Remove all unused images?',
|
||||
'systemManager.docker.pause': 'Pause',
|
||||
'systemManager.docker.unpause': 'Unpause',
|
||||
'systemManager.docker.restart': 'Restart',
|
||||
'systemManager.docker.kill': 'Kill',
|
||||
'systemManager.docker.renamePrompt': 'Container name',
|
||||
'systemManager.docker.prune': 'Prune',
|
||||
'systemManager.docker.pruneAll': 'Prune all',
|
||||
'systemManager.docker.tag': 'Tag',
|
||||
'systemManager.docker.tagRepoPrompt': 'Repository name',
|
||||
'systemManager.docker.tagNamePrompt': 'Tag name',
|
||||
'systemManager.docker.meta': '{{count}} container(s)',
|
||||
'systemManager.docker.imagesMeta': '{{count}} image(s)',
|
||||
'systemManager.docker.start': 'Start',
|
||||
'systemManager.docker.stop': 'Stop',
|
||||
|
||||
'systemManager.inspect.status': 'Status',
|
||||
'systemManager.inspect.image': 'Image',
|
||||
'systemManager.inspect.created': 'Created',
|
||||
'systemManager.inspect.started': 'Started',
|
||||
'systemManager.inspect.restartPolicy': 'Restart policy',
|
||||
'systemManager.inspect.command': 'Command',
|
||||
'systemManager.inspect.ports': 'Ports',
|
||||
'systemManager.inspect.networks': 'Networks',
|
||||
'systemManager.inspect.mounts': 'Mounts',
|
||||
'systemManager.inspect.env': 'Environment',
|
||||
'systemManager.inspect.labels': 'Labels',
|
||||
'systemManager.inspect.tags': 'Tags',
|
||||
'systemManager.inspect.digests': 'Digests',
|
||||
'systemManager.inspect.size': 'Size',
|
||||
'systemManager.inspect.platform': 'Platform',
|
||||
'systemManager.inspect.workdir': 'Working dir',
|
||||
'systemManager.inspect.exposedPorts': 'Exposed ports',
|
||||
'systemManager.inspect.showRaw': 'JSON',
|
||||
'systemManager.inspect.hideRaw': 'Hide JSON',
|
||||
};
|
||||
@@ -5,14 +5,35 @@ export const enTerminalMessages: Messages = {
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': 'Open SFTP',
|
||||
'terminal.toolbar.availableAfterConnect': 'Available after connect',
|
||||
'terminal.toolbar.sendYmodem': 'Send with YMODEM',
|
||||
'terminal.toolbar.receiveYmodem': 'Receive with YMODEM',
|
||||
'terminal.toolbar.sftp': 'SFTP',
|
||||
'terminal.toolbar.more': 'More actions',
|
||||
'terminal.toolbar.scripts': 'Scripts',
|
||||
'terminal.toolbar.history': 'Command history',
|
||||
'terminal.toolbar.configureOsc7': 'Configure directory tracking',
|
||||
'history.scope.label': 'History scope',
|
||||
'history.tab.host': 'Host',
|
||||
'history.tab.global': 'Global',
|
||||
'history.searchPlaceholder': 'Search history...',
|
||||
'history.loading': 'Loading remote history...',
|
||||
'history.meta.count': '{count} commands',
|
||||
'history.empty.noSession': 'Open a remote session to view its command history.',
|
||||
'history.empty.unsupportedProtocol': 'Command history is only available for SSH/Mosh/ET sessions.',
|
||||
'history.empty.noHistory': 'No command history found on this host.',
|
||||
'history.empty.noGlobalHistory': 'No global command history yet. Commands you run will appear here.',
|
||||
'history.action.refresh': 'Refresh',
|
||||
'history.action.retry': 'Retry',
|
||||
'history.action.paste': 'Paste to terminal',
|
||||
'history.action.run': 'Run in terminal',
|
||||
'history.action.saveAsSnippet': 'Save as snippet',
|
||||
'terminal.toolbar.library': 'Library',
|
||||
'terminal.toolbar.noSnippets': 'No snippets available',
|
||||
'terminal.toolbar.terminalSettings': 'Terminal settings',
|
||||
'terminal.toolbar.searchTerminal': 'Search terminal',
|
||||
'terminal.toolbar.search': 'Search',
|
||||
'terminal.toolbar.timestampsEnable': 'Show timestamps',
|
||||
'terminal.toolbar.timestampsDisable': 'Hide timestamps',
|
||||
'terminal.toolbar.broadcast': 'Broadcast',
|
||||
'terminal.toolbar.broadcastEnable': 'Enable Broadcast Mode',
|
||||
'terminal.toolbar.broadcastDisable': 'Disable Broadcast Mode',
|
||||
@@ -21,8 +42,17 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.composeBar.send': 'Send',
|
||||
'terminal.composeBar.close': 'Close compose bar',
|
||||
'terminal.composeBar.broadcasting': 'Broadcasting to all sessions',
|
||||
'terminal.composeBar.resize': 'Resize compose bar height',
|
||||
'terminal.composeBar.manageSnippets': 'Manage quick snippets',
|
||||
'terminal.composeBar.searchSnippets': 'Search snippets...',
|
||||
'terminal.composeBar.noPinnedSnippets': 'Pin snippets with + for quick access',
|
||||
'terminal.composeBar.noMatchingSnippets': 'No matching snippets',
|
||||
'terminal.composeBar.pinnedCount': '{count} pinned',
|
||||
'terminal.composeBar.unpinSnippet': 'Remove {label} from quick bar',
|
||||
'terminal.composeBar.snippetClickHint': 'Click to insert · Shift+Click to send',
|
||||
'terminal.toolbar.focus': 'Focus',
|
||||
'terminal.toolbar.focusMode': 'Focus Mode',
|
||||
'terminal.toolbar.detach': 'Detach to standalone tab',
|
||||
'terminal.toolbar.encoding': 'Terminal Encoding',
|
||||
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
||||
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
||||
@@ -61,7 +91,9 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.dragDrop.localTitle': 'Drop to Insert Paths',
|
||||
'terminal.dragDrop.localMessage': 'File paths will be inserted into the terminal',
|
||||
'terminal.dragDrop.remoteTitle': 'Drop to Upload Files',
|
||||
'terminal.dragDrop.remoteMessage': 'Files will be uploaded via SFTP',
|
||||
'terminal.dragDrop.remoteZmodemMessage': 'Files will be uploaded via ZMODEM (PTY)',
|
||||
'terminal.dragDrop.remoteSftpMessage': 'Files will be uploaded via SFTP',
|
||||
'terminal.dragDrop.noFiles': 'No files to upload',
|
||||
'terminal.dragDrop.notConnected': 'Cannot drop files - terminal is not connected',
|
||||
'terminal.dragDrop.errorTitle': 'Drop Error',
|
||||
'terminal.dragDrop.errorMessage': 'Failed to process dropped files',
|
||||
@@ -71,14 +103,40 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.search.nextMatch': 'Next match (Enter)',
|
||||
'terminal.menu.copy': 'Copy',
|
||||
'terminal.menu.paste': 'Paste',
|
||||
'terminal.menu.uploadClipboardImage': 'Upload clipboard image',
|
||||
'terminal.menu.addSelectionToAI': 'Add to Conversation',
|
||||
'terminal.menu.pasteSelection': 'Paste Selection',
|
||||
'terminal.menu.selectAll': 'Select All',
|
||||
'terminal.menu.reconnect': 'Reconnect',
|
||||
'terminal.menu.sendYmodem': 'Send with YMODEM',
|
||||
'terminal.menu.receiveYmodem': 'Receive with YMODEM',
|
||||
'terminal.menu.splitHorizontal': 'Split Horizontal',
|
||||
'terminal.menu.splitVertical': 'Split Vertical',
|
||||
'terminal.menu.clearBuffer': 'Clear Buffer',
|
||||
'terminal.menu.closeTerminal': 'Close terminal',
|
||||
'terminal.menu.rename': 'Rename',
|
||||
'terminal.menu.detach': 'Detach from workspace',
|
||||
'terminal.menu.detachSession': 'Detach {name}',
|
||||
'terminal.clipboardImageUpload.noImage': 'Clipboard does not contain an image',
|
||||
'terminal.clipboardImageUpload.failed': 'Could not upload clipboard image',
|
||||
'terminal.osc7Setup.title': 'Configure directory tracking',
|
||||
'terminal.osc7Setup.desc': 'Netcatty will add OSC 7 prompt hooks for the current remote user. This helps SFTP follow the terminal directory after sudo or su.',
|
||||
'terminal.osc7Setup.targets': 'Possible files to update',
|
||||
'terminal.osc7Setup.command': 'Command to run',
|
||||
'terminal.osc7Setup.run': 'Run setup',
|
||||
'terminal.osc7Setup.sent': 'Directory tracking setup sent to terminal',
|
||||
'terminal.ymodem.selectFile': 'Select file to send',
|
||||
'terminal.ymodem.allFiles': 'All files',
|
||||
'terminal.ymodem.started': 'YMODEM sending {fileName}',
|
||||
'terminal.ymodem.complete': 'YMODEM sent {fileName}',
|
||||
'terminal.ymodem.failed': 'YMODEM send failed',
|
||||
'terminal.ymodem.selectReceiveDirectory': 'Select folder to save received files',
|
||||
'terminal.ymodem.receiveStarted': 'YMODEM receiving...',
|
||||
'terminal.ymodem.receiveComplete': 'YMODEM received {fileName}',
|
||||
'terminal.ymodem.receiveCompleteMultiple': 'YMODEM received {count} files',
|
||||
'terminal.ymodem.receiveEmpty': 'No YMODEM files received',
|
||||
'terminal.ymodem.receiveFailed': 'YMODEM receive failed',
|
||||
'terminal.ymodem.unavailable': 'YMODEM is unavailable',
|
||||
'terminal.selection.addToAI': 'Add to Conversation',
|
||||
'terminal.selection.addToAIDesc': 'Attach selected terminal output to the AI draft',
|
||||
'terminal.auth.password': 'Password',
|
||||
@@ -286,7 +344,7 @@ export const enTerminalMessages: Messages = {
|
||||
'cloudSync.localBackups.reason.appVersionChange': 'Before app version change',
|
||||
'cloudSync.localBackups.reason.beforeRestore': 'Before restore',
|
||||
'cloudSync.localBackups.versionChange': '{from} -> {to}',
|
||||
'cloudSync.localBackups.counts': '{hosts} hosts, {keys} keys, {snippets} snippets',
|
||||
'cloudSync.localBackups.counts': '{hosts} hosts, {keys} keys, {snippets} snippets, {notes} notes',
|
||||
'cloudSync.localBackups.restore': 'Restore',
|
||||
'cloudSync.localBackups.restoreSuccess': 'Local backup restored.',
|
||||
'cloudSync.localBackups.restoreFailedTitle': 'Restore failed',
|
||||
@@ -311,6 +369,7 @@ export const enTerminalMessages: Messages = {
|
||||
'cloudSync.revisionHistory.hosts': 'Hosts',
|
||||
'cloudSync.revisionHistory.keys': 'Keys',
|
||||
'cloudSync.revisionHistory.snippets': 'Snippets',
|
||||
'cloudSync.revisionHistory.notes': 'Notes',
|
||||
'cloudSync.revisionHistory.identities': 'Identities',
|
||||
'cloudSync.revisionHistory.restoreButton': 'Restore This Version',
|
||||
'cloudSync.revisionHistory.restored': 'Vault restored from selected revision.',
|
||||
@@ -364,6 +423,8 @@ export const enTerminalMessages: Messages = {
|
||||
'cloudSync.conflict.entity.identities': 'Identities',
|
||||
'cloudSync.conflict.entity.proxyProfiles': 'Proxy profiles',
|
||||
'cloudSync.conflict.entity.snippets': 'Snippets',
|
||||
'cloudSync.conflict.entity.notes': 'Notes',
|
||||
'cloudSync.conflict.entity.noteGroups': 'Note groups',
|
||||
'cloudSync.conflict.entity.customGroups': 'Groups',
|
||||
'cloudSync.conflict.entity.snippetPackages': 'Snippet packages',
|
||||
'cloudSync.conflict.entity.portForwardingRules': 'Port forwarding',
|
||||
@@ -515,6 +576,8 @@ export const enTerminalMessages: Messages = {
|
||||
'snippets.searchPlaceholder': 'Search snippets...',
|
||||
'snippets.action.newSnippet': 'New Snippet',
|
||||
'snippets.action.newPackage': 'New Package',
|
||||
'snippets.action.import': 'Import',
|
||||
'snippets.action.selectSnippets': 'Select snippets',
|
||||
'snippets.panel.newTitle': 'New Snippet',
|
||||
'snippets.panel.editTitle': 'Edit Snippet',
|
||||
'snippets.field.description': 'Action description',
|
||||
@@ -552,6 +615,34 @@ export const enTerminalMessages: Messages = {
|
||||
'snippets.commandFallback': 'Command',
|
||||
'snippets.view.grid': 'Grid',
|
||||
'snippets.view.list': 'List',
|
||||
'snippets.selection.selected': '{count} selected',
|
||||
'snippets.selection.selectVisible': 'Select visible',
|
||||
'snippets.selection.deselectAll': 'Deselect all',
|
||||
'snippets.selection.exportSelected': 'Export selected ({count})',
|
||||
'snippets.export.snippet': 'Export snippet',
|
||||
'snippets.export.package': 'Export package',
|
||||
'snippets.export.toast.empty': 'No snippets to export.',
|
||||
'snippets.export.toast.successTitle': 'Export ready',
|
||||
'snippets.export.toast.success': 'Exported {count} snippet(s).',
|
||||
'snippets.import.toast.empty': 'No importable snippets found.',
|
||||
'snippets.import.toast.failedTitle': 'Import failed',
|
||||
'snippets.import.toast.invalidDesc': 'This is not a valid Netcatty snippets file.',
|
||||
'snippets.import.toast.successTitle': 'Import completed',
|
||||
'snippets.import.toast.summary': 'Imported {imported}, overwritten {overwritten}, skipped {skipped}.',
|
||||
'snippets.import.modal.title': 'Import snippets',
|
||||
'snippets.import.modal.desc': 'Choose one or more Netcatty snippets JSON files. Netcatty will preview them before anything is imported.',
|
||||
'snippets.import.modal.exampleTitle': 'Example JSON',
|
||||
'snippets.import.modal.noFile': 'No file selected yet. Use the example format below, or a plain JSON array of snippets, then choose one or more files.',
|
||||
'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.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.parent': 'Parent: {parent}',
|
||||
'snippets.packageDialog.root': 'Root',
|
||||
|
||||
@@ -1,6 +1,34 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enVaultMessages: Messages = {
|
||||
// Notes
|
||||
'notes.search.placeholder': 'Search notes, hosts, tags...',
|
||||
'notes.action.newNote': 'New Note',
|
||||
'notes.action.newGroup': 'New Folder',
|
||||
'notes.action.importMarkdown': 'Import Markdown',
|
||||
'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.codeBlock.copied': 'Copied',
|
||||
'notes.codeBlock.copyFailed': 'Failed to copy code block.',
|
||||
'notes.group.prompt': 'Folder path',
|
||||
'notes.group.all': 'Notebook',
|
||||
'notes.group.ungrouped': 'Ungrouped',
|
||||
'notes.section.notebooks': 'Notebook',
|
||||
'notes.section.notes': 'Notes',
|
||||
'notes.mode.edit': 'Edit',
|
||||
'notes.mode.split': 'Split',
|
||||
'notes.mode.preview': 'Preview',
|
||||
'notes.title.placeholder': 'Note title',
|
||||
'notes.editor.placeholder': 'Write Markdown notes here...',
|
||||
'notes.preview.empty': 'Nothing to preview yet.',
|
||||
'notes.empty.title': 'No notes yet',
|
||||
'notes.empty.desc': 'Create Markdown notes for runbooks, reminders, and server handoff details.',
|
||||
'notes.empty.group': 'No notes in this notebook.',
|
||||
'notes.search.noResults': 'No notes found.',
|
||||
|
||||
// Vault import
|
||||
'vault.import.title': 'Add data to your vault',
|
||||
'vault.import.desc':
|
||||
@@ -101,8 +129,9 @@ export const enVaultMessages: Messages = {
|
||||
'pf.form.openWizardTitle': 'Open Port Forwarding Wizard',
|
||||
'pf.view.grid': 'Grid',
|
||||
'pf.view.list': 'List',
|
||||
'pf.rule.summary.local': 'Local {bindAddress}:{localPort} -> SSH tunnel -> {remoteHost}:{remotePort}',
|
||||
'pf.rule.summary.remote': 'Remote {bindAddress}:{localPort} -> SSH tunnel -> local {remoteHost}:{remotePort}',
|
||||
'pf.rule.summary.dynamic': 'SOCKS on {bindAddress}:{localPort}',
|
||||
'pf.rule.summary.default': '{bindAddress}:{localPort} -> {remoteHost}:{remotePort}',
|
||||
'pf.tooltip.relayHost': 'Relay Host',
|
||||
'pf.tooltip.hostLabel': 'Host',
|
||||
'pf.tooltip.hostAddress': 'Address',
|
||||
@@ -123,6 +152,7 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.filter.placeholder': 'Filter by filename...',
|
||||
'sftp.bookmark.add': 'Bookmark this path',
|
||||
'sftp.bookmark.remove': 'Remove bookmark',
|
||||
'sftp.bookmark.list': 'Bookmarked paths',
|
||||
'sftp.bookmark.addGlobal': '+Global',
|
||||
'sftp.bookmark.addGlobalTooltip': 'Save as global bookmark (shared across all hosts)',
|
||||
'sftp.bookmark.empty': 'No bookmarks yet',
|
||||
@@ -150,9 +180,14 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.moveTo.pathNotFound': 'Directory not found or inaccessible',
|
||||
'sftp.context.download': 'Download',
|
||||
'sftp.context.copyToOtherPane': 'Copy to other pane',
|
||||
'sftp.copyCurrentPath': 'Copy current path',
|
||||
'sftp.copyCurrentPath.success': 'Current path copied',
|
||||
'sftp.copyCurrentPath.error': 'Could not copy current path',
|
||||
'sftp.viewMode.label': 'View mode',
|
||||
'sftp.viewMode.list': 'List view',
|
||||
'sftp.viewMode.tree': 'Tree view',
|
||||
'sftp.viewMode.switchToList': 'Switch to list view',
|
||||
'sftp.viewMode.switchToTree': 'Switch to tree view',
|
||||
'sftp.tree.loadError': 'Failed to load directory',
|
||||
'sftp.tree.loading': 'Loading...',
|
||||
'sftp.kind.folder': 'Folder',
|
||||
@@ -255,6 +290,8 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.tabs.addTab': 'Add new tab',
|
||||
'sftp.tabs.closeTab': 'Close tab',
|
||||
'sftp.tabs.newTab': 'New Tab',
|
||||
'sftp.tabs.copyDefaultPath': 'Copy tab (default path)',
|
||||
'sftp.tabs.copyCurrentPath': 'Copy and go to current path',
|
||||
'sftp.conflict.title': 'File Conflict',
|
||||
'sftp.conflict.desc': 'A file with the same name already exists at the destination',
|
||||
'sftp.conflict.alreadyExistsSuffix': 'already exists',
|
||||
@@ -339,6 +376,9 @@ export const enVaultMessages: Messages = {
|
||||
'settings.sftp.doubleClickBehavior.transfer': 'Transfer to other pane',
|
||||
'settings.sftp.doubleClickBehavior.openDesc': 'Open the file in the default application',
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': 'Transfer the file to the other pane\'s active host',
|
||||
'settings.sshDeepLink.title': 'SSH links',
|
||||
'settings.sshDeepLink.enable': 'Open ssh:// links with Netcatty',
|
||||
'settings.sshDeepLink.enableDesc': 'Allow Netcatty to handle ssh:// links from browsers and other apps.',
|
||||
|
||||
// Settings > SFTP Auto Sync
|
||||
'settings.sftp.autoSync': 'Auto-sync to remote',
|
||||
@@ -460,6 +500,63 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.section.appearance': 'Appearance',
|
||||
'hostDetails.distro.title': 'Linux Distribution',
|
||||
'hostDetails.distro.desc': 'Auto-detect on connect, or override the distro icon manually.',
|
||||
'hostDetails.icon.title': 'Host Icon',
|
||||
'hostDetails.icon.sectionTitle': 'Color & Icon',
|
||||
'hostDetails.icon.desc': 'Choose the icon source and color for this host.',
|
||||
'hostDetails.icon.source': 'Source',
|
||||
'hostDetails.icon.manualLabel': 'Manual icon',
|
||||
'hostDetails.icon.tab.brand': 'Brand',
|
||||
'hostDetails.icon.tab.type': 'Type',
|
||||
'hostDetails.icon.mode.auto': 'Automatic',
|
||||
'hostDetails.icon.mode.custom': 'Custom',
|
||||
'hostDetails.icon.reset': 'Reset host icon',
|
||||
'hostDetails.icon.colorLabel': 'Icon color',
|
||||
'hostDetails.icon.colorDefault': 'Default',
|
||||
'hostDetails.icon.colorAuto': 'Automatic',
|
||||
'hostDetails.icon.colorManual': 'Manual',
|
||||
'hostDetails.icon.colorChoice': 'Color',
|
||||
'hostDetails.icon.autoColorLabel': 'Current color',
|
||||
'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.',
|
||||
'hostDetails.icon.customOverridesDistro': 'Built-in icon replaces Linux Distribution for this host.',
|
||||
'hostDetails.icon.option.server': 'Server',
|
||||
'hostDetails.icon.option.terminal': 'Terminal',
|
||||
'hostDetails.icon.option.database': 'Database',
|
||||
'hostDetails.icon.option.cloud': 'Cloud',
|
||||
'hostDetails.icon.option.router': 'Router',
|
||||
'hostDetails.icon.option.shield': 'Shield',
|
||||
'hostDetails.icon.option.code': 'Code',
|
||||
'hostDetails.icon.option.box': 'Box',
|
||||
'hostDetails.icon.option.globe': 'Globe',
|
||||
'hostDetails.icon.option.cpu': 'CPU',
|
||||
'hostDetails.icon.option.hard-drive': 'Storage',
|
||||
'hostDetails.icon.option.network': 'Network',
|
||||
'hostDetails.icon.option.wifi': 'Wireless',
|
||||
'hostDetails.icon.option.lock': 'Lock',
|
||||
'hostDetails.icon.option.key': 'Key',
|
||||
'hostDetails.icon.option.monitor': 'Monitor',
|
||||
'hostDetails.icon.option.container': 'Container',
|
||||
'hostDetails.icon.option.activity': 'Activity',
|
||||
'hostDetails.icon.option.zap': 'Fast',
|
||||
'hostDetails.icon.option.server-cog': 'Server settings',
|
||||
'hostDetails.icon.color.blue': 'Blue',
|
||||
'hostDetails.icon.color.green': 'Green',
|
||||
'hostDetails.icon.color.red': 'Red',
|
||||
'hostDetails.icon.color.amber': 'Amber',
|
||||
'hostDetails.icon.color.purple': 'Purple',
|
||||
'hostDetails.icon.color.cyan': 'Cyan',
|
||||
'hostDetails.icon.color.orange': 'Orange',
|
||||
'hostDetails.icon.color.slate': 'Slate',
|
||||
'hostDetails.icon.color.violet': 'Violet',
|
||||
'hostDetails.icon.color.pink': 'Pink',
|
||||
'hostDetails.icon.color.rose': 'Rose',
|
||||
'hostDetails.icon.color.lime': 'Lime',
|
||||
'hostDetails.icon.color.teal': 'Teal',
|
||||
'hostDetails.icon.color.sky': 'Sky',
|
||||
'hostDetails.icon.color.indigo': 'Indigo',
|
||||
'hostDetails.icon.color.zinc': 'Zinc',
|
||||
'hostDetails.icon.color.custom': 'Custom color',
|
||||
'hostDetails.distro.mode': 'Source',
|
||||
'hostDetails.distro.mode.auto': 'Auto-detect',
|
||||
'hostDetails.distro.mode.manual': 'Manual override',
|
||||
@@ -480,12 +577,14 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
|
||||
'hostDetails.distro.option.almalinux': 'AlmaLinux',
|
||||
'hostDetails.distro.option.alinux': 'Alibaba Cloud Linux',
|
||||
'hostDetails.distro.option.openeuler': 'openEuler',
|
||||
'hostDetails.distro.option.oracle': 'Oracle Linux',
|
||||
'hostDetails.distro.option.kali': 'Kali Linux',
|
||||
'hostDetails.distro.option.cisco': 'Cisco',
|
||||
'hostDetails.distro.option.juniper': 'Juniper Networks',
|
||||
'hostDetails.distro.option.huawei': 'Huawei',
|
||||
'hostDetails.distro.option.hpe': 'HPE / H3C',
|
||||
'hostDetails.distro.option.h3c': 'H3C',
|
||||
'hostDetails.distro.option.hpe': 'HPE',
|
||||
'hostDetails.distro.option.mikrotik': 'MikroTik',
|
||||
'hostDetails.distro.option.fortinet': 'Fortinet',
|
||||
'hostDetails.distro.option.paloalto': 'Palo Alto Networks',
|
||||
@@ -527,6 +626,10 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.deviceType.warning': 'AI agent commands will be sent directly without exit code tracking. Only enable for devices that do not run a standard shell.',
|
||||
'hostDetails.section.sshAlgorithms': 'SSH Algorithms',
|
||||
'hostDetails.section.terminalBehavior': 'Terminal Behavior',
|
||||
'hostDetails.lineTimestamps': 'Show output timestamps',
|
||||
'hostDetails.lineTimestamps.desc': 'Show local time beside visible output lines for this host without changing terminal text.',
|
||||
'hostDetails.disableDynamicTabTitle': 'Disable dynamic tab title',
|
||||
'hostDetails.disableDynamicTabTitle.desc': 'Keep the connection name on session tabs instead of following the shell-reported window title.',
|
||||
'hostDetails.legacyAlgorithms': 'Allow Legacy Algorithms',
|
||||
'hostDetails.legacyAlgorithms.desc': 'Enable deprecated SSH algorithms (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) for connecting to older network equipment.',
|
||||
'hostDetails.legacyAlgorithms.warning': 'These algorithms have known security weaknesses. Only enable for legacy devices that do not support modern cryptography.',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ruCoreMessages } from './ru/core';
|
||||
import { ruVaultMessages } from './ru/vault';
|
||||
import { ruTerminalMessages } from './ru/terminal';
|
||||
import { ruAiMessages } from './ru/ai';
|
||||
import { ruSystemManagerMessages } from './ru/systemManager';
|
||||
|
||||
export type { Messages } from './types';
|
||||
|
||||
@@ -11,6 +12,7 @@ const ru: Messages = {
|
||||
...ruVaultMessages,
|
||||
...ruTerminalMessages,
|
||||
...ruAiMessages,
|
||||
...ruSystemManagerMessages,
|
||||
};
|
||||
|
||||
export default ru;
|
||||
@@ -3,9 +3,11 @@ import type { Messages } from '../types';
|
||||
export const ruAiMessages: Messages = {
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Настройки агента',
|
||||
'ai.chat.preparing': 'Подготовка…',
|
||||
'ai.title': 'AI',
|
||||
'ai.description': 'Настройка AI-провайдеров, агентов и параметров безопасности',
|
||||
'ai.providers': 'Провайдеры',
|
||||
'ai.agents': 'Агенты',
|
||||
'ai.providers.empty': 'Провайдеры не настроены. Добавьте провайдера, чтобы начать.',
|
||||
'ai.providers.add': 'Добавить провайдера',
|
||||
'ai.providers.active': 'Активен',
|
||||
@@ -69,6 +71,7 @@ export const ruAiMessages: Messages = {
|
||||
'ai.codex.notFoundHint': 'Не удалось найти codex в PATH. Установите его или укажите путь к исполняемому файлу ниже.',
|
||||
'ai.codex.customPathPlaceholder': 'например, /usr/local/bin/codex',
|
||||
'ai.codex.check': 'Проверить',
|
||||
'ai.codex.resetPath': 'Сбросить',
|
||||
'ai.codex.openLogin': 'Открыть вход',
|
||||
'ai.codex.logout': 'Выйти',
|
||||
'ai.codex.connectChatGPT': 'Подключить ChatGPT',
|
||||
@@ -94,6 +97,7 @@ export const ruAiMessages: Messages = {
|
||||
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
|
||||
'ai.claude.envVars.hint': 'По одному KEY=VALUE в строке, передаётся агенту Claude. Хранится локально в открытом виде — для API-ключей и учётных данных используйте «Каталог конфигурации» выше (вход `claude`).',
|
||||
'ai.claude.check': 'Проверить',
|
||||
'ai.claude.resetPath': 'Сбросить',
|
||||
|
||||
// AI GitHub Copilot CLI
|
||||
'ai.copilot.title': 'GitHub Copilot CLI',
|
||||
@@ -105,6 +109,56 @@ export const ruAiMessages: Messages = {
|
||||
'ai.copilot.notFoundHint': 'Не удалось найти copilot в PATH. Установите его или укажите путь к исполняемому файлу ниже.',
|
||||
'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-ключ, чтобы включить Cursor.',
|
||||
'ai.cursor.notInstalledHint': 'Cursor SDK не обнаружен.',
|
||||
'ai.cursor.installStatus': 'Cursor SDK',
|
||||
'ai.cursor.installed': 'Обнаружено',
|
||||
'ai.cursor.notInstalled': 'Не обнаружено',
|
||||
'ai.cursor.apiKeyStatus': 'API-ключ',
|
||||
'ai.cursor.apiKeyConfigured': 'Настроен',
|
||||
'ai.cursor.apiKeyMissing': 'Не указан',
|
||||
'ai.cursor.apiKeyFromEnv': 'Из окружения',
|
||||
'ai.cursor.apiKey': 'API-ключ',
|
||||
'ai.cursor.apiKeyPlaceholder': 'Введите API-ключ Cursor',
|
||||
'ai.cursor.apiKeyPlaceholder.env': 'Используется CURSOR_API_KEY; введите ключ для замены',
|
||||
'ai.cursor.apiKeyEnvHint': 'Cursor может использовать CURSOR_API_KEY из shell. Сохраняйте ключ здесь только если хотите переопределить его в Netcatty.',
|
||||
'ai.cursor.apiKeyOverrideHint': 'Netcatty сначала использует сохранённый здесь ключ, затем CURSOR_API_KEY.',
|
||||
'ai.cursor.saveApiKey': 'Сохранить',
|
||||
'ai.cursor.saved': 'Сохранено',
|
||||
'ai.cursor.showApiKey': 'Показать API-ключ',
|
||||
'ai.cursor.hideApiKey': 'Скрыть API-ключ',
|
||||
'ai.cursor.customPathPlaceholder': 'например, /usr/local/bin/cursor',
|
||||
'ai.cursor.check': 'Проверить',
|
||||
|
||||
// AI CodeBuddy Code
|
||||
'ai.codebuddy.title': 'CodeBuddy Code',
|
||||
'ai.codebuddy.description': 'Использует CodeBuddy Code через официальный Agent SDK (`@tencent-ai/agent-sdk`). После обнаружения может быть выбран как внешний агент для программирования.',
|
||||
'ai.codebuddy.detecting': 'Обнаружение...',
|
||||
'ai.codebuddy.detected': 'Обнаружен',
|
||||
'ai.codebuddy.notFound': 'Не найден',
|
||||
'ai.codebuddy.path': 'Путь:',
|
||||
'ai.codebuddy.notFoundHint': 'Не удалось найти codebuddy в PATH. Установите его или укажите путь к исполняемому файлу ниже.',
|
||||
'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. Укажите CODEBUDDY_API_KEY или CODEBUDDY_AUTH_TOKEN для аутентификации. Хранятся локально в открытом виде.',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': 'Агент по умолчанию',
|
||||
@@ -127,6 +181,29 @@ export const ruAiMessages: Messages = {
|
||||
'ai.userSkills.status.ready': 'Готово',
|
||||
'ai.userSkills.status.warning': 'Предупреждение',
|
||||
|
||||
// AI Quick Messages
|
||||
'ai.quickMessages.title': 'Быстрые сообщения',
|
||||
'ai.quickMessages.description': 'Создавайте часто используемые подсказки и вставляйте их в AI-чат через / или кнопку быстрых сообщений. В отличие от user 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': 'Команда конфликтует с user skill «/{slug}». Выберите другую.',
|
||||
'ai.quickMessages.error.maxItems': 'Можно сохранить не более {max} быстрых сообщений.',
|
||||
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': 'AI-провайдер не настроен. Перейдите в **Настройки → AI → Провайдеры**, чтобы добавить и включить провайдера.',
|
||||
'ai.chat.toolDenied': 'Действие было отклонено пользователем.',
|
||||
@@ -175,6 +252,7 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.newChat': 'Новый чат',
|
||||
'ai.chat.allSessions': 'Все сессии',
|
||||
'ai.chat.loadEarlierMessages': 'Загрузить более ранние сообщения (ещё {n})',
|
||||
'ai.chat.usedTools': 'Использовано инструментов: {n}',
|
||||
'ai.chat.loadMoreSessions': 'Загрузить больше сессий (ещё {n})',
|
||||
'ai.chat.noSessions': 'Предыдущих сессий нет',
|
||||
'ai.chat.retryHint': 'Вы можете повторить попытку, отправив сообщение ещё раз.',
|
||||
@@ -185,6 +263,18 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.menuImage': 'Изображение',
|
||||
'ai.chat.menuMentionHost': 'Упомянуть хост',
|
||||
'ai.chat.menuUserSkills': 'Пользовательские skills',
|
||||
'ai.chat.menuSlashCommands': 'Команды /',
|
||||
'ai.chat.slashCommands': 'Команды /',
|
||||
'ai.chat.slashQuickMessages': 'Быстрые сообщения',
|
||||
'ai.chat.slashUserSkills': 'User skills',
|
||||
'ai.chat.quickMessages': 'Команды /',
|
||||
'ai.chat.slashNoResults': 'Нет подходящих команд',
|
||||
'ai.chat.slashEmptyHint': 'Добавьте подсказки в Настройки → AI → Быстрые сообщения.',
|
||||
|
||||
// AI Chat Shortcuts
|
||||
'ai.chatShortcuts.title': 'Быстрые действия чата',
|
||||
'ai.chatShortcuts.selectionAction': 'Показывать «Добавить в чат» при выделении в терминале',
|
||||
'ai.chatShortcuts.selectionAction.description': 'Показывать небольшую кнопку AI рядом с выделенным текстом терминала.',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Обработчики главного процесса Codex ещё не загружены. Полностью перезапустите Netcatty или dev-процесс Electron и попробуйте снова.',
|
||||
@@ -228,7 +318,9 @@ export const ruAiMessages: Messages = {
|
||||
'terminal.layer.switchToSplitView': 'Переключить в режим разделения',
|
||||
'terminal.layer.sftp': 'SFTP',
|
||||
'terminal.layer.scripts': 'Скрипты',
|
||||
'terminal.layer.history': 'История',
|
||||
'terminal.layer.theme': 'Тема',
|
||||
'terminal.layer.notes': 'Заметки',
|
||||
'terminal.layer.aiChat': 'AI-чат',
|
||||
'terminal.layer.movePanelLeft': 'Переместить панель влево',
|
||||
'terminal.layer.movePanelRight': 'Переместить панель вправо',
|
||||
|
||||
@@ -42,6 +42,7 @@ export const ruCoreMessages: Messages = {
|
||||
'common.more': 'Ещё',
|
||||
'common.selectAHost': 'Выберите хост',
|
||||
'common.selectAHostPlaceholder': 'Выберите хост...',
|
||||
'sort.manual': 'Ручной порядок',
|
||||
'sort.az': 'А-Я',
|
||||
'sort.za': 'Я-А',
|
||||
'sort.newest': 'Сначала новые',
|
||||
@@ -225,6 +226,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'Если включено, в корневом списке хостов будут показаны только хосты без группы. Откройте группу на боковой панели, чтобы увидеть сгруппированные хосты.',
|
||||
'settings.vault.showSftpTab': 'Показывать вкладку SFTP',
|
||||
'settings.vault.showSftpTabDesc': 'Показывать отдельный SFTP-вид в верхней панели вкладок. Если скрыто, используйте боковую панель SFTP внутри сессии.',
|
||||
'settings.vault.showHostTreeSidebar': 'Показывать боковую панель хостов',
|
||||
'settings.vault.showHostTreeSidebarDesc': 'Показывать список хостов и кнопку в верхней панели для вкладок терминала и редактора.',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': 'Доступно обновление',
|
||||
@@ -264,9 +267,9 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.appearance.themeColor.dark': 'Палитра тёмной темы',
|
||||
'settings.appearance.customCss': 'Пользовательский CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'Добавьте пользовательский CSS, чтобы настроить внешний вид приложения. Изменения применяются сразу. Основные области интерфейса имеют атрибут [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.',
|
||||
'Добавьте пользовательский CSS, чтобы настроить внешний вид приложения. Изменения применяются сразу. Основные области интерфейса имеют атрибут [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':
|
||||
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\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/* Или: Настройки → Терминал → Индикатор фокуса → Рамка вокруг активной панели */',
|
||||
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\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': 'Шрифт интерфейса',
|
||||
@@ -309,8 +312,19 @@ export const ruCoreMessages: Messages = {
|
||||
'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': 'Тип эмуляции терминала',
|
||||
@@ -338,6 +352,11 @@ export const ruCoreMessages: Messages = {
|
||||
'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': 'Режим bracketed paste',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'Оборачивать вставляемый текст escape-последовательностями, чтобы оболочка отличала вставку от обычного ввода. Отключите, если видите артефакты вида ^[[200~.',
|
||||
@@ -429,6 +448,15 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.connection.x11Display.desc': 'Необязательный адрес локального дисплея для перенаправления X11. Оставьте пустым, чтобы использовать системное значение по умолчанию.',
|
||||
'settings.terminal.connection.x11Display.placeholder': 'Авто (:0 или DISPLAY)',
|
||||
'settings.terminal.section.serverStats': 'Статистика сервера (Linux)',
|
||||
'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': 'Как часто обновлять CPU/память/сеть контейнеров Docker.',
|
||||
'settings.terminal.serverStats.show': 'Показывать статистику сервера',
|
||||
'settings.terminal.serverStats.show.desc': 'Показывать загрузку CPU, памяти и диска в строке состояния терминала (только для Linux-серверов).',
|
||||
'settings.terminal.serverStats.refreshInterval': 'Интервал обновления',
|
||||
@@ -440,8 +468,6 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.rendering.renderer': 'Рендерер',
|
||||
'settings.terminal.rendering.renderer.desc': 'Выберите технологию рендеринга терминала. В режиме "Авто" на устройствах с малым объёмом памяти будет использоваться DOM. Изменения применяются к новым терминальным сессиям.',
|
||||
'settings.terminal.rendering.auto': 'Авто',
|
||||
'settings.terminal.rendering.lineTimestamps': 'Добавлять время к выводу',
|
||||
'settings.terminal.rendering.lineTimestamps.desc': 'Вставлять локальное время перед строками вывода терминала. Метка времени становится частью видимого содержимого терминала.',
|
||||
|
||||
// Settings > Terminal > Workspace Focus Indicator
|
||||
'settings.terminal.section.workspaceFocus': 'Индикатор фокуса рабочей области',
|
||||
@@ -466,6 +492,10 @@ export const ruCoreMessages: Messages = {
|
||||
'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] переключает только рабочие вкладки (терминалы, рабочие области, редакторы), а не закреплённые Vault и SFTP.',
|
||||
'settings.shortcuts.section.custom': 'Пользовательские сочетания',
|
||||
'settings.shortcuts.resetAll': 'Сбросить все',
|
||||
'settings.shortcuts.recording': 'Нажмите клавиши...',
|
||||
@@ -481,6 +511,7 @@ export const ruCoreMessages: Messages = {
|
||||
'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': 'Вставить в терминал',
|
||||
@@ -488,9 +519,13 @@ export const ruCoreMessages: Messages = {
|
||||
'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',
|
||||
@@ -570,7 +605,7 @@ export const ruCoreMessages: Messages = {
|
||||
'sync.autoSync.emptyVaultConflict.restoreDesc': 'Рекомендуется — восстановить ваши хосты, ключи и сниппеты из облачной резервной копии',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmpty': 'Оставить пустым',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': 'Начать заново с пустым хранилищем',
|
||||
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} хостов, {keys} ключей, {snippets} сниппетов, {proxyProfiles} прокси',
|
||||
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} хостов, {keys} ключей, {snippets} сниппетов, {notes} заметок, {proxyProfiles} прокси',
|
||||
'sync.autoSync.emptyVaultManual': 'Синхронизация невозможна: локальное хранилище пусто. Сначала восстановите его из локальной резервной копии или включите принудительную отправку в панели синхронизации.',
|
||||
|
||||
'sync.blocked.title': 'Синхронизация приостановлена',
|
||||
@@ -607,6 +642,7 @@ export const ruCoreMessages: Messages = {
|
||||
'vault.nav.proxies': 'Прокси',
|
||||
'vault.nav.portForwarding': 'Проброс портов',
|
||||
'vault.nav.snippets': 'Сниппеты',
|
||||
'vault.nav.notes': 'Заметки',
|
||||
'vault.nav.knownHosts': 'Известные хосты',
|
||||
'vault.nav.logs': 'Журналы',
|
||||
|
||||
@@ -670,5 +706,6 @@ export const ruCoreMessages: Messages = {
|
||||
|
||||
'vault.hosts.header.entries': 'Записей: {count}',
|
||||
'vault.hosts.header.live': 'Активных: {count}',
|
||||
'deepLink.ssh.invalid': 'Недопустимая ссылка ssh://',
|
||||
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const ruSystemManagerMessages: 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': 'Renice',
|
||||
'systemManager.processes.renicePrompt': 'Значение nice (-20 до 19)',
|
||||
'systemManager.processes.reniceInvalid': 'Nice должно быть от -20 до 19',
|
||||
'systemManager.processes.confirmKill': 'Отправить SIGKILL процессу {{pid}}?',
|
||||
'systemManager.processes.confirmSignal': 'Отправить SIG{{signal}} процессу {{pid}}?',
|
||||
'systemManager.processes.filter.all': 'Все',
|
||||
'systemManager.processes.filter.running': 'Активные',
|
||||
'systemManager.processes.ppid': 'Родительский PID',
|
||||
'systemManager.processes.rss': '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': 'Остановить сервер',
|
||||
'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': 'Удалить dangling-образы?',
|
||||
'systemManager.docker.confirmPruneAll': 'Удалить все неиспользуемые образы?',
|
||||
'systemManager.docker.pause': 'Пауза',
|
||||
'systemManager.docker.unpause': 'Возобновить',
|
||||
'systemManager.docker.restart': 'Перезапустить',
|
||||
'systemManager.docker.kill': 'Kill',
|
||||
'systemManager.docker.renamePrompt': 'Имя контейнера',
|
||||
'systemManager.docker.prune': 'Prune',
|
||||
'systemManager.docker.pruneAll': 'Prune all',
|
||||
'systemManager.docker.tag': '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',
|
||||
};
|
||||
@@ -26,14 +26,35 @@ export const ruTerminalMessages: Messages = {
|
||||
// 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.configureOsc7': 'Настроить отслеживание каталога',
|
||||
'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': 'Сохранить как сниппет',
|
||||
'terminal.toolbar.library': 'Библиотека',
|
||||
'terminal.toolbar.noSnippets': 'Нет доступных сниппетов',
|
||||
'terminal.toolbar.terminalSettings': 'Настройки терминала',
|
||||
'terminal.toolbar.searchTerminal': 'Поиск по терминалу',
|
||||
'terminal.toolbar.search': 'Поиск',
|
||||
'terminal.toolbar.timestampsEnable': 'Показать время',
|
||||
'terminal.toolbar.timestampsDisable': 'Скрыть время',
|
||||
'terminal.toolbar.broadcast': 'Трансляция',
|
||||
'terminal.toolbar.broadcastEnable': 'Включить режим трансляции',
|
||||
'terminal.toolbar.broadcastDisable': 'Отключить режим трансляции',
|
||||
@@ -42,8 +63,17 @@ export const ruTerminalMessages: Messages = {
|
||||
'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',
|
||||
@@ -82,7 +112,9 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.dragDrop.localTitle': 'Перетащите для вставки путей',
|
||||
'terminal.dragDrop.localMessage': 'Пути к файлам будут вставлены в терминал',
|
||||
'terminal.dragDrop.remoteTitle': 'Перетащите для загрузки файлов',
|
||||
'terminal.dragDrop.remoteMessage': 'Файлы будут загружены через SFTP',
|
||||
'terminal.dragDrop.remoteZmodemMessage': 'Файлы будут загружены через ZMODEM (PTY)',
|
||||
'terminal.dragDrop.remoteSftpMessage': 'Файлы будут загружены через SFTP',
|
||||
'terminal.dragDrop.noFiles': 'Нет файлов для загрузки',
|
||||
'terminal.dragDrop.notConnected': 'Нельзя перетащить файлы — терминал не подключён',
|
||||
'terminal.dragDrop.errorTitle': 'Ошибка перетаскивания',
|
||||
'terminal.dragDrop.errorMessage': 'Не удалось обработать перетащенные файлы',
|
||||
@@ -92,14 +124,40 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.search.nextMatch': 'Следующее совпадение (Enter)',
|
||||
'terminal.menu.copy': 'Копировать',
|
||||
'terminal.menu.paste': 'Вставить',
|
||||
'terminal.menu.uploadClipboardImage': 'Загрузить изображение из буфера',
|
||||
'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.osc7Setup.title': 'Настроить отслеживание каталога',
|
||||
'terminal.osc7Setup.desc': 'Netcatty добавит хуки OSC 7 для текущего удалённого пользователя. Это помогает SFTP следовать за каталогом терминала после sudo или su.',
|
||||
'terminal.osc7Setup.targets': 'Возможные файлы для обновления',
|
||||
'terminal.osc7Setup.command': 'Команда для запуска',
|
||||
'terminal.osc7Setup.run': 'Запустить настройку',
|
||||
'terminal.osc7Setup.sent': 'Настройка отслеживания каталога отправлена в терминал',
|
||||
'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.clipboardImageUpload.noImage': 'В буфере обмена нет изображения',
|
||||
'terminal.clipboardImageUpload.failed': 'Не удалось загрузить изображение из буфера обмена',
|
||||
'terminal.selection.addToAI': 'Добавить в чат',
|
||||
'terminal.selection.addToAIDesc': 'Прикрепить выбранный вывод терминала к черновику AI',
|
||||
'terminal.auth.password': 'Пароль',
|
||||
@@ -304,7 +362,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'cloudSync.localBackups.reason.appVersionChange': 'Перед сменой версии приложения',
|
||||
'cloudSync.localBackups.reason.beforeRestore': 'Перед восстановлением',
|
||||
'cloudSync.localBackups.versionChange': '{from} -> {to}',
|
||||
'cloudSync.localBackups.counts': '{hosts} хостов, {keys} ключей, {snippets} сниппетов',
|
||||
'cloudSync.localBackups.counts': '{hosts} хостов, {keys} ключей, {snippets} сниппетов, {notes} заметок',
|
||||
'cloudSync.localBackups.restore': 'Восстановить',
|
||||
'cloudSync.localBackups.restoreSuccess': 'Локальная резервная копия восстановлена.',
|
||||
'cloudSync.localBackups.restoreFailedTitle': 'Ошибка восстановления',
|
||||
@@ -329,6 +387,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'cloudSync.revisionHistory.hosts': 'Хосты',
|
||||
'cloudSync.revisionHistory.keys': 'Ключи',
|
||||
'cloudSync.revisionHistory.snippets': 'Сниппеты',
|
||||
'cloudSync.revisionHistory.notes': 'Заметки',
|
||||
'cloudSync.revisionHistory.identities': 'Идентификаторы',
|
||||
'cloudSync.revisionHistory.restoreButton': 'Восстановить эту версию',
|
||||
'cloudSync.revisionHistory.restored': 'Хранилище восстановлено из выбранной ревизии.',
|
||||
@@ -382,6 +441,8 @@ export const ruTerminalMessages: Messages = {
|
||||
'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': 'Проброс портов',
|
||||
@@ -530,6 +591,8 @@ export const ruTerminalMessages: Messages = {
|
||||
'snippets.searchPlaceholder': 'Поиск сниппетов...',
|
||||
'snippets.action.newSnippet': 'Новый сниппет',
|
||||
'snippets.action.newPackage': 'Новый пакет',
|
||||
'snippets.action.import': 'Импорт',
|
||||
'snippets.action.selectSnippets': 'Выбрать сниппеты',
|
||||
'snippets.panel.newTitle': 'Новый сниппет',
|
||||
'snippets.panel.editTitle': 'Редактировать сниппет',
|
||||
'snippets.field.description': 'Описание действия',
|
||||
@@ -578,6 +641,34 @@ export const ruTerminalMessages: Messages = {
|
||||
'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': 'Выберите один или несколько JSON-файлов сниппетов Netcatty. Netcatty сначала покажет результат разбора и ничего не импортирует без подтверждения.',
|
||||
'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': 'Корень',
|
||||
|
||||
@@ -1,6 +1,34 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const ruVaultMessages: Messages = {
|
||||
// Notes
|
||||
'notes.search.placeholder': 'Поиск заметок, хостов, тегов...',
|
||||
'notes.action.newNote': 'Новая заметка',
|
||||
'notes.action.newGroup': 'Новая папка',
|
||||
'notes.action.importMarkdown': 'Импорт Markdown',
|
||||
'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.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': 'Создавайте Markdown-заметки для инструкций, напоминаний и передачи серверов.',
|
||||
'notes.empty.group': 'В этом блокноте пока нет заметок.',
|
||||
'notes.search.noResults': 'Заметки не найдены.',
|
||||
|
||||
// Vault hosts header/actions
|
||||
'vault.hosts.search.placeholder': 'Найти хост или ssh user@hostname / ssh -p 2222 user@hostname...',
|
||||
'vault.hosts.connect': 'Подключиться',
|
||||
@@ -136,8 +164,9 @@ export const ruVaultMessages: Messages = {
|
||||
'pf.form.openWizardTitle': 'Открыть мастер проброса портов',
|
||||
'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.rule.summary.default': '{bindAddress}:{localPort} -> {remoteHost}:{remotePort}',
|
||||
'pf.tooltip.relayHost': 'Промежуточный хост',
|
||||
'pf.tooltip.hostLabel': 'Хост',
|
||||
'pf.tooltip.hostAddress': 'Адрес',
|
||||
@@ -158,6 +187,7 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.filter.placeholder': 'Фильтр по имени файла...',
|
||||
'sftp.bookmark.add': 'Добавить путь в закладки',
|
||||
'sftp.bookmark.remove': 'Удалить закладку',
|
||||
'sftp.bookmark.list': 'Закладки путей',
|
||||
'sftp.bookmark.addGlobal': '+Глобальная',
|
||||
'sftp.bookmark.addGlobalTooltip': 'Сохранить как глобальную закладку (общую для всех хостов)',
|
||||
'sftp.bookmark.empty': 'Пока нет закладок',
|
||||
@@ -185,9 +215,14 @@ export const ruVaultMessages: Messages = {
|
||||
'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': 'Папка',
|
||||
@@ -290,6 +325,8 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.tabs.addTab': 'Добавить новую вкладку',
|
||||
'sftp.tabs.closeTab': 'Закрыть вкладку',
|
||||
'sftp.tabs.newTab': 'Новая вкладка',
|
||||
'sftp.tabs.copyDefaultPath': 'Копировать вкладку (путь по умолчанию)',
|
||||
'sftp.tabs.copyCurrentPath': 'Копировать и перейти к текущему пути',
|
||||
'sftp.conflict.title': 'Конфликт файлов',
|
||||
'sftp.conflict.desc': 'В месте назначения уже существует файл с таким именем',
|
||||
'sftp.conflict.alreadyExistsSuffix': 'уже существует',
|
||||
@@ -374,6 +411,9 @@ export const ruVaultMessages: Messages = {
|
||||
'settings.sftp.doubleClickBehavior.transfer': 'Передать в другую панель',
|
||||
'settings.sftp.doubleClickBehavior.openDesc': 'Открыть файл в приложении по умолчанию',
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': 'Передать файл на активный хост другой панели',
|
||||
'settings.sshDeepLink.title': 'SSH-ссылки',
|
||||
'settings.sshDeepLink.enable': 'Открывать ssh:// ссылки в Netcatty',
|
||||
'settings.sshDeepLink.enableDesc': 'Разрешить Netcatty обрабатывать ssh:// ссылки из браузеров и других приложений.',
|
||||
|
||||
// Settings > SFTP Auto Sync
|
||||
'settings.sftp.autoSync': 'Автосинхронизация с удалённым сервером',
|
||||
@@ -494,7 +534,64 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.section.portCredentials': 'Порт и учётные данные',
|
||||
'hostDetails.section.appearance': 'Внешний вид',
|
||||
'hostDetails.distro.title': 'Дистрибутив Linux',
|
||||
'hostDetails.distro.desc': 'Автоопределение при подключении или ручное переопределение значка дистрибутива.',
|
||||
'hostDetails.distro.desc': 'Автоопределение при подключении или ручная замена значка дистрибутива.',
|
||||
'hostDetails.icon.title': 'Значок хоста',
|
||||
'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': 'CPU',
|
||||
'hostDetails.icon.option.hard-drive': 'Хранилище',
|
||||
'hostDetails.icon.option.network': 'Сеть',
|
||||
'hostDetails.icon.option.wifi': 'Wi-Fi',
|
||||
'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': 'Ручное переопределение',
|
||||
@@ -515,12 +612,14 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
|
||||
'hostDetails.distro.option.almalinux': 'AlmaLinux',
|
||||
'hostDetails.distro.option.alinux': 'Alibaba Cloud Linux',
|
||||
'hostDetails.distro.option.openeuler': 'openEuler',
|
||||
'hostDetails.distro.option.oracle': 'Oracle Linux',
|
||||
'hostDetails.distro.option.kali': 'Kali Linux',
|
||||
'hostDetails.distro.option.cisco': 'Cisco',
|
||||
'hostDetails.distro.option.juniper': 'Juniper Networks',
|
||||
'hostDetails.distro.option.huawei': 'Huawei',
|
||||
'hostDetails.distro.option.hpe': 'HPE / H3C',
|
||||
'hostDetails.distro.option.h3c': 'H3C',
|
||||
'hostDetails.distro.option.hpe': 'HPE',
|
||||
'hostDetails.distro.option.mikrotik': 'MikroTik',
|
||||
'hostDetails.distro.option.fortinet': 'Fortinet',
|
||||
'hostDetails.distro.option.paloalto': 'Palo Alto Networks',
|
||||
@@ -559,6 +658,10 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.deviceType.warning': 'Команды AI-агента будут отправляться напрямую без отслеживания кода выхода. Включайте только для устройств, на которых нет стандартной оболочки.',
|
||||
'hostDetails.section.sshAlgorithms': 'SSH-алгоритмы',
|
||||
'hostDetails.section.terminalBehavior': 'Поведение терминала',
|
||||
'hostDetails.lineTimestamps': 'Показывать время вывода',
|
||||
'hostDetails.lineTimestamps.desc': 'Показывать локальное время рядом с видимыми строками вывода для этого хоста, не изменяя текст терминала.',
|
||||
'hostDetails.disableDynamicTabTitle': 'Отключить динамический заголовок вкладки',
|
||||
'hostDetails.disableDynamicTabTitle.desc': 'Показывать имя подключения на вкладках сессии вместо заголовка окна от shell.',
|
||||
'hostDetails.legacyAlgorithms': 'Разрешить устаревшие алгоритмы',
|
||||
'hostDetails.legacyAlgorithms.desc': 'Включить устаревшие SSH-алгоритмы (diffie-hellman-group1, ssh-dss, 3des-cbc и т. д.) для подключения к старому сетевому оборудованию.',
|
||||
'hostDetails.legacyAlgorithms.warning': 'У этих алгоритмов есть известные слабые места безопасности. Включайте только для устаревших устройств, которые не поддерживают современную криптографию.',
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings.ts";
|
||||
import { HOST_ICON_COLORS, HOST_ICON_IDS } from "../../../domain/hostIcon.ts";
|
||||
import zhCN from "./zh-CN.ts";
|
||||
import ru from "./ru.ts";
|
||||
|
||||
const LOCALIZED_SETTINGS_LOCALES = [
|
||||
{ name: "zh-CN", messages: zhCN },
|
||||
{ name: "ru", messages: ru },
|
||||
];
|
||||
|
||||
test("localized settings include names for every default shortcut", () => {
|
||||
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
|
||||
const missing = DEFAULT_KEY_BINDINGS
|
||||
.map((binding) => `settings.shortcuts.binding.${binding.id}`)
|
||||
.filter((key) => !locale.messages[key]);
|
||||
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing shortcut labels`);
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include workspace focus indicator labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.section.workspaceFocus",
|
||||
"settings.terminal.workspaceFocus.style",
|
||||
"settings.terminal.workspaceFocus.style.desc",
|
||||
"settings.terminal.workspaceFocus.dim",
|
||||
"settings.terminal.workspaceFocus.border",
|
||||
];
|
||||
|
||||
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing workspace focus labels`);
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include terminal font weight option labels", () => {
|
||||
const keys = [
|
||||
"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",
|
||||
];
|
||||
|
||||
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing font weight labels`);
|
||||
}
|
||||
});
|
||||
|
||||
test("localized vault messages include host icon labels", () => {
|
||||
const keys = [
|
||||
"hostDetails.icon.title",
|
||||
"hostDetails.icon.desc",
|
||||
"hostDetails.icon.mode.auto",
|
||||
"hostDetails.icon.mode.custom",
|
||||
"hostDetails.icon.reset",
|
||||
"hostDetails.icon.showLibrary",
|
||||
"hostDetails.icon.hideLibrary",
|
||||
"hostDetails.icon.autoUsesDistro",
|
||||
"hostDetails.icon.customOverridesDistro",
|
||||
...HOST_ICON_IDS.map((id) => `hostDetails.icon.option.${id}`),
|
||||
...HOST_ICON_COLORS.map((color) => `hostDetails.icon.color.${color.id}`),
|
||||
];
|
||||
|
||||
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing host icon labels`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import en from "./en.ts";
|
||||
import ru from "./ru.ts";
|
||||
import zhCN from "./zh-CN.ts";
|
||||
|
||||
const osc7Keys = [
|
||||
"terminal.toolbar.configureOsc7",
|
||||
"terminal.osc7Setup.title",
|
||||
"terminal.osc7Setup.desc",
|
||||
"terminal.osc7Setup.targets",
|
||||
"terminal.osc7Setup.command",
|
||||
"terminal.osc7Setup.run",
|
||||
"terminal.osc7Setup.sent",
|
||||
] as const;
|
||||
|
||||
test("OSC 7 setup copy exists in every bundled locale", () => {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, zhCN })) {
|
||||
for (const key of osc7Keys) {
|
||||
assert.equal(typeof messages[key], "string", `${locale} is missing ${key}`);
|
||||
assert.notEqual(messages[key], "", `${locale} has empty ${key}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { zhCNCoreMessages } from './zh-CN/core';
|
||||
import { zhCNVaultMessages } from './zh-CN/vault';
|
||||
import { zhCNTerminalMessages } from './zh-CN/terminal';
|
||||
import { zhCNAiMessages } from './zh-CN/ai';
|
||||
import { zhCnSystemManagerMessages } from './zh-CN/systemManager';
|
||||
|
||||
export type { Messages } from './types';
|
||||
|
||||
@@ -11,6 +12,7 @@ const zhCN: Messages = {
|
||||
...zhCNVaultMessages,
|
||||
...zhCNTerminalMessages,
|
||||
...zhCNAiMessages,
|
||||
...zhCnSystemManagerMessages,
|
||||
};
|
||||
|
||||
export default zhCN;
|
||||
@@ -3,9 +3,11 @@ import type { Messages } from '../types';
|
||||
export const zhCNAiMessages: Messages = {
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent 设置',
|
||||
'ai.chat.preparing': '准备中…',
|
||||
'ai.title': 'AI',
|
||||
'ai.description': '配置 AI 提供商、Agent 和安全设置',
|
||||
'ai.providers': '提供商',
|
||||
'ai.agents': 'Agent',
|
||||
'ai.providers.empty': '尚未配置提供商。添加一个提供商以开始使用。',
|
||||
'ai.providers.add': '添加提供商',
|
||||
'ai.providers.active': '活跃',
|
||||
@@ -69,6 +71,7 @@ export const zhCNAiMessages: Messages = {
|
||||
'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',
|
||||
@@ -94,6 +97,7 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
|
||||
'ai.claude.envVars.hint': '每行一个 KEY=VALUE,传给 Claude agent。明文存在本地——API key/凭据建议用上面的「配置目录」(claude 登录),不要放这里。',
|
||||
'ai.claude.check': '检查',
|
||||
'ai.claude.resetPath': '重置',
|
||||
|
||||
// AI GitHub Copilot CLI
|
||||
'ai.copilot.title': 'GitHub Copilot CLI',
|
||||
@@ -105,6 +109,68 @@ export const zhCNAiMessages: Messages = {
|
||||
'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',
|
||||
@@ -127,6 +193,29 @@ export const zhCNAiMessages: Messages = {
|
||||
'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': '操作已被用户拒绝。',
|
||||
@@ -175,6 +264,7 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.newChat': '新对话',
|
||||
'ai.chat.allSessions': '所有会话',
|
||||
'ai.chat.loadEarlierMessages': '加载更早的消息(还有 {n} 条)',
|
||||
'ai.chat.usedTools': '已使用 {n} 个工具',
|
||||
'ai.chat.loadMoreSessions': '加载更多会话(还有 {n} 条)',
|
||||
'ai.chat.noSessions': '没有历史会话',
|
||||
'ai.chat.retryHint': '你可以重新发送消息来重试。',
|
||||
@@ -185,6 +275,18 @@ export const zhCNAiMessages: Messages = {
|
||||
'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 开发进程,然后重试。',
|
||||
@@ -228,7 +330,9 @@ export const zhCNAiMessages: Messages = {
|
||||
'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': '面板移至右侧',
|
||||
@@ -243,6 +347,13 @@ export const zhCNAiMessages: Messages = {
|
||||
'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 助手',
|
||||
|
||||
@@ -29,6 +29,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'common.right': '右侧',
|
||||
'common.more': '更多',
|
||||
'common.selectAHost': '选择主机',
|
||||
'sort.manual': '手动顺序',
|
||||
'sort.az': 'A-z',
|
||||
'sort.za': 'Z-a',
|
||||
'sort.newest': '从新到旧',
|
||||
@@ -127,6 +128,17 @@ export const zhCNCoreMessages: Messages = {
|
||||
'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': '配置会话日志导出和自动保存设置。',
|
||||
@@ -209,6 +221,8 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': '开启后,主机库根目录的主机列表只显示没有分组的主机,已分组主机请从左侧分组进入查看。',
|
||||
'settings.vault.showSftpTab': '显示 SFTP 标签页',
|
||||
'settings.vault.showSftpTabDesc': '在顶部标签栏显示独立的 SFTP 视图。关闭后可改用会话内左侧的 SFTP 侧栏。',
|
||||
'settings.vault.showHostTreeSidebar': '显示主机列表侧栏',
|
||||
'settings.vault.showHostTreeSidebarDesc': '在终端和编辑器标签页显示主机列表侧栏及顶部开关。',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': '发现新版本',
|
||||
@@ -248,9 +262,9 @@ export const zhCNCoreMessages: Messages = {
|
||||
'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。',
|
||||
'使用自定义 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/* SFTP / 操作侧栏边框(关闭侧栏后不会残留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整个操作侧栏背景,而不只是顶部标签 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改选中的 SFTP 文件行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分屏分割线 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 高亮当前聚焦的分屏 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 设置 → 终端 → 工作区聚焦指示 → 聚焦窗格显示边框 */',
|
||||
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* 隐藏顶部标签栏里的主机列表开关 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隐藏打开快速切换器的加号按钮 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* SFTP / 操作侧栏边框(关闭侧栏后不会残留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整个操作侧栏背景,而不只是顶部标签 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改选中的 SFTP 文件行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分屏分割线 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 高亮当前聚焦的分屏 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 设置 → 终端 → 工作区聚焦指示 → 聚焦窗格显示边框 */',
|
||||
'settings.appearance.language': '语言',
|
||||
'settings.appearance.language.desc': '选择界面语言',
|
||||
'settings.appearance.uiFont': '界面字体',
|
||||
@@ -307,7 +321,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sync.autoSync.emptyVaultConflict.restoreDesc': '推荐 — 从云端备份恢复主机、密钥和代码片段',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmpty': '保持为空',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': '从头开始,使用空的主机库',
|
||||
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} 台主机,{keys} 个密钥,{snippets} 个代码片段,{proxyProfiles} 个代理',
|
||||
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} 台主机,{keys} 个密钥,{snippets} 个代码片段,{notes} 条笔记,{proxyProfiles} 个代理',
|
||||
'sync.autoSync.emptyVaultManual': '无法同步:本地 vault 为空。请先从本地备份恢复,或在同步面板里使用"强制推送"。',
|
||||
|
||||
'sync.blocked.title': '同步已暂停',
|
||||
@@ -344,6 +358,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'vault.nav.proxies': '代理',
|
||||
'vault.nav.portForwarding': '端口转发',
|
||||
'vault.nav.snippets': '代码片段',
|
||||
'vault.nav.notes': '笔记',
|
||||
'vault.nav.knownHosts': '已知主机',
|
||||
'vault.nav.logs': '日志',
|
||||
|
||||
@@ -441,8 +456,10 @@ export const zhCNCoreMessages: Messages = {
|
||||
'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',
|
||||
@@ -541,6 +558,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.filter.placeholder': '按文件名筛选...',
|
||||
'sftp.bookmark.add': '收藏此路径',
|
||||
'sftp.bookmark.remove': '取消收藏',
|
||||
'sftp.bookmark.list': '收藏路径',
|
||||
'sftp.bookmark.addGlobal': '+全局',
|
||||
'sftp.bookmark.addGlobalTooltip': '保存为全局收藏(所有主机共享)',
|
||||
'sftp.bookmark.empty': '暂无收藏路径',
|
||||
@@ -568,9 +586,14 @@ export const zhCNCoreMessages: Messages = {
|
||||
'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': '文件夹',
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCnSystemManagerMessages: 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',
|
||||
};
|
||||
@@ -2,9 +2,40 @@ import type { Messages } from '../types';
|
||||
|
||||
export const zhCNTerminalMessages: 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.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': '打开方式...',
|
||||
@@ -67,6 +98,9 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'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': '自动同步到远程',
|
||||
@@ -169,8 +203,19 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'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': '终端仿真类型',
|
||||
@@ -194,6 +239,11 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'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~ 字样请关闭此选项。',
|
||||
@@ -286,14 +336,40 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'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.lineTimestamps': '给输出加时间戳',
|
||||
'settings.terminal.rendering.lineTimestamps.desc': '在终端输出行前插入本地时间,时间戳会成为终端可见内容的一部分。',
|
||||
'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': '自动补全',
|
||||
@@ -311,6 +387,10 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'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] 仅在终端、工作区、编辑器等可关闭标签页之间切换,不包括固定的 Vault 和 SFTP 标签页。',
|
||||
'settings.shortcuts.section.custom': '自定义快捷键',
|
||||
'settings.shortcuts.resetAll': '全部重置',
|
||||
'settings.shortcuts.recording': '请按键...',
|
||||
@@ -325,18 +405,25 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'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': '快速切换',
|
||||
@@ -352,6 +439,9 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'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/命令代理',
|
||||
@@ -506,6 +596,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'snippets.searchPlaceholder': '搜索代码片段...',
|
||||
'snippets.action.newSnippet': '新建代码片段',
|
||||
'snippets.action.newPackage': '新建代码包',
|
||||
'snippets.action.import': '导入',
|
||||
'snippets.action.selectSnippets': '选择代码片段',
|
||||
'snippets.panel.newTitle': '新建代码片段',
|
||||
'snippets.panel.editTitle': '编辑代码片段',
|
||||
'snippets.field.description': '描述',
|
||||
@@ -543,6 +635,34 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'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': '根目录',
|
||||
|
||||
@@ -1,6 +1,34 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCNVaultMessages: Messages = {
|
||||
// Notes
|
||||
'notes.search.placeholder': '搜索笔记、主机、标签...',
|
||||
'notes.action.newNote': '新建笔记',
|
||||
'notes.action.newGroup': '新建文件夹',
|
||||
'notes.action.importMarkdown': '导入 Markdown',
|
||||
'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.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': '未找到主机',
|
||||
@@ -46,6 +74,63 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.section.appearance': '外观',
|
||||
'hostDetails.distro.title': 'Linux 发行版',
|
||||
'hostDetails.distro.desc': '可在连接后自动探测,也可以手动覆盖图标所用的发行版。',
|
||||
'hostDetails.icon.title': '主机图标',
|
||||
'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': '手动覆盖',
|
||||
@@ -66,12 +151,14 @@ export const zhCNVaultMessages: Messages = {
|
||||
'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.hpe': '慧与 / H3C',
|
||||
'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',
|
||||
@@ -113,6 +200,10 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.deviceType.warning': 'AI 代理命令将直接发送,无法获取退出码。仅建议在设备不运行标准 Shell 时启用。',
|
||||
'hostDetails.section.sshAlgorithms': 'SSH 算法',
|
||||
'hostDetails.section.terminalBehavior': '终端行为',
|
||||
'hostDetails.lineTimestamps': '显示输出时间',
|
||||
'hostDetails.lineTimestamps.desc': '在终端输出行旁边显示本地时间,不改变终端文本内容。',
|
||||
'hostDetails.disableDynamicTabTitle': '禁用动态标签页标题',
|
||||
'hostDetails.disableDynamicTabTitle.desc': '标签页始终显示连接名称,不跟随 Shell 报告的窗口标题变化。',
|
||||
'hostDetails.legacyAlgorithms': '允许旧版算法',
|
||||
'hostDetails.legacyAlgorithms.desc': '启用已弃用的 SSH 算法(diffie-hellman-group1、ssh-dss、3des-cbc 等)以连接老旧网络设备。',
|
||||
'hostDetails.legacyAlgorithms.warning': '这些算法存在已知安全漏洞,仅建议在老旧设备不支持现代加密时启用。',
|
||||
@@ -213,9 +304,12 @@ export const zhCNVaultMessages: Messages = {
|
||||
// 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': '终端设置',
|
||||
@@ -229,8 +323,17 @@ export const zhCNVaultMessages: Messages = {
|
||||
'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',
|
||||
@@ -269,7 +372,9 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.dragDrop.localTitle': '拖放以插入路径',
|
||||
'terminal.dragDrop.localMessage': '文件路径将被插入到终端',
|
||||
'terminal.dragDrop.remoteTitle': '拖放以上传文件',
|
||||
'terminal.dragDrop.remoteMessage': '文件将通过 SFTP 上传',
|
||||
'terminal.dragDrop.remoteZmodemMessage': '文件将通过 ZMODEM(PTY)上传',
|
||||
'terminal.dragDrop.remoteSftpMessage': '文件将通过 SFTP 上传',
|
||||
'terminal.dragDrop.noFiles': '没有可上传的文件',
|
||||
'terminal.dragDrop.notConnected': '无法拖放文件 - 终端未连接',
|
||||
'terminal.dragDrop.errorTitle': '拖放错误',
|
||||
'terminal.dragDrop.errorMessage': '处理拖放文件失败',
|
||||
@@ -283,10 +388,27 @@ export const zhCNVaultMessages: Messages = {
|
||||
'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': '密码',
|
||||
@@ -491,7 +613,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'cloudSync.localBackups.reason.appVersionChange': '版本变化前',
|
||||
'cloudSync.localBackups.reason.beforeRestore': '恢复前',
|
||||
'cloudSync.localBackups.versionChange': '{from} -> {to}',
|
||||
'cloudSync.localBackups.counts': '{hosts} 台主机,{keys} 个密钥,{snippets} 个代码片段',
|
||||
'cloudSync.localBackups.counts': '{hosts} 台主机,{keys} 个密钥,{snippets} 个代码片段,{notes} 条笔记',
|
||||
'cloudSync.localBackups.restore': '恢复',
|
||||
'cloudSync.localBackups.restoreSuccess': '已恢复本地备份。',
|
||||
'cloudSync.localBackups.restoreFailedTitle': '恢复失败',
|
||||
@@ -516,6 +638,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'cloudSync.revisionHistory.hosts': '主机',
|
||||
'cloudSync.revisionHistory.keys': '密钥',
|
||||
'cloudSync.revisionHistory.snippets': '代码片段',
|
||||
'cloudSync.revisionHistory.notes': '笔记',
|
||||
'cloudSync.revisionHistory.identities': '身份',
|
||||
'cloudSync.revisionHistory.restoreButton': '恢复此版本',
|
||||
'cloudSync.revisionHistory.restored': '已从选中的修订恢复主机库数据。',
|
||||
@@ -568,6 +691,8 @@ export const zhCNVaultMessages: Messages = {
|
||||
'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': '端口转发',
|
||||
@@ -633,8 +758,9 @@ export const zhCNVaultMessages: Messages = {
|
||||
'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.rule.summary.default': '{bindAddress}:{localPort} -> {remoteHost}:{remotePort}',
|
||||
'pf.tooltip.relayHost': '中转主机',
|
||||
'pf.tooltip.hostLabel': '主机',
|
||||
'pf.tooltip.hostAddress': '地址',
|
||||
@@ -657,6 +783,8 @@ export const zhCNVaultMessages: Messages = {
|
||||
'sftp.tabs.addTab': '新建标签页',
|
||||
'sftp.tabs.closeTab': '关闭标签页',
|
||||
'sftp.tabs.newTab': '新标签页',
|
||||
'sftp.tabs.copyDefaultPath': '复制标签页(默认路径)',
|
||||
'sftp.tabs.copyCurrentPath': '复制并跳转到当前路径',
|
||||
'sftp.conflict.title': '文件冲突',
|
||||
'sftp.conflict.desc': '目标位置已存在同名文件',
|
||||
'sftp.conflict.alreadyExistsSuffix': '已存在',
|
||||
|
||||
@@ -45,6 +45,7 @@ export interface LocalVaultBackupPreview {
|
||||
hostCount: number;
|
||||
keyCount: number;
|
||||
snippetCount: number;
|
||||
noteCount: number;
|
||||
identityCount: number;
|
||||
portForwardingRuleCount: number;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
test("active tab changes notify chrome theme before react subscribers", () => {
|
||||
const storeSource = readFileSync(new URL("./activeTabStore.ts", import.meta.url), "utf8");
|
||||
const syncSource = readFileSync(new URL("./activeChromeThemeSync.ts", import.meta.url), "utf8");
|
||||
|
||||
const setActiveTabIdBody = storeSource.match(/setActiveTabId = \(id: string\) => \{[\s\S]*?\n {2}\};/)?.[0] ?? "";
|
||||
assert.match(setActiveTabIdBody, /this\.syncListeners\.forEach\(\(listener\) => listener\(id\)\)/);
|
||||
assert.match(setActiveTabIdBody, /this\.scheduleNotify\(\)/);
|
||||
assert.ok(
|
||||
setActiveTabIdBody.indexOf("syncListeners.forEach") < setActiveTabIdBody.indexOf("scheduleNotify"),
|
||||
"sync chrome theme listeners must run before deferred react notify",
|
||||
);
|
||||
assert.match(syncSource, /activeTabStore\.subscribeSync\(notifyActiveChromeThemeForTab\)/);
|
||||
assert.match(syncSource, /isActiveChromeThemeResolvable/);
|
||||
assert.match(syncSource, /clearTopTabsChromeThemeVars/);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { isActiveChromeThemeResolvable, resolveActiveChromeTheme } from '../app/activeChromeTheme';
|
||||
import { clearTopTabsChromeThemeVars } from '../app/topTabsChromeTheme';
|
||||
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
|
||||
import { activeTabStore } from './activeTabStore';
|
||||
import type { EditorTab } from './editorTabStore';
|
||||
import type { LogView } from './logViewState';
|
||||
import { syncActiveChromeTheme } from './useActiveChromeTheme';
|
||||
|
||||
export type ActiveChromeThemeDeps = {
|
||||
accentMode: 'theme' | 'custom';
|
||||
applyAppTheme: () => void;
|
||||
currentTerminalTheme: TerminalTheme;
|
||||
customAccent: string;
|
||||
editorTabs: readonly EditorTab[];
|
||||
followAppTerminalTheme: boolean;
|
||||
hostById: Map<string, Host>;
|
||||
logViews: readonly LogView[];
|
||||
sessionById: Map<string, TerminalSession>;
|
||||
themeById: Map<string, TerminalTheme>;
|
||||
workspaceById: Map<string, Workspace>;
|
||||
};
|
||||
|
||||
let depsRef: ActiveChromeThemeDeps | null = null;
|
||||
|
||||
export function updateActiveChromeThemeDeps(deps: ActiveChromeThemeDeps): void {
|
||||
depsRef = deps;
|
||||
}
|
||||
|
||||
export function notifyActiveChromeThemeForTab(activeTabId: string): void {
|
||||
if (!depsRef || typeof document === 'undefined') return;
|
||||
if (activeTabId === 'vault' || activeTabId === 'sftp') {
|
||||
clearTopTabsChromeThemeVars();
|
||||
}
|
||||
if (!isActiveChromeThemeResolvable({ ...depsRef, activeTabId })) return;
|
||||
const activeTheme = resolveActiveChromeTheme({ ...depsRef, activeTabId });
|
||||
syncActiveChromeTheme(activeTheme, depsRef.applyAppTheme);
|
||||
}
|
||||
|
||||
activeTabStore.subscribeSync(notifyActiveChromeThemeForTab);
|
||||
@@ -0,0 +1,14 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { fromEditorTabId, isEditorTabId, toEditorTabId } from './activeTabStore';
|
||||
|
||||
test('editor tab helpers round trip ids', () => {
|
||||
assert.equal(toEditorTabId('file-1'), 'editor:file-1');
|
||||
assert.equal(fromEditorTabId('editor:file-1'), 'file-1');
|
||||
});
|
||||
|
||||
test('editor tab helper detects editor top-tab ids', () => {
|
||||
assert.equal(isEditorTabId('editor:file-1'), true);
|
||||
assert.equal(isEditorTabId('session-1'), false);
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { terminalLayoutSuppressStore } from './terminalLayoutSuppressStore';
|
||||
|
||||
// Simple store for active tab that allows fine-grained subscriptions
|
||||
type Listener = () => void;
|
||||
type SyncListener = (activeTabId: string) => void;
|
||||
|
||||
// ----- Editor tab id helpers -----
|
||||
export const EDITOR_PREFIX = 'editor:';
|
||||
@@ -20,6 +21,7 @@ export const fromEditorTabId = (tabId: string): string => tabId.slice(EDITOR_PRE
|
||||
class ActiveTabStore {
|
||||
private activeTabId: string = 'vault';
|
||||
private listeners = new Set<Listener>();
|
||||
private syncListeners = new Set<SyncListener>();
|
||||
private notifyRafId: number | null = null;
|
||||
|
||||
getActiveTabId = () => this.activeTabId;
|
||||
@@ -39,6 +41,7 @@ class ActiveTabStore {
|
||||
if (this.activeTabId !== id) {
|
||||
terminalLayoutSuppressStore.begin();
|
||||
this.activeTabId = id;
|
||||
this.syncListeners.forEach((listener) => listener(id));
|
||||
// Coalesce rapid tab switches into one notification per frame and avoid
|
||||
// "setState during render" if called from a render phase.
|
||||
this.scheduleNotify();
|
||||
@@ -57,6 +60,11 @@ class ActiveTabStore {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
|
||||
subscribeSync = (listener: SyncListener) => {
|
||||
this.syncListeners.add(listener);
|
||||
return () => this.syncListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export const activeTabStore = new ActiveTabStore();
|
||||
@@ -109,15 +117,3 @@ export const useIsEditorTabActive = (tabId: string): boolean => {
|
||||
const getSnapshot = useCallback(() => activeTabStore.getActiveTabId() === editorTopId, [editorTopId]);
|
||||
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot, getSnapshot);
|
||||
};
|
||||
|
||||
// Check if terminal layer should be visible
|
||||
// Editor tabs are NOT terminal tabs, so exclude them from the visibility condition.
|
||||
export const useIsTerminalLayerVisible = (draggingSessionId: string | null) => {
|
||||
const getSnapshot = useCallback(() => {
|
||||
const activeTabId = activeTabStore.getActiveTabId();
|
||||
const isTerminalTab = activeTabId !== 'vault' && activeTabId !== 'sftp' && !isEditorTabId(activeTabId);
|
||||
return isTerminalTab || !!draggingSessionId;
|
||||
}, [draggingSessionId]);
|
||||
|
||||
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot, getSnapshot);
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ensureDraftForScopeState,
|
||||
getDraftMutationVersionState,
|
||||
getDraftUploadGenerationState,
|
||||
pruneStaleSessionPanelViews,
|
||||
pruneTerminalScopeState,
|
||||
pruneTerminalTransientState,
|
||||
resolvePanelView,
|
||||
@@ -89,6 +90,39 @@ test("setSessionView records target session id", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("pruneStaleSessionPanelViews resets session views that no longer exist", () => {
|
||||
const panelViewByScope = {
|
||||
"terminal:1": { mode: "session", sessionId: "deleted-session" },
|
||||
"workspace:2": { mode: "session", sessionId: "session-keep" },
|
||||
"terminal:3": { mode: "draft" },
|
||||
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
|
||||
|
||||
const next = pruneStaleSessionPanelViews(
|
||||
panelViewByScope,
|
||||
new Set(["session-keep"]),
|
||||
);
|
||||
|
||||
assert.deepEqual(next, {
|
||||
"terminal:1": { mode: "draft" },
|
||||
"workspace:2": { mode: "session", sessionId: "session-keep" },
|
||||
"terminal:3": { mode: "draft" },
|
||||
});
|
||||
});
|
||||
|
||||
test("pruneStaleSessionPanelViews returns the original ref when nothing is stale", () => {
|
||||
const panelViewByScope = {
|
||||
"terminal:1": { mode: "session", sessionId: "session-keep" },
|
||||
"terminal:2": { mode: "draft" },
|
||||
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
|
||||
|
||||
const next = pruneStaleSessionPanelViews(
|
||||
panelViewByScope,
|
||||
new Set(["session-keep"]),
|
||||
);
|
||||
|
||||
assert.equal(next, panelViewByScope);
|
||||
});
|
||||
|
||||
test("clearScopeDraftState removes both the draft and current panel view", () => {
|
||||
const draftsByScope = {
|
||||
"terminal:1": createEmptyDraft("agent-alpha"),
|
||||
|
||||
@@ -115,6 +115,25 @@ export function setSessionView(
|
||||
};
|
||||
}
|
||||
|
||||
export function pruneStaleSessionPanelViews(
|
||||
panelViewByScope: PanelViewByScope,
|
||||
validSessionIds: Set<string>,
|
||||
): PanelViewByScope {
|
||||
let next = panelViewByScope;
|
||||
|
||||
for (const [scopeKey, panelView] of Object.entries(panelViewByScope)) {
|
||||
if (panelView?.mode !== 'session' || validSessionIds.has(panelView.sessionId)) {
|
||||
continue;
|
||||
}
|
||||
const updated = setDraftView(next, scopeKey);
|
||||
if (updated !== next) {
|
||||
next = updated;
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export function updateDraftForScope(
|
||||
draftsByScope: DraftsByScope,
|
||||
scopeKey: string,
|
||||
|
||||
@@ -65,7 +65,7 @@ test("pruneInactiveScopedTransientState removes closed workspace and terminal sc
|
||||
});
|
||||
});
|
||||
|
||||
test("pruneInactiveScopedSessions preserves restorable terminal external session ids across reconnects", () => {
|
||||
test("pruneInactiveScopedSessions reports inactive targets without deleting persisted history", () => {
|
||||
const sessions = [
|
||||
createSession("terminal-restorable", {
|
||||
type: "terminal",
|
||||
@@ -98,10 +98,32 @@ test("pruneInactiveScopedSessions preserves restorable terminal external session
|
||||
"terminal-local",
|
||||
"workspace-closed",
|
||||
]);
|
||||
assert.deepEqual(next.sessions, [
|
||||
sessions[0],
|
||||
sessions[3],
|
||||
assert.equal(next.sessions, sessions);
|
||||
});
|
||||
|
||||
test("pruneInactiveScopedSessions preserves inactive workspace and local terminal history after restart", () => {
|
||||
const sessions = [
|
||||
createSession("terminal-local", {
|
||||
type: "terminal",
|
||||
targetId: "closed-local",
|
||||
hostIds: ["local-shell"],
|
||||
}, "ext-local"),
|
||||
createSession("workspace-closed", {
|
||||
type: "workspace",
|
||||
targetId: "closed-workspace",
|
||||
}, "ext-workspace"),
|
||||
];
|
||||
|
||||
const next = pruneInactiveScopedSessions(
|
||||
sessions,
|
||||
new Set(),
|
||||
);
|
||||
|
||||
assert.deepEqual(next.orphanedSessionIds, [
|
||||
"terminal-local",
|
||||
"workspace-closed",
|
||||
]);
|
||||
assert.equal(next.sessions, sessions);
|
||||
});
|
||||
|
||||
test("pruneInactiveScopedSessions preserves original sessions when orphaned restorable chats are already detached", () => {
|
||||
|
||||
@@ -90,12 +90,6 @@ export function pruneInactiveScopedTransientState(
|
||||
};
|
||||
}
|
||||
|
||||
function isRestorableTerminalSession(session: AISession): boolean {
|
||||
return session.scope.type === "terminal"
|
||||
&& !!session.scope.hostIds?.length
|
||||
&& session.scope.hostIds.some((id) => !id.startsWith("local-") && !id.startsWith("serial-"));
|
||||
}
|
||||
|
||||
export function pruneInactiveScopedSessions(
|
||||
sessions: AISession[],
|
||||
activeTargetIds: Set<string>,
|
||||
@@ -123,23 +117,8 @@ export function pruneInactiveScopedSessions(
|
||||
};
|
||||
}
|
||||
|
||||
const orphanedSessionIdSet = new Set(orphanedSessionIds);
|
||||
let sessionsChanged = false;
|
||||
|
||||
const nextSessions = sessions.flatMap((session) => {
|
||||
if (!orphanedSessionIdSet.has(session.id)) {
|
||||
return [session];
|
||||
}
|
||||
|
||||
if (!isRestorableTerminalSession(session)) {
|
||||
sessionsChanged = true;
|
||||
return [];
|
||||
}
|
||||
return [session];
|
||||
});
|
||||
|
||||
return {
|
||||
sessions: sessionsChanged ? nextSessions : sessions,
|
||||
sessions,
|
||||
orphanedSessionIds,
|
||||
};
|
||||
}
|
||||
@@ -196,6 +196,21 @@ export function setLatestAIActiveSessionMapSnapshot(activeSessionIdMap: Record<s
|
||||
latestAIActiveSessionMapSnapshot = activeSessionIdMap;
|
||||
}
|
||||
|
||||
export function prewarmAIStateStorageSnapshots() {
|
||||
try {
|
||||
if (latestAISessionsSnapshot === null) {
|
||||
latestAISessionsSnapshot =
|
||||
localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? [];
|
||||
}
|
||||
if (latestAIActiveSessionMapSnapshot === null) {
|
||||
latestAIActiveSessionMapSnapshot =
|
||||
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {};
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[AIState] Failed to prewarm AI state storage snapshots:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function setLatestAIDraftsByScopeSnapshot(draftsByScope: DraftsByScope) {
|
||||
latestAIDraftsByScopeSnapshot = draftsByScope;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { resolveAutoSyncHashDecision } from "./autoSyncHashDecision.ts";
|
||||
|
||||
test("remote-applied data is skipped only when the current hash still matches it", () => {
|
||||
assert.equal(
|
||||
resolveAutoSyncHashDecision({
|
||||
currentHash: "remote-applied",
|
||||
lastSyncedHash: "old-local",
|
||||
appliedSkipHash: "remote-applied",
|
||||
}),
|
||||
"skip-applied",
|
||||
);
|
||||
});
|
||||
|
||||
test("user edits after remote apply still sync even when they match the old baseline", () => {
|
||||
assert.equal(
|
||||
resolveAutoSyncHashDecision({
|
||||
currentHash: "old-local",
|
||||
lastSyncedHash: "old-local",
|
||||
appliedSkipHash: "remote-applied",
|
||||
}),
|
||||
"sync",
|
||||
);
|
||||
});
|
||||
|
||||
test("unchanged data is skipped only when there is no pending remote-apply hash", () => {
|
||||
assert.equal(
|
||||
resolveAutoSyncHashDecision({
|
||||
currentHash: "same",
|
||||
lastSyncedHash: "same",
|
||||
appliedSkipHash: null,
|
||||
}),
|
||||
"unchanged",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
export type AutoSyncHashDecision = 'skip-applied' | 'unchanged' | 'sync';
|
||||
|
||||
export function resolveAutoSyncHashDecision({
|
||||
currentHash,
|
||||
lastSyncedHash,
|
||||
appliedSkipHash,
|
||||
}: {
|
||||
currentHash: string;
|
||||
lastSyncedHash: string;
|
||||
appliedSkipHash: string | null;
|
||||
}): AutoSyncHashDecision {
|
||||
if (appliedSkipHash !== null) {
|
||||
return currentHash === appliedSkipHash ? 'skip-applied' : 'sync';
|
||||
}
|
||||
return currentHash === lastSyncedHash ? 'unchanged' : 'sync';
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
export type HostTreeInlineHostEdit = {
|
||||
hostId: string;
|
||||
initialName: string;
|
||||
};
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
class HostTreeInlineHostEditStore {
|
||||
private edit: HostTreeInlineHostEdit | null = null;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
getEdit = () => this.edit;
|
||||
|
||||
startEdit = (edit: HostTreeInlineHostEdit) => {
|
||||
this.edit = edit;
|
||||
this.listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
clear = () => {
|
||||
if (!this.edit) return;
|
||||
this.edit = null;
|
||||
this.listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
subscribe = (listener: Listener) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export const hostTreeInlineHostEditStore = new HostTreeInlineHostEditStore();
|
||||
|
||||
export const useHostTreeInlineHostEdit = () => {
|
||||
return useSyncExternalStore(
|
||||
hostTreeInlineHostEditStore.subscribe,
|
||||
hostTreeInlineHostEditStore.getEdit,
|
||||
hostTreeInlineHostEditStore.getEdit,
|
||||
);
|
||||
};
|
||||
@@ -1,32 +0,0 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
/**
|
||||
* Tiny external store for "immersive mode active" (whether the active terminal
|
||||
* tab's theme is driving the app chrome). Kept out of the App component's render
|
||||
* so that toggling immersive — and tab switches in general — do not force a
|
||||
* full App re-render. The owner (AppActiveTabChrome) calls setImmersiveActive;
|
||||
* AppView/TopTabs read it via useImmersiveActive without re-rendering App.
|
||||
*/
|
||||
type Listener = () => void;
|
||||
|
||||
let immersiveActive = false;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
export function setImmersiveActive(active: boolean): void {
|
||||
if (immersiveActive === active) return;
|
||||
immersiveActive = active;
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
function subscribe(listener: Listener): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
function getSnapshot(): boolean {
|
||||
return immersiveActive;
|
||||
}
|
||||
|
||||
export function useImmersiveActive(): boolean {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { resolveTerminalSessionExitIntent } from "./resolveTerminalSessionExitIntent.ts";
|
||||
import {
|
||||
resolveTerminalSessionExitIntent,
|
||||
shouldCloseTerminalPopupOnExit,
|
||||
} from "./resolveTerminalSessionExitIntent.ts";
|
||||
|
||||
test("normal backend exited events close the session tab", () => {
|
||||
assert.deepEqual(
|
||||
@@ -30,3 +33,10 @@ test("backend closed events keep the tab and mark it disconnected", () => {
|
||||
{ kind: "markDisconnected" },
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal popup only auto-closes after clean command exit", () => {
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 0 }), true);
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 1 }), false);
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "error", error: "connection reset" }), false);
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "closed", exitCode: 0 }), false);
|
||||
});
|
||||
@@ -20,3 +20,7 @@ export function resolveTerminalSessionExitIntent(
|
||||
// so the user can inspect output and reconnect.
|
||||
return { kind: "markDisconnected" };
|
||||
}
|
||||
|
||||
export function shouldCloseTerminalPopupOnExit(evt: TerminalSessionExitEvent): boolean {
|
||||
return evt.reason === "exited" && evt.exitCode === 0;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { SessionCapabilities } from '../../domain/systemManager/types';
|
||||
|
||||
/** Internal entry: capabilities plus computed expiry timestamp. */
|
||||
interface StoreEntry {
|
||||
capabilities: SessionCapabilities;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
const capabilitiesBySessionId = new Map<string, StoreEntry>();
|
||||
const listenersBySessionId = new Map<string, Set<Listener>>();
|
||||
|
||||
function isExpired(entry: StoreEntry): boolean {
|
||||
return Date.now() > entry.expiresAt;
|
||||
}
|
||||
|
||||
function notifySession(sessionId: string) {
|
||||
listenersBySessionId.get(sessionId)?.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export const sessionCapabilitiesStore = {
|
||||
get(sessionId: string): SessionCapabilities | undefined {
|
||||
const entry = capabilitiesBySessionId.get(sessionId);
|
||||
if (!entry) return undefined;
|
||||
if (isExpired(entry)) {
|
||||
capabilitiesBySessionId.delete(sessionId);
|
||||
notifySession(sessionId);
|
||||
return undefined;
|
||||
}
|
||||
return entry.capabilities;
|
||||
},
|
||||
|
||||
set(sessionId: string, capabilities: SessionCapabilities, ttlMs: number) {
|
||||
const entry: StoreEntry = {
|
||||
capabilities: {
|
||||
...capabilities,
|
||||
probedAt: Date.now(),
|
||||
},
|
||||
expiresAt: Date.now() + ttlMs,
|
||||
};
|
||||
capabilitiesBySessionId.set(sessionId, entry);
|
||||
notifySession(sessionId);
|
||||
},
|
||||
|
||||
delete(sessionId: string) {
|
||||
if (!capabilitiesBySessionId.delete(sessionId)) return;
|
||||
notifySession(sessionId);
|
||||
listenersBySessionId.delete(sessionId);
|
||||
},
|
||||
|
||||
/** Drop cached capabilities for sessions that no longer exist. */
|
||||
prune(liveSessionIds: ReadonlySet<string>) {
|
||||
for (const sessionId of capabilitiesBySessionId.keys()) {
|
||||
if (!liveSessionIds.has(sessionId)) {
|
||||
capabilitiesBySessionId.delete(sessionId);
|
||||
listenersBySessionId.delete(sessionId);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
subscribe(sessionId: string, listener: Listener): () => void {
|
||||
let set = listenersBySessionId.get(sessionId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
listenersBySessionId.set(sessionId, set);
|
||||
}
|
||||
set.add(listener);
|
||||
return () => {
|
||||
set?.delete(listener);
|
||||
if (set && set.size === 0) {
|
||||
listenersBySessionId.delete(sessionId);
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,191 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import {
|
||||
DEFAULT_RESTORE_TERMINAL_CWD,
|
||||
DEFAULT_RESTORE_PREVIOUS_SESSION,
|
||||
resolveRestoreTerminalCwdSetting,
|
||||
resolveRestorePreviousSessionSetting,
|
||||
} from "./sessionRestoreSettings.ts";
|
||||
|
||||
test("restore previous session setting defaults on", () => {
|
||||
assert.equal(DEFAULT_RESTORE_PREVIOUS_SESSION, true);
|
||||
assert.equal(resolveRestorePreviousSessionSetting(null), true);
|
||||
});
|
||||
|
||||
test("restore previous session setting preserves explicit stored values", () => {
|
||||
assert.equal(resolveRestorePreviousSessionSetting(true), true);
|
||||
assert.equal(resolveRestorePreviousSessionSetting(false), false);
|
||||
});
|
||||
|
||||
test("restore terminal cwd setting defaults off", () => {
|
||||
assert.equal(DEFAULT_RESTORE_TERMINAL_CWD, false);
|
||||
assert.equal(resolveRestoreTerminalCwdSetting(null), false);
|
||||
});
|
||||
|
||||
test("restore terminal cwd setting preserves explicit stored values", () => {
|
||||
assert.equal(resolveRestoreTerminalCwdSetting(true), true);
|
||||
assert.equal(resolveRestoreTerminalCwdSetting(false), false);
|
||||
});
|
||||
|
||||
test("restore previous session setting participates in cross-window settings sync", () => {
|
||||
const storageSyncSource = readFileSync(new URL("./settingsStorageSync.ts", import.meta.url), "utf8");
|
||||
const ipcSyncSource = readFileSync(new URL("./settingsIpcSync.ts", import.meta.url), "utf8");
|
||||
|
||||
assert.match(storageSyncSource, /STORAGE_KEY_RESTORE_PREVIOUS_SESSION/);
|
||||
assert.match(storageSyncSource, /setRestorePreviousSessionState/);
|
||||
assert.match(storageSyncSource, /e\.key === STORAGE_KEY_RESTORE_PREVIOUS_SESSION/);
|
||||
|
||||
assert.match(ipcSyncSource, /STORAGE_KEY_RESTORE_PREVIOUS_SESSION/);
|
||||
assert.match(ipcSyncSource, /setRestorePreviousSessionState/);
|
||||
assert.match(ipcSyncSource, /key === STORAGE_KEY_RESTORE_PREVIOUS_SESSION/);
|
||||
});
|
||||
|
||||
test("disabling restore previous session clears the stored restore snapshot", () => {
|
||||
const settingsSource = readFileSync(new URL("./useSettingsState.ts", import.meta.url), "utf8");
|
||||
const importIndex = settingsSource.indexOf("sessionRestoreStorage");
|
||||
const setterIndex = settingsSource.indexOf("const setRestorePreviousSession = useCallback");
|
||||
const clearIndex = settingsSource.indexOf("sessionRestoreStorage.clear()", setterIndex);
|
||||
const writeIndex = settingsSource.indexOf("localStorageAdapter.writeBoolean(STORAGE_KEY_RESTORE_PREVIOUS_SESSION", setterIndex);
|
||||
|
||||
assert.notEqual(importIndex, -1);
|
||||
assert.notEqual(setterIndex, -1);
|
||||
assert.notEqual(clearIndex, -1);
|
||||
assert.notEqual(writeIndex, -1);
|
||||
assert.ok(
|
||||
writeIndex < clearIndex,
|
||||
"the setting should be persisted before clearing the restore snapshot",
|
||||
);
|
||||
});
|
||||
|
||||
test("session restore persistence re-arms when restore previous session is enabled", () => {
|
||||
const source = readFileSync(new URL("./useSessionState.ts", import.meta.url), "utf8");
|
||||
const adapterEventImportIndex = source.indexOf("LOCAL_STORAGE_ADAPTER_CHANGED_EVENT");
|
||||
const revisionStateIndex = source.indexOf("restorePreviousSessionRevision");
|
||||
const keyGuardIndex = source.indexOf("key !== STORAGE_KEY_RESTORE_PREVIOUS_SESSION");
|
||||
const listenerIndex = source.indexOf("addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT");
|
||||
const effectDependencyIndex = source.indexOf("restorePreviousSessionRevision, persistSessionRestore]", source.indexOf("beforeunload"));
|
||||
|
||||
assert.notEqual(adapterEventImportIndex, -1);
|
||||
assert.notEqual(revisionStateIndex, -1);
|
||||
assert.notEqual(keyGuardIndex, -1);
|
||||
assert.notEqual(listenerIndex, -1);
|
||||
assert.notEqual(effectDependencyIndex, -1);
|
||||
});
|
||||
|
||||
test("session restore persistence can be disabled for non-main windows", () => {
|
||||
const hookSource = readFileSync(new URL("./useSessionState.ts", import.meta.url), "utf8");
|
||||
const traySource = readFileSync(new URL("../../components/TrayPanel.tsx", import.meta.url), "utf8");
|
||||
const appSource = readFileSync(new URL("../../App.tsx", import.meta.url), "utf8");
|
||||
const indexSource = readFileSync(new URL("../../index.tsx", import.meta.url), "utf8");
|
||||
const registerBridgesSource = readFileSync(new URL("../../electron/main/registerBridges.cjs", import.meta.url), "utf8");
|
||||
const mainWindowSource = readFileSync(new URL("../../electron/bridges/windowManager/mainWindow.cjs", import.meta.url), "utf8");
|
||||
|
||||
assert.match(hookSource, /persistSessionRestore\?: boolean/);
|
||||
assert.match(hookSource, /restoreEnabled: persistSessionRestore && resolveRestorePreviousSessionSetting/);
|
||||
assert.match(hookSource, /payload: persistSessionRestore \? sessionRestoreStorage\.read\(\) : null/);
|
||||
assert.match(hookSource, /if \(!persistSessionRestore\) return;/);
|
||||
assert.match(traySource, /useSessionState\(\{ persistSessionRestore: false \}\)/);
|
||||
assert.match(appSource, /window\.location\.hash\.startsWith\('#\/session-window'\)/);
|
||||
assert.match(appSource, /persistSessionRestore: !isPeerSessionWindow/);
|
||||
assert.match(indexSource, /hash === '#\/session-window'/);
|
||||
assert.match(registerBridgesSource, /route: "session-window"/);
|
||||
assert.match(registerBridgesSource, /registerAsMainWindow: false/);
|
||||
assert.match(mainWindowSource, /const rendererHash = typeof route === "string"/);
|
||||
assert.match(mainWindowSource, /registerAsMainWindow = true/);
|
||||
assert.match(mainWindowSource, /if \(registerAsMainWindow\)/);
|
||||
assert.match(mainWindowSource, /loadURL\(`\$\{getDevRendererBaseUrl\(devServerUrl\)\}\$\{rendererHash\}`\)/);
|
||||
assert.match(mainWindowSource, /loadURL\(`app:\/\/netcatty\/index\.html\$\{rendererHash\}`\)/);
|
||||
});
|
||||
|
||||
test("session peer windows do not run main-window startup effects", () => {
|
||||
const appSource = readFileSync(new URL("../../App.tsx", import.meta.url), "utf8");
|
||||
const autoSyncSource = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
|
||||
const startupEffectsSource = readFileSync(new URL("../app/useAppStartupEffects.ts", import.meta.url), "utf8");
|
||||
const updateCheckSource = readFileSync(new URL("./useUpdateCheck.ts", import.meta.url), "utf8");
|
||||
const settingsStateSource = readFileSync(new URL("./useSettingsState.ts", import.meta.url), "utf8");
|
||||
const settingsIpcSyncSource = readFileSync(new URL("./settingsIpcSync.ts", import.meta.url), "utf8");
|
||||
const storageSyncSource = readFileSync(new URL("./settingsStorageSync.ts", import.meta.url), "utf8");
|
||||
const systemEffectsSource = readFileSync(new URL("./systemSettingsEffects.ts", import.meta.url), "utf8");
|
||||
const trayFocusIndex = appSource.indexOf("onTrayFocusSession");
|
||||
const trayPanelJumpIndex = appSource.indexOf("onTrayPanelJumpToSession");
|
||||
|
||||
assert.match(appSource, /const isPeerSessionWindow = typeof window !== 'undefined' && window\.location\.hash\.startsWith\('#\/session-window'\)/);
|
||||
assert.match(appSource, /useSettingsState\(\{[^}]*enableSettingsSync: !isPeerSessionWindow[^}]*enableSystemEffects: !isPeerSessionWindow/s);
|
||||
assert.match(appSource, /useAppStartupEffects\(\{[^}]*enabled: !isPeerSessionWindow/s);
|
||||
assert.match(appSource, /useUpdateCheck\(\{[^}]*enabled: !isPeerSessionWindow/s);
|
||||
assert.match(appSource, /if \(isPeerSessionWindow \|\| !isVaultInitialized \|\| versionBackupAttemptedRef\.current\) return;/);
|
||||
assert.match(appSource, /useAutoSync\(\{[^}]*enabled: !isPeerSessionWindow/s);
|
||||
assert.match(autoSyncSource, /enabled\?: boolean/);
|
||||
assert.match(autoSyncSource, /const enabled = config\.enabled !== false/);
|
||||
assert.match(autoSyncSource, /if \(!enabled\) return;/);
|
||||
assert.match(updateCheckSource, /enabled\?: boolean/);
|
||||
assert.match(updateCheckSource, /const enabled = options\?\.enabled !== false/);
|
||||
assert.match(updateCheckSource, /if \(!enabled\) return;/);
|
||||
assert.match(settingsStateSource, /enableSystemEffects\?: boolean/);
|
||||
assert.match(settingsStateSource, /enableSettingsSync\?: boolean/);
|
||||
assert.match(settingsStateSource, /const enableSettingsSync = options\.enableSettingsSync !== false/);
|
||||
assert.match(settingsStateSource, /useSettingsIpcSync\(\{[^}]*enabled: enableSettingsSync/s);
|
||||
assert.match(settingsStateSource, /useSettingsStorageSync\(\{[^}]*enabled: enableSettingsSync/s);
|
||||
assert.match(settingsStateSource, /if \(!enableSettingsSync\) return;\s*try \{\s*netcattyBridge\.get\(\)\?\.notifySettingsChanged/s);
|
||||
assert.match(settingsStateSource, /useSystemSettingsEffects\(\{[^}]*enabled: enableSystemEffects/s);
|
||||
assert.match(settingsIpcSyncSource, /enabled\?: boolean/);
|
||||
assert.match(settingsIpcSyncSource, /if \(!enabled\) return;/);
|
||||
assert.match(storageSyncSource, /enabled\?: boolean/);
|
||||
assert.match(storageSyncSource, /if \(!enabled\) return;/);
|
||||
assert.match(systemEffectsSource, /enabled\?: boolean/);
|
||||
assert.match(systemEffectsSource, /if \(!enabled\) return;/);
|
||||
assert.ok(
|
||||
appSource.lastIndexOf("if (isPeerSessionWindow) return;", trayFocusIndex) !== -1,
|
||||
"peer session windows should not register tray focus/toggle listeners",
|
||||
);
|
||||
assert.ok(
|
||||
appSource.lastIndexOf("if (isPeerSessionWindow) return;", trayPanelJumpIndex) !== -1,
|
||||
"peer session windows should not register tray panel listeners",
|
||||
);
|
||||
assert.match(startupEffectsSource, /enabled = true/);
|
||||
assert.match(startupEffectsSource, /if \(!enabled\) return;/);
|
||||
assert.match(startupEffectsSource, /export function shouldQueueKeyboardInteractiveRequest/);
|
||||
assert.match(startupEffectsSource, /request\.scope !== "terminal"/);
|
||||
assert.match(startupEffectsSource, /shouldQueueKeyboardInteractiveRequest\(request, sessionsRef\.current\)/);
|
||||
assert.doesNotMatch(
|
||||
startupEffectsSource,
|
||||
/if \(!enabled\) return;\s*const bridge = netcattyBridge\.get\(\);\s*if \(!bridge\?\.onCheckDirtyEditors\) return;/,
|
||||
"dirty editor quit guard must remain registered in peer session windows",
|
||||
);
|
||||
});
|
||||
|
||||
test("restore-only settings do not bump the cloud sync settings version", () => {
|
||||
const settingsSource = readFileSync(new URL("./useSettingsState.ts", import.meta.url), "utf8");
|
||||
const settingsVersionIndex = settingsSource.indexOf("settingsVersion: useMemo");
|
||||
const settingsVersionSource = settingsSource.slice(settingsVersionIndex);
|
||||
|
||||
assert.notEqual(settingsVersionIndex, -1);
|
||||
assert.doesNotMatch(settingsVersionSource, /restorePreviousSession|restoreTerminalCwd/);
|
||||
});
|
||||
|
||||
test("restore previous session re-arms after cross-window settings ipc sync", () => {
|
||||
const hookSource = readFileSync(new URL("./useSessionState.ts", import.meta.url), "utf8");
|
||||
const settingsIpcSyncSource = readFileSync(new URL("./settingsIpcSync.ts", import.meta.url), "utf8");
|
||||
|
||||
assert.match(settingsIpcSyncSource, /STORAGE_KEY_RESTORE_PREVIOUS_SESSION/);
|
||||
assert.match(hookSource, /netcattyBridge/);
|
||||
assert.match(hookSource, /onSettingsChanged/);
|
||||
assert.match(hookSource, /handleRestorePreviousSessionChanged\(payload\?\.key\)/);
|
||||
assert.match(hookSource, /key !== STORAGE_KEY_RESTORE_PREVIOUS_SESSION/);
|
||||
assert.match(hookSource, /setRestorePreviousSessionRevision\(\(revision\) => revision \+ 1\)/);
|
||||
});
|
||||
|
||||
test("restore terminal cwd setting participates in cross-window settings sync", () => {
|
||||
const storageSyncSource = readFileSync(new URL("./settingsStorageSync.ts", import.meta.url), "utf8");
|
||||
const ipcSyncSource = readFileSync(new URL("./settingsIpcSync.ts", import.meta.url), "utf8");
|
||||
|
||||
assert.match(storageSyncSource, /STORAGE_KEY_RESTORE_TERMINAL_CWD/);
|
||||
assert.match(storageSyncSource, /setRestoreTerminalCwdState/);
|
||||
assert.match(storageSyncSource, /e\.key === STORAGE_KEY_RESTORE_TERMINAL_CWD/);
|
||||
|
||||
assert.match(ipcSyncSource, /STORAGE_KEY_RESTORE_TERMINAL_CWD/);
|
||||
assert.match(ipcSyncSource, /setRestoreTerminalCwdState/);
|
||||
assert.match(ipcSyncSource, /key === STORAGE_KEY_RESTORE_TERMINAL_CWD/);
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
export const DEFAULT_RESTORE_PREVIOUS_SESSION = true;
|
||||
export const DEFAULT_RESTORE_TERMINAL_CWD = false;
|
||||
|
||||
export const resolveRestorePreviousSessionSetting = (stored: boolean | null): boolean =>
|
||||
stored ?? DEFAULT_RESTORE_PREVIOUS_SESSION;
|
||||
|
||||
export const resolveRestoreTerminalCwdSetting = (stored: boolean | null): boolean =>
|
||||
stored ?? DEFAULT_RESTORE_TERMINAL_CWD;
|
||||
@@ -0,0 +1,225 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
buildAndWriteSessionRestorePayload,
|
||||
createInitialRestoredSessionState,
|
||||
mergeSessionRestoreCwd,
|
||||
updateRestoredSessionStatusState,
|
||||
shouldPersistSessionRestoreState,
|
||||
} from "./sessionRestoreState.ts";
|
||||
import type { SessionRestorePayload } from "../../domain/sessionRestore.ts";
|
||||
|
||||
const payload: SessionRestorePayload = {
|
||||
version: 1,
|
||||
savedAt: 1,
|
||||
activeTabId: "ws-1",
|
||||
tabOrder: ["ws-1"],
|
||||
sessions: [{
|
||||
id: "s1",
|
||||
hostId: "host-s1",
|
||||
hostLabel: "Host s1",
|
||||
hostname: "s1.example.test",
|
||||
username: "root",
|
||||
status: "disconnected",
|
||||
workspaceId: "ws-1",
|
||||
restoreState: "restored-disconnected",
|
||||
}],
|
||||
workspaces: [{
|
||||
id: "ws-1",
|
||||
title: "Workspace",
|
||||
root: { id: "pane-1", type: "pane", sessionId: "s1" },
|
||||
}],
|
||||
};
|
||||
|
||||
test("restored session state hydrates sessions, workspaces, tab order, and active tab", () => {
|
||||
const restored = createInitialRestoredSessionState({
|
||||
restoreEnabled: true,
|
||||
payload,
|
||||
});
|
||||
|
||||
assert.equal(restored.sessions[0].status, "disconnected");
|
||||
assert.equal(restored.sessions[0].restoreState, "restored-disconnected");
|
||||
assert.equal(restored.workspaces[0].id, "ws-1");
|
||||
assert.deepEqual(restored.tabOrder, ["ws-1"]);
|
||||
assert.equal(restored.activeTabId, "ws-1");
|
||||
});
|
||||
|
||||
test("restored session state is empty when restore is disabled", () => {
|
||||
const restored = createInitialRestoredSessionState({ restoreEnabled: false, payload });
|
||||
assert.deepEqual(restored.sessions, []);
|
||||
assert.deepEqual(restored.workspaces, []);
|
||||
assert.deepEqual(restored.tabOrder, []);
|
||||
assert.equal(restored.activeTabId, "vault");
|
||||
});
|
||||
|
||||
test("mergeSessionRestoreCwd records latest cwd metadata without terminal data", () => {
|
||||
const next = mergeSessionRestoreCwd(payload, "s1", "/usr/local/src");
|
||||
assert.equal(next.sessions[0].lastCwd, "/usr/local/src");
|
||||
assert.equal("terminalData" in next.sessions[0], false);
|
||||
});
|
||||
|
||||
test("mergeSessionRestoreCwd removes cwd when terminal reports null", () => {
|
||||
const next = mergeSessionRestoreCwd({
|
||||
...payload,
|
||||
sessions: [{ ...payload.sessions[0], lastCwd: "/tmp" }],
|
||||
}, "s1", null);
|
||||
assert.equal(next.sessions[0].lastCwd, undefined);
|
||||
});
|
||||
|
||||
test("updateRestoredSessionStatusState clears restore marker after reconnect starts", () => {
|
||||
const next = updateRestoredSessionStatusState(payload.sessions, "s1", "connecting");
|
||||
|
||||
assert.equal(next[0].status, "connecting");
|
||||
assert.equal(next[0].restoreState, undefined);
|
||||
});
|
||||
|
||||
test("updateRestoredSessionStatusState keeps restore marker for disconnected placeholders", () => {
|
||||
const next = updateRestoredSessionStatusState(payload.sessions, "s1", "disconnected");
|
||||
|
||||
assert.equal(next[0].status, "disconnected");
|
||||
assert.equal(next[0].restoreState, "restored-disconnected");
|
||||
});
|
||||
|
||||
test("shouldPersistSessionRestoreState skips transient empty startup state", () => {
|
||||
assert.equal(shouldPersistSessionRestoreState([], [], []), false);
|
||||
assert.equal(shouldPersistSessionRestoreState(payload.sessions, payload.workspaces, payload.tabOrder), true);
|
||||
});
|
||||
|
||||
test("restored session state preserves lightweight workspace chrome only", () => {
|
||||
const restored = createInitialRestoredSessionState({
|
||||
restoreEnabled: true,
|
||||
payload: {
|
||||
...payload,
|
||||
workspaces: [{
|
||||
...payload.workspaces[0],
|
||||
viewMode: "focus",
|
||||
focusedSessionId: "s1",
|
||||
focusSessionOrder: ["s1"],
|
||||
broadcastEnabled: true,
|
||||
transientPanelState: { selected: "history" },
|
||||
} as never],
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(restored.workspaces[0].viewMode, "focus");
|
||||
assert.equal(restored.workspaces[0].focusedSessionId, "s1");
|
||||
assert.deepEqual(restored.workspaces[0].focusSessionOrder, ["s1"]);
|
||||
assert.equal("transientPanelState" in restored.workspaces[0], false);
|
||||
});
|
||||
|
||||
test("session restore flush writes through the same sanitized payload path", () => {
|
||||
const writes: SessionRestorePayload[] = [];
|
||||
const storage = {
|
||||
write: (next: SessionRestorePayload) => {
|
||||
writes.push(next);
|
||||
return true;
|
||||
},
|
||||
clear: () => {
|
||||
throw new Error("should not clear non-empty restore state");
|
||||
},
|
||||
};
|
||||
|
||||
const wrote = buildAndWriteSessionRestorePayload({
|
||||
sessions: [{
|
||||
...payload.sessions[0],
|
||||
status: "connected",
|
||||
terminalData: "do-not-store",
|
||||
} as never],
|
||||
workspaces: [{
|
||||
...payload.workspaces[0],
|
||||
transientPanelState: { selected: "history" },
|
||||
} as never],
|
||||
tabOrder: ["ws-1"],
|
||||
activeTabId: "ws-1",
|
||||
now: 42,
|
||||
storage,
|
||||
});
|
||||
|
||||
assert.equal(wrote, true);
|
||||
assert.equal(writes.length, 1);
|
||||
assert.equal(writes[0].sessions[0].status, "disconnected");
|
||||
assert.equal("terminalData" in writes[0].sessions[0], false);
|
||||
assert.equal("transientPanelState" in writes[0].workspaces[0], false);
|
||||
assert.equal(writes[0].savedAt, 42);
|
||||
});
|
||||
|
||||
test("session restore flush clears storage instead of writing when restore is disabled", () => {
|
||||
const writes: SessionRestorePayload[] = [];
|
||||
let clearCount = 0;
|
||||
const storage = {
|
||||
write: (next: SessionRestorePayload) => {
|
||||
writes.push(next);
|
||||
return true;
|
||||
},
|
||||
clear: () => {
|
||||
clearCount += 1;
|
||||
},
|
||||
};
|
||||
|
||||
const wrote = buildAndWriteSessionRestorePayload({
|
||||
restoreEnabled: false,
|
||||
sessions: payload.sessions,
|
||||
workspaces: payload.workspaces,
|
||||
tabOrder: payload.tabOrder,
|
||||
activeTabId: payload.activeTabId,
|
||||
storage,
|
||||
});
|
||||
|
||||
assert.equal(wrote, false);
|
||||
assert.equal(clearCount, 1);
|
||||
assert.equal(writes.length, 0);
|
||||
});
|
||||
|
||||
test("session restore flush skips empty transient windows without clearing existing snapshots", () => {
|
||||
const writes: SessionRestorePayload[] = [];
|
||||
let clearCount = 0;
|
||||
const storage = {
|
||||
write: (next: SessionRestorePayload) => {
|
||||
writes.push(next);
|
||||
return true;
|
||||
},
|
||||
clear: () => {
|
||||
clearCount += 1;
|
||||
},
|
||||
};
|
||||
|
||||
const wrote = buildAndWriteSessionRestorePayload({
|
||||
sessions: [],
|
||||
workspaces: [],
|
||||
tabOrder: [],
|
||||
activeTabId: "vault",
|
||||
storage,
|
||||
});
|
||||
|
||||
assert.equal(wrote, false);
|
||||
assert.equal(clearCount, 0);
|
||||
assert.equal(writes.length, 0);
|
||||
});
|
||||
|
||||
test("session restore flush clears stale snapshots when main window becomes empty after restorable state existed", () => {
|
||||
const writes: SessionRestorePayload[] = [];
|
||||
let clearCount = 0;
|
||||
const storage = {
|
||||
write: (next: SessionRestorePayload) => {
|
||||
writes.push(next);
|
||||
return true;
|
||||
},
|
||||
clear: () => {
|
||||
clearCount += 1;
|
||||
},
|
||||
};
|
||||
|
||||
const wrote = buildAndWriteSessionRestorePayload({
|
||||
sessions: [],
|
||||
workspaces: [],
|
||||
tabOrder: [],
|
||||
activeTabId: "vault",
|
||||
clearOnEmpty: true,
|
||||
storage,
|
||||
});
|
||||
|
||||
assert.equal(wrote, false);
|
||||
assert.equal(clearCount, 1);
|
||||
assert.equal(writes.length, 0);
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { TerminalSession, Workspace } from "../../domain/models";
|
||||
import {
|
||||
buildSessionRestorePayload,
|
||||
sanitizeSessionRestorePayload,
|
||||
type SessionRestorePayload,
|
||||
} from "../../domain/sessionRestore";
|
||||
|
||||
export type InitialRestoredSessionState = {
|
||||
sessions: TerminalSession[];
|
||||
workspaces: Workspace[];
|
||||
tabOrder: string[];
|
||||
activeTabId: string;
|
||||
};
|
||||
|
||||
export function createInitialRestoredSessionState({
|
||||
restoreEnabled,
|
||||
payload,
|
||||
}: {
|
||||
restoreEnabled: boolean;
|
||||
payload: SessionRestorePayload | null;
|
||||
}): InitialRestoredSessionState {
|
||||
if (!restoreEnabled || !payload) {
|
||||
return {
|
||||
sessions: [],
|
||||
workspaces: [],
|
||||
tabOrder: [],
|
||||
activeTabId: "vault",
|
||||
};
|
||||
}
|
||||
|
||||
const sanitized = sanitizeSessionRestorePayload(payload);
|
||||
return {
|
||||
sessions: sanitized.sessions,
|
||||
workspaces: sanitized.workspaces,
|
||||
tabOrder: sanitized.tabOrder,
|
||||
activeTabId: sanitized.activeTabId,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldPersistSessionRestoreState(
|
||||
sessions: readonly TerminalSession[],
|
||||
workspaces: readonly Workspace[],
|
||||
tabOrder: readonly string[],
|
||||
): boolean {
|
||||
return sessions.length > 0 || workspaces.length > 0 || tabOrder.length > 0;
|
||||
}
|
||||
|
||||
export function buildPersistableSessionRestorePayload({
|
||||
sessions,
|
||||
workspaces,
|
||||
tabOrder,
|
||||
activeTabId,
|
||||
now,
|
||||
}: {
|
||||
sessions: TerminalSession[];
|
||||
workspaces: Workspace[];
|
||||
tabOrder: string[];
|
||||
activeTabId: string;
|
||||
now?: number;
|
||||
}): SessionRestorePayload | null {
|
||||
if (!shouldPersistSessionRestoreState(sessions, workspaces, tabOrder)) return null;
|
||||
return buildSessionRestorePayload({
|
||||
sessions,
|
||||
workspaces,
|
||||
tabOrder,
|
||||
activeTabId,
|
||||
now,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildAndWriteSessionRestorePayload({
|
||||
restoreEnabled = true,
|
||||
clearOnEmpty = false,
|
||||
sessions,
|
||||
workspaces,
|
||||
tabOrder,
|
||||
activeTabId,
|
||||
now,
|
||||
storage,
|
||||
}: {
|
||||
restoreEnabled?: boolean;
|
||||
clearOnEmpty?: boolean;
|
||||
sessions: TerminalSession[];
|
||||
workspaces: Workspace[];
|
||||
tabOrder: string[];
|
||||
activeTabId: string;
|
||||
now?: number;
|
||||
storage: {
|
||||
write(payload: SessionRestorePayload): boolean;
|
||||
clear(): void;
|
||||
};
|
||||
}): boolean {
|
||||
if (!restoreEnabled) {
|
||||
storage.clear();
|
||||
return false;
|
||||
}
|
||||
const payload = buildPersistableSessionRestorePayload({
|
||||
sessions,
|
||||
workspaces,
|
||||
tabOrder,
|
||||
activeTabId,
|
||||
now,
|
||||
});
|
||||
if (!payload) {
|
||||
if (clearOnEmpty) {
|
||||
storage.clear();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return storage.write(payload);
|
||||
}
|
||||
|
||||
export function mergeSessionRestoreCwd(
|
||||
payload: SessionRestorePayload,
|
||||
sessionId: string,
|
||||
cwd: string | null,
|
||||
): SessionRestorePayload {
|
||||
return sanitizeSessionRestorePayload({
|
||||
...payload,
|
||||
sessions: payload.sessions.map((session) => {
|
||||
if (session.id !== sessionId) return session;
|
||||
const { lastCwd: _lastCwd, ...rest } = session;
|
||||
return cwd ? { ...rest, lastCwd: cwd } : rest;
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateRestoredSessionStatusState<T extends Pick<TerminalSession, "id" | "status" | "restoreState">>(
|
||||
sessions: readonly T[],
|
||||
sessionId: string,
|
||||
status: TerminalSession["status"],
|
||||
): T[] {
|
||||
let changed = false;
|
||||
const next = sessions.map((session) => {
|
||||
if (session.id !== sessionId) return session;
|
||||
const shouldClearRestoreState = status === "connecting" || status === "connected";
|
||||
if (session.status === status && (!shouldClearRestoreState || session.restoreState === undefined)) {
|
||||
return session;
|
||||
}
|
||||
changed = true;
|
||||
if (!shouldClearRestoreState) return { ...session, status };
|
||||
const { restoreState: _restoreState, ...rest } = session;
|
||||
return { ...rest, status } as T;
|
||||
});
|
||||
return changed ? next : sessions as T[];
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { createSessionRestoreStorage } from "./sessionRestoreStorage.ts";
|
||||
import type { SessionRestorePayload } from "../../domain/sessionRestore.ts";
|
||||
|
||||
const payload: SessionRestorePayload = {
|
||||
version: 1,
|
||||
savedAt: 1,
|
||||
activeTabId: "vault",
|
||||
tabOrder: [],
|
||||
sessions: [],
|
||||
workspaces: [],
|
||||
};
|
||||
|
||||
test("session restore storage reads a valid payload", () => {
|
||||
const backing = new Map<string, unknown>();
|
||||
const storage = createSessionRestoreStorage({
|
||||
read: <T,>(key: string): T | null => (backing.get(key) as T) ?? null,
|
||||
write: <T,>(key: string, value: T): boolean => {
|
||||
backing.set(key, value);
|
||||
return true;
|
||||
},
|
||||
remove: (key: string) => {
|
||||
backing.delete(key);
|
||||
},
|
||||
});
|
||||
|
||||
storage.write(payload);
|
||||
assert.deepEqual(storage.read(), payload);
|
||||
});
|
||||
|
||||
test("session restore storage removes invalid payloads", () => {
|
||||
const backing = new Map<string, unknown>([["netcatty_session_restore_v1", { version: 999 }]]);
|
||||
const storage = createSessionRestoreStorage({
|
||||
read: <T,>(key: string): T | null => (backing.get(key) as T) ?? null,
|
||||
write: () => true,
|
||||
remove: (key: string) => {
|
||||
backing.delete(key);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(storage.read(), null);
|
||||
assert.equal(backing.has("netcatty_session_restore_v1"), false);
|
||||
});
|
||||
|
||||
test("session restore storage removes malformed payloads that pass shallow checks", () => {
|
||||
const backing = new Map<string, unknown>([[
|
||||
"netcatty_session_restore_v1",
|
||||
{
|
||||
version: 1,
|
||||
savedAt: 1,
|
||||
activeTabId: "vault",
|
||||
tabOrder: [],
|
||||
sessions: [null],
|
||||
workspaces: [],
|
||||
},
|
||||
]]);
|
||||
const storage = createSessionRestoreStorage({
|
||||
read: <T,>(key: string): T | null => (backing.get(key) as T) ?? null,
|
||||
write: () => true,
|
||||
remove: (key: string) => {
|
||||
backing.delete(key);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(storage.read(), null);
|
||||
assert.equal(backing.has("netcatty_session_restore_v1"), false);
|
||||
});
|
||||
|
||||
test("session restore storage sanitizes payloads before writing", () => {
|
||||
const backing = new Map<string, unknown>();
|
||||
const storage = createSessionRestoreStorage({
|
||||
read: <T,>(key: string): T | null => (backing.get(key) as T) ?? null,
|
||||
write: <T,>(key: string, value: T): boolean => {
|
||||
backing.set(key, value);
|
||||
return true;
|
||||
},
|
||||
remove: (key: string) => {
|
||||
backing.delete(key);
|
||||
},
|
||||
});
|
||||
|
||||
storage.write({
|
||||
...payload,
|
||||
sessions: [{
|
||||
id: "s1",
|
||||
hostId: "h1",
|
||||
hostLabel: "Host 1",
|
||||
hostname: "example.test",
|
||||
username: "root",
|
||||
status: "connected",
|
||||
}],
|
||||
tabOrder: ["s1"],
|
||||
activeTabId: "s1",
|
||||
} as unknown as SessionRestorePayload);
|
||||
|
||||
assert.equal(storage.read()?.sessions[0].status, "disconnected");
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
STORAGE_KEY_SESSION_RESTORE,
|
||||
} from "../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import {
|
||||
SESSION_RESTORE_VERSION,
|
||||
sanitizeSessionRestorePayload,
|
||||
type SessionRestorePayload,
|
||||
} from "../../domain/sessionRestore";
|
||||
|
||||
type RestoreStorageAdapter = {
|
||||
read<T>(key: string): T | null;
|
||||
write<T>(key: string, value: T): boolean;
|
||||
remove(key: string): void;
|
||||
};
|
||||
|
||||
const isRestorePayload = (value: unknown): value is SessionRestorePayload => {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const record = value as Partial<SessionRestorePayload>;
|
||||
return record.version === SESSION_RESTORE_VERSION
|
||||
&& Array.isArray(record.sessions)
|
||||
&& record.sessions.every((session) => (
|
||||
Boolean(session)
|
||||
&& typeof session === "object"
|
||||
&& typeof (session as { id?: unknown }).id === "string"
|
||||
&& typeof (session as { hostId?: unknown }).hostId === "string"
|
||||
&& typeof (session as { hostLabel?: unknown }).hostLabel === "string"
|
||||
&& typeof (session as { hostname?: unknown }).hostname === "string"
|
||||
&& typeof (session as { username?: unknown }).username === "string"
|
||||
))
|
||||
&& Array.isArray(record.workspaces)
|
||||
&& record.workspaces.every((workspace) => (
|
||||
Boolean(workspace)
|
||||
&& typeof workspace === "object"
|
||||
&& typeof (workspace as { id?: unknown }).id === "string"
|
||||
&& Boolean((workspace as { root?: unknown }).root)
|
||||
&& typeof (workspace as { root?: unknown }).root === "object"
|
||||
))
|
||||
&& Array.isArray(record.tabOrder)
|
||||
&& record.tabOrder.every((tabId) => typeof tabId === "string")
|
||||
&& typeof record.activeTabId === "string";
|
||||
};
|
||||
|
||||
export function createSessionRestoreStorage(adapter: RestoreStorageAdapter = localStorageAdapter) {
|
||||
return {
|
||||
read(): SessionRestorePayload | null {
|
||||
const payload = adapter.read<unknown>(STORAGE_KEY_SESSION_RESTORE);
|
||||
if (!isRestorePayload(payload)) {
|
||||
if (payload !== null) adapter.remove(STORAGE_KEY_SESSION_RESTORE);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return sanitizeSessionRestorePayload(payload);
|
||||
} catch {
|
||||
adapter.remove(STORAGE_KEY_SESSION_RESTORE);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
write(payload: SessionRestorePayload): boolean {
|
||||
return adapter.write(STORAGE_KEY_SESSION_RESTORE, sanitizeSessionRestorePayload(payload));
|
||||
},
|
||||
clear(): void {
|
||||
adapter.remove(STORAGE_KEY_SESSION_RESTORE);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const sessionRestoreStorage = createSessionRestoreStorage();
|
||||
@@ -0,0 +1,123 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { TerminalSession, Workspace } from "../../domain/models";
|
||||
import {
|
||||
closeSessionWorkspaceLayoutState,
|
||||
detachSessionFromWorkspaceState,
|
||||
replaceDissolvedWorkspaceTabOrder,
|
||||
} from "./sessionWorkspaceDetach";
|
||||
|
||||
const session = (id: string, workspaceId = "ws-1"): TerminalSession => ({
|
||||
id,
|
||||
hostId: id,
|
||||
hostLabel: id,
|
||||
status: "connected",
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const workspace = (sessionIds: string[]): Workspace => ({
|
||||
id: "ws-1",
|
||||
title: "Workspace",
|
||||
focusedSessionId: sessionIds[0],
|
||||
focusSessionOrder: sessionIds,
|
||||
root: sessionIds.length === 1
|
||||
? { id: "pane-1", type: "pane", sessionId: sessionIds[0] }
|
||||
: {
|
||||
id: "split-1",
|
||||
type: "split",
|
||||
direction: "vertical",
|
||||
children: sessionIds.map((sessionId, index) => ({
|
||||
id: `pane-${index + 1}`,
|
||||
type: "pane" as const,
|
||||
sessionId,
|
||||
})),
|
||||
sizes: sessionIds.map(() => 1),
|
||||
},
|
||||
});
|
||||
|
||||
test("detach dissolves the original workspace when one session remains", () => {
|
||||
const result = detachSessionFromWorkspaceState({
|
||||
sessions: [session("s1"), session("s2")],
|
||||
workspaces: [workspace(["s1", "s2"])],
|
||||
sessionId: "s1",
|
||||
});
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
assert.equal(result.activeTabId, "s1");
|
||||
assert.deepEqual(result.sessions.map((s) => [s.id, s.workspaceId]), [
|
||||
["s1", undefined],
|
||||
["s2", undefined],
|
||||
]);
|
||||
assert.equal(result.workspaces.length, 0);
|
||||
assert.equal(result.dissolvedWorkspaceId, "ws-1");
|
||||
assert.deepEqual(result.replacementTabIds, ["s1", "s2"]);
|
||||
});
|
||||
|
||||
test("detach preserves the other sessions in a multi-pane workspace", () => {
|
||||
const result = detachSessionFromWorkspaceState({
|
||||
sessions: [session("s1"), session("s2"), session("s3")],
|
||||
workspaces: [workspace(["s1", "s2", "s3"])],
|
||||
sessionId: "s2",
|
||||
});
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
assert.deepEqual(result.sessions.map((s) => [s.id, s.workspaceId]), [
|
||||
["s1", "ws-1"],
|
||||
["s2", undefined],
|
||||
["s3", "ws-1"],
|
||||
]);
|
||||
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s1", "s3"]);
|
||||
assert.equal(result.workspaces[0].focusedSessionId, "s1");
|
||||
assert.deepEqual(
|
||||
result.workspaces[0].root.type === "split"
|
||||
? result.workspaces[0].root.children.map((child) => child.type === "pane" ? child.sessionId : null)
|
||||
: [],
|
||||
["s1", "s3"],
|
||||
);
|
||||
});
|
||||
|
||||
test("dissolved workspace replacement preserves its tab position", () => {
|
||||
assert.deepEqual(
|
||||
replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]),
|
||||
["log-1", "s1", "s2", "session-3"],
|
||||
);
|
||||
});
|
||||
|
||||
test("dissolved workspace replacement removes duplicate replacement ids", () => {
|
||||
assert.deepEqual(
|
||||
replaceDissolvedWorkspaceTabOrder(["s1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]),
|
||||
["s1", "s2", "session-3"],
|
||||
);
|
||||
});
|
||||
|
||||
test("dissolved workspace replacement is idempotent", () => {
|
||||
const once = replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]);
|
||||
|
||||
assert.deepEqual(
|
||||
replaceDissolvedWorkspaceTabOrder(once, "ws-1", ["s1", "s2"]),
|
||||
once,
|
||||
);
|
||||
});
|
||||
|
||||
test("single remaining session preserves dissolved workspace tab position", () => {
|
||||
assert.deepEqual(
|
||||
replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s2"]),
|
||||
["log-1", "s2", "session-3"],
|
||||
);
|
||||
});
|
||||
|
||||
test("closing a workspace session dissolves the workspace when one terminal remains", () => {
|
||||
const result = closeSessionWorkspaceLayoutState([workspace(["s1", "s2"])], "ws-1", "s1");
|
||||
|
||||
assert.equal(result.dissolvedWorkspaceId, "ws-1");
|
||||
assert.equal(result.lastRemainingSessionId, "s2");
|
||||
assert.deepEqual(result.workspaces, []);
|
||||
assert.deepEqual(
|
||||
replaceDissolvedWorkspaceTabOrder(
|
||||
["log-1", result.dissolvedWorkspaceId!, "session-3"],
|
||||
result.dissolvedWorkspaceId,
|
||||
result.lastRemainingSessionId ? [result.lastRemainingSessionId] : undefined,
|
||||
),
|
||||
["log-1", "s2", "session-3"],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import type { TerminalSession, Workspace } from "../../domain/models";
|
||||
import { collectSessionIds, pruneWorkspaceNode } from "../../domain/workspace";
|
||||
|
||||
export type DetachSessionFromWorkspaceStateResult = {
|
||||
changed: boolean;
|
||||
sessions: TerminalSession[];
|
||||
workspaces: Workspace[];
|
||||
activeTabId?: string;
|
||||
dissolvedWorkspaceId?: string;
|
||||
replacementTabIds?: string[];
|
||||
};
|
||||
|
||||
export type CloseSessionWorkspaceLayoutResult = {
|
||||
workspaces: Workspace[];
|
||||
removedWorkspaceId?: string;
|
||||
dissolvedWorkspaceId?: string;
|
||||
lastRemainingSessionId?: string;
|
||||
};
|
||||
|
||||
type DetachSessionFromWorkspaceStateOptions = {
|
||||
sessions: TerminalSession[];
|
||||
workspaces: Workspace[];
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
export function replaceDissolvedWorkspaceTabOrder(
|
||||
tabOrder: readonly string[],
|
||||
workspaceId: string | undefined,
|
||||
replacementTabIds: readonly string[] | undefined,
|
||||
): string[] {
|
||||
if (!workspaceId || !replacementTabIds?.length) return [...tabOrder];
|
||||
|
||||
const uniqueReplacementIds = replacementTabIds.filter((tabId, index, list) => (
|
||||
tabId && list.indexOf(tabId) === index
|
||||
));
|
||||
if (uniqueReplacementIds.length === 0) return [...tabOrder];
|
||||
|
||||
if (!tabOrder.includes(workspaceId)) {
|
||||
const hasAllReplacementIds = uniqueReplacementIds.every((tabId) => tabOrder.includes(tabId));
|
||||
return hasAllReplacementIds ? [...tabOrder] : [
|
||||
...tabOrder,
|
||||
...uniqueReplacementIds.filter((tabId) => !tabOrder.includes(tabId)),
|
||||
];
|
||||
}
|
||||
|
||||
const replacementIdSet = new Set(uniqueReplacementIds);
|
||||
let inserted = false;
|
||||
const nextOrder: string[] = [];
|
||||
|
||||
for (const tabId of tabOrder) {
|
||||
if (tabId === workspaceId) {
|
||||
if (!inserted) {
|
||||
nextOrder.push(...uniqueReplacementIds);
|
||||
inserted = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!replacementIdSet.has(tabId)) {
|
||||
nextOrder.push(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
return nextOrder;
|
||||
}
|
||||
|
||||
export function closeSessionWorkspaceLayoutState(
|
||||
workspaces: readonly Workspace[],
|
||||
workspaceId: string | undefined,
|
||||
sessionId: string,
|
||||
): CloseSessionWorkspaceLayoutResult {
|
||||
if (!workspaceId) return { workspaces: [...workspaces] };
|
||||
|
||||
let removedWorkspaceId: string | undefined;
|
||||
let dissolvedWorkspaceId: string | undefined;
|
||||
let lastRemainingSessionId: string | undefined;
|
||||
const nextWorkspaces = workspaces
|
||||
.map((workspace) => {
|
||||
if (workspace.id !== workspaceId) return workspace;
|
||||
const prunedRoot = pruneWorkspaceNode(workspace.root, sessionId);
|
||||
if (!prunedRoot) {
|
||||
removedWorkspaceId = workspace.id;
|
||||
return null;
|
||||
}
|
||||
|
||||
const remainingSessionIds = collectSessionIds(prunedRoot);
|
||||
if (remainingSessionIds.length === 1) {
|
||||
dissolvedWorkspaceId = workspace.id;
|
||||
lastRemainingSessionId = remainingSessionIds[0];
|
||||
return null;
|
||||
}
|
||||
|
||||
return { ...workspace, root: prunedRoot };
|
||||
})
|
||||
.filter((workspace): workspace is Workspace => Boolean(workspace));
|
||||
|
||||
return {
|
||||
workspaces: nextWorkspaces,
|
||||
removedWorkspaceId,
|
||||
dissolvedWorkspaceId,
|
||||
lastRemainingSessionId,
|
||||
};
|
||||
}
|
||||
|
||||
export function detachSessionFromWorkspaceState({
|
||||
sessions,
|
||||
workspaces,
|
||||
sessionId,
|
||||
}: DetachSessionFromWorkspaceStateOptions): DetachSessionFromWorkspaceStateResult {
|
||||
const session = sessions.find((candidate) => candidate.id === sessionId);
|
||||
if (!session?.workspaceId) {
|
||||
return { changed: false, sessions, workspaces };
|
||||
}
|
||||
|
||||
const workspaceId = session.workspaceId;
|
||||
const targetWorkspace = workspaces.find((workspace) => workspace.id === workspaceId);
|
||||
if (!targetWorkspace) {
|
||||
return { changed: false, sessions, workspaces };
|
||||
}
|
||||
|
||||
const prunedRoot = pruneWorkspaceNode(targetWorkspace.root, sessionId);
|
||||
let nextSessions = sessions.map((candidate) => (
|
||||
candidate.id === sessionId ? { ...candidate, workspaceId: undefined } : candidate
|
||||
));
|
||||
|
||||
if (!prunedRoot) {
|
||||
return {
|
||||
changed: true,
|
||||
sessions: nextSessions,
|
||||
workspaces: workspaces.filter((workspace) => workspace.id !== workspaceId),
|
||||
activeTabId: sessionId,
|
||||
dissolvedWorkspaceId: workspaceId,
|
||||
replacementTabIds: [sessionId],
|
||||
};
|
||||
}
|
||||
|
||||
const remainingSessionIds = collectSessionIds(prunedRoot);
|
||||
if (remainingSessionIds.length === 1) {
|
||||
nextSessions = nextSessions.map((candidate) => (
|
||||
candidate.id === remainingSessionIds[0] ? { ...candidate, workspaceId: undefined } : candidate
|
||||
));
|
||||
|
||||
return {
|
||||
changed: true,
|
||||
sessions: nextSessions,
|
||||
workspaces: workspaces.filter((workspace) => workspace.id !== workspaceId),
|
||||
activeTabId: sessionId,
|
||||
dissolvedWorkspaceId: workspaceId,
|
||||
replacementTabIds: [sessionId, ...remainingSessionIds],
|
||||
};
|
||||
}
|
||||
|
||||
const nextFocusedSessionId = remainingSessionIds.includes(targetWorkspace.focusedSessionId)
|
||||
? targetWorkspace.focusedSessionId
|
||||
: remainingSessionIds[0];
|
||||
const nextFocusSessionOrder = (targetWorkspace.focusSessionOrder ?? [])
|
||||
.filter((candidateId, index, list) => (
|
||||
candidateId !== sessionId &&
|
||||
remainingSessionIds.includes(candidateId) &&
|
||||
list.indexOf(candidateId) === index
|
||||
));
|
||||
for (const remainingSessionId of remainingSessionIds) {
|
||||
if (!nextFocusSessionOrder.includes(remainingSessionId)) {
|
||||
nextFocusSessionOrder.push(remainingSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
changed: true,
|
||||
sessions: nextSessions,
|
||||
workspaces: workspaces.map((workspace) => (
|
||||
workspace.id === workspaceId
|
||||
? {
|
||||
...workspace,
|
||||
root: prunedRoot,
|
||||
focusedSessionId: nextFocusedSessionId,
|
||||
focusSessionOrder: nextFocusSessionOrder,
|
||||
}
|
||||
: workspace
|
||||
)),
|
||||
activeTabId: sessionId,
|
||||
};
|
||||
}
|
||||
@@ -12,11 +12,15 @@ import {
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_HOTKEY_RECORDING,
|
||||
STORAGE_KEY_HOTKEY_SCHEME,
|
||||
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
STORAGE_KEY_RESTORE_PREVIOUS_SESSION,
|
||||
STORAGE_KEY_RESTORE_TERMINAL_CWD,
|
||||
STORAGE_KEY_SESSION_LOGS_DIR,
|
||||
STORAGE_KEY_SESSION_LOGS_ENABLED,
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
|
||||
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
|
||||
STORAGE_KEY_SSH_DEEP_LINK_ENABLED,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
|
||||
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
|
||||
@@ -34,6 +38,7 @@ import {
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
@@ -44,6 +49,7 @@ import {
|
||||
} from './settingsStateDefaults';
|
||||
|
||||
interface UseSettingsIpcSyncParams {
|
||||
enabled?: boolean;
|
||||
syncAppearanceFromStorage: () => void;
|
||||
syncCustomCssFromStorage: () => void;
|
||||
setUiLanguage: Dispatch<SetStateAction<UILanguage>>;
|
||||
@@ -61,6 +67,7 @@ interface UseSettingsIpcSyncParams {
|
||||
setSessionLogsFormat: Dispatch<SetStateAction<SessionLogFormat>>;
|
||||
setSessionLogsTimestampsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSshDeepLinkEnabledState: (enabled: boolean) => void;
|
||||
setHotkeyScheme: Dispatch<SetStateAction<HotkeyScheme>>;
|
||||
applyIncomingCustomKeyBindings: (incoming: { bindings: CustomKeyBindings; version: number; origin: string }) => void;
|
||||
setIsHotkeyRecordingState: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -71,10 +78,15 @@ interface UseSettingsIpcSyncParams {
|
||||
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
|
||||
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
|
||||
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
|
||||
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
|
||||
setRestorePreviousSessionState: Dispatch<SetStateAction<boolean>>;
|
||||
setRestoreTerminalCwdState: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpTransferConcurrencyState: Dispatch<SetStateAction<number>>;
|
||||
}
|
||||
|
||||
export function useSettingsIpcSync({
|
||||
enabled = true,
|
||||
syncAppearanceFromStorage,
|
||||
syncCustomCssFromStorage,
|
||||
setUiLanguage,
|
||||
@@ -92,6 +104,7 @@ export function useSettingsIpcSync({
|
||||
setSessionLogsFormat,
|
||||
setSessionLogsTimestampsEnabled,
|
||||
setSshDebugLogsEnabled,
|
||||
setSshDeepLinkEnabledState,
|
||||
setHotkeyScheme,
|
||||
applyIncomingCustomKeyBindings,
|
||||
setIsHotkeyRecordingState,
|
||||
@@ -102,10 +115,15 @@ export function useSettingsIpcSync({
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setWorkspaceFocusStyleState,
|
||||
setShowHostTreeSidebarState,
|
||||
setDisableTerminalFontZoomState,
|
||||
setRestorePreviousSessionState,
|
||||
setRestoreTerminalCwdState,
|
||||
setSftpTransferConcurrencyState,
|
||||
}: UseSettingsIpcSyncParams) {
|
||||
// Listen for settings changes from other windows via IPC
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onSettingsChanged) return;
|
||||
const unsubscribe = bridge.onSettingsChanged((payload) => {
|
||||
@@ -186,6 +204,9 @@ export function useSettingsIpcSync({
|
||||
if (key === STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED && typeof value === 'boolean') {
|
||||
setSshDebugLogsEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SSH_DEEP_LINK_ENABLED && typeof value === 'boolean') {
|
||||
setSshDeepLinkEnabledState(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_HOTKEY_SCHEME && (value === 'disabled' || value === 'mac' || value === 'pc')) {
|
||||
setHotkeyScheme(value);
|
||||
}
|
||||
@@ -222,6 +243,18 @@ export function useSettingsIpcSync({
|
||||
if (key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && (value === 'dim' || value === 'border')) {
|
||||
setWorkspaceFocusStyleState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR && typeof value === 'boolean') {
|
||||
setShowHostTreeSidebarState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM && typeof value === 'boolean') {
|
||||
setDisableTerminalFontZoomState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_RESTORE_PREVIOUS_SESSION && typeof value === 'boolean') {
|
||||
setRestorePreviousSessionState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_RESTORE_TERMINAL_CWD && typeof value === 'boolean') {
|
||||
setRestoreTerminalCwdState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY && typeof value === 'number') {
|
||||
setSftpTransferConcurrencyState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
@@ -234,6 +267,7 @@ export function useSettingsIpcSync({
|
||||
}
|
||||
};
|
||||
}, [
|
||||
enabled,
|
||||
applyIncomingCustomKeyBindings,
|
||||
mergeIncomingTerminalSettings,
|
||||
setAutoUpdateEnabled,
|
||||
@@ -247,10 +281,15 @@ export function useSettingsIpcSync({
|
||||
setSessionLogsEnabled,
|
||||
setSessionLogsFormat,
|
||||
setSessionLogsTimestampsEnabled,
|
||||
setSshDeepLinkEnabledState,
|
||||
setSshDebugLogsEnabled,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setShowHostTreeSidebarState,
|
||||
setDisableTerminalFontZoomState,
|
||||
setRestorePreviousSessionState,
|
||||
setRestoreTerminalCwdState,
|
||||
setSftpTransferConcurrencyState,
|
||||
setTerminalFontFamilyId,
|
||||
setTerminalFontSize,
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
getContrastRatio,
|
||||
buildAppThemeCssVars,
|
||||
getHslTokenRelativeLuminance,
|
||||
resolveReadableForegroundForHsl,
|
||||
resolveThemeAccentForeground,
|
||||
} from "./settingsStateDefaults.ts";
|
||||
import type { UiThemeTokens } from "../../infrastructure/config/uiThemes.ts";
|
||||
|
||||
test("readable foreground picks white text for dark accent colors", () => {
|
||||
assert.equal(resolveReadableForegroundForHsl("270 70% 45%"), "0 0% 100%");
|
||||
});
|
||||
|
||||
test("readable foreground picks black text for light accent colors", () => {
|
||||
assert.equal(resolveReadableForegroundForHsl("48 95% 72%"), "0 0% 0%");
|
||||
});
|
||||
|
||||
test("computed contrast chooses the stronger black or white foreground", () => {
|
||||
const purpleLuminance = getHslTokenRelativeLuminance("270 70% 45%");
|
||||
assert.equal(typeof purpleLuminance, "number");
|
||||
assert.ok(getContrastRatio(1, purpleLuminance as number) > getContrastRatio(0, purpleLuminance as number));
|
||||
|
||||
const yellowLuminance = getHslTokenRelativeLuminance("48 95% 72%");
|
||||
assert.equal(typeof yellowLuminance, "number");
|
||||
assert.ok(getContrastRatio(0, yellowLuminance as number) > getContrastRatio(1, yellowLuminance as number));
|
||||
});
|
||||
|
||||
test("theme accent foreground uses the computed color for preset accent buttons", () => {
|
||||
const tokens: UiThemeTokens = {
|
||||
background: "0 0% 100%",
|
||||
foreground: "222 47% 12%",
|
||||
card: "0 0% 100%",
|
||||
cardForeground: "222 47% 12%",
|
||||
popover: "0 0% 100%",
|
||||
popoverForeground: "222 47% 12%",
|
||||
primary: "270 70% 45%",
|
||||
primaryForeground: "0 0% 0%",
|
||||
secondary: "220 12% 95%",
|
||||
secondaryForeground: "222 47% 12%",
|
||||
muted: "220 12% 95%",
|
||||
mutedForeground: "220 10% 45%",
|
||||
accent: "270 70% 45%",
|
||||
accentForeground: "0 0% 0%",
|
||||
destructive: "0 70% 50%",
|
||||
destructiveForeground: "0 0% 100%",
|
||||
border: "220 12% 88%",
|
||||
input: "220 12% 88%",
|
||||
ring: "270 70% 45%",
|
||||
};
|
||||
|
||||
assert.equal(resolveThemeAccentForeground(tokens, "theme", "48 95% 72%"), "0 0% 100%");
|
||||
assert.equal(resolveThemeAccentForeground(tokens, "custom", "48 95% 72%"), "0 0% 0%");
|
||||
});
|
||||
|
||||
test("app surface theme vars isolate non-terminal pages from active terminal chrome", () => {
|
||||
const tokens: UiThemeTokens = {
|
||||
background: "0 0% 100%",
|
||||
foreground: "222 47% 12%",
|
||||
card: "0 0% 100%",
|
||||
cardForeground: "222 47% 12%",
|
||||
popover: "0 0% 100%",
|
||||
popoverForeground: "222 47% 12%",
|
||||
primary: "270 70% 45%",
|
||||
primaryForeground: "0 0% 0%",
|
||||
secondary: "220 12% 95%",
|
||||
secondaryForeground: "222 47% 12%",
|
||||
muted: "220 12% 95%",
|
||||
mutedForeground: "220 10% 45%",
|
||||
accent: "270 70% 45%",
|
||||
accentForeground: "0 0% 0%",
|
||||
destructive: "0 70% 50%",
|
||||
destructiveForeground: "0 0% 100%",
|
||||
border: "220 12% 88%",
|
||||
input: "220 12% 88%",
|
||||
ring: "270 70% 45%",
|
||||
};
|
||||
|
||||
assert.deepEqual(buildAppThemeCssVars(tokens, "theme", "48 95% 72%"), {
|
||||
"--background": "0 0% 100%",
|
||||
"--foreground": "222 47% 12%",
|
||||
"--card": "0 0% 100%",
|
||||
"--card-foreground": "222 47% 12%",
|
||||
"--popover": "0 0% 100%",
|
||||
"--popover-foreground": "222 47% 12%",
|
||||
"--primary": "270 70% 45%",
|
||||
"--primary-foreground": "0 0% 100%",
|
||||
"--secondary": "220 12% 95%",
|
||||
"--secondary-foreground": "222 47% 12%",
|
||||
"--muted": "220 12% 95%",
|
||||
"--muted-foreground": "220 10% 45%",
|
||||
"--accent": "270 70% 45%",
|
||||
"--accent-foreground": "0 0% 100%",
|
||||
"--destructive": "0 70% 50%",
|
||||
"--destructive-foreground": "0 0% 100%",
|
||||
"--border": "220 12% 88%",
|
||||
"--input": "220 12% 88%",
|
||||
"--ring": "270 70% 45%",
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,13 @@ import { UI_FONTS } from '../../infrastructure/config/uiFonts';
|
||||
import { uiFontStore } from './uiFontStore';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { resolveReadableForegroundForHsl } from '../../domain/colorContrast';
|
||||
|
||||
export {
|
||||
getContrastRatio,
|
||||
getHslTokenRelativeLuminance,
|
||||
resolveReadableForegroundForHsl,
|
||||
} from '../../domain/colorContrast';
|
||||
|
||||
export const DEFAULT_THEME: 'light' | 'dark' | 'system' = 'dark';
|
||||
export const DEFAULT_WINDOW_OPACITY = 1;
|
||||
@@ -63,6 +70,10 @@ export const DEFAULT_SFTP_DEFAULT_VIEW_MODE: 'list' | 'tree' = 'list';
|
||||
export const DEFAULT_SHOW_RECENT_HOSTS = true;
|
||||
export const DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT = false;
|
||||
export const DEFAULT_SHOW_SFTP_TAB = true;
|
||||
export const DEFAULT_SHOW_HOST_TREE_SIDEBAR = true;
|
||||
export const DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS = false;
|
||||
export const DEFAULT_DISABLE_TERMINAL_FONT_ZOOM = false;
|
||||
export { DEFAULT_RESTORE_PREVIOUS_SESSION } from './sessionRestoreSettings';
|
||||
|
||||
// Editor defaults
|
||||
export const DEFAULT_EDITOR_WORD_WRAP = false;
|
||||
@@ -72,6 +83,7 @@ export const DEFAULT_SESSION_LOGS_ENABLED = false;
|
||||
export const DEFAULT_SESSION_LOGS_FORMAT: SessionLogFormat = 'txt';
|
||||
export const DEFAULT_SESSION_LOGS_TIMESTAMPS_ENABLED = false;
|
||||
export const DEFAULT_SSH_DEBUG_LOGS_ENABLED = false;
|
||||
export const DEFAULT_SSH_DEEP_LINK_ENABLED = true;
|
||||
|
||||
export const readStoredString = (key: string): string | null => {
|
||||
const raw = localStorageAdapter.readString(key);
|
||||
@@ -94,6 +106,46 @@ export const isValidHslToken = (value: string): boolean => {
|
||||
return /^\s*\d+(\.\d+)?\s+\d+(\.\d+)?%\s+\d+(\.\d+)?%\s*$/.test(value);
|
||||
};
|
||||
|
||||
export const resolveThemeAccentForeground = (
|
||||
tokens: UiThemeTokens,
|
||||
accentMode: 'theme' | 'custom',
|
||||
accentOverride: string,
|
||||
): string => {
|
||||
const accentToken = accentMode === 'custom' ? accentOverride : tokens.accent;
|
||||
return resolveReadableForegroundForHsl(accentToken, tokens.primaryForeground);
|
||||
};
|
||||
|
||||
export const buildAppThemeCssVars = (
|
||||
tokens: UiThemeTokens,
|
||||
accentMode: 'theme' | 'custom',
|
||||
accentOverride: string,
|
||||
): Record<string, string> => {
|
||||
const accentToken = accentMode === 'custom' ? accentOverride : tokens.accent;
|
||||
const computedAccentForeground = resolveThemeAccentForeground(tokens, accentMode, accentOverride);
|
||||
|
||||
return {
|
||||
'--background': tokens.background,
|
||||
'--foreground': tokens.foreground,
|
||||
'--card': tokens.card,
|
||||
'--card-foreground': tokens.cardForeground,
|
||||
'--popover': tokens.popover,
|
||||
'--popover-foreground': tokens.popoverForeground,
|
||||
'--primary': accentToken,
|
||||
'--primary-foreground': computedAccentForeground,
|
||||
'--secondary': tokens.secondary,
|
||||
'--secondary-foreground': tokens.secondaryForeground,
|
||||
'--muted': tokens.muted,
|
||||
'--muted-foreground': tokens.mutedForeground,
|
||||
'--accent': accentToken,
|
||||
'--accent-foreground': computedAccentForeground,
|
||||
'--destructive': tokens.destructive,
|
||||
'--destructive-foreground': tokens.destructiveForeground,
|
||||
'--border': tokens.border,
|
||||
'--input': tokens.input,
|
||||
'--ring': accentToken,
|
||||
};
|
||||
};
|
||||
|
||||
export const isValidUiThemeId = (theme: 'light' | 'dark', value: string): boolean => {
|
||||
const list = theme === 'dark' ? DARK_UI_THEMES : LIGHT_UI_THEMES;
|
||||
return list.some((preset) => preset.id === value);
|
||||
@@ -129,36 +181,12 @@ export const applyThemeTokens = (
|
||||
accentOverride: string,
|
||||
) => {
|
||||
const root = window.document.documentElement;
|
||||
// If immersive override is active (style tag present), it owns the dark/light class — don't override
|
||||
if (!document.getElementById('netcatty-immersive-override')) {
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(resolvedTheme);
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(resolvedTheme);
|
||||
const cssVars = buildAppThemeCssVars(tokens, accentMode, accentOverride);
|
||||
for (const [property, value] of Object.entries(cssVars)) {
|
||||
root.style.setProperty(property, value);
|
||||
}
|
||||
root.style.setProperty('--background', tokens.background);
|
||||
root.style.setProperty('--foreground', tokens.foreground);
|
||||
root.style.setProperty('--card', tokens.card);
|
||||
root.style.setProperty('--card-foreground', tokens.cardForeground);
|
||||
root.style.setProperty('--popover', tokens.popover);
|
||||
root.style.setProperty('--popover-foreground', tokens.popoverForeground);
|
||||
const accentToken = accentMode === 'custom' ? accentOverride : tokens.accent;
|
||||
const accentLightness = parseFloat(accentToken.split(/\s+/)[2]?.replace('%', '') || '');
|
||||
const computedAccentForeground = resolvedTheme === 'dark'
|
||||
? '220 40% 96%'
|
||||
: (!Number.isNaN(accentLightness) && accentLightness < 55 ? '0 0% 98%' : '222 47% 12%');
|
||||
|
||||
root.style.setProperty('--primary', accentToken);
|
||||
root.style.setProperty('--primary-foreground', accentMode === 'custom' ? computedAccentForeground : tokens.primaryForeground);
|
||||
root.style.setProperty('--secondary', tokens.secondary);
|
||||
root.style.setProperty('--secondary-foreground', tokens.secondaryForeground);
|
||||
root.style.setProperty('--muted', tokens.muted);
|
||||
root.style.setProperty('--muted-foreground', tokens.mutedForeground);
|
||||
root.style.setProperty('--accent', accentToken);
|
||||
root.style.setProperty('--accent-foreground', accentMode === 'custom' ? computedAccentForeground : tokens.accentForeground);
|
||||
root.style.setProperty('--destructive', tokens.destructive);
|
||||
root.style.setProperty('--destructive-foreground', tokens.destructiveForeground);
|
||||
root.style.setProperty('--border', tokens.border);
|
||||
root.style.setProperty('--input', tokens.input);
|
||||
root.style.setProperty('--ring', accentToken);
|
||||
|
||||
// Sync with native window title bar (Electron)
|
||||
netcattyBridge.get()?.setTheme?.(themeSource);
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
|
||||
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
|
||||
STORAGE_KEY_SSH_DEEP_LINK_ENABLED,
|
||||
STORAGE_KEY_RESTORE_PREVIOUS_SESSION,
|
||||
STORAGE_KEY_RESTORE_TERMINAL_CWD,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
|
||||
STORAGE_KEY_SFTP_AUTO_SYNC,
|
||||
@@ -27,6 +30,9 @@ import {
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
@@ -52,6 +58,7 @@ import {
|
||||
} from './settingsStateDefaults';
|
||||
|
||||
interface UseSettingsStorageSyncParams {
|
||||
enabled?: boolean;
|
||||
theme: 'dark' | 'light' | 'system';
|
||||
lightUiThemeId: string;
|
||||
darkUiThemeId: string;
|
||||
@@ -75,12 +82,18 @@ interface UseSettingsStorageSyncParams {
|
||||
showRecentHosts: boolean;
|
||||
showOnlyUngroupedHostsInRoot: boolean;
|
||||
showSftpTab: boolean;
|
||||
showHostTreeSidebar: boolean;
|
||||
shellOnlyTabNumberShortcuts: boolean;
|
||||
disableTerminalFontZoom: boolean;
|
||||
restorePreviousSession: boolean;
|
||||
restoreTerminalCwd: boolean;
|
||||
editorWordWrap: boolean;
|
||||
sessionLogsEnabled: boolean;
|
||||
sessionLogsDir: string;
|
||||
sessionLogsFormat: SessionLogFormat;
|
||||
sessionLogsTimestampsEnabled: boolean;
|
||||
sshDebugLogsEnabled: boolean;
|
||||
sshDeepLinkEnabled: boolean;
|
||||
globalHotkeyEnabled: boolean;
|
||||
autoUpdateEnabled: boolean;
|
||||
windowOpacity: number;
|
||||
@@ -109,12 +122,18 @@ interface UseSettingsStorageSyncParams {
|
||||
setShowRecentHostsState: Dispatch<SetStateAction<boolean>>;
|
||||
setShowOnlyUngroupedHostsInRootState: Dispatch<SetStateAction<boolean>>;
|
||||
setShowSftpTabState: Dispatch<SetStateAction<boolean>>;
|
||||
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
|
||||
setShellOnlyTabNumberShortcutsState: Dispatch<SetStateAction<boolean>>;
|
||||
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
|
||||
setRestorePreviousSessionState: Dispatch<SetStateAction<boolean>>;
|
||||
setRestoreTerminalCwdState: Dispatch<SetStateAction<boolean>>;
|
||||
setEditorWordWrapState: Dispatch<SetStateAction<boolean>>;
|
||||
setSessionLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSessionLogsDir: Dispatch<SetStateAction<string>>;
|
||||
setSessionLogsFormat: Dispatch<SetStateAction<SessionLogFormat>>;
|
||||
setSessionLogsTimestampsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSshDeepLinkEnabledState: (enabled: boolean) => void;
|
||||
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setWindowOpacity: Dispatch<SetStateAction<number>>;
|
||||
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -125,13 +144,14 @@ interface UseSettingsStorageSyncParams {
|
||||
}
|
||||
|
||||
export function useSettingsStorageSync({
|
||||
enabled = true,
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
|
||||
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
|
||||
@@ -139,8 +159,8 @@ export function useSettingsStorageSync({
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
}: UseSettingsStorageSyncParams) {
|
||||
@@ -153,8 +173,8 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
});
|
||||
settingsSnapshotRef.current = {
|
||||
@@ -163,13 +183,14 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
};
|
||||
|
||||
// Listen for storage changes from other windows (cross-window sync)
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
const s = settingsSnapshotRef.current;
|
||||
if (e.key === STORAGE_KEY_THEME && e.newValue) {
|
||||
@@ -327,6 +348,12 @@ export function useSettingsStorageSync({
|
||||
setSshDebugLogsEnabled(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SSH_DEEP_LINK_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.sshDeepLinkEnabled) {
|
||||
setSshDeepLinkEnabledState(newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP compressed upload setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true' || e.newValue === 'enabled';
|
||||
@@ -371,6 +398,36 @@ export function useSettingsStorageSync({
|
||||
setShowSftpTabState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.showHostTreeSidebar) {
|
||||
setShowHostTreeSidebarState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.shellOnlyTabNumberShortcuts) {
|
||||
setShellOnlyTabNumberShortcutsState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.disableTerminalFontZoom) {
|
||||
setDisableTerminalFontZoomState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_RESTORE_PREVIOUS_SESSION && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.restorePreviousSession) {
|
||||
setRestorePreviousSessionState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_RESTORE_TERMINAL_CWD && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.restoreTerminalCwd) {
|
||||
setRestoreTerminalCwdState(newValue);
|
||||
}
|
||||
}
|
||||
// Sync global hotkey enabled setting from other windows
|
||||
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
@@ -409,6 +466,7 @@ export function useSettingsStorageSync({
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, [
|
||||
enabled,
|
||||
applyIncomingCustomKeyBindings,
|
||||
mergeIncomingTerminalSettings,
|
||||
setAccentMode,
|
||||
@@ -426,6 +484,7 @@ export function useSettingsStorageSync({
|
||||
setSessionLogsEnabled,
|
||||
setSessionLogsFormat,
|
||||
setSessionLogsTimestampsEnabled,
|
||||
setSshDeepLinkEnabledState,
|
||||
setSshDebugLogsEnabled,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
@@ -436,8 +495,13 @@ export function useSettingsStorageSync({
|
||||
setSftpTransferConcurrencyState,
|
||||
setSftpUseCompressedUpload,
|
||||
setShowOnlyUngroupedHostsInRootState,
|
||||
setShowHostTreeSidebarState,
|
||||
setShowRecentHostsState,
|
||||
setShowSftpTabState,
|
||||
setShellOnlyTabNumberShortcutsState,
|
||||
setDisableTerminalFontZoomState,
|
||||
setRestorePreviousSessionState,
|
||||
setRestoreTerminalCwdState,
|
||||
setTerminalFontFamilyId,
|
||||
setTerminalFontSize,
|
||||
setTerminalThemeDarkId,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type { RemoteSftpStartCache } from "./sftpConnectStartPath.ts";
|
||||
import {
|
||||
normalizeSftpInitialPath,
|
||||
resolveRemoteSftpStartState,
|
||||
} from "./sftpConnectStartPath.ts";
|
||||
|
||||
const cached: RemoteSftpStartCache = {
|
||||
path: "/var/cache",
|
||||
homeDir: "/home/deploy",
|
||||
files: [],
|
||||
filenameEncoding: "auto",
|
||||
};
|
||||
|
||||
test("remote SFTP default-path duplication ignores the shared host cache", () => {
|
||||
const state = resolveRemoteSftpStartState({
|
||||
filenameEncoding: "auto",
|
||||
ignoreSharedCache: true,
|
||||
sharedHostCacheCandidate: cached,
|
||||
});
|
||||
|
||||
assert.equal(state.initialPath, undefined);
|
||||
assert.equal(state.sharedHostCache, null);
|
||||
assert.equal(state.cachedStartPath, "/");
|
||||
});
|
||||
|
||||
test("remote SFTP current-path duplication uses the requested path instead of stale cache", () => {
|
||||
const state = resolveRemoteSftpStartState({
|
||||
filenameEncoding: "auto",
|
||||
initialPath: "/var/www/app",
|
||||
sharedHostCacheCandidate: cached,
|
||||
});
|
||||
|
||||
assert.equal(state.initialPath, "/var/www/app");
|
||||
assert.equal(state.sharedHostCache, null);
|
||||
assert.equal(state.cachedStartPath, "/var/www/app");
|
||||
});
|
||||
|
||||
test("remote SFTP initial paths preserve meaningful whitespace", () => {
|
||||
assert.equal(normalizeSftpInitialPath("/var/www/app "), "/var/www/app ");
|
||||
|
||||
const state = resolveRemoteSftpStartState({
|
||||
filenameEncoding: "auto",
|
||||
initialPath: "/var/www/app ",
|
||||
sharedHostCacheCandidate: {
|
||||
...cached,
|
||||
path: "/var/www/app",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(state.initialPath, "/var/www/app ");
|
||||
assert.equal(state.sharedHostCache, null);
|
||||
assert.equal(state.cachedStartPath, "/var/www/app ");
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
|
||||
|
||||
export interface RemoteSftpStartCache {
|
||||
path: string;
|
||||
homeDir: string;
|
||||
files: SftpFileEntry[];
|
||||
filenameEncoding: SftpFilenameEncoding;
|
||||
}
|
||||
|
||||
interface ResolveRemoteSftpStartStateParams {
|
||||
filenameEncoding: SftpFilenameEncoding;
|
||||
ignoreSharedCache?: boolean;
|
||||
initialPath?: string;
|
||||
sharedHostCacheCandidate: RemoteSftpStartCache | null;
|
||||
}
|
||||
|
||||
export function normalizeSftpInitialPath(initialPath?: string): string | undefined {
|
||||
return initialPath === undefined || initialPath.length === 0 ? undefined : initialPath;
|
||||
}
|
||||
|
||||
export function resolveRemoteSftpStartState({
|
||||
filenameEncoding,
|
||||
ignoreSharedCache,
|
||||
initialPath,
|
||||
sharedHostCacheCandidate,
|
||||
}: ResolveRemoteSftpStartStateParams): {
|
||||
initialPath: string | undefined;
|
||||
sharedHostCache: RemoteSftpStartCache | null;
|
||||
cachedStartPath: string;
|
||||
} {
|
||||
const requestedInitialPath = normalizeSftpInitialPath(initialPath);
|
||||
const sharedHostCache =
|
||||
!ignoreSharedCache
|
||||
&& sharedHostCacheCandidate?.filenameEncoding === filenameEncoding
|
||||
&& (!requestedInitialPath || sharedHostCacheCandidate.path === requestedInitialPath)
|
||||
? sharedHostCacheCandidate
|
||||
: null;
|
||||
|
||||
return {
|
||||
initialPath: requestedInitialPath,
|
||||
sharedHostCache,
|
||||
cachedStartPath: requestedInitialPath ?? sharedHostCache?.path ?? "/",
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
|
||||
|
||||
interface SharedRemoteHostCacheEntry {
|
||||
export interface SharedRemoteHostCacheEntry {
|
||||
path: string;
|
||||
homeDir: string;
|
||||
files: SftpFileEntry[];
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY,
|
||||
resolveSftpTransferConcurrency,
|
||||
runSftpTransferWorkers,
|
||||
} from "./transferConcurrency";
|
||||
|
||||
test("defaults folder file transfers to two concurrent files", () => {
|
||||
assert.equal(resolveSftpTransferConcurrency(() => null), DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY);
|
||||
assert.equal(DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY, 2);
|
||||
});
|
||||
|
||||
test("keeps explicit folder transfer concurrency within the supported range", () => {
|
||||
assert.equal(resolveSftpTransferConcurrency(() => 1), 1);
|
||||
assert.equal(resolveSftpTransferConcurrency(() => 16), 16);
|
||||
assert.equal(resolveSftpTransferConcurrency(() => 0), DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY);
|
||||
assert.equal(resolveSftpTransferConcurrency(() => 17), DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY);
|
||||
});
|
||||
|
||||
test("limits default multi-file transfer scheduling to two concurrent workers", async () => {
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
|
||||
await runSftpTransferWorkers([1, 2, 3, 4], () => null, async () => {
|
||||
active += 1;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
active -= 1;
|
||||
});
|
||||
|
||||
assert.equal(maxActive, 2);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
export const DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY = 2;
|
||||
export const MIN_SFTP_FILE_TRANSFER_CONCURRENCY = 1;
|
||||
export const MAX_SFTP_FILE_TRANSFER_CONCURRENCY = 16;
|
||||
|
||||
export function resolveSftpTransferConcurrency(readStoredValue: () => number | null | undefined): number {
|
||||
const stored = readStoredValue();
|
||||
return stored != null &&
|
||||
stored >= MIN_SFTP_FILE_TRANSFER_CONCURRENCY &&
|
||||
stored <= MAX_SFTP_FILE_TRANSFER_CONCURRENCY
|
||||
? stored
|
||||
: DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY;
|
||||
}
|
||||
|
||||
export async function runSftpTransferWorkers<T>(
|
||||
items: T[],
|
||||
readStoredConcurrency: () => number | null | undefined,
|
||||
worker: (item: T, index: number) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const concurrency = resolveSftpTransferConcurrency(readStoredConcurrency);
|
||||
let nextIndex = 0;
|
||||
|
||||
const runNext = async () => {
|
||||
while (nextIndex < items.length) {
|
||||
const index = nextIndex++;
|
||||
await worker(items[index], index);
|
||||
}
|
||||
};
|
||||
|
||||
const workers = Array.from(
|
||||
{ length: Math.min(concurrency, items.length) },
|
||||
() => runNext(),
|
||||
);
|
||||
await Promise.all(workers);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from "../../../infrastructure/c
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { runSftpTransferWorkers } from "./transferConcurrency";
|
||||
import { joinPath } from "./utils";
|
||||
|
||||
interface UseSftpDirectoryTransferOpsParams {
|
||||
@@ -185,11 +186,6 @@ export function useSftpDirectoryTransferOps({
|
||||
});
|
||||
};
|
||||
|
||||
const getTransferConcurrency = () => {
|
||||
const stored = localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY);
|
||||
return stored != null && stored >= 1 && stored <= 16 ? stored : 4;
|
||||
};
|
||||
|
||||
/** Recursively count all files under a directory (for progress display). */
|
||||
const countDirectoryFiles = async (
|
||||
sourcePath: string,
|
||||
@@ -308,8 +304,8 @@ export function useSftpDirectoryTransferOps({
|
||||
|
||||
// Process subdirectories sequentially to avoid unbounded concurrent SFTP
|
||||
// requests from nested Promise.all + worker pools across the tree.
|
||||
// File-level concurrency within each directory is still governed by
|
||||
// getTransferConcurrency().
|
||||
// File-level concurrency within each directory is still governed by the
|
||||
// shared SFTP transfer worker scheduler below.
|
||||
for (const dir of dirs) {
|
||||
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
@@ -346,17 +342,16 @@ export function useSftpDirectoryTransferOps({
|
||||
|
||||
// Transfer files in parallel with concurrency limit
|
||||
if (regularFiles.length > 0) {
|
||||
let fileIndex = 0;
|
||||
const errors: Error[] = [];
|
||||
|
||||
const worker = async () => {
|
||||
while (fileIndex < regularFiles.length) {
|
||||
await runSftpTransferWorkers(
|
||||
regularFiles,
|
||||
() => localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
|
||||
async (file) => {
|
||||
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
const idx = fileIndex++;
|
||||
const file = regularFiles[idx];
|
||||
const fileId = crypto.randomUUID();
|
||||
const fileSize = getEntrySize(file);
|
||||
|
||||
@@ -431,15 +426,8 @@ export function useSftpDirectoryTransferOps({
|
||||
if (err instanceof Error && err.message === "Transfer cancelled") throw err;
|
||||
errors.push(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const concurrency = getTransferConcurrency();
|
||||
const workers = Array.from(
|
||||
{ length: Math.min(concurrency, regularFiles.length) },
|
||||
() => worker(),
|
||||
},
|
||||
);
|
||||
await Promise.all(workers);
|
||||
|
||||
totalErrors += errors.length;
|
||||
if (errors.length > 0) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SftpConnection, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
|
||||
import { KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
|
||||
|
||||
export interface SftpPane {
|
||||
id: string;
|
||||
@@ -15,6 +15,22 @@ export interface SftpPane {
|
||||
transferMutationToken: number;
|
||||
}
|
||||
|
||||
export interface SftpHostKeyInfo {
|
||||
hostname: string;
|
||||
port: number;
|
||||
keyType: string;
|
||||
fingerprint: string;
|
||||
publicKey?: string;
|
||||
status?: "unknown" | "changed";
|
||||
knownHostId?: string;
|
||||
knownFingerprint?: string;
|
||||
}
|
||||
|
||||
export interface SftpHostKeyVerificationState {
|
||||
hostKeyInfo: SftpHostKeyInfo;
|
||||
progressLogs: string[];
|
||||
}
|
||||
|
||||
// Multi-tab state for left and right sides
|
||||
export interface SftpSideTabs {
|
||||
tabs: SftpPane[];
|
||||
@@ -70,4 +86,6 @@ export interface SftpStateOptions {
|
||||
* is honored for SFTP browsing too (not just the terminal session).
|
||||
*/
|
||||
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
|
||||
knownHosts?: KnownHost[];
|
||||
onAddKnownHost?: (knownHost: KnownHost) => void;
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
import React, { useCallback, useEffect, useRef } from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import type { Host, Identity, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
|
||||
import type { SftpPane } from "./types";
|
||||
import type { Host, Identity, KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
|
||||
import type { SftpHostKeyInfo, SftpHostKeyVerificationState, SftpPane } from "./types";
|
||||
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
|
||||
import { useSftpHostCredentials } from "./useSftpHostCredentials";
|
||||
import { buildCacheKey, getSharedRemoteHostCache, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
|
||||
import { resolveRemoteSftpStartState } from "./sftpConnectStartPath";
|
||||
|
||||
interface UseSftpConnectionsParams {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
knownHosts?: KnownHost[];
|
||||
onAddKnownHost?: (knownHost: KnownHost) => void;
|
||||
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
|
||||
leftTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
|
||||
rightTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
|
||||
@@ -34,17 +37,61 @@ interface UseSftpConnectionsParams {
|
||||
autoConnectLocalOnMount?: boolean;
|
||||
}
|
||||
|
||||
export interface SftpConnectOptions {
|
||||
forceNewTab?: boolean;
|
||||
ignoreSharedCache?: boolean;
|
||||
initialPath?: string;
|
||||
onTabCreated?: (tabId: string) => void;
|
||||
sourceSessionId?: string;
|
||||
}
|
||||
|
||||
interface UseSftpConnectionsResult {
|
||||
connect: (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean; onTabCreated?: (tabId: string) => void }) => Promise<void>;
|
||||
connect: (side: "left" | "right", host: Host | "local", options?: SftpConnectOptions) => Promise<void>;
|
||||
disconnect: (side: "left" | "right") => Promise<void>;
|
||||
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
|
||||
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
|
||||
hostKeyVerification: SftpHostKeyVerificationState | null;
|
||||
rejectHostKeyVerification: () => void;
|
||||
acceptHostKeyVerification: () => void;
|
||||
acceptAndSaveHostKeyVerification: () => void;
|
||||
}
|
||||
|
||||
type HostKeyVerificationRequest = SftpHostKeyInfo & {
|
||||
requestId: string;
|
||||
sessionId?: string;
|
||||
};
|
||||
|
||||
const toSftpHostKeyInfo = (request: HostKeyVerificationRequest): SftpHostKeyInfo => ({
|
||||
hostname: request.hostname,
|
||||
port: request.port || 22,
|
||||
keyType: request.keyType,
|
||||
fingerprint: request.fingerprint,
|
||||
publicKey: request.publicKey,
|
||||
status: request.status,
|
||||
knownHostId: request.knownHostId,
|
||||
knownFingerprint: request.knownFingerprint,
|
||||
});
|
||||
|
||||
const createKnownHostFromSftpHostKeyInfo = (
|
||||
hostKeyInfo: SftpHostKeyInfo,
|
||||
now = Date.now(),
|
||||
idSuffix = Math.random().toString(36).slice(2, 11),
|
||||
): KnownHost => ({
|
||||
id: hostKeyInfo.knownHostId || `kh-${now}-${idSuffix}`,
|
||||
hostname: hostKeyInfo.hostname,
|
||||
port: hostKeyInfo.port || 22,
|
||||
keyType: hostKeyInfo.keyType,
|
||||
publicKey: hostKeyInfo.publicKey || `SHA256:${hostKeyInfo.fingerprint}`,
|
||||
fingerprint: hostKeyInfo.fingerprint,
|
||||
discoveredAt: now,
|
||||
});
|
||||
|
||||
export const useSftpConnections = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
knownHosts,
|
||||
onAddKnownHost,
|
||||
terminalSettings,
|
||||
leftTabsRef,
|
||||
rightTabsRef,
|
||||
@@ -67,11 +114,79 @@ export const useSftpConnections = ({
|
||||
createEmptyPane,
|
||||
autoConnectLocalOnMount = true,
|
||||
}: UseSftpConnectionsParams): UseSftpConnectionsResult => {
|
||||
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities, terminalSettings });
|
||||
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities, knownHosts, terminalSettings });
|
||||
const { listLocalFiles, listRemoteFiles } = useSftpDirectoryListing();
|
||||
const [hostKeyVerification, setHostKeyVerification] = useState<SftpHostKeyVerificationState | null>(null);
|
||||
const hostKeyVerificationRef = useRef<(SftpHostKeyVerificationState & { requestId: string; sessionId: string }) | null>(null);
|
||||
const activeHostKeySessionsRef = useRef<Map<string, { side: "left" | "right"; tabId: string }>>(new Map());
|
||||
|
||||
const setPendingHostKeyVerification = useCallback((
|
||||
next: (SftpHostKeyVerificationState & { requestId: string; sessionId: string }) | null,
|
||||
) => {
|
||||
hostKeyVerificationRef.current = next;
|
||||
setHostKeyVerification(next ? {
|
||||
hostKeyInfo: next.hostKeyInfo,
|
||||
progressLogs: next.progressLogs,
|
||||
} : null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const dispose = netcattyBridge.get()?.onHostKeyVerification?.((request: HostKeyVerificationRequest) => {
|
||||
const sessionId = request.sessionId;
|
||||
if (!sessionId) return;
|
||||
const activeSession = activeHostKeySessionsRef.current.get(sessionId);
|
||||
if (!activeSession) return;
|
||||
|
||||
const hostKeyInfo = toSftpHostKeyInfo(request);
|
||||
const logLine = request.status === "changed"
|
||||
? `Host key changed for ${request.hostname}. Waiting for confirmation...`
|
||||
: `Host key verification required for ${request.hostname}.`;
|
||||
|
||||
updateTab(activeSession.side, activeSession.tabId, (prev) => ({
|
||||
...prev,
|
||||
connectionLogs: [...prev.connectionLogs, logLine],
|
||||
}));
|
||||
setPendingHostKeyVerification({
|
||||
requestId: request.requestId,
|
||||
sessionId,
|
||||
hostKeyInfo,
|
||||
progressLogs: [logLine],
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
dispose?.();
|
||||
};
|
||||
}, [setPendingHostKeyVerification, updateTab]);
|
||||
|
||||
const respondToHostKeyVerification = useCallback((accept: boolean, addToKnownHosts = false) => {
|
||||
const pending = hostKeyVerificationRef.current;
|
||||
if (!pending) return;
|
||||
if (accept && addToKnownHosts) {
|
||||
onAddKnownHost?.(createKnownHostFromSftpHostKeyInfo(pending.hostKeyInfo));
|
||||
}
|
||||
void netcattyBridge.get()?.respondHostKeyVerification?.(
|
||||
pending.requestId,
|
||||
accept,
|
||||
addToKnownHosts,
|
||||
);
|
||||
setPendingHostKeyVerification(null);
|
||||
}, [onAddKnownHost, setPendingHostKeyVerification]);
|
||||
|
||||
const rejectHostKeyVerification = useCallback(() => {
|
||||
respondToHostKeyVerification(false);
|
||||
}, [respondToHostKeyVerification]);
|
||||
|
||||
const acceptHostKeyVerification = useCallback(() => {
|
||||
respondToHostKeyVerification(true, false);
|
||||
}, [respondToHostKeyVerification]);
|
||||
|
||||
const acceptAndSaveHostKeyVerification = useCallback(() => {
|
||||
respondToHostKeyVerification(true, true);
|
||||
}, [respondToHostKeyVerification]);
|
||||
|
||||
const connect = useCallback(
|
||||
async (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean; onTabCreated?: (tabId: string) => void; sourceSessionId?: string }) => {
|
||||
async (side: "left" | "right", host: Host | "local", options?: SftpConnectOptions) => {
|
||||
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
|
||||
|
||||
let activeTabId: string | null = null;
|
||||
@@ -101,6 +216,33 @@ export const useSftpConnections = ({
|
||||
|
||||
navSeqRef.current[side] += 1;
|
||||
const connectRequestId = navSeqRef.current[side];
|
||||
const getTargetPane = () => {
|
||||
const tabs = side === "left" ? leftTabsRef.current.tabs : rightTabsRef.current.tabs;
|
||||
return tabs.find((tab) => tab.id === activeTabId) ?? null;
|
||||
};
|
||||
const isTargetConnectionCurrent = () => {
|
||||
const pane = getTargetPane();
|
||||
if (!pane) return false;
|
||||
if (pane.connection?.id === connectionId) return true;
|
||||
return !pane.connection && navSeqRef.current[side] === connectRequestId;
|
||||
};
|
||||
const isTargetConnectionAtPath = (path: string) => {
|
||||
const connection = getTargetPane()?.connection;
|
||||
if (!connection) return navSeqRef.current[side] === connectRequestId;
|
||||
return connection?.id === connectionId && connection.currentPath === path;
|
||||
};
|
||||
const closeSftpSessionForConnection = async () => {
|
||||
const sftpId = sftpSessionsRef.current.get(connectionId);
|
||||
sftpSessionsRef.current.delete(connectionId);
|
||||
connectionCacheKeyMapRef.current.delete(connectionId);
|
||||
clearCacheForConnection(connectionId);
|
||||
if (!sftpId) return;
|
||||
try {
|
||||
await netcattyBridge.get()?.closeSftp(sftpId);
|
||||
} catch {
|
||||
// Ignore errors when closing stale SFTP sessions
|
||||
}
|
||||
};
|
||||
|
||||
lastConnectedHostRef.current[side] = host;
|
||||
// Store the cache key for this connection so pane actions can look it up
|
||||
@@ -147,13 +289,15 @@ export const useSftpConnections = ({
|
||||
homeDir = isWindows ? "C:\\Users\\damao" : "/Users/damao";
|
||||
}
|
||||
|
||||
const startPath = options?.initialPath || homeDir;
|
||||
|
||||
const connection: SftpConnection = {
|
||||
id: connectionId,
|
||||
hostId: "local",
|
||||
hostLabel: "Local",
|
||||
isLocal: true,
|
||||
status: "connected",
|
||||
currentPath: homeDir,
|
||||
currentPath: startPath,
|
||||
homeDir,
|
||||
};
|
||||
|
||||
@@ -168,9 +312,9 @@ export const useSftpConnections = ({
|
||||
}));
|
||||
|
||||
try {
|
||||
const files = await listLocalFiles(homeDir);
|
||||
if (navSeqRef.current[side] !== connectRequestId) return;
|
||||
dirCacheRef.current.set(makeCacheKey(connectionId, homeDir, filenameEncoding), {
|
||||
const files = await listLocalFiles(startPath);
|
||||
if (!isTargetConnectionAtPath(startPath)) return;
|
||||
dirCacheRef.current.set(makeCacheKey(connectionId, startPath, filenameEncoding), {
|
||||
files,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
@@ -182,7 +326,7 @@ export const useSftpConnections = ({
|
||||
reconnecting: false,
|
||||
}));
|
||||
} catch (err) {
|
||||
if (navSeqRef.current[side] !== connectRequestId) return;
|
||||
if (!isTargetConnectionAtPath(startPath)) return;
|
||||
reconnectingRef.current[side] = false;
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
@@ -193,12 +337,15 @@ export const useSftpConnections = ({
|
||||
}
|
||||
} else {
|
||||
const hostCacheKey = buildCacheKey(host.id, host.hostname, host.port, host.protocol, host.sftpSudo, host.username);
|
||||
const sharedHostCacheCandidate = getSharedRemoteHostCache(hostCacheKey);
|
||||
const sharedHostCache =
|
||||
sharedHostCacheCandidate?.filenameEncoding === filenameEncoding
|
||||
? sharedHostCacheCandidate
|
||||
: null;
|
||||
const cachedStartPath = sharedHostCache?.path ?? "/";
|
||||
const sharedHostCacheCandidate = options?.ignoreSharedCache
|
||||
? null
|
||||
: getSharedRemoteHostCache(hostCacheKey);
|
||||
const { initialPath, sharedHostCache, cachedStartPath } = resolveRemoteSftpStartState({
|
||||
filenameEncoding,
|
||||
ignoreSharedCache: options?.ignoreSharedCache,
|
||||
initialPath: options?.initialPath,
|
||||
sharedHostCacheCandidate,
|
||||
});
|
||||
|
||||
const connection: SftpConnection = {
|
||||
id: connectionId,
|
||||
@@ -230,6 +377,7 @@ export const useSftpConnections = ({
|
||||
|
||||
// Subscribe to SFTP connection progress events for auth logging
|
||||
const sftpSessionId = `sftp-${connectionId}`;
|
||||
activeHostKeySessionsRef.current.set(sftpSessionId, { side, tabId: activeTabId });
|
||||
let unsubSftpProgress: (() => void) | undefined;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.onSftpConnectionProgress) {
|
||||
@@ -264,7 +412,7 @@ export const useSftpConnections = ({
|
||||
logLine = `${label} - ${status}${detail ? `: ${detail}` : ''}`;
|
||||
}
|
||||
// Only update if this is still the active request (avoids stale logs leaking)
|
||||
if (navSeqRef.current[side] !== connectRequestId) return;
|
||||
if (!isTargetConnectionCurrent()) return;
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connectionLogs: [...prev.connectionLogs, logLine],
|
||||
@@ -295,7 +443,7 @@ export const useSftpConnections = ({
|
||||
if (hasKey) {
|
||||
try {
|
||||
const keyFirstCredentials = {
|
||||
sessionId: `sftp-${connectionId}`,
|
||||
sessionId: sftpSessionId,
|
||||
...credentials,
|
||||
sourceSessionId: options?.sourceSessionId,
|
||||
};
|
||||
@@ -306,7 +454,7 @@ export const useSftpConnections = ({
|
||||
} catch (err) {
|
||||
if (hasPassword && isAuthError(err)) {
|
||||
sftpId = await openSftp({
|
||||
sessionId: `sftp-${connectionId}`,
|
||||
sessionId: sftpSessionId,
|
||||
...credentials,
|
||||
sourceSessionId: options?.sourceSessionId,
|
||||
privateKey: undefined,
|
||||
@@ -322,7 +470,7 @@ export const useSftpConnections = ({
|
||||
}
|
||||
} else {
|
||||
sftpId = await openSftp({
|
||||
sessionId: `sftp-${connectionId}`,
|
||||
sessionId: sftpSessionId,
|
||||
...credentials,
|
||||
sourceSessionId: options?.sourceSessionId,
|
||||
});
|
||||
@@ -331,6 +479,10 @@ export const useSftpConnections = ({
|
||||
if (!sftpId) throw new Error("Failed to open SFTP session");
|
||||
|
||||
sftpSessionsRef.current.set(connectionId, sftpId);
|
||||
if (!isTargetConnectionCurrent()) {
|
||||
await closeSftpSessionForConnection();
|
||||
return;
|
||||
}
|
||||
|
||||
let startPath = sharedHostCache?.path ?? "/";
|
||||
let homeDir = sharedHostCache?.homeDir ?? startPath;
|
||||
@@ -395,6 +547,10 @@ export const useSftpConnections = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (initialPath) {
|
||||
startPath = initialPath;
|
||||
}
|
||||
|
||||
const provisionalCacheKey = sharedHostCache
|
||||
? makeCacheKey(connectionId, startPath, filenameEncoding)
|
||||
: null;
|
||||
@@ -438,7 +594,10 @@ export const useSftpConnections = ({
|
||||
throw new Error("Cannot list any remote directory");
|
||||
}
|
||||
}
|
||||
if (navSeqRef.current[side] !== connectRequestId) return;
|
||||
if (!isTargetConnectionCurrent()) {
|
||||
await closeSftpSessionForConnection();
|
||||
return;
|
||||
}
|
||||
dirCacheRef.current.set(makeCacheKey(connectionId, startPath, filenameEncoding), {
|
||||
files,
|
||||
timestamp: Date.now(),
|
||||
@@ -469,7 +628,10 @@ export const useSftpConnections = ({
|
||||
connectionLogs: [], // Clear after successful connect to avoid replay during navigation
|
||||
}));
|
||||
} catch (err) {
|
||||
if (navSeqRef.current[side] !== connectRequestId) return;
|
||||
if (!isTargetConnectionCurrent()) {
|
||||
await closeSftpSessionForConnection();
|
||||
return;
|
||||
}
|
||||
reconnectingRef.current[side] = false;
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
@@ -489,6 +651,10 @@ export const useSftpConnections = ({
|
||||
reconnecting: false,
|
||||
}));
|
||||
} finally {
|
||||
activeHostKeySessionsRef.current.delete(sftpSessionId);
|
||||
if (hostKeyVerificationRef.current?.sessionId === sftpSessionId) {
|
||||
setPendingHostKeyVerification(null);
|
||||
}
|
||||
unsubSftpProgress?.();
|
||||
}
|
||||
}
|
||||
@@ -503,6 +669,7 @@ export const useSftpConnections = ({
|
||||
makeCacheKey,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
setPendingHostKeyVerification,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -588,5 +755,9 @@ export const useSftpConnections = ({
|
||||
disconnect,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
hostKeyVerification,
|
||||
rejectHostKeyVerification,
|
||||
acceptHostKeyVerification,
|
||||
acceptAndSaveHostKeyVerification,
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useRef, useMemo, useState } from "react";
|
||||
import { FileConflict, FileConflictAction, TransferStatus, SftpFilenameEncoding } from "../../../domain/models";
|
||||
import { getSftpConflictTypeKey } from "../../../domain/sftpConflict";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { notify } from "../../notification";
|
||||
@@ -501,7 +502,7 @@ export const useSftpExternalOperations = (
|
||||
newModified: number;
|
||||
applyToAllCount: number;
|
||||
}): Promise<FileConflictAction> => {
|
||||
const conflictType = conflict.isDirectory ? "directory" : "file";
|
||||
const conflictType = getSftpConflictTypeKey(conflict.isDirectory, conflict.existingType);
|
||||
const defaultAction = conflictDefaults.get(conflictType);
|
||||
if (defaultAction) return defaultAction;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { buildSftpHostCredentials } from "./useSftpHostCredentials.ts";
|
||||
import type { Host, SSHKey } from "../../../domain/models.ts";
|
||||
import type { Host, KnownHost, SSHKey } from "../../../domain/models.ts";
|
||||
|
||||
const host = (overrides: Partial<Host> = {}): Host => ({
|
||||
id: "host-1",
|
||||
@@ -102,6 +102,28 @@ test("buildSftpHostCredentials passes reference keys as identity file paths", ()
|
||||
assert.equal(credentials.passphrase, "saved-passphrase");
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials forwards known hosts for SFTP host-key checks", () => {
|
||||
const knownHosts: KnownHost[] = [{
|
||||
id: "kh-1",
|
||||
hostname: "example.com",
|
||||
port: 22,
|
||||
keyType: "ssh-ed25519",
|
||||
publicKey: "SHA256:abc",
|
||||
fingerprint: "abc",
|
||||
discoveredAt: 1,
|
||||
}];
|
||||
|
||||
const credentials = buildSftpHostCredentials({
|
||||
host: host(),
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
knownHosts,
|
||||
});
|
||||
|
||||
assert.equal(credentials.knownHosts, knownHosts);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials passes jump host reference keys as identity file paths", () => {
|
||||
const key: SSHKey = {
|
||||
id: "jump-key",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback } from "react";
|
||||
import type { Host, Identity, SSHKey, TerminalSettings } from "../../../domain/models";
|
||||
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from "../../../domain/models";
|
||||
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
|
||||
import { resolveBridgeKeyAuth, resolveHostAuth } from "../../../domain/sshAuth";
|
||||
import { resolveHostKeepalive } from "../../../domain/host";
|
||||
@@ -14,6 +14,7 @@ interface UseSftpHostCredentialsParams {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
knownHosts?: KnownHost[];
|
||||
terminalSettings?: Pick<TerminalSettings, 'keepaliveInterval' | 'keepaliveCountMax'>;
|
||||
}
|
||||
|
||||
@@ -22,6 +23,7 @@ export const buildSftpHostCredentials = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
knownHosts,
|
||||
terminalSettings,
|
||||
}: UseSftpHostCredentialsParams & { host: Host }): NetcattySSHOptions => {
|
||||
const globalKeepalive = terminalSettings ?? FALLBACK_KEEPALIVE;
|
||||
@@ -165,6 +167,7 @@ export const buildSftpHostCredentials = ({
|
||||
identityFilePaths: keyAuth.identityFilePaths,
|
||||
keepaliveInterval: targetKeepalive.interval,
|
||||
keepaliveCountMax: targetKeepalive.countMax,
|
||||
knownHosts,
|
||||
// Algorithm settings — must reach the SFTP bridge or hosts that need
|
||||
// legacy mode / the ECDSA skip / advanced overrides would still hit
|
||||
// the original negotiation failure when opening their SFTP pane,
|
||||
@@ -179,9 +182,10 @@ export const useSftpHostCredentials = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
knownHosts,
|
||||
terminalSettings,
|
||||
}: UseSftpHostCredentialsParams) =>
|
||||
useCallback(
|
||||
(host: Host): NetcattySSHOptions => buildSftpHostCredentials({ host, hosts, keys, identities, terminalSettings }),
|
||||
[hosts, identities, keys, terminalSettings],
|
||||
(host: Host): NetcattySSHOptions => buildSftpHostCredentials({ host, hosts, keys, identities, knownHosts, terminalSettings }),
|
||||
[hosts, identities, keys, knownHosts, terminalSettings],
|
||||
);
|
||||
@@ -3,7 +3,18 @@ import type { Host, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { SftpPane } from "./types";
|
||||
import { getFileName, getParentPath, isNavigableDirectory, isWindowsRoot, joinPath } from "./utils";
|
||||
import {
|
||||
getFileName,
|
||||
getParentPath,
|
||||
getSftpFilterAfterPathChange,
|
||||
getSftpFilterAfterPathChangeError,
|
||||
isNavigableDirectory,
|
||||
isSameSftpPath,
|
||||
isWindowsRoot,
|
||||
joinPath,
|
||||
normalizeSftpPathForCompare,
|
||||
shouldClearSftpFilterForPathChange,
|
||||
} from "./utils";
|
||||
import { buildCacheKey, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
|
||||
|
||||
/** Shared empty set for navigation resets — never mutate this. */
|
||||
@@ -32,8 +43,10 @@ interface UseSftpPaneActionsParams {
|
||||
dirCacheTtlMs: number;
|
||||
}
|
||||
|
||||
export type SftpNavigateResult = "reached" | "failed" | "aborted";
|
||||
|
||||
interface UseSftpPaneActionsResult {
|
||||
navigateTo: (side: "left" | "right", path: string, options?: { force?: boolean; tabId?: string }) => Promise<void>;
|
||||
navigateTo: (side: "left" | "right", path: string, options?: { force?: boolean; tabId?: string }) => Promise<SftpNavigateResult>;
|
||||
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
|
||||
navigateUp: (side: "left" | "right") => Promise<void>;
|
||||
openEntry: (side: "left" | "right", entry: SftpFileEntry) => Promise<void>;
|
||||
@@ -83,17 +96,12 @@ export const useSftpPaneActions = ({
|
||||
dirCacheTtlMs,
|
||||
}: UseSftpPaneActionsParams): UseSftpPaneActionsResult => {
|
||||
const normalizePathForCompare = useCallback((path: string): string => {
|
||||
if (isWindowsRoot(path)) return path.replace(/\//g, "\\").toLowerCase();
|
||||
if (/^[A-Za-z]:/.test(path)) {
|
||||
return path.replace(/\//g, "\\").replace(/[\\]+$/, "").toLowerCase();
|
||||
}
|
||||
if (path === "/") return "/";
|
||||
return path.replace(/\/+$/, "");
|
||||
return normalizeSftpPathForCompare(path);
|
||||
}, []);
|
||||
|
||||
const isSamePath = useCallback((a: string, b: string): boolean => {
|
||||
return normalizePathForCompare(a) === normalizePathForCompare(b);
|
||||
}, [normalizePathForCompare]);
|
||||
return isSameSftpPath(a, b);
|
||||
}, []);
|
||||
|
||||
const isDescendantPath = useCallback((candidate: string, parent: string): boolean => {
|
||||
const normalizedCandidate = normalizePathForCompare(candidate);
|
||||
@@ -146,7 +154,7 @@ export const useSftpPaneActions = ({
|
||||
// than an intermediate optimistic state from another in-flight navigation.
|
||||
// Includes connectionId so stale entries from a previous host are ignored.
|
||||
const lastConfirmedRef = useRef(
|
||||
new Map<string, { connectionId: string; path: string; files: SftpFileEntry[]; selectedFiles: Set<string> }>(),
|
||||
new Map<string, { connectionId: string; path: string; files: SftpFileEntry[]; selectedFiles: Set<string>; filter: string }>(),
|
||||
);
|
||||
|
||||
const navigateTo = useCallback(
|
||||
@@ -154,7 +162,7 @@ export const useSftpPaneActions = ({
|
||||
side: "left" | "right",
|
||||
path: string,
|
||||
options?: { force?: boolean; tabId?: string },
|
||||
) => {
|
||||
): Promise<SftpNavigateResult> => {
|
||||
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
// When tabId is specified, target that specific tab instead of the active one.
|
||||
// This allows refreshing a background tab (e.g. after a transfer completes
|
||||
@@ -165,12 +173,14 @@ export const useSftpPaneActions = ({
|
||||
: getActivePane(side);
|
||||
|
||||
if (!pane?.connection || !targetTabId) {
|
||||
return;
|
||||
return "aborted";
|
||||
}
|
||||
|
||||
const connectionId = pane.connection.id;
|
||||
const requestId = ++navSeqRef.current[side];
|
||||
const cacheKey = makeCacheKey(connectionId, path, pane.filenameEncoding);
|
||||
const clearFilterForPathChange = shouldClearSftpFilterForPathChange(pane.connection.currentPath, path);
|
||||
const nextConfirmedFilter = getSftpFilterAfterPathChange(pane.connection.currentPath, path, pane.filter);
|
||||
const cached = options?.force
|
||||
? undefined
|
||||
: dirCacheRef.current.get(cacheKey);
|
||||
@@ -186,6 +196,7 @@ export const useSftpPaneActions = ({
|
||||
path,
|
||||
files: cached.files,
|
||||
selectedFiles: EMPTY_SET,
|
||||
filter: nextConfirmedFilter,
|
||||
});
|
||||
updateTab(side, targetTabId, (prev) => ({
|
||||
...prev,
|
||||
@@ -196,6 +207,7 @@ export const useSftpPaneActions = ({
|
||||
loading: false,
|
||||
error: null,
|
||||
selectedFiles: EMPTY_SET,
|
||||
filter: clearFilterForPathChange ? "" : prev.filter,
|
||||
}));
|
||||
if (!pane.connection.isLocal) {
|
||||
// Use hostId as the shared cache key — this is safe because the
|
||||
@@ -210,7 +222,7 @@ export const useSftpPaneActions = ({
|
||||
filenameEncoding: pane.filenameEncoding,
|
||||
});
|
||||
}
|
||||
return;
|
||||
return "reached";
|
||||
}
|
||||
|
||||
// Re-seed confirmed state whenever the pane is settled (not loading), or
|
||||
@@ -224,12 +236,14 @@ export const useSftpPaneActions = ({
|
||||
path: pane.connection.currentPath,
|
||||
files: pane.files,
|
||||
selectedFiles: pane.selectedFiles,
|
||||
filter: pane.filter,
|
||||
});
|
||||
}
|
||||
const confirmed = lastConfirmedRef.current.get(targetTabId)!;
|
||||
const previousPath = confirmed.path;
|
||||
const previousFiles = confirmed.files;
|
||||
const previousSelection = confirmed.selectedFiles;
|
||||
const previousFilter = confirmed.filter;
|
||||
tabNavSeqRef.current.set(targetTabId, requestId);
|
||||
// Keep existing files visible during loading — the loading overlay
|
||||
// (pointer-events-none) prevents interaction. This avoids blanking a tab
|
||||
@@ -240,6 +254,7 @@ export const useSftpPaneActions = ({
|
||||
? { ...prev.connection, currentPath: path }
|
||||
: null,
|
||||
selectedFiles: EMPTY_SET,
|
||||
filter: clearFilterForPathChange ? "" : prev.filter,
|
||||
loading: true,
|
||||
error: null,
|
||||
}));
|
||||
@@ -264,7 +279,7 @@ export const useSftpPaneActions = ({
|
||||
} else {
|
||||
handleSessionError(side, new Error("SFTP session lost"));
|
||||
}
|
||||
return;
|
||||
return "aborted";
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -282,7 +297,7 @@ export const useSftpPaneActions = ({
|
||||
} else {
|
||||
handleSessionError(side, err as Error);
|
||||
}
|
||||
return;
|
||||
return "aborted";
|
||||
}
|
||||
throw err as Error;
|
||||
}
|
||||
@@ -293,7 +308,7 @@ export const useSftpPaneActions = ({
|
||||
// a connect/disconnect. Check if THIS tab's request is still current.
|
||||
if (tabNavSeqRef.current.get(targetTabId) !== requestId) {
|
||||
// This tab also has a newer navigation — drop completely.
|
||||
return;
|
||||
return "aborted";
|
||||
}
|
||||
// Side was superseded by another tab, but this tab's request is
|
||||
// still current. The fetched files are valid — fall through to
|
||||
@@ -310,6 +325,7 @@ export const useSftpPaneActions = ({
|
||||
path,
|
||||
files,
|
||||
selectedFiles: EMPTY_SET,
|
||||
filter: nextConfirmedFilter,
|
||||
});
|
||||
|
||||
updateTab(side, targetTabId, (prev) => ({
|
||||
@@ -320,6 +336,7 @@ export const useSftpPaneActions = ({
|
||||
files,
|
||||
loading: false,
|
||||
selectedFiles: EMPTY_SET,
|
||||
filter: clearFilterForPathChange ? "" : prev.filter,
|
||||
}));
|
||||
if (!pane.connection.isLocal) {
|
||||
setSharedRemoteHostCache(getActivePaneCacheKey(side, pane.connection.hostId, pane.connection.id), {
|
||||
@@ -329,28 +346,33 @@ export const useSftpPaneActions = ({
|
||||
filenameEncoding: pane.filenameEncoding,
|
||||
});
|
||||
}
|
||||
return "reached";
|
||||
} catch (err) {
|
||||
if (navSeqRef.current[side] !== requestId) {
|
||||
if (tabNavSeqRef.current.get(targetTabId) !== requestId) {
|
||||
return;
|
||||
return "aborted";
|
||||
}
|
||||
// Side superseded by another tab, but this tab's request is
|
||||
// current — fall through to show the error on this tab.
|
||||
}
|
||||
let navigationFailed = false;
|
||||
updateTab(side, targetTabId, (prev) => {
|
||||
if (prev.connection?.id !== connectionId) {
|
||||
return prev;
|
||||
}
|
||||
navigationFailed = true;
|
||||
return {
|
||||
...prev,
|
||||
connection: { ...prev.connection, currentPath: previousPath },
|
||||
files: previousFiles,
|
||||
selectedFiles: previousSelection,
|
||||
filter: getSftpFilterAfterPathChangeError(clearFilterForPathChange, previousFilter, prev.filter),
|
||||
error:
|
||||
err instanceof Error ? err.message : "Failed to list directory",
|
||||
loading: false,
|
||||
};
|
||||
});
|
||||
return navigationFailed ? "failed" : "aborted";
|
||||
}
|
||||
},
|
||||
[
|
||||
|
||||
@@ -7,6 +7,12 @@ import {
|
||||
TransferStatus,
|
||||
TransferTask,
|
||||
} from "../../../domain/models";
|
||||
import {
|
||||
canReplaceSftpConflict,
|
||||
describeSftpExistingKind,
|
||||
describeSftpIncomingKind,
|
||||
getSftpConflictTypeKey,
|
||||
} from "../../../domain/sftpConflict";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { SftpPane } from "./types";
|
||||
@@ -69,8 +75,14 @@ export const useSftpTransfers = ({
|
||||
);
|
||||
|
||||
const conflictDefaultKey = useCallback(
|
||||
(batchId: string | undefined, isDirectory: boolean) =>
|
||||
`${batchId ?? "global"}:${isDirectory ? "directory" : "file"}`,
|
||||
(batchId: string | undefined, isDirectory: boolean, existingType?: "file" | "directory" | "symlink") =>
|
||||
`${batchId ?? "global"}:${getSftpConflictTypeKey(isDirectory, existingType)}`,
|
||||
[],
|
||||
);
|
||||
|
||||
const buildReplaceTypeMismatchError = useCallback(
|
||||
(isDirectory: boolean, existingType: "file" | "directory" | "symlink" | undefined, targetPath: string) =>
|
||||
`Cannot replace existing ${describeSftpExistingKind(existingType)} with ${describeSftpIncomingKind(isDirectory)}: ${targetPath}`,
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -233,6 +245,33 @@ export const useSftpTransfers = ({
|
||||
const existingStat = await statTargetPath(targetPane, targetSftpId, task.targetPath, targetEncoding);
|
||||
|
||||
if (existingStat) {
|
||||
const applyToAllCount = task.batchId
|
||||
? await (async () => {
|
||||
const candidates = transfersRef.current.filter((candidate) =>
|
||||
candidate.batchId === task.batchId &&
|
||||
candidate.isDirectory === task.isDirectory &&
|
||||
!candidate.parentTaskId &&
|
||||
candidate.status !== "completed" &&
|
||||
candidate.status !== "cancelled",
|
||||
);
|
||||
const matches = await Promise.all(candidates.map(async (candidate) => {
|
||||
if (candidate.id === task.id) return true;
|
||||
try {
|
||||
const candidateStat = await statTargetPath(
|
||||
targetPane,
|
||||
targetSftpId,
|
||||
candidate.targetPath,
|
||||
targetEncoding,
|
||||
);
|
||||
return candidateStat?.type === existingStat.type;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}));
|
||||
return Math.max(1, matches.filter(Boolean).length);
|
||||
})()
|
||||
: 1;
|
||||
|
||||
return {
|
||||
transferId: task.id,
|
||||
batchId: task.batchId,
|
||||
@@ -241,15 +280,7 @@ export const useSftpTransfers = ({
|
||||
targetPath: task.targetPath,
|
||||
isDirectory: task.isDirectory,
|
||||
existingType: existingStat.type,
|
||||
applyToAllCount: task.batchId
|
||||
? transfersRef.current.filter((candidate) =>
|
||||
candidate.batchId === task.batchId &&
|
||||
candidate.isDirectory === task.isDirectory &&
|
||||
!candidate.parentTaskId &&
|
||||
candidate.status !== "completed" &&
|
||||
candidate.status !== "cancelled",
|
||||
).length
|
||||
: 1,
|
||||
applyToAllCount,
|
||||
existingSize: existingStat.size,
|
||||
newSize: sourceStat?.size || task.totalBytes || 0,
|
||||
existingModified: existingStat.mtime,
|
||||
@@ -271,7 +302,9 @@ export const useSftpTransfers = ({
|
||||
const conflict = await conflictCheckPromise;
|
||||
|
||||
if (conflict) {
|
||||
const defaultAction = conflictDefaultsRef.current.get(conflictDefaultKey(task.batchId, task.isDirectory));
|
||||
const defaultAction = conflictDefaultsRef.current.get(
|
||||
conflictDefaultKey(task.batchId, task.isDirectory, conflict.existingType),
|
||||
);
|
||||
if (defaultAction) {
|
||||
if (defaultAction === "stop") {
|
||||
await markBatchStopped(task);
|
||||
@@ -285,6 +318,16 @@ export const useSftpTransfers = ({
|
||||
return "cancelled";
|
||||
}
|
||||
|
||||
if (defaultAction === "replace" && !canReplaceSftpConflict(task.isDirectory, conflict.existingType)) {
|
||||
updateTask({
|
||||
status: "failed",
|
||||
endTime: Date.now(),
|
||||
error: buildReplaceTypeMismatchError(task.isDirectory, conflict.existingType, task.targetPath),
|
||||
retryable: false,
|
||||
});
|
||||
return "failed";
|
||||
}
|
||||
|
||||
const duplicateTarget = defaultAction === "duplicate"
|
||||
? await getDuplicateTarget(task, targetPane, targetSftpId, targetEncoding)
|
||||
: null;
|
||||
@@ -728,16 +771,19 @@ export const useSftpTransfers = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedConflictKey = conflictDefaultKey(task.batchId, task.isDirectory);
|
||||
const selectedConflictKey = conflictDefaultKey(conflict.batchId, conflict.isDirectory, conflict.existingType);
|
||||
const affectedConflicts = applyToAll
|
||||
? conflictsRef.current.filter((candidate) =>
|
||||
conflictDefaultKey(candidate.batchId, candidate.isDirectory) === selectedConflictKey,
|
||||
conflictDefaultKey(candidate.batchId, candidate.isDirectory, candidate.existingType) === selectedConflictKey,
|
||||
)
|
||||
: [conflict];
|
||||
const affectedConflictIds = new Set(affectedConflicts.map((candidate) => candidate.transferId));
|
||||
const affectedTasks = affectedConflicts
|
||||
.map((candidate) => transfersRef.current.find((transfer) => transfer.id === candidate.transferId))
|
||||
.filter((candidate): candidate is TransferTask => Boolean(candidate));
|
||||
const affectedConflictById = new Map<string, FileConflict>(
|
||||
affectedConflicts.map((candidate): [string, FileConflict] => [candidate.transferId, candidate]),
|
||||
);
|
||||
|
||||
if (applyToAll) {
|
||||
conflictDefaultsRef.current.set(selectedConflictKey, action);
|
||||
@@ -771,9 +817,11 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
|
||||
const updatedTasks: TransferTask[] = [];
|
||||
const blockedReplaceTasks: Array<{ task: TransferTask; conflict: FileConflict }> = [];
|
||||
|
||||
for (const affectedTask of affectedTasks) {
|
||||
let updatedTask = { ...affectedTask };
|
||||
const affectedConflict = affectedConflictById.get(affectedTask.id);
|
||||
|
||||
if (action === "duplicate") {
|
||||
const endpoints = resolveTaskEndpoints(affectedTask);
|
||||
@@ -792,6 +840,13 @@ export const useSftpTransfers = ({
|
||||
skipConflictCheck: true,
|
||||
};
|
||||
} else if (action === "replace") {
|
||||
if (
|
||||
affectedConflict &&
|
||||
!canReplaceSftpConflict(affectedTask.isDirectory, affectedConflict.existingType)
|
||||
) {
|
||||
blockedReplaceTasks.push({ task: affectedTask, conflict: affectedConflict });
|
||||
continue;
|
||||
}
|
||||
updatedTask = {
|
||||
...affectedTask,
|
||||
skipConflictCheck: true,
|
||||
@@ -808,6 +863,28 @@ export const useSftpTransfers = ({
|
||||
updatedTasks.push(updatedTask);
|
||||
}
|
||||
|
||||
if (blockedReplaceTasks.length > 0) {
|
||||
const blockedTaskIds = new Set(blockedReplaceTasks.map(({ task }) => task.id));
|
||||
const blockedErrors = new Map(
|
||||
blockedReplaceTasks.map(({ task, conflict }) => [
|
||||
task.id,
|
||||
buildReplaceTypeMismatchError(task.isDirectory, conflict.existingType, task.targetPath),
|
||||
]),
|
||||
);
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => blockedTaskIds.has(t.id)
|
||||
? {
|
||||
...t,
|
||||
status: "failed" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
error: blockedErrors.get(t.id),
|
||||
retryable: false,
|
||||
}
|
||||
: t,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const updatedTaskMap = new Map(updatedTasks.map((updatedTask) => [updatedTask.id, updatedTask]));
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { isConcreteTransferTargetPath } from "./utils";
|
||||
import {
|
||||
getParentPath,
|
||||
getSftpFilterAfterPathChange,
|
||||
getSftpFilterAfterPathChangeError,
|
||||
isConcreteTransferTargetPath,
|
||||
shouldClearSftpFilterForPathChange,
|
||||
} from "./utils";
|
||||
|
||||
test("concrete transfer target paths exclude temporary placeholders", () => {
|
||||
assert.equal(isConcreteTransferTargetPath({ targetPath: "/Users/alice/Downloads/report.pdf" }), true);
|
||||
@@ -9,3 +15,30 @@ test("concrete transfer target paths exclude temporary placeholders", () => {
|
||||
assert.equal(isConcreteTransferTargetPath({ targetPath: "(temp)" }), false);
|
||||
assert.equal(isConcreteTransferTargetPath({ targetPath: " " }), false);
|
||||
});
|
||||
|
||||
test("SFTP filter clears when entering a different directory", () => {
|
||||
assert.equal(shouldClearSftpFilterForPathChange("/srv", "/srv/app"), true);
|
||||
assert.equal(getSftpFilterAfterPathChange("/srv", "/srv/app", "log"), "");
|
||||
});
|
||||
|
||||
test("SFTP filter clears when navigating to the parent directory", () => {
|
||||
assert.equal(shouldClearSftpFilterForPathChange("/srv/app", getParentPath("/srv/app")), true);
|
||||
assert.equal(getSftpFilterAfterPathChange("/srv/app", getParentPath("/srv/app"), "dist"), "");
|
||||
});
|
||||
|
||||
test("SFTP filter stays when refreshing the same directory", () => {
|
||||
assert.equal(shouldClearSftpFilterForPathChange("/srv/app", "/srv/app"), false);
|
||||
assert.equal(shouldClearSftpFilterForPathChange("/srv/app", "/srv/app/"), false);
|
||||
assert.equal(shouldClearSftpFilterForPathChange("C:\\Users\\alice", "c:/Users/alice"), false);
|
||||
assert.equal(getSftpFilterAfterPathChange("/srv/app", "/srv/app", "log"), "log");
|
||||
assert.equal(getSftpFilterAfterPathChange("/srv/app", "/srv/app/", "log"), "log");
|
||||
assert.equal(getSftpFilterAfterPathChange("C:\\Users\\alice", "c:/Users/alice", "docs"), "docs");
|
||||
});
|
||||
|
||||
test("SFTP filter restores when changed-directory navigation fails", () => {
|
||||
assert.equal(getSftpFilterAfterPathChangeError(true, "log", "typed-while-loading"), "log");
|
||||
});
|
||||
|
||||
test("SFTP filter preserves in-flight edits when same-directory refresh fails", () => {
|
||||
assert.equal(getSftpFilterAfterPathChangeError(false, "log", "typed-while-loading"), "typed-while-loading");
|
||||
});
|
||||
@@ -85,3 +85,39 @@ export const getFileName = (path: string): string => {
|
||||
const parts = path.split(/[\\/]/).filter(Boolean);
|
||||
return parts[parts.length - 1] || "";
|
||||
};
|
||||
|
||||
export const normalizeSftpPathForCompare = (path: string): string => {
|
||||
if (isWindowsRoot(path)) return path.replace(/\//g, "\\").toLowerCase();
|
||||
if (/^[A-Za-z]:/.test(path)) {
|
||||
return path.replace(/\//g, "\\").replace(/[\\]+$/, "").toLowerCase();
|
||||
}
|
||||
if (path === "/") return "/";
|
||||
return path.replace(/\/+$/, "");
|
||||
};
|
||||
|
||||
export const isSameSftpPath = (a: string, b: string): boolean => {
|
||||
return normalizeSftpPathForCompare(a) === normalizeSftpPathForCompare(b);
|
||||
};
|
||||
|
||||
export const shouldClearSftpFilterForPathChange = (
|
||||
currentPath: string,
|
||||
nextPath: string,
|
||||
): boolean => {
|
||||
return !isSameSftpPath(currentPath, nextPath);
|
||||
};
|
||||
|
||||
export const getSftpFilterAfterPathChange = (
|
||||
currentPath: string,
|
||||
nextPath: string,
|
||||
currentFilter: string,
|
||||
): string => {
|
||||
return shouldClearSftpFilterForPathChange(currentPath, nextPath) ? "" : currentFilter;
|
||||
};
|
||||
|
||||
export const getSftpFilterAfterPathChangeError = (
|
||||
clearFilterForPathChange: boolean,
|
||||
previousFilter: string,
|
||||
currentFilter: string,
|
||||
): string => {
|
||||
return clearFilterForPathChange ? previousFilter : currentFilter;
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { buildDockerLogsCommand } from '../../domain/systemManager/dockerShell.ts';
|
||||
import { loadSanitizedShellHistory } from './shellHistoryPersistence.ts';
|
||||
import type { ShellHistoryEntry } from '../../domain/models.ts';
|
||||
|
||||
const entry = (id: string, command: string): ShellHistoryEntry => ({
|
||||
id,
|
||||
command,
|
||||
hostId: 'host-1',
|
||||
hostLabel: 'Host',
|
||||
sessionId: 'session-1',
|
||||
timestamp: 1000,
|
||||
});
|
||||
|
||||
test('loadSanitizedShellHistory removes persisted managed startup commands and writes back cleaned history', () => {
|
||||
const stored = [
|
||||
entry('managed', buildDockerLogsCommand('587abcdef123')),
|
||||
entry('user', 'docker ps -a'),
|
||||
];
|
||||
let written: ShellHistoryEntry[] | null = null;
|
||||
|
||||
const loaded = loadSanitizedShellHistory({
|
||||
read: () => stored,
|
||||
write: (_key, value) => {
|
||||
written = value;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
loaded?.map((item) => item.command),
|
||||
['docker ps -a'],
|
||||
);
|
||||
assert.deepEqual(written, loaded);
|
||||
});
|
||||
|
||||
test('loadSanitizedShellHistory does not write when persisted history is already clean', () => {
|
||||
const stored = [entry('user', 'docker ps -a')];
|
||||
let writeCount = 0;
|
||||
|
||||
const loaded = loadSanitizedShellHistory({
|
||||
read: () => stored,
|
||||
write: () => {
|
||||
writeCount += 1;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(loaded, stored);
|
||||
assert.equal(writeCount, 0);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ShellHistoryEntry } from '../../domain/models';
|
||||
import { sanitizeGlobalHistoryEntries } from '../../domain/globalHistory';
|
||||
import { STORAGE_KEY_SHELL_HISTORY } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
|
||||
type ShellHistoryStorage = {
|
||||
read<T>(key: string): T | null;
|
||||
write<T>(key: string, value: T): boolean;
|
||||
};
|
||||
|
||||
export function loadSanitizedShellHistory(
|
||||
storage: ShellHistoryStorage = localStorageAdapter,
|
||||
storageKey = STORAGE_KEY_SHELL_HISTORY,
|
||||
): ShellHistoryEntry[] | null {
|
||||
const savedShellHistory = storage.read<ShellHistoryEntry[]>(storageKey);
|
||||
if (!savedShellHistory) return null;
|
||||
|
||||
const cleanedShellHistory = sanitizeGlobalHistoryEntries(savedShellHistory);
|
||||
if (cleanedShellHistory.length !== savedShellHistory.length) {
|
||||
storage.write(storageKey, cleanedShellHistory);
|
||||
}
|
||||
return cleanedShellHistory;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export async function writeSystemManagerDiagnostic(
|
||||
message: string,
|
||||
extra?: Record<string, unknown>,
|
||||
) {
|
||||
try {
|
||||
await netcattyBridge.get()?.logDiagnostic?.({
|
||||
source: 'system-manager',
|
||||
message,
|
||||
extra,
|
||||
});
|
||||
} catch {
|
||||
// Diagnostics must never block the user action being diagnosed.
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { localStorageAdapter } from '../../infrastructure/persistence/localStora
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
interface UseSystemSettingsEffectsParams {
|
||||
enabled?: boolean;
|
||||
toggleWindowHotkey: string;
|
||||
globalHotkeyEnabled: boolean;
|
||||
closeToTray: boolean;
|
||||
@@ -22,6 +23,7 @@ interface UseSystemSettingsEffectsParams {
|
||||
}
|
||||
|
||||
export function useSystemSettingsEffects({
|
||||
enabled = true,
|
||||
toggleWindowHotkey,
|
||||
globalHotkeyEnabled,
|
||||
closeToTray,
|
||||
@@ -34,6 +36,7 @@ export function useSystemSettingsEffects({
|
||||
}: UseSystemSettingsEffectsParams) {
|
||||
// Persist and sync toggle window hotkey setting
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
// Register/unregister the global hotkey in main process (needed on mount)
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.registerGlobalHotkey) {
|
||||
@@ -64,6 +67,7 @@ export function useSystemSettingsEffects({
|
||||
notifySettingsChanged(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
|
||||
}, [
|
||||
toggleWindowHotkey,
|
||||
enabled,
|
||||
globalHotkeyEnabled,
|
||||
notifySettingsChanged,
|
||||
persistMountedRef,
|
||||
@@ -72,13 +76,15 @@ export function useSystemSettingsEffects({
|
||||
|
||||
// Persist global hotkey enabled setting
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
localStorageAdapter.writeString(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled);
|
||||
}, [globalHotkeyEnabled, notifySettingsChanged, persistMountedRef]);
|
||||
}, [enabled, globalHotkeyEnabled, notifySettingsChanged, persistMountedRef]);
|
||||
|
||||
// Persist and sync close to tray setting
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
// Update main process tray behavior (needed on mount)
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.setCloseToTray) {
|
||||
@@ -90,10 +96,11 @@ export function useSystemSettingsEffects({
|
||||
// Skip IPC on initial mount
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
|
||||
}, [closeToTray, notifySettingsChanged, persistMountedRef]);
|
||||
}, [enabled, closeToTray, notifySettingsChanged, persistMountedRef]);
|
||||
|
||||
// Persist and sync window opacity
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.setWindowOpacity?.(windowOpacity).catch((err) => {
|
||||
console.warn('[WindowOpacity] Failed to apply window opacity:', err);
|
||||
@@ -101,12 +108,13 @@ export function useSystemSettingsEffects({
|
||||
localStorageAdapter.writeString(STORAGE_KEY_WINDOW_OPACITY, String(windowOpacity));
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_WINDOW_OPACITY, windowOpacity);
|
||||
}, [windowOpacity, notifySettingsChanged, persistMountedRef]);
|
||||
}, [enabled, windowOpacity, notifySettingsChanged, persistMountedRef]);
|
||||
|
||||
// Hydrate auto-update state from the main-process preference file on mount.
|
||||
// This reconciles localStorage (renderer) with auto-update-pref.json (main)
|
||||
// in case localStorage was cleared or is stale.
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
void bridge?.getAutoUpdate?.().then((result) => {
|
||||
if (result && typeof result.enabled === 'boolean') {
|
||||
@@ -118,11 +126,12 @@ export function useSystemSettingsEffects({
|
||||
});
|
||||
}
|
||||
}).catch(() => { /* bridge unavailable */ });
|
||||
}, [setAutoUpdateEnabled]);
|
||||
}, [enabled, setAutoUpdateEnabled]);
|
||||
|
||||
// Persist auto-update enabled setting.
|
||||
// Initial mount still writes localStorage, but skips cross-window/main-process IPC.
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled);
|
||||
@@ -131,7 +140,7 @@ export function useSystemSettingsEffects({
|
||||
bridge?.setAutoUpdate?.(autoUpdateEnabled).catch((err: unknown) => {
|
||||
console.warn('[AutoUpdate] Failed to set auto-update:', err);
|
||||
});
|
||||
}, [autoUpdateEnabled, notifySettingsChanged, persistMountedRef]);
|
||||
}, [enabled, autoUpdateEnabled, notifySettingsChanged, persistMountedRef]);
|
||||
|
||||
|
||||
}
|
||||
@@ -43,6 +43,8 @@ function createTerminalSessionClone(
|
||||
localShellArgs: session.localShellArgs,
|
||||
localShellName: session.localShellName,
|
||||
localShellIcon: session.localShellIcon,
|
||||
fontSize: session.fontSize,
|
||||
fontSizeOverride: session.fontSizeOverride,
|
||||
reuseConnectionFromSessionId: canReuseTerminalConnection(session) ? session.id : undefined,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
export const WORKSPACE_SESSION_DRAG_TYPE = 'application/x-netcatty-workspace-session';
|
||||
|
||||
type DataTransferLike = {
|
||||
types: DOMStringList | readonly string[];
|
||||
getData: (format: string) => string;
|
||||
};
|
||||
|
||||
export function dataTransferHasType(dataTransfer: Pick<DataTransferLike, 'types'>, type: string): boolean {
|
||||
return Array.prototype.includes.call(dataTransfer.types, type);
|
||||
}
|
||||
|
||||
export function hasWorkspaceSessionDrag(dataTransfer: Pick<DataTransferLike, 'types'>): boolean {
|
||||
return dataTransferHasType(dataTransfer, WORKSPACE_SESSION_DRAG_TYPE);
|
||||
}
|
||||
|
||||
export function getWorkspaceSessionDragId(dataTransfer: DataTransferLike): string {
|
||||
return dataTransfer.getData(WORKSPACE_SESSION_DRAG_TYPE) || dataTransfer.getData('session-id');
|
||||
}
|
||||
|
||||
export function isPointInsideRect(
|
||||
point: { clientX: number; clientY: number },
|
||||
rect: Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom'>,
|
||||
): boolean {
|
||||
return point.clientX >= rect.left
|
||||
&& point.clientX <= rect.right
|
||||
&& point.clientY >= rect.top
|
||||
&& point.clientY <= rect.bottom;
|
||||
}
|
||||
|
||||
export type TopTabInsertionTarget = {
|
||||
tabId: string;
|
||||
position: 'before' | 'after';
|
||||
};
|
||||
|
||||
export function getTopTabInsertionTarget(
|
||||
point: { clientX: number; clientY: number },
|
||||
topTabsRoot: HTMLElement | null,
|
||||
): TopTabInsertionTarget | null {
|
||||
if (!topTabsRoot || !isPointInsideRect(point, topTabsRoot.getBoundingClientRect())) return null;
|
||||
|
||||
const tabs = Array.from(topTabsRoot.querySelectorAll<HTMLElement>('[data-tab-id]'))
|
||||
.filter((tab) => tab.dataset.tabType !== 'root');
|
||||
|
||||
if (tabs.length === 0) return null;
|
||||
|
||||
for (const tab of tabs) {
|
||||
const rect = tab.getBoundingClientRect();
|
||||
const midpoint = rect.left + rect.width / 2;
|
||||
const tabId = tab.dataset.tabId;
|
||||
if (!tabId) continue;
|
||||
if (point.clientX <= midpoint) {
|
||||
return { tabId, position: 'before' };
|
||||
}
|
||||
if (point.clientX <= rect.right) {
|
||||
return { tabId, position: 'after' };
|
||||
}
|
||||
}
|
||||
|
||||
const lastTab = tabs[tabs.length - 1];
|
||||
const lastTabId = lastTab?.dataset.tabId;
|
||||
return lastTabId ? { tabId: lastTabId, position: 'after' } : null;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { terminalHiddenRendererStore } from "./terminalHiddenRendererStore.ts";
|
||||
|
||||
test("terminalHiddenRendererStore evicts oldest soft-hidden session", () => {
|
||||
terminalHiddenRendererStore.clearSoftHidden("a");
|
||||
terminalHiddenRendererStore.clearSoftHidden("b");
|
||||
terminalHiddenRendererStore.markSoftHidden("a");
|
||||
terminalHiddenRendererStore.markSoftHidden("b");
|
||||
assert.equal(terminalHiddenRendererStore.pickEvictionCandidate(2), "a");
|
||||
terminalHiddenRendererStore.clearSoftHidden("a");
|
||||
terminalHiddenRendererStore.clearSoftHidden("b");
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
type Listener = () => void;
|
||||
|
||||
const softHiddenSessions = new Map<string, number>();
|
||||
const listeners = new Set<Listener>();
|
||||
let evictionRequestSessionId: string | null = null;
|
||||
|
||||
function emit(): void {
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export const terminalHiddenRendererStore = {
|
||||
getSoftHiddenCount: () => softHiddenSessions.size,
|
||||
|
||||
isSoftHidden: (sessionId: string) => softHiddenSessions.has(sessionId),
|
||||
|
||||
markSoftHidden: (sessionId: string) => {
|
||||
softHiddenSessions.set(sessionId, Date.now());
|
||||
emit();
|
||||
},
|
||||
|
||||
clearSoftHidden: (sessionId: string) => {
|
||||
if (!softHiddenSessions.delete(sessionId)) return;
|
||||
emit();
|
||||
},
|
||||
|
||||
/** Returns the oldest soft-hidden session id when over the keep limit. */
|
||||
pickEvictionCandidate: (keepCount: number): string | null => {
|
||||
if (softHiddenSessions.size < keepCount) return null;
|
||||
let oldestId: string | null = null;
|
||||
let oldestTs = Number.POSITIVE_INFINITY;
|
||||
for (const [sessionId, ts] of softHiddenSessions) {
|
||||
if (ts < oldestTs) {
|
||||
oldestTs = ts;
|
||||
oldestId = sessionId;
|
||||
}
|
||||
}
|
||||
return oldestId;
|
||||
},
|
||||
|
||||
requestEviction: (sessionId: string) => {
|
||||
evictionRequestSessionId = sessionId;
|
||||
emit();
|
||||
},
|
||||
|
||||
consumeEvictionRequest: (sessionId: string): boolean => {
|
||||
if (evictionRequestSessionId !== sessionId) return false;
|
||||
evictionRequestSessionId = null;
|
||||
return true;
|
||||
},
|
||||
|
||||
subscribe: (listener: Listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export const TERMINAL_HOST_TREE_ANIMATION_MS = 220;
|
||||
export const TERMINAL_HOST_TREE_ANIMATION_EASING = 'cubic-bezier(0.4, 0, 0.2, 1)';
|
||||
export const TERMINAL_HOST_TREE_ANIMATION = `${TERMINAL_HOST_TREE_ANIMATION_MS}ms ${TERMINAL_HOST_TREE_ANIMATION_EASING}`;
|
||||
export const TERMINAL_HOST_TREE_LEFT_TRANSITION = `left ${TERMINAL_HOST_TREE_ANIMATION}`;
|
||||
export const TERMINAL_HOST_TREE_WIDTH_TRANSITION = `width ${TERMINAL_HOST_TREE_ANIMATION}`;
|
||||
Loaded 100 of 903 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user