Compare commits
126 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
031bf0ee45 | ||
|
|
0efe80b06d | ||
|
|
3fb7c6dd21 | ||
|
|
c7e4ac82ca | ||
|
|
d5e29598d3 | ||
|
|
fca7782634 | ||
|
|
42b23a9faa | ||
|
|
06011d01d6 | ||
|
|
4bf4e65df8 | ||
|
|
45e62ed43e | ||
|
|
368c31e48d | ||
|
|
0fa926de26 | ||
|
|
b9b7db2a4e | ||
|
|
e3f68e1a3f | ||
|
|
3c4746aea0 | ||
|
|
463dd4464f | ||
|
|
63b95bb68e | ||
|
|
ea41389842 | ||
|
|
429cb8d6e9 | ||
|
|
55faae767a | ||
|
|
94b8f298ae | ||
|
|
1ef3f9f979 | ||
|
|
e88313eb84 | ||
|
|
03cd9bc968 | ||
|
|
4d7c56e537 | ||
|
|
4769668ff9 | ||
|
|
8ca36a695b | ||
|
|
053a976d37 | ||
|
|
40fb5b62a9 | ||
|
|
1fec5925eb | ||
|
|
23d4b342b9 | ||
|
|
2c716cd74c | ||
|
|
6c23514d84 | ||
|
|
456ddcfe68 | ||
|
|
2a283a4f83 | ||
|
|
b29533259b | ||
|
|
0f8aa08994 | ||
|
|
fb522c5016 | ||
|
|
7272f2564d | ||
|
|
07a2f3a899 | ||
|
|
399e6a6f2d | ||
|
|
46d1cf1696 | ||
|
|
5be9bb58df | ||
|
|
cab4fc36ab | ||
|
|
53d3e05bb4 | ||
|
|
0c4de74c84 | ||
|
|
2a4feea40f | ||
|
|
faa90e1aa5 | ||
|
|
1aa96c3490 | ||
|
|
0e80955e96 | ||
|
|
7771592cf2 | ||
|
|
6e9e8fc40d | ||
|
|
67448cea65 | ||
|
|
770b06a9ee | ||
|
|
1d50b2c4a1 | ||
|
|
453202df8f | ||
|
|
a78c052d86 | ||
|
|
e6b0a551e8 | ||
|
|
38775245d2 | ||
|
|
fcb699ffb9 | ||
|
|
e889d8fc20 | ||
|
|
bf1c95500a | ||
|
|
f9d00c9d23 | ||
|
|
8fd7ff6475 | ||
|
|
02c80ae7d2 | ||
|
|
e5d3d02b17 | ||
|
|
78186d8d46 | ||
|
|
c899653621 | ||
|
|
a91fbcdd68 | ||
|
|
74b315e285 | ||
|
|
60eeafe7a9 | ||
|
|
ee2c21e712 | ||
|
|
e678ad3546 | ||
|
|
c47c780b48 | ||
|
|
88074ac9b3 | ||
|
|
59cb0c4b65 | ||
|
|
bf0bd193eb | ||
|
|
7661375925 | ||
|
|
308fb45985 | ||
|
|
f4aa6ddb46 | ||
|
|
f6cb73fdd6 | ||
|
|
3c100b0ae2 | ||
|
|
168e42b5fa | ||
|
|
2ce6bd5ed1 | ||
|
|
7bd5d6465a | ||
|
|
65387d4c61 | ||
|
|
6084e8e94f | ||
|
|
3ccc5c9fc6 | ||
|
|
d07859f604 | ||
|
|
88a322a03b | ||
|
|
0e02bbc2fb | ||
|
|
affd9217e2 | ||
|
|
7b4a349e3f | ||
|
|
7dc5ab5035 | ||
|
|
3e8965f9a9 | ||
|
|
23a27bf544 | ||
|
|
86a815ad46 | ||
|
|
cb4fb091aa | ||
|
|
b30696c98b | ||
|
|
6b8f05c65a | ||
|
|
64dd3a4a2f | ||
|
|
88732040aa | ||
|
|
b9f3bfa8bb | ||
|
|
b7ec3c12f7 | ||
|
|
d20a18b862 | ||
|
|
ff6b4a4625 | ||
|
|
5a94b4cf39 | ||
|
|
3963cd4af9 | ||
|
|
5b2a048917 | ||
|
|
2414cb00e4 | ||
|
|
03f980e939 | ||
|
|
ac819fd4fd | ||
|
|
fb9400a5fb | ||
|
|
7da983a56c | ||
|
|
344b226ce8 | ||
|
|
86e47b5f9e | ||
|
|
37012da26a | ||
|
|
0fd6a8c31d | ||
|
|
10af904681 | ||
|
|
b02b83f225 | ||
|
|
bca5d63a4e | ||
|
|
67c5571df5 | ||
|
|
ea5320d94a | ||
|
|
ffd3111b71 | ||
|
|
b0949f1a1e | ||
|
|
84416d04bf |
89
.github/scripts/bump-homebrew-cask.sh
vendored
Executable file
89
.github/scripts/bump-homebrew-cask.sh
vendored
Executable file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# bump-homebrew-cask.sh — push a new version of the Netcatty cask to the
|
||||
# binaricat/homebrew-netcatty tap.
|
||||
#
|
||||
# Called from the release pipeline (`build.yml` → `homebrew-tap` job) after
|
||||
# the GitHub Release has been published with the signed + notarized DMGs.
|
||||
# Computes SHA-256 of the arm64 and x64 DMGs, rewrites the cask file, and
|
||||
# pushes the bump back to the tap repository using HOMEBREW_TAP_TOKEN.
|
||||
#
|
||||
# Required env vars:
|
||||
# VERSION — semver without leading "v" (e.g. 1.1.6)
|
||||
# HOMEBREW_TAP_TOKEN — PAT with contents:write on the tap repo
|
||||
#
|
||||
# Optional env vars:
|
||||
# TAP_REPO — default: binaricat/homebrew-netcatty
|
||||
# ARTIFACTS_DIR — default: artifacts
|
||||
# CASK_PATH — default: Casks/netcatty.rb
|
||||
set -euo pipefail
|
||||
|
||||
: "${VERSION:?VERSION env var required (no leading v)}"
|
||||
: "${HOMEBREW_TAP_TOKEN:?HOMEBREW_TAP_TOKEN env var required}"
|
||||
|
||||
TAP_REPO="${TAP_REPO:-binaricat/homebrew-netcatty}"
|
||||
ARTIFACTS_DIR="${ARTIFACTS_DIR:-artifacts}"
|
||||
CASK_PATH="${CASK_PATH:-Casks/netcatty.rb}"
|
||||
|
||||
ARM_DMG="${ARTIFACTS_DIR}/Netcatty-${VERSION}-mac-arm64.dmg"
|
||||
X64_DMG="${ARTIFACTS_DIR}/Netcatty-${VERSION}-mac-x64.dmg"
|
||||
|
||||
for f in "$ARM_DMG" "$X64_DMG"; do
|
||||
if [[ ! -f "$f" ]]; then
|
||||
echo "::error::Required DMG artifact not found: $f"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
ARM_SHA=$(shasum -a 256 "$ARM_DMG" | awk '{print $1}')
|
||||
X64_SHA=$(shasum -a 256 "$X64_DMG" | awk '{print $1}')
|
||||
|
||||
echo "Computed checksums:"
|
||||
echo " arm64: ${ARM_SHA}"
|
||||
echo " x64 : ${X64_SHA}"
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
git clone --depth 1 \
|
||||
"https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/${TAP_REPO}.git" \
|
||||
"$TMP/tap"
|
||||
cd "$TMP/tap"
|
||||
|
||||
if [[ ! -f "$CASK_PATH" ]]; then
|
||||
echo "::error::Cask file not found in tap: $CASK_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Patch the cask in place. The three lines we touch are anchored well enough
|
||||
# that we don't need anything fancier than sed:
|
||||
# - the `version "X.Y.Z"` line (single line, anchored to start)
|
||||
# - the `sha256 arm: "..."` line
|
||||
# - the ` intel: "..."` line (anchor on "intel:" at start, after the
|
||||
# leading whitespace, so we don't accidentally match the `arch arm:
|
||||
# "...", intel: "..."` line earlier in the file)
|
||||
sed -i -E 's|^(\s*version)\s+"[^"]+"|\1 "'"$VERSION"'"|' "$CASK_PATH"
|
||||
sed -i -E 's|(sha256\s+arm:\s+)"[^"]+"|\1"'"$ARM_SHA"'"|' "$CASK_PATH"
|
||||
sed -i -E 's|^(\s*intel:\s+)"[^"]+"|\1"'"$X64_SHA"'"|' "$CASK_PATH"
|
||||
|
||||
# Sanity-check: parsed file should still be valid Ruby. Catches a broken
|
||||
# substitution before we push.
|
||||
if command -v ruby >/dev/null 2>&1; then
|
||||
ruby -c "$CASK_PATH" >/dev/null
|
||||
fi
|
||||
|
||||
if git diff --quiet; then
|
||||
echo "Cask already at ${VERSION} with matching checksums — nothing to push."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Cask diff:"
|
||||
git --no-pager diff "$CASK_PATH"
|
||||
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config user.name "github-actions[bot]"
|
||||
git add "$CASK_PATH"
|
||||
git commit -m "Bump netcatty to ${VERSION}"
|
||||
git push origin HEAD:main
|
||||
|
||||
echo "Pushed bump for ${VERSION} to ${TAP_REPO}."
|
||||
30
.github/workflows/build.yml
vendored
30
.github/workflows/build.yml
vendored
@@ -604,3 +604,33 @@ jobs:
|
||||
generate_release_notes: true
|
||||
fail_on_unmatched_files: false
|
||||
token: ${{ secrets.RELEASE_TOKEN }}
|
||||
|
||||
homebrew-tap:
|
||||
name: bump homebrew tap
|
||||
runs-on: ubuntu-latest
|
||||
needs: release
|
||||
# Only stable release tags update the Cask. Prerelease tags
|
||||
# (e.g. v1.2.0-rc.1) are skipped so brew users stay on stable.
|
||||
if: |
|
||||
startsWith(github.ref, 'refs/tags/v')
|
||||
&& !contains(github.ref_name, '-')
|
||||
&& (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release))
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download macOS artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: netcatty-macos
|
||||
path: artifacts/
|
||||
|
||||
- name: Bump Cask in binaricat/homebrew-netcatty
|
||||
env:
|
||||
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
ARTIFACTS_DIR: artifacts
|
||||
run: |
|
||||
# Strip the leading "v" — Cask version is plain semver.
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
export VERSION
|
||||
bash .github/scripts/bump-homebrew-cask.sh
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -40,7 +40,6 @@ coverage
|
||||
|
||||
# Codex
|
||||
/.codex/
|
||||
/CLAUDE.md
|
||||
|
||||
# AI / Superpowers generated docs (local only)
|
||||
/docs/superpowers/
|
||||
|
||||
62
CLAUDE.md
Normal file
62
CLAUDE.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start dev server (runs lint first, then Vite + Electron concurrently)
|
||||
npm run dev
|
||||
|
||||
# Lint
|
||||
npm run lint
|
||||
npm run lint:fix
|
||||
|
||||
# Run all tests
|
||||
npm test
|
||||
|
||||
# Run a single test file
|
||||
node --test --import tsx path/to/file.test.ts
|
||||
|
||||
# Build renderer
|
||||
npm run build
|
||||
|
||||
# Package for current platform
|
||||
npm run pack
|
||||
|
||||
# Package for specific platforms
|
||||
npm run pack:mac
|
||||
npm run pack:win
|
||||
npm run pack:linux
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Netcatty is an Electron + React desktop app (SSH manager, terminal, SFTP browser). It has two runtimes:
|
||||
|
||||
### Electron Main Process (`electron/`)
|
||||
- **`main.cjs`** — entry point; wires crash logging, process error guards, and delegates to `main/registerBridges.cjs`
|
||||
- **`bridges/`** — one `.cjs` file per capability domain (sshBridge, sftpBridge, terminalBridge, portForwardingBridge, aiBridge, etc.). Each bridge exposes IPC handlers via `ipcMain`. Tests live alongside the bridge file (`*.test.cjs`).
|
||||
- **`preload.cjs`** — exposes a typed `window.electron` API to the renderer via `contextBridge`. Uses `preload/api.cjs` for the generated API surface.
|
||||
- **`cli/`** — `netcatty-tool-cli.cjs` is a separate internal binary for tool/MCP integration; treat as internal surface only.
|
||||
|
||||
### Renderer Process (React + Vite)
|
||||
Three-layer architecture (see `AGENTS.md` for full detail):
|
||||
|
||||
- **`domain/`** — pure TypeScript logic, no side effects. Models (`models.ts`), host helpers, workspace tree operations.
|
||||
- **`application/state/`** — React hooks that own state and persistence boundaries. Key hooks: `useVaultState` (hosts/keys/snippets), `useSessionState` (terminal sessions/workspace), `useSettingsState` (theme/config).
|
||||
- **`infrastructure/`** — external edges: `persistence/localStorageAdapter.ts` for storage, `services/` for network calls (Gemini AI, GitHub Gist sync), `config/` for defaults, storage keys, and terminal themes.
|
||||
- **`components/`** — presentation only. `App.tsx` wires hooks to components; no business logic in components.
|
||||
|
||||
### IPC Pattern
|
||||
UI calls `window.electron.*` (preload API) → IPC → bridge handler in main process. Never call `ipcRenderer` directly from components.
|
||||
|
||||
### Key Conventions
|
||||
- All storage reads/writes go through `localStorageAdapter`; storage keys are in `infrastructure/config/storageKeys.ts`.
|
||||
- Temporary files must use `tempDirBridge.getTempFilePath(fileName)` — never `os.tmpdir()` directly.
|
||||
- Aside panels (VaultView subpages) use the shared design system in `components/ui/aside-panel.tsx` — see `AGENTS.md` for usage patterns.
|
||||
- Renderer code is TypeScript/ESM; Electron main/bridges are CommonJS (`.cjs`).
|
||||
- Path alias `@/` resolves to the repo root (configured in `vite.config.ts` and `tsconfig.json`).
|
||||
70
application/AppHandlers.globalHotkeys.test.ts
Normal file
70
application/AppHandlers.globalHotkeys.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { handleGlobalHotkeyKeyDownImpl } from './app/AppHandlers.ts';
|
||||
import { matchesKeyBinding } from '../domain/models.ts';
|
||||
import { DEFAULT_KEY_BINDINGS } from '../domain/models/keyBindings.ts';
|
||||
|
||||
class FakeHTMLElement {
|
||||
tagName = 'TEXTAREA';
|
||||
isContentEditable = false;
|
||||
classList = {
|
||||
contains: (className: string) => className === 'xterm-helper-textarea',
|
||||
};
|
||||
|
||||
closest(selector: string): FakeHTMLElement | null {
|
||||
return selector.includes('xterm') ? this : null;
|
||||
}
|
||||
|
||||
hasAttribute(name: string): boolean {
|
||||
return name === 'data-session-id';
|
||||
}
|
||||
}
|
||||
|
||||
const previousHTMLElement = globalThis.HTMLElement;
|
||||
globalThis.HTMLElement = FakeHTMLElement as unknown as typeof HTMLElement;
|
||||
|
||||
test.after(() => {
|
||||
globalThis.HTMLElement = previousHTMLElement;
|
||||
});
|
||||
|
||||
test('global hotkey handler lets terminal font size shortcuts reach xterm', () => {
|
||||
const target = new FakeHTMLElement();
|
||||
const handledActions: string[] = [];
|
||||
let prevented = false;
|
||||
let stopped = false;
|
||||
const event = {
|
||||
key: '=',
|
||||
code: 'Equal',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
target,
|
||||
composedPath: () => [target],
|
||||
preventDefault: () => {
|
||||
prevented = true;
|
||||
},
|
||||
stopPropagation: () => {
|
||||
stopped = true;
|
||||
},
|
||||
} 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, []);
|
||||
assert.equal(prevented, false);
|
||||
assert.equal(stopped, false);
|
||||
});
|
||||
831
application/app/AppHandlers.ts
Normal file
831
application/app/AppHandlers.ts
Normal file
@@ -0,0 +1,831 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type React from 'react';
|
||||
import type { Host, HostProtocol } from '../../types';
|
||||
import type { PassphraseRequest } from '../../components/PassphraseModal';
|
||||
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
|
||||
|
||||
type AppContextGetter = () => Record<string, any>;
|
||||
const TERMINAL_PASSTHROUGH_ACTIONS = getTerminalPassthroughActions();
|
||||
|
||||
export function handleTrayJumpToSessionImpl(getCtx: AppContextGetter, sessionId: string) {
|
||||
const { sessions, setActiveTabId, setWorkspaceFocusedSession } = getCtx();
|
||||
{
|
||||
const session = sessions.find((item) => item.id === sessionId);
|
||||
if (session?.workspaceId) {
|
||||
setActiveTabId(session.workspaceId);
|
||||
setWorkspaceFocusedSession(session.workspaceId, sessionId);
|
||||
return;
|
||||
}
|
||||
setActiveTabId(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
export function handleTrayTogglePortForwardImpl(getCtx: AppContextGetter, ruleId: string, start: boolean) {
|
||||
const { hosts, identities, keys, portForwardingRules, resolveEffectiveHost, startTunnel, stopTunnel, t, terminalSettings, toast } = getCtx();
|
||||
{
|
||||
const rule = portForwardingRules.find((item) => item.id === ruleId);
|
||||
if (!rule) return;
|
||||
const host = rule.hostId ? hosts.find((item) => item.id === rule.hostId) : undefined;
|
||||
if (!host) {
|
||||
toast.error(t("pf.error.hostNotFound"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (start) {
|
||||
const effectiveHost = resolveEffectiveHost(host);
|
||||
void startTunnel(rule, effectiveHost, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
|
||||
if (status === "error" && error) toast.error(error);
|
||||
}, rule.autoStart, terminalSettings);
|
||||
return;
|
||||
}
|
||||
|
||||
void stopTunnel(ruleId);
|
||||
}
|
||||
}
|
||||
|
||||
export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: string) {
|
||||
const { addConnectionLog, connectToHost, hosts, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef, t, toast } = getCtx();
|
||||
{
|
||||
const host = hosts.find((item) => item.id === hostId);
|
||||
if (!host) {
|
||||
toast.error(t("pf.error.hostNotFound"));
|
||||
return;
|
||||
}
|
||||
|
||||
const effectiveHost = resolveEffectiveHost(host);
|
||||
|
||||
const { username, hostname: localHost } = systemInfoRef.current;
|
||||
if (effectiveHost.protocol === 'serial') {
|
||||
const portName = host.hostname.split('/').pop() || host.hostname;
|
||||
const sessionId = connectToHost(effectiveHost);
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
hostLabel: host.label || `Serial: ${portName}`,
|
||||
hostname: host.hostname,
|
||||
username,
|
||||
protocol: 'serial',
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: localHost,
|
||||
saved: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = effectiveHost.moshEnabled ? 'mosh' : (effectiveHost.protocol || 'ssh');
|
||||
const resolvedAuth = resolveHostAuth({ host: effectiveHost, keys, identities });
|
||||
const sessionId = connectToHost(effectiveHost);
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
username: resolvedAuth.username || 'root',
|
||||
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh',
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: localHost,
|
||||
saved: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: KeyboardEvent) {
|
||||
const { HOTKEY_DEBUG, closeTabKeyStr, executeHotkeyAction, hotkeyScheme, keyBindings, matchesKeyBinding } = getCtx();
|
||||
{
|
||||
const isMac = hotkeyScheme === 'mac';
|
||||
const target = e.target as HTMLElement;
|
||||
const isCloseTabHotkey = closeTabKeyStr ? matchesKeyBinding(e, closeTabKeyStr, isMac) : false;
|
||||
const dialogHotkeyScope = target.closest?.('[data-hotkey-close-tab="true"]');
|
||||
|
||||
if (isCloseTabHotkey && dialogHotkeyScope) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCloseTabHotkey) {
|
||||
const openDialogs = Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"][data-state="open"]'));
|
||||
const topmostOpenDialog = openDialogs[openDialogs.length - 1] ?? null;
|
||||
const topmostDialogClose = topmostOpenDialog?.querySelector<HTMLElement>('[data-dialog-close="true"]');
|
||||
if (topmostDialogClose) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
topmostDialogClose.click();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
|
||||
const isMonacoElement =
|
||||
target instanceof HTMLElement &&
|
||||
!!target.closest?.('.monaco-editor, .monaco-diff-editor, .monaco-inputbox');
|
||||
const isXtermInput =
|
||||
target instanceof HTMLElement &&
|
||||
!!target.closest?.(".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport");
|
||||
|
||||
if ((isFormElement || isMonacoElement) && !isXtermInput && e.key !== 'Escape') {
|
||||
return;
|
||||
}
|
||||
|
||||
const isTerminalElement =
|
||||
target instanceof HTMLElement &&
|
||||
!!target.closest?.(".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport");
|
||||
const isTerminalInPath = Boolean(
|
||||
e.composedPath?.().some(
|
||||
(node) =>
|
||||
node instanceof HTMLElement &&
|
||||
(node.classList.contains("xterm") ||
|
||||
node.classList.contains("xterm-helper-textarea") ||
|
||||
node.classList.contains("xterm-screen") ||
|
||||
node.classList.contains("xterm-viewport") ||
|
||||
node.hasAttribute("data-session-id")),
|
||||
),
|
||||
);
|
||||
|
||||
for (const binding of keyBindings) {
|
||||
const keyStr = isMac ? binding.mac : binding.pc;
|
||||
if (!matchesKeyBinding(e, keyStr, isMac)) continue;
|
||||
if (HOTKEY_DEBUG) console.log('[Hotkeys] Matched binding:', binding.action, keyStr);
|
||||
if (binding.category === 'sftp') {
|
||||
continue;
|
||||
}
|
||||
if (TERMINAL_PASSTHROUGH_ACTIONS.has(binding.action)) {
|
||||
if (isTerminalElement) {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (HOTKEY_DEBUG) {
|
||||
console.log('[Hotkeys] Global handle', {
|
||||
action: binding.action,
|
||||
key: e.key,
|
||||
meta: e.metaKey,
|
||||
ctrl: e.ctrlKey,
|
||||
alt: e.altKey,
|
||||
shift: e.shiftKey,
|
||||
targetTag: target?.tagName,
|
||||
isTerminalElement,
|
||||
isTerminalInPath,
|
||||
});
|
||||
}
|
||||
executeHotkeyAction(binding.action, e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function handleEscapeKeyDownImpl(getCtx: AppContextGetter, e: KeyboardEvent) {
|
||||
const { isQuickSwitcherOpen, setIsQuickSwitcherOpen } = getCtx();
|
||||
{
|
||||
if (e.key === 'Escape' && isQuickSwitcherOpen) {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function handleKeyboardInteractiveSubmitImpl(getCtx: AppContextGetter, requestId: string, responses: string[], savePassword?: string) {
|
||||
const { hosts, keyboardInteractiveQueue, netcattyBridge, sessions, setKeyboardInteractiveQueue, updateHosts } = getCtx();
|
||||
{
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.respondKeyboardInteractive) {
|
||||
void bridge.respondKeyboardInteractive(requestId, responses, false);
|
||||
}
|
||||
// Save password to host if requested
|
||||
if (savePassword) {
|
||||
const request = keyboardInteractiveQueue.find(r => r.requestId === requestId);
|
||||
if (request?.sessionId) {
|
||||
const session = sessions.find(s => s.id === request.sessionId);
|
||||
// Only save when the prompting hostname matches the session's host,
|
||||
// to avoid overwriting the destination host's password with a jump host's password
|
||||
if (session?.hostId && (!request.hostname || request.hostname === session.hostname)) {
|
||||
const host = hosts.find(h => h.id === session.hostId);
|
||||
if (host) {
|
||||
updateHosts(hosts.map(h => h.id === host.id ? { ...h, password: savePassword } : h));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Remove from queue by requestId
|
||||
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
}
|
||||
}
|
||||
|
||||
export function handleKeyboardInteractiveCancelImpl(getCtx: AppContextGetter, requestId: string) {
|
||||
const { netcattyBridge, setKeyboardInteractiveQueue } = getCtx();
|
||||
{
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.respondKeyboardInteractive) {
|
||||
void bridge.respondKeyboardInteractive(requestId, [], true);
|
||||
}
|
||||
// Remove from queue by requestId
|
||||
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
}
|
||||
}
|
||||
|
||||
export async function handlePassphraseSubmitImpl(getCtx: AppContextGetter, requestId: string, passphrase: string, remember: boolean) {
|
||||
const { keysRef, netcattyBridge, passphraseQueue, rememberKeyPassphrase, setPassphraseQueue, updateKeys } = getCtx();
|
||||
{
|
||||
const bridge = netcattyBridge.get();
|
||||
const request = passphraseQueue.find((r: PassphraseRequest) => r.requestId === requestId);
|
||||
|
||||
// Save passphrase if requested
|
||||
if (remember && request?.keyPath) {
|
||||
console.log('[App] Saving passphrase for:', request.keyPath);
|
||||
try {
|
||||
await rememberKeyPassphrase({
|
||||
keyPath: request.keyPath,
|
||||
passphrase,
|
||||
keys: keysRef.current,
|
||||
updateKeys,
|
||||
setCurrentKeys: (updated) => {
|
||||
keysRef.current = updated;
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[App] Failed to save passphrase:', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (bridge?.respondPassphrase) {
|
||||
void bridge.respondPassphrase(requestId, passphrase, false);
|
||||
}
|
||||
|
||||
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
}
|
||||
}
|
||||
|
||||
export function handlePassphraseCancelImpl(getCtx: AppContextGetter, requestId: string) {
|
||||
const { netcattyBridge, setPassphraseQueue } = getCtx();
|
||||
{
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.respondPassphrase) {
|
||||
// Cancel = stop the entire passphrase flow
|
||||
void bridge.respondPassphrase(requestId, '', true);
|
||||
}
|
||||
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
}
|
||||
}
|
||||
|
||||
export function handlePassphraseSkipImpl(getCtx: AppContextGetter, requestId: string) {
|
||||
const { netcattyBridge, setPassphraseQueue } = getCtx();
|
||||
{
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.respondPassphraseSkip) {
|
||||
// Skip = skip this key but continue asking for others
|
||||
void bridge.respondPassphraseSkip(requestId);
|
||||
} else if (bridge?.respondPassphrase) {
|
||||
// Fallback for older API
|
||||
void bridge.respondPassphrase(requestId, '', false);
|
||||
}
|
||||
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
}
|
||||
}
|
||||
|
||||
export function createLocalTerminalWithCurrentShellImpl(getCtx: AppContextGetter) {
|
||||
const { classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, terminalSettings } = getCtx();
|
||||
{
|
||||
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
|
||||
const matchedShell = discoveredShells.find(s => s.id === terminalSettings.localShell);
|
||||
return createLocalTerminal({
|
||||
shellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
|
||||
shell: resolved?.command,
|
||||
shellArgs: resolved?.args,
|
||||
shellName: matchedShell?.name,
|
||||
shellIcon: matchedShell?.icon,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function splitSessionWithCurrentShellImpl(getCtx: AppContextGetter, sessionId: string, direction: 'horizontal' | 'vertical') {
|
||||
const { classifyLocalShellType, discoveredShells, resolveShellSetting, splitSession, terminalSettings } = getCtx();
|
||||
{
|
||||
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
|
||||
return splitSession(sessionId, direction, {
|
||||
localShellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function copySessionWithCurrentShellImpl(getCtx: AppContextGetter, sessionId: string) {
|
||||
const { classifyLocalShellType, copySession, discoveredShells, resolveShellSetting, terminalSettings } = getCtx();
|
||||
{
|
||||
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
|
||||
return copySession(sessionId, {
|
||||
localShellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function confirmIfBusyLocalTerminalImpl(getCtx: AppContextGetter, sessionIds: string[]) {
|
||||
const { netcattyBridge, sessions, t } = getCtx();
|
||||
{
|
||||
const bridge = netcattyBridge.get();
|
||||
const localIds = sessionIds.filter((id) => {
|
||||
const s = sessions.find((x) => x.id === id);
|
||||
return s?.protocol === 'local';
|
||||
});
|
||||
const busyCommands: string[] = [];
|
||||
for (const id of localIds) {
|
||||
const children = (await bridge?.ptyGetChildProcesses?.(id)) ?? [];
|
||||
if (children.length > 0) {
|
||||
busyCommands.push(children[0].command);
|
||||
}
|
||||
}
|
||||
if (busyCommands.length === 0) return true;
|
||||
|
||||
const primary = busyCommands[0];
|
||||
const extraCount = busyCommands.length - 1;
|
||||
const message =
|
||||
extraCount > 0
|
||||
? t('confirm.closeBusyTerminal.messageWithMore', {
|
||||
command: primary,
|
||||
count: extraCount,
|
||||
})
|
||||
: t('confirm.closeBusyTerminal.message', { command: primary });
|
||||
|
||||
const ok = await bridge?.confirmCloseBusy?.({
|
||||
command: primary,
|
||||
title: t('confirm.closeBusyTerminal.title'),
|
||||
message,
|
||||
cancelLabel: t('confirm.closeBusyTerminal.cancel'),
|
||||
closeLabel: t('confirm.closeBusyTerminal.close'),
|
||||
});
|
||||
return ok === true;
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: string[]) {
|
||||
const { closeLogView, closeSession, closeTabsInFlightRef, closeWorkspace, confirmIfBusyLocalTerminal, logViews, sessions, workspaces } = getCtx();
|
||||
{
|
||||
if (targetIds.length === 0) return;
|
||||
if (closeTabsInFlightRef.current) return;
|
||||
|
||||
// Expand workspace ids into their constituent session ids so the busy
|
||||
// probe sees every local shell that's about to be killed.
|
||||
const sessionIdsToProbe: string[] = [];
|
||||
for (const tabId of targetIds) {
|
||||
const ws = workspaces.find((w) => w.id === tabId);
|
||||
if (ws) {
|
||||
for (const s of sessions) {
|
||||
if (s.workspaceId === tabId) sessionIdsToProbe.push(s.id);
|
||||
}
|
||||
} else if (sessions.find((s) => s.id === tabId)) {
|
||||
sessionIdsToProbe.push(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
closeTabsInFlightRef.current = true;
|
||||
try {
|
||||
const ok = await confirmIfBusyLocalTerminal(sessionIdsToProbe);
|
||||
if (!ok) return;
|
||||
for (const tabId of targetIds) {
|
||||
if (workspaces.find((w) => w.id === tabId)) {
|
||||
closeWorkspace(tabId);
|
||||
} else if (sessions.find((s) => s.id === tabId)) {
|
||||
closeSession(tabId);
|
||||
} else if (logViews.find((lv) => lv.id === tabId)) {
|
||||
closeLogView(tabId);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
closeTabsInFlightRef.current = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
{
|
||||
// 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
|
||||
// doesn't land on a hidden tab (which would get redirected back) and so
|
||||
// number shortcuts don't shift.
|
||||
const allTabs = settings.showSftpTab
|
||||
? ['vault', 'sftp', ...orderedTabs, ...editorTabs.map((t) => toEditorTabId(t.id))]
|
||||
: ['vault', ...orderedTabs, ...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]);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'nextTab': {
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
const currentIdx = allTabs.indexOf(currentId);
|
||||
if (currentIdx !== -1 && allTabs.length > 0) {
|
||||
const nextIdx = (currentIdx + 1) % allTabs.length;
|
||||
setActiveTabId(allTabs[nextIdx]);
|
||||
} else if (allTabs.length > 0) {
|
||||
setActiveTabId(allTabs[0]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'prevTab': {
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
const currentIdx = allTabs.indexOf(currentId);
|
||||
if (currentIdx !== -1 && allTabs.length > 0) {
|
||||
const prevIdx = (currentIdx - 1 + allTabs.length) % allTabs.length;
|
||||
setActiveTabId(allTabs[prevIdx]);
|
||||
} else if (allTabs.length > 0) {
|
||||
setActiveTabId(allTabs[allTabs.length - 1]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'closeTab': {
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
if (!currentId || currentId === 'vault' || currentId === 'sftp') break;
|
||||
if (closeTabInFlightRef.current) break;
|
||||
|
||||
// Editor tabs route through their own dirty-confirm close flow.
|
||||
if (isEditorTabId(currentId)) {
|
||||
const editorId = fromEditorTabId(currentId);
|
||||
if (editorId) handleRequestCloseEditorTabRef.current(editorId);
|
||||
break;
|
||||
}
|
||||
|
||||
const session = sessions.find((s) => s.id === currentId) ?? null;
|
||||
const workspace = workspaces.find((w) => w.id === currentId) ?? null;
|
||||
|
||||
const focusIsInsideTerminal = !!document.activeElement?.closest('[data-session-id]');
|
||||
|
||||
const intent = resolveCloseIntent({
|
||||
activeTabId: currentId,
|
||||
workspace: workspace ? { id: workspace.id, focusedSessionId: workspace.focusedSessionId } : null,
|
||||
sessionForTab: session,
|
||||
focusIsInsideTerminal,
|
||||
});
|
||||
|
||||
closeTabInFlightRef.current = true;
|
||||
(async () => {
|
||||
try {
|
||||
switch (intent.kind) {
|
||||
case 'closeTerminal':
|
||||
case 'closeSingleTab': {
|
||||
const ok = await confirmIfBusyLocalTerminal([intent.sessionId]);
|
||||
if (ok) closeSession(intent.sessionId);
|
||||
return;
|
||||
}
|
||||
case 'closeWorkspace': {
|
||||
const ids = sessions.filter((s) => s.workspaceId === intent.workspaceId).map((s) => s.id);
|
||||
const ok = await confirmIfBusyLocalTerminal(ids);
|
||||
if (ok) closeWorkspace(intent.workspaceId);
|
||||
return;
|
||||
}
|
||||
case 'noop':
|
||||
default:
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
closeTabInFlightRef.current = false;
|
||||
}
|
||||
})();
|
||||
|
||||
break;
|
||||
}
|
||||
case 'newTab':
|
||||
case 'openLocal':
|
||||
// Add connection log for local terminal
|
||||
addConnectionLogRef.current({
|
||||
hostId: '',
|
||||
hostLabel: 'Local Terminal',
|
||||
hostname: 'localhost',
|
||||
username: systemInfoRef.current.username,
|
||||
protocol: 'local',
|
||||
startTime: Date.now(),
|
||||
localUsername: systemInfoRef.current.username,
|
||||
localHostname: systemInfoRef.current.hostname,
|
||||
saved: false,
|
||||
});
|
||||
createLocalTerminalWithCurrentShell();
|
||||
break;
|
||||
case 'openHosts':
|
||||
setActiveTabId('vault');
|
||||
break;
|
||||
case 'openSftp':
|
||||
if (settings.showSftpTab) {
|
||||
setActiveTabId('sftp');
|
||||
}
|
||||
break;
|
||||
case 'quickSwitch':
|
||||
case 'commandPalette':
|
||||
setIsQuickSwitcherOpen(true);
|
||||
break;
|
||||
case 'newWorkspace':
|
||||
// Dedicated shortcut to launch the AddToWorkspaceDialog in
|
||||
// create mode — same entry as QuickSwitcher's "New Workspace"
|
||||
// button, but without having to open QS first.
|
||||
setAddToWorkspaceDialog({ mode: 'create' });
|
||||
break;
|
||||
case 'portForwarding':
|
||||
// Navigate to vault and open port forwarding section
|
||||
setActiveTabId('vault');
|
||||
setNavigateToSection('port');
|
||||
break;
|
||||
case 'snippets':
|
||||
{
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
const intent = resolveSnippetsShortcutIntent({
|
||||
activeTabId: currentId,
|
||||
sessionForTab: sessions.find((s) => s.id === currentId) ?? null,
|
||||
workspaceForTab: workspaces.find((w) => w.id === currentId) ?? null,
|
||||
terminalScriptsToggleAvailable: !!toggleScriptsSidePanelRef.current,
|
||||
});
|
||||
|
||||
if (intent.kind === 'toggleTerminalScripts') {
|
||||
toggleScriptsSidePanelRef.current();
|
||||
break;
|
||||
}
|
||||
|
||||
setActiveTabId('vault');
|
||||
setNavigateToSection('snippets');
|
||||
}
|
||||
break;
|
||||
case 'toggleSidePanel':
|
||||
toggleSidePanelRef.current?.();
|
||||
break;
|
||||
case 'broadcast': {
|
||||
// Toggle broadcast mode for the active workspace
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
const activeWs = workspaces.find(w => w.id === currentId);
|
||||
if (activeWs) {
|
||||
toggleBroadcast(activeWs.id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'openSettings':
|
||||
handleOpenSettingsRef.current();
|
||||
break;
|
||||
case 'splitHorizontal': {
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
const activeSession = sessions.find(s => s.id === currentId);
|
||||
const activeWs = workspaces.find(w => w.id === currentId);
|
||||
if (activeSession && !activeSession.workspaceId) {
|
||||
splitSessionWithCurrentShell(activeSession.id, 'horizontal');
|
||||
} else if (activeWs) {
|
||||
const liveIds = collectSessionIds(activeWs.root);
|
||||
const targetId = (activeWs.focusedSessionId && liveIds.includes(activeWs.focusedSessionId))
|
||||
? activeWs.focusedSessionId
|
||||
: liveIds[0];
|
||||
if (targetId) splitSessionWithCurrentShell(targetId, 'horizontal');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'splitVertical': {
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
const activeSession = sessions.find(s => s.id === currentId);
|
||||
const activeWs = workspaces.find(w => w.id === currentId);
|
||||
if (activeSession && !activeSession.workspaceId) {
|
||||
splitSessionWithCurrentShell(activeSession.id, 'vertical');
|
||||
} else if (activeWs) {
|
||||
const liveIds = collectSessionIds(activeWs.root);
|
||||
const targetId = (activeWs.focusedSessionId && liveIds.includes(activeWs.focusedSessionId))
|
||||
? activeWs.focusedSessionId
|
||||
: liveIds[0];
|
||||
if (targetId) splitSessionWithCurrentShell(targetId, 'vertical');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'moveFocus': {
|
||||
// Debounce to prevent double-triggering when focus switches between terminals
|
||||
const now = Date.now();
|
||||
if (now - lastMoveFocusTimeRef.current < MOVE_FOCUS_DEBOUNCE_MS) {
|
||||
if (IS_DEV) console.log('[App] moveFocus debounced, ignoring');
|
||||
break;
|
||||
}
|
||||
lastMoveFocusTimeRef.current = now;
|
||||
|
||||
// Move focus between split panes
|
||||
if (IS_DEV) console.log('[App] moveFocus action triggered, key:', e.key);
|
||||
const direction = e.key === 'ArrowUp' ? 'up'
|
||||
: e.key === 'ArrowDown' ? 'down'
|
||||
: e.key === 'ArrowLeft' ? 'left'
|
||||
: e.key === 'ArrowRight' ? 'right'
|
||||
: null;
|
||||
if (IS_DEV) console.log('[App] moveFocus direction:', direction);
|
||||
if (direction) {
|
||||
// Find the active workspace
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
if (IS_DEV) console.log('[App] Active tab ID:', currentId);
|
||||
const activeWs = workspaces.find(w => w.id === currentId);
|
||||
if (IS_DEV) console.log('[App] Active workspace:', activeWs?.id, activeWs?.title);
|
||||
if (activeWs) {
|
||||
const result = moveFocusInWorkspace(activeWs.id, direction as 'up' | 'down' | 'left' | 'right');
|
||||
if (IS_DEV) console.log('[App] moveFocusInWorkspace result:', result);
|
||||
} else {
|
||||
if (IS_DEV) console.log('[App] No active workspace found');
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function handleCreateLocalTerminalImpl(getCtx: AppContextGetter, shell?: { command: string; args?: string[]; name?: string; icon?: string }) {
|
||||
const { addConnectionLog, classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, systemInfoRef, terminalSettings } = getCtx();
|
||||
{
|
||||
const { username, hostname } = systemInfoRef.current;
|
||||
const resolved = shell ?? resolveShellSetting(terminalSettings.localShell, discoveredShells);
|
||||
// Match by ID (not command) to avoid WSL distros all sharing wsl.exe
|
||||
const matchedShell = !shell ? discoveredShells.find(s => s.id === terminalSettings.localShell) : undefined;
|
||||
const shellName = shell?.name ?? matchedShell?.name;
|
||||
const shellIcon = shell?.icon ?? matchedShell?.icon;
|
||||
const sessionId = createLocalTerminal({
|
||||
shellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
|
||||
shell: resolved?.command,
|
||||
shellArgs: resolved?.args,
|
||||
shellName,
|
||||
shellIcon,
|
||||
});
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: '',
|
||||
hostLabel: shellName || 'Local Terminal',
|
||||
hostname: 'localhost',
|
||||
username: username,
|
||||
protocol: 'local',
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: hostname,
|
||||
saved: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host) {
|
||||
const { addConnectionLog, connectToHost, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef } = getCtx();
|
||||
{
|
||||
const { username, hostname: localHost } = systemInfoRef.current;
|
||||
|
||||
const effectiveHost = resolveEffectiveHost(host);
|
||||
|
||||
// Handle serial hosts separately
|
||||
if (effectiveHost.protocol === 'serial') {
|
||||
const portName = host.hostname.split('/').pop() || host.hostname;
|
||||
const sessionId = connectToHost(effectiveHost);
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
hostLabel: host.label || `Serial: ${portName}`,
|
||||
hostname: host.hostname,
|
||||
username: username,
|
||||
protocol: 'serial',
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: localHost,
|
||||
saved: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = effectiveHost.moshEnabled ? 'mosh' : (effectiveHost.protocol || 'ssh');
|
||||
const resolvedAuth = resolveHostAuth({ host: effectiveHost, keys, identities });
|
||||
const sessionId = connectToHost(effectiveHost);
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
username: resolvedAuth.username || 'root',
|
||||
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh',
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: localHost,
|
||||
saved: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function handleTerminalDataCaptureImpl(getCtx: AppContextGetter, sessionId: string, data: string) {
|
||||
const { IS_DEV, connectionLogs, selectConnectionLogForTerminalDataCapture, sessions, updateConnectionLog } = getCtx();
|
||||
{
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Called', { sessionId, dataLength: data.length });
|
||||
const session = sessions.find(s => s.id === sessionId);
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Session', session);
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] All logs:', connectionLogs.map(l => ({ id: l.id, sessionId: l.sessionId, hostname: l.hostname, endTime: l.endTime, hasTerminalData: !!l.terminalData })));
|
||||
|
||||
const matchingLog = selectConnectionLogForTerminalDataCapture(
|
||||
connectionLogs,
|
||||
{ sessionId, hostname: session?.hostname },
|
||||
);
|
||||
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Matching log', matchingLog);
|
||||
|
||||
if (matchingLog) {
|
||||
updateConnectionLog(matchingLog.id, {
|
||||
endTime: Date.now(),
|
||||
terminalData: data,
|
||||
});
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Updated log with terminalData');
|
||||
|
||||
// Auto-save is now handled by real-time streaming in the main process
|
||||
// via sessionLogStreamManager. No renderer-side fallback needed.
|
||||
} else {
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] No matching log found!');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function hasMultipleProtocolsImpl(getCtx: AppContextGetter, host: Host) {
|
||||
const { resolveEffectiveHost } = getCtx();
|
||||
{
|
||||
// Gates the protocol picker (legacy name kept for its existing wiring).
|
||||
// Only prompt when Telnet is available but isn't the host's default protocol;
|
||||
// SSH-only, SSH+Mosh and Telnet-default all connect directly.
|
||||
const effective = resolveEffectiveHost(host);
|
||||
return Boolean(effective.telnetEnabled) && effective.protocol !== 'telnet';
|
||||
}
|
||||
}
|
||||
|
||||
export function handleHostConnectWithProtocolCheckImpl(getCtx: AppContextGetter, host: Host) {
|
||||
const { handleConnectToHost, hasMultipleProtocols, resolveEffectiveHost, setIsQuickSwitcherOpen, setProtocolSelectHost, setQuickSearch } = getCtx();
|
||||
{
|
||||
if (hasMultipleProtocols(host)) {
|
||||
setProtocolSelectHost(resolveEffectiveHost(host));
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
} else {
|
||||
handleConnectToHost(host);
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function handleProtocolSelectImpl(getCtx: AppContextGetter, protocol: HostProtocol, port: number) {
|
||||
const { handleConnectToHost, protocolSelectHost, setProtocolSelectHost } = getCtx();
|
||||
{
|
||||
if (protocolSelectHost) {
|
||||
const hostWithProtocol: Host = {
|
||||
...protocolSelectHost,
|
||||
protocol: protocol === 'mosh' ? 'ssh' : protocol,
|
||||
port,
|
||||
moshEnabled: protocol === 'mosh',
|
||||
};
|
||||
handleConnectToHost(hostWithProtocol);
|
||||
setProtocolSelectHost(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function handleToggleThemeImpl(getCtx: AppContextGetter) {
|
||||
const { openSettingsWindow, resolvedTheme, setTheme, t, theme, toast } = getCtx();
|
||||
{
|
||||
if (theme === 'system') {
|
||||
toast.info(
|
||||
t('topTabs.toggleTheme.systemExitMessage'),
|
||||
{
|
||||
title: t('topTabs.toggleTheme.systemExitTitle'),
|
||||
actionLabel: t('topTabs.toggleTheme.openSettings'),
|
||||
onClick: () => {
|
||||
void (async () => {
|
||||
const opened = await openSettingsWindow();
|
||||
if (!opened) toast.error(t('toast.settingsUnavailable'), t('common.settings'));
|
||||
})();
|
||||
},
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
|
||||
}
|
||||
}
|
||||
|
||||
export function handleRootContextMenuImpl(getCtx: AppContextGetter, e: React.MouseEvent<HTMLDivElement>) {
|
||||
void getCtx;
|
||||
{
|
||||
const editableSelector =
|
||||
"input, textarea, [contenteditable], .monaco-editor, .monaco-diff-editor, .monaco-inputbox, .monaco-menu-container";
|
||||
|
||||
const nativeEvent = e.nativeEvent;
|
||||
const path = typeof nativeEvent.composedPath === "function" ? nativeEvent.composedPath() : [];
|
||||
const allowFromPath = path.some(
|
||||
(node) => node instanceof Element && !!node.closest(editableSelector),
|
||||
);
|
||||
|
||||
const target = e.target;
|
||||
const targetElement =
|
||||
target instanceof Element
|
||||
? target
|
||||
: target instanceof Node
|
||||
? target.parentElement
|
||||
: null;
|
||||
const allowFromTarget = !!targetElement?.closest(editableSelector);
|
||||
|
||||
const allowNativeContextMenu = allowFromPath || allowFromTarget;
|
||||
|
||||
if (allowNativeContextMenu) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
119
application/app/AppMounts.tsx
Normal file
119
application/app/AppMounts.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import React, { Suspense, lazy, useEffect, useState } from 'react';
|
||||
import { useActiveTabId, useIsSftpActive, useIsTerminalLayerVisible, useIsVaultActive } from '../state/activeTabStore';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { ConnectionLog, TerminalTheme } from '../../types';
|
||||
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 }) => {
|
||||
const isActive = useIsVaultActive();
|
||||
const containerStyle: React.CSSProperties = isActive
|
||||
? {}
|
||||
: { visibility: 'hidden', pointerEvents: 'none', position: 'absolute', zIndex: -1 };
|
||||
|
||||
return (
|
||||
<div className={cn("absolute inset-0", isActive ? "z-20" : "")} style={containerStyle}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// LogView wrapper - manages visibility based on active tab
|
||||
interface LogViewWrapperProps {
|
||||
logView: LogViewType;
|
||||
defaultTerminalTheme: TerminalTheme;
|
||||
defaultFontSize: number;
|
||||
onClose: () => void;
|
||||
onUpdateLog: (logId: string, updates: Partial<ConnectionLog>) => void;
|
||||
}
|
||||
|
||||
export const LogViewWrapper: React.FC<LogViewWrapperProps> = ({ logView, defaultTerminalTheme, defaultFontSize, onClose, onUpdateLog }) => {
|
||||
const activeTabId = useActiveTabId();
|
||||
const isVisible = activeTabId === logView.id;
|
||||
|
||||
// Use same pattern as VaultViewContainer for visibility
|
||||
const containerStyle: React.CSSProperties = isVisible
|
||||
? {}
|
||||
: { visibility: 'hidden', pointerEvents: 'none', position: 'absolute', zIndex: -1 };
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
const LazyLogView = lazy(() => import('../../components/LogView'));
|
||||
|
||||
const LazySftpView = lazy(() =>
|
||||
import('../../components/SftpView').then((m) => ({ default: m.SftpView })),
|
||||
);
|
||||
|
||||
const LazyTerminalLayer = lazy(() =>
|
||||
import('../../components/TerminalLayer').then((m) => ({ default: m.TerminalLayer })),
|
||||
);
|
||||
|
||||
type SftpViewProps = React.ComponentProps<typeof SftpViewComponent>;
|
||||
type TerminalLayerProps = React.ComponentProps<typeof TerminalLayerComponent>;
|
||||
|
||||
export const SftpViewMount: React.FC<SftpViewProps> = (props) => {
|
||||
const isActive = useIsSftpActive();
|
||||
const [shouldMount, setShouldMount] = useState(isActive);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) setShouldMount(true);
|
||||
}, [isActive]);
|
||||
|
||||
if (!shouldMount) return null;
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LazySftpView {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export const TerminalLayerMount: React.FC<TerminalLayerProps> = (props) => {
|
||||
const isVisible = useIsTerminalLayerVisible(props.draggingSessionId);
|
||||
const [shouldMount, setShouldMount] = useState(isVisible);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible) setShouldMount(true);
|
||||
}, [isVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldMount) return;
|
||||
type IdleWindow = Window & {
|
||||
requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number;
|
||||
cancelIdleCallback?: (id: number) => void;
|
||||
};
|
||||
const idleWindow = window as IdleWindow;
|
||||
if (typeof idleWindow.requestIdleCallback === "function") {
|
||||
const id = idleWindow.requestIdleCallback(() => setShouldMount(true), { timeout: 5000 });
|
||||
return () => idleWindow.cancelIdleCallback?.(id);
|
||||
}
|
||||
const id = window.setTimeout(() => setShouldMount(true), 5000);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [shouldMount]);
|
||||
|
||||
const shouldRender = shouldMount || isVisible;
|
||||
|
||||
if (!shouldRender) return null;
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LazyTerminalLayer {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
556
application/app/AppView.tsx
Normal file
556
application/app/AppView.tsx
Normal file
@@ -0,0 +1,556 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React, { Suspense, lazy } from 'react';
|
||||
import { AlertTriangle, Download, Trash2 } from 'lucide-react';
|
||||
import { activeTabStore, toEditorTabId } from '../state/activeTabStore';
|
||||
import { editorTabStore } from '../state/editorTabStore';
|
||||
import { releaseEditorTabSaveCoordinator, saveEditorTab } from '../state/editorTabSave';
|
||||
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 { toast } from '../../components/ui/toast';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const LazyProtocolSelectDialog = lazy(() => import('../../components/ProtocolSelectDialog'));
|
||||
const LazyQuickSwitcher = lazy(() =>
|
||||
import('../../components/QuickSwitcher').then((m) => ({ default: m.QuickSwitcher })),
|
||||
);
|
||||
const LazyCreateWorkspaceDialog = lazy(() =>
|
||||
import('../../components/CreateWorkspaceDialog').then((m) => ({ default: m.CreateWorkspaceDialog })),
|
||||
);
|
||||
|
||||
type AppViewContext = Record<string, any>;
|
||||
|
||||
export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
const {
|
||||
accentMode, activeTabId, activeTerminalTheme, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
|
||||
clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, closeWorkspace, copySessionWithCurrentShell,
|
||||
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, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionRenameTarget,
|
||||
sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen,
|
||||
setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId,
|
||||
setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
|
||||
sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, 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,
|
||||
} = ctx;
|
||||
|
||||
return (
|
||||
<SnippetExecutionProvider>
|
||||
<UnsavedChangesProvider>
|
||||
{({ prompt }) => {
|
||||
// Helper: close an editor tab and activate the neighbor (left-preference), or vault.
|
||||
const closeEditorAndActivateNeighbor = (id: string) => {
|
||||
const closingTabId = toEditorTabId(id);
|
||||
const list = orderedTabsWithEditors;
|
||||
const idx = list.indexOf(closingTabId);
|
||||
releaseEditorTabSaveCoordinator(id);
|
||||
editorTabStore.close(id);
|
||||
if (activeTabStore.getActiveTabId() !== closingTabId) return;
|
||||
const next = list[idx - 1] ?? list[idx + 1] ?? 'vault';
|
||||
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
|
||||
};
|
||||
|
||||
// Real dirty-confirm close handler.
|
||||
const handleRequestCloseEditorTab = async (id: string) => {
|
||||
const tab = editorTabStore.getTab(id);
|
||||
if (!tab) return;
|
||||
const dirty = tab.content !== tab.baselineContent;
|
||||
if (!dirty) {
|
||||
closeEditorAndActivateNeighbor(id);
|
||||
return;
|
||||
}
|
||||
const choice = await prompt(tab.fileName);
|
||||
if (choice === 'cancel') return;
|
||||
if (choice === 'discard') {
|
||||
closeEditorAndActivateNeighbor(id);
|
||||
return;
|
||||
}
|
||||
if (choice === 'save') {
|
||||
const ok = await saveEditorTab(id);
|
||||
if (!ok) {
|
||||
const msg = editorTabStore.getTab(id)?.saveError ?? 'Save failed';
|
||||
toast.error(msg, 'SFTP');
|
||||
return;
|
||||
}
|
||||
const latest = editorTabStore.getTab(id);
|
||||
if (!latest || latest.content !== latest.baselineContent) return;
|
||||
closeEditorAndActivateNeighbor(id);
|
||||
}
|
||||
};
|
||||
|
||||
// Expose to the hotkey dispatcher (Cmd/Ctrl+W).
|
||||
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-screen text-foreground font-sans netcatty-shell", activeTerminalTheme && "immersive-transition")} onContextMenu={handleRootContextMenu}>
|
||||
<TopTabs
|
||||
theme={resolvedTheme}
|
||||
followAppTerminalTheme={followAppTerminalTheme}
|
||||
hosts={hosts}
|
||||
sessions={sessions}
|
||||
orphanSessions={orphanSessions}
|
||||
workspaces={workspaces}
|
||||
logViews={logViews}
|
||||
orderedTabs={orderedTabsWithEditors}
|
||||
draggingSessionId={draggingSessionId}
|
||||
isMacClient={isMacClient}
|
||||
onCloseSession={closeSession}
|
||||
onRenameSession={startSessionRename}
|
||||
onCopySession={copySessionWithCurrentShell}
|
||||
onRenameWorkspace={startWorkspaceRename}
|
||||
onCloseWorkspace={closeWorkspace}
|
||||
onCloseLogView={closeLogView}
|
||||
onCloseTabsBatch={closeTabsBatch}
|
||||
onOpenQuickSwitcher={handleOpenQuickSwitcher}
|
||||
onToggleTheme={handleToggleTheme}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
onSyncNow={handleSyncNowManual}
|
||||
isImmersiveActive={activeTerminalTheme !== null}
|
||||
onStartSessionDrag={setDraggingSessionId}
|
||||
onEndSessionDrag={handleEndSessionDrag}
|
||||
onReorderTabs={reorderTabs}
|
||||
showSftpTab={settings.showSftpTab}
|
||||
editorTabs={editorTabs}
|
||||
onRequestCloseEditorTab={handleRequestCloseEditorTab}
|
||||
hostById={hostById}
|
||||
/>
|
||||
|
||||
<div className="flex-1 relative min-h-0">
|
||||
<VaultViewContainer>
|
||||
<VaultView
|
||||
hosts={hosts}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
proxyProfiles={proxyProfiles}
|
||||
snippets={snippets}
|
||||
snippetPackages={snippetPackages}
|
||||
customGroups={customGroups}
|
||||
knownHosts={effectiveKnownHosts}
|
||||
shellHistory={shellHistory}
|
||||
connectionLogs={connectionLogs}
|
||||
managedSources={managedSources}
|
||||
sessionCount={sessions.length}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
terminalThemeId={terminalThemeId}
|
||||
terminalFontSize={terminalFontSize}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
onOpenQuickSwitcher={handleOpenQuickSwitcher}
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
onConnectSerial={handleConnectSerial}
|
||||
onDeleteHost={handleDeleteHost}
|
||||
onConnect={handleConnectToHost}
|
||||
groupConfigs={groupConfigs}
|
||||
onUpdateGroupConfigs={updateGroupConfigs}
|
||||
onUpdateHosts={updateHosts}
|
||||
onUpdateKeys={updateKeys}
|
||||
onImportOrReuseKey={importOrReuseKey}
|
||||
onUpdateIdentities={updateIdentities}
|
||||
onUpdateProxyProfiles={updateProxyProfiles}
|
||||
onUpdateSnippets={updateSnippets}
|
||||
onUpdateSnippetPackages={updateSnippetPackages}
|
||||
onUpdateCustomGroups={updateCustomGroups}
|
||||
onUpdateKnownHosts={updateKnownHosts}
|
||||
onUpdateManagedSources={updateManagedSources}
|
||||
onClearAndRemoveManagedSource={clearAndRemoveSource}
|
||||
onClearAndRemoveManagedSources={clearAndRemoveSources}
|
||||
onUnmanageSource={unmanageSource}
|
||||
onConvertKnownHost={convertKnownHostToHost}
|
||||
onToggleConnectionLogSaved={toggleConnectionLogSaved}
|
||||
onDeleteConnectionLog={deleteConnectionLog}
|
||||
onClearUnsavedConnectionLogs={clearUnsavedConnectionLogs}
|
||||
onRunSnippet={runSnippet}
|
||||
onOpenLogView={openLogView}
|
||||
showRecentHosts={settings.showRecentHosts}
|
||||
showOnlyUngroupedHostsInRoot={settings.showOnlyUngroupedHostsInRoot}
|
||||
navigateToSection={navigateToSection}
|
||||
onNavigateToSectionHandled={() => setNavigateToSection(null)}
|
||||
terminalSettings={terminalSettings}
|
||||
/>
|
||||
</VaultViewContainer>
|
||||
|
||||
<SftpViewMount
|
||||
hosts={hosts}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
proxyProfiles={proxyProfiles}
|
||||
groupConfigs={groupConfigs}
|
||||
updateHosts={updateHosts}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
||||
sftpAutoSync={sftpAutoSync}
|
||||
sftpShowHiddenFiles={sftpShowHiddenFiles}
|
||||
sftpUseCompressedUpload={sftpUseCompressedUpload}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
editorWordWrap={editorWordWrap}
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
terminalSettings={terminalSettings}
|
||||
/>
|
||||
|
||||
<TerminalLayerMount
|
||||
hosts={hosts}
|
||||
groupConfigs={groupConfigs}
|
||||
proxyProfiles={proxyProfiles}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
snippets={snippets}
|
||||
snippetPackages={snippetPackages}
|
||||
sessions={sessions}
|
||||
workspaces={workspaces}
|
||||
knownHosts={effectiveKnownHosts}
|
||||
draggingSessionId={draggingSessionId}
|
||||
terminalTheme={currentTerminalTheme}
|
||||
followAppTerminalTheme={followAppTerminalTheme}
|
||||
accentMode={accentMode}
|
||||
customAccent={customAccent}
|
||||
terminalSettings={terminalSettings}
|
||||
terminalFontFamilyId={terminalFontFamilyId}
|
||||
fontSize={terminalFontSize}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
onHotkeyAction={handleHotkeyAction}
|
||||
onUpdateTerminalThemeId={setTerminalThemeId}
|
||||
onUpdateTerminalFontFamilyId={setTerminalFontFamilyId}
|
||||
onUpdateTerminalFontSize={setTerminalFontSize}
|
||||
onUpdateTerminalFontWeight={(w) => updateTerminalSetting('fontWeight', w)}
|
||||
onCloseSession={closeSession}
|
||||
onUpdateSessionStatus={handleSessionStatusChange}
|
||||
onUpdateHostDistro={updateHostDistro}
|
||||
onUpdateHost={handleUpdateHostFromTerminal}
|
||||
onAddKnownHost={handleAddKnownHost}
|
||||
onCommandExecuted={(command, hostId, hostLabel, sessionId) => {
|
||||
addShellHistoryEntry({ command, hostId, hostLabel, sessionId });
|
||||
}}
|
||||
onTerminalDataCapture={handleTerminalDataCapture}
|
||||
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
|
||||
onAddSessionToWorkspace={addSessionToWorkspace}
|
||||
onRequestAddToWorkspace={(workspaceId) =>
|
||||
setAddToWorkspaceDialog({ mode: 'append', workspaceId })
|
||||
}
|
||||
onUpdateSplitSizes={updateSplitSizes}
|
||||
onSetDraggingSessionId={setDraggingSessionId}
|
||||
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
|
||||
onSetWorkspaceFocusedSession={setWorkspaceFocusedSession}
|
||||
onReorderWorkspaceSessions={reorderWorkspaceSessions}
|
||||
onSplitSession={splitSessionWithCurrentShell}
|
||||
isBroadcastEnabled={isBroadcastEnabled}
|
||||
onToggleBroadcast={toggleBroadcast}
|
||||
updateHosts={updateHosts}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
||||
sftpAutoSync={sftpAutoSync}
|
||||
sftpShowHiddenFiles={sftpShowHiddenFiles}
|
||||
sftpUseCompressedUpload={sftpUseCompressedUpload}
|
||||
sftpAutoOpenSidebar={sftpAutoOpenSidebar}
|
||||
editorWordWrap={editorWordWrap}
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
sessionLogsEnabled={sessionLogsEnabled}
|
||||
sessionLogsDir={sessionLogsDir}
|
||||
sessionLogsFormat={sessionLogsFormat}
|
||||
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
|
||||
toggleSidePanelRef={toggleSidePanelRef}
|
||||
/>
|
||||
|
||||
{/* Log Views - readonly terminal replays */}
|
||||
{logViews.map(logView => {
|
||||
// Get the latest log data from connectionLogs to reflect updates
|
||||
const latestLog = connectionLogs.find(l => l.id === logView.connectionLogId) || logView.log;
|
||||
return (
|
||||
<LogViewWrapper
|
||||
key={logView.id}
|
||||
logView={{ ...logView, log: latestLog }}
|
||||
defaultTerminalTheme={currentTerminalTheme}
|
||||
defaultFontSize={terminalFontSize}
|
||||
onClose={() => closeLogView(logView.id)}
|
||||
onUpdateLog={updateConnectionLog}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Editor Tabs — kept mounted for Monaco instance persistence; visibility toggled via CSS */}
|
||||
{editorTabs.map((tab) => (
|
||||
<TextEditorTabView
|
||||
key={tab.id}
|
||||
tabId={tab.id}
|
||||
isVisible={activeTabId === toEditorTabId(tab.id)}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
hostById={hostById}
|
||||
onRequestClose={(id) => handleRequestCloseEditorTabRef.current(id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Global "quick add / edit snippet" dialog, triggered by the
|
||||
netcatty:snippets:add and :edit window events (from ScriptsSidePanel
|
||||
"+" button and right-click menu). Delete is handled by a sibling
|
||||
useEffect above — it does not need a dialog. */}
|
||||
<QuickAddSnippetDialog
|
||||
snippets={snippets}
|
||||
packages={snippetPackages}
|
||||
onCreateSnippet={(snippet) => updateSnippets([...snippets, snippet])}
|
||||
onUpdateSnippet={(snippet) =>
|
||||
updateSnippets(snippets.map((s) => (s.id === snippet.id ? snippet : s)))
|
||||
}
|
||||
onCreatePackage={(pkg) =>
|
||||
updateSnippetPackages(Array.from(new Set([...snippetPackages, pkg])))
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Root-mounted AddToWorkspaceDialog — triggered by the focus-mode
|
||||
"+" button (mode='append') or QuickSwitcher's "New Workspace"
|
||||
button (mode='create'). Single instance so dialog state and
|
||||
styling stay consistent across entry points. */}
|
||||
{addToWorkspaceDialog && (
|
||||
<AddToWorkspaceDialog
|
||||
open
|
||||
onOpenChange={(open) => { if (!open) setAddToWorkspaceDialog(null); }}
|
||||
// Filter serial hosts only in append mode — appendHostToWorkspace
|
||||
// has no serial code path. Create mode goes through
|
||||
// createWorkspaceFromTargets, which builds a SerialConfig-backed
|
||||
// session for serial hosts, so those should remain pickable.
|
||||
hosts={addToWorkspaceDialog.mode === 'append'
|
||||
? hosts.filter((h) => h.protocol !== 'serial')
|
||||
: hosts}
|
||||
workspaceTitle={
|
||||
addToWorkspaceDialog.mode === 'append'
|
||||
? workspaces.find((w) => w.id === addToWorkspaceDialog.workspaceId)?.title
|
||||
: 'New Workspace'
|
||||
}
|
||||
onAdd={(targets) => {
|
||||
if (addToWorkspaceDialog.mode === 'append') {
|
||||
// Match the workspace root's current split direction so
|
||||
// the new panes peer the existing siblings instead of
|
||||
// wrapping the whole tree into one side of a fresh split
|
||||
// (which would happen if we always passed the helper's
|
||||
// default 'vertical').
|
||||
const ws = workspaces.find((w) => w.id === addToWorkspaceDialog.workspaceId);
|
||||
const rootDir = ws && ws.root.type === 'split' ? ws.root.direction : 'vertical';
|
||||
for (const target of targets) {
|
||||
if (target.kind === 'local') {
|
||||
appendLocalTerminalToWorkspace(addToWorkspaceDialog.workspaceId, undefined, rootDir);
|
||||
} else {
|
||||
appendHostToWorkspace(addToWorkspaceDialog.workspaceId, target.host, rootDir);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
createWorkspaceFromTargets(targets);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
<Dialog open={!!sessionRenameTarget} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
resetSessionRename();
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('dialog.renameSession.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 py-2">
|
||||
<Label htmlFor="session-name">{t('field.name')}</Label>
|
||||
<Input
|
||||
id="session-name"
|
||||
value={sessionRenameValue}
|
||||
onChange={(e) => setSessionRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') submitSessionRename(); }}
|
||||
autoFocus
|
||||
placeholder={t('placeholder.sessionName')}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={resetSessionRename}>{t('common.cancel')}</Button>
|
||||
<Button onClick={submitSessionRename} disabled={!sessionRenameValue.trim()}>{t('common.save')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={!!workspaceRenameTarget} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
resetWorkspaceRename();
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('dialog.renameWorkspace.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 py-2">
|
||||
<Label htmlFor="workspace-name">{t('field.name')}</Label>
|
||||
<Input
|
||||
id="workspace-name"
|
||||
value={workspaceRenameValue}
|
||||
onChange={(e) => setWorkspaceRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') submitWorkspaceRename(); }}
|
||||
autoFocus
|
||||
placeholder={t('placeholder.workspaceName')}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={resetWorkspaceRename}>{t('common.cancel')}</Button>
|
||||
<Button onClick={submitWorkspaceRename} disabled={!workspaceRenameValue.trim()}>{t('common.save')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{isCreateWorkspaceOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<LazyCreateWorkspaceDialog
|
||||
isOpen={isCreateWorkspaceOpen}
|
||||
onClose={() => setIsCreateWorkspaceOpen(false)}
|
||||
hosts={hosts}
|
||||
onCreate={createWorkspaceWithHosts}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{/* Protocol Select Dialog for QuickSwitcher */}
|
||||
{protocolSelectHost && (
|
||||
<Suspense fallback={null}>
|
||||
<LazyProtocolSelectDialog
|
||||
host={protocolSelectHost}
|
||||
onSelect={handleProtocolSelect}
|
||||
onCancel={() => setProtocolSelectHost(null)}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{/* Global Keyboard-Interactive Authentication Modal (2FA/MFA) - processes queue */}
|
||||
<KeyboardInteractiveModal
|
||||
request={keyboardInteractiveQueue[0] || null}
|
||||
onSubmit={handleKeyboardInteractiveSubmit}
|
||||
onCancel={handleKeyboardInteractiveCancel}
|
||||
/>
|
||||
{/* Indicator when more 2FA requests are pending */}
|
||||
{keyboardInteractiveQueue.length > 1 && (
|
||||
<div className="fixed bottom-4 right-4 z-50 bg-muted/90 backdrop-blur-sm text-sm px-3 py-1.5 rounded-full border shadow-sm">
|
||||
{keyboardInteractiveQueue.length - 1} more pending
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Global Passphrase Modal for encrypted SSH keys */}
|
||||
<PassphraseModal
|
||||
request={passphraseQueue[0] || null}
|
||||
onSubmit={handlePassphraseSubmit}
|
||||
onCancel={handlePassphraseCancel}
|
||||
onSkip={handlePassphraseSkip}
|
||||
/>
|
||||
|
||||
{/* Empty vault vs cloud data confirmation dialog (#679).
|
||||
This dialog intentionally cannot be dismissed — the user MUST
|
||||
choose "Restore" or "Keep Empty" before the sync flow can
|
||||
proceed. hideCloseButton removes the X button, onOpenChange
|
||||
is a no-op so ESC also does nothing, and onInteractOutside
|
||||
prevents click-away. */}
|
||||
<Dialog open={!!emptyVaultConflict} onOpenChange={() => { /* intentionally non-dismissable */ }}>
|
||||
<DialogContent className="max-w-md" hideCloseButton onInteractOutside={(e) => e.preventDefault()} onEscapeKeyDown={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-500" />
|
||||
{t('sync.autoSync.emptyVaultConflict.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('sync.autoSync.emptyVaultConflict.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{emptyVaultConflict && (
|
||||
<div className="bg-muted/30 rounded-lg p-3 text-sm">
|
||||
<div className="font-medium text-muted-foreground mb-1">{t('sync.autoSync.emptyVaultConflict.cloudLabel')}</div>
|
||||
<div>{t('sync.autoSync.emptyVaultConflict.cloudSummary', {
|
||||
hosts: emptyVaultConflict.hostCount,
|
||||
keys: emptyVaultConflict.keyCount,
|
||||
snippets: emptyVaultConflict.snippetCount,
|
||||
proxyProfiles: emptyVaultConflict.proxyProfileCount,
|
||||
})}</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter className="flex-col gap-2 sm:flex-col">
|
||||
<Button
|
||||
onClick={() => resolveEmptyVaultConflict('restore')}
|
||||
className="w-full justify-start gap-2"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
<span>
|
||||
{t('sync.autoSync.emptyVaultConflict.restore')}
|
||||
<span className="text-xs opacity-70 ml-1">— {t('sync.autoSync.emptyVaultConflict.restoreDesc')}</span>
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => resolveEmptyVaultConflict('keep-empty')}
|
||||
className="w-full justify-start gap-2"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<span>
|
||||
{t('sync.autoSync.emptyVaultConflict.keepEmpty')}
|
||||
<span className="text-xs opacity-70 ml-1">— {t('sync.autoSync.emptyVaultConflict.keepEmptyDesc')}</span>
|
||||
</span>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</UnsavedChangesProvider>
|
||||
</SnippetExecutionProvider>
|
||||
);
|
||||
}
|
||||
176
application/app/useAppStartupEffects.ts
Normal file
176
application/app/useAppStartupEffects.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { usePortForwardingAutoStart } from '../state/usePortForwardingAutoStart';
|
||||
import { editorTabStore } from '../state/editorTabStore';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { toast } from '../../components/ui/toast';
|
||||
|
||||
type StartupEffectsContext = Record<string, any>;
|
||||
|
||||
export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
const {dismissUpdate, groupConfigs, hosts, identities,
|
||||
installUpdate, isVaultInitialized, keys, openSettingsWindow, portForwardingRules, proxyProfiles, sessions, setKeyboardInteractiveQueue,
|
||||
t, terminalSettings, updateState, workspaces,
|
||||
} = ctx;
|
||||
|
||||
// Show toast notification when update is available (only when auto-download is idle)
|
||||
useEffect(() => {
|
||||
// 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
|
||||
if (localStorageAdapter.readString('netcatty_auto_update_enabled_v1') === 'false') return;
|
||||
if (updateState.hasUpdate && updateState.latestRelease) {
|
||||
const version = updateState.latestRelease.version;
|
||||
toast.info(
|
||||
t('update.available.message', { version }),
|
||||
{
|
||||
title: t('update.available.title'),
|
||||
duration: 8000, // Show longer for update notifications
|
||||
onClick: () => {
|
||||
void openSettingsWindow();
|
||||
// Dismiss the update so the toast doesn't re-fire on every render.
|
||||
// On unsupported platforms (where autoDownloadStatus stays 'idle')
|
||||
// this is the only way to suppress the notification for this version.
|
||||
// On supported platforms this toast only shows before auto-download
|
||||
// starts, and the Settings window's own useUpdateCheck will pick up
|
||||
// the download state via IPC events independently of the dismiss.
|
||||
dismissUpdate();
|
||||
},
|
||||
actionLabel: t('update.viewInSettings'),
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [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(() => {
|
||||
const prev = prevAutoDownloadStatusRef.current;
|
||||
prevAutoDownloadStatusRef.current = updateState.autoDownloadStatus;
|
||||
if (prev === updateState.autoDownloadStatus) return;
|
||||
|
||||
if (updateState.autoDownloadStatus === 'ready') {
|
||||
const version = updateState.latestRelease?.version ?? '';
|
||||
toast.info(
|
||||
t('update.readyToInstall.message', { version }),
|
||||
{
|
||||
title: t('update.readyToInstall.title'),
|
||||
duration: 0,
|
||||
actionLabel: t('update.restartNow'),
|
||||
onClick: () => installUpdate(),
|
||||
}
|
||||
);
|
||||
} else if (updateState.autoDownloadStatus === 'error') {
|
||||
toast.error(
|
||||
t('update.downloadFailed.message'),
|
||||
{
|
||||
title: t('update.downloadFailed.title'),
|
||||
actionLabel: t('update.viewInSettings'),
|
||||
onClick: () => void openSettingsWindow(),
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [updateState.autoDownloadStatus, updateState.latestRelease?.version, t, installUpdate, openSettingsWindow]);
|
||||
|
||||
// Auto-start port forwarding rules on app launch
|
||||
usePortForwardingAutoStart({
|
||||
isVaultInitialized,
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
proxyProfiles,
|
||||
groupConfigs,
|
||||
terminalSettings,
|
||||
});
|
||||
|
||||
// Sync tray menu data + handle tray actions
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.updateTrayMenuData) return;
|
||||
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (cancelled) return;
|
||||
|
||||
const sessionsForTray = sessions.map((s) => {
|
||||
const ws = s.workspaceId ? workspaces.find((w) => w.id === s.workspaceId) : undefined;
|
||||
return {
|
||||
id: s.id,
|
||||
label: s.hostname,
|
||||
hostLabel: s.hostLabel,
|
||||
status: s.status,
|
||||
workspaceId: s.workspaceId,
|
||||
workspaceTitle: ws?.title,
|
||||
};
|
||||
});
|
||||
|
||||
void bridge.updateTrayMenuData({
|
||||
sessions: sessionsForTray,
|
||||
portForwardRules: portForwardingRules,
|
||||
});
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [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.
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onCheckDirtyEditors) return;
|
||||
const unsub = bridge.onCheckDirtyEditors(() => {
|
||||
// Always report SOMETHING so the main process doesn't time out for
|
||||
// 5 s on an unhandled exception. If we can't determine the state,
|
||||
// fail open — losing unsaved work is bad, but stranding the user
|
||||
// on a slow quit and then quitting anyway after the timeout is
|
||||
// exactly the same outcome.
|
||||
let hasDirty = false;
|
||||
try {
|
||||
hasDirty = editorTabStore.getTabs().some((tab) => tab.content !== tab.baselineContent);
|
||||
if (hasDirty) toast.warning(t('sftp.editor.quitBlockedByDirty'), 'SFTP');
|
||||
} catch (err) {
|
||||
console.error('[App] dirty-editors check failed:', err);
|
||||
}
|
||||
try {
|
||||
bridge.reportDirtyEditorsResult?.(hasDirty);
|
||||
} catch (err) {
|
||||
// Reporting itself shouldn't throw, but if the IPC bridge is in a
|
||||
// bad state we'd rather log than bubble out of the listener and
|
||||
// disable the quit guard for the rest of the session.
|
||||
console.error('[App] reportDirtyEditorsResult failed:', err);
|
||||
}
|
||||
});
|
||||
return unsub;
|
||||
}, [t]);
|
||||
|
||||
// Keyboard-interactive authentication (2FA/MFA) event listener
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onKeyboardInteractive) return;
|
||||
|
||||
const unsubscribe = bridge.onKeyboardInteractive((request) => {
|
||||
console.log('[App] Keyboard-interactive request received:', request);
|
||||
// Add to queue instead of replacing - supports multiple concurrent sessions
|
||||
setKeyboardInteractiveQueue(prev => [...prev, {
|
||||
requestId: request.requestId,
|
||||
sessionId: request.sessionId,
|
||||
name: request.name,
|
||||
instructions: request.instructions,
|
||||
prompts: request.prompts,
|
||||
hostname: request.hostname,
|
||||
savedPassword: request.savedPassword,
|
||||
}]);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [setKeyboardInteractiveQueue]);
|
||||
|
||||
|
||||
}
|
||||
30
application/i18n/locales/cloudSyncStrategyLocales.test.ts
Normal file
30
application/i18n/locales/cloudSyncStrategyLocales.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import en from "../locales/en.ts";
|
||||
import ru from "../locales/ru.ts";
|
||||
import zhCN from "../locales/zh-CN.ts";
|
||||
|
||||
const strategyKeys = [
|
||||
"cloudSync.strategy.title",
|
||||
"cloudSync.strategy.desc",
|
||||
"cloudSync.strategy.smartMerge",
|
||||
"cloudSync.strategy.smartMergeDesc",
|
||||
"cloudSync.strategy.preferCloud",
|
||||
"cloudSync.strategy.preferCloudDesc",
|
||||
"cloudSync.strategy.preferLocal",
|
||||
"cloudSync.strategy.preferLocalDesc",
|
||||
] as const;
|
||||
|
||||
test("cloud sync strategy copy exists in every bundled locale", () => {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, zhCN })) {
|
||||
for (const key of strategyKeys) {
|
||||
assert.equal(
|
||||
typeof messages[key],
|
||||
"string",
|
||||
`${locale} is missing ${key}`,
|
||||
);
|
||||
assert.notEqual(messages[key], "", `${locale} has empty ${key}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
247
application/i18n/locales/en/ai.ts
Normal file
247
application/i18n/locales/en/ai.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enAiMessages: Messages = {
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent Settings',
|
||||
'ai.title': 'AI',
|
||||
'ai.description': 'Configure AI providers, agents, and safety settings',
|
||||
'ai.providers': 'Providers',
|
||||
'ai.providers.empty': 'No providers configured. Add a provider to get started.',
|
||||
'ai.providers.add': 'Add Provider',
|
||||
'ai.providers.active': 'Active',
|
||||
'ai.providers.apiKeyConfigured': 'API key configured',
|
||||
'ai.providers.noApiKey': 'No API key',
|
||||
'ai.providers.configure': 'Configure',
|
||||
'ai.providers.remove': 'Remove',
|
||||
'ai.providers.name': 'Display Name',
|
||||
'ai.providers.name.placeholder': 'e.g. My Provider',
|
||||
'ai.providers.style': 'Protocol style',
|
||||
'ai.providers.style.anthropic': 'Anthropic-compatible',
|
||||
'ai.providers.style.openai': 'OpenAI-compatible',
|
||||
'ai.providers.style.google': 'Google-compatible',
|
||||
'ai.providers.style.inherited': 'auto',
|
||||
'ai.providers.style.help': 'Selects which API format requests use. Override when a third-party endpoint speaks a different dialect than its provider type suggests.',
|
||||
'ai.providers.icon.change': 'Change icon',
|
||||
'ai.providers.icon.upload': 'Upload image',
|
||||
'ai.providers.icon.reset': 'Reset',
|
||||
'ai.providers.icon.close': 'Close',
|
||||
'ai.providers.icon.uploadedNote': 'Custom icon (64×64 WebP)',
|
||||
'ai.providers.icon.errorType': 'Please choose an image file.',
|
||||
'ai.providers.apiKey': 'API Key',
|
||||
'ai.providers.apiKey.placeholder': 'Enter API key',
|
||||
'ai.providers.apiKey.decrypting': 'Decrypting...',
|
||||
'ai.providers.baseUrl': 'Base URL',
|
||||
'ai.providers.skipTLSVerify': 'Skip TLS certificate verification (for self-signed certs)',
|
||||
'ai.providers.defaultModel': 'Default Model',
|
||||
'ai.providers.defaultModel.placeholder': 'e.g. gpt-4o, claude-sonnet-4-20250514',
|
||||
'ai.providers.refreshModels': 'Refresh models',
|
||||
'ai.providers.searchModel': 'Search or type model ID...',
|
||||
'ai.providers.filterModels': 'Filter models...',
|
||||
'ai.providers.loadingModels': 'Loading models...',
|
||||
'ai.providers.noMatchingModels': 'No matching models',
|
||||
'ai.providers.clickToLoadModels': 'Click to load models',
|
||||
'ai.providers.showingModels': 'Showing first 100 of {count} models. Type to filter.',
|
||||
'ai.providers.advancedParams': 'Advanced Parameters',
|
||||
'ai.providers.advancedParams.hint': 'Leave blank to use provider defaults.',
|
||||
'ai.providers.advancedParams.maxTokens.placeholder': 'e.g. 4096',
|
||||
'ai.providers.advancedParams.default': 'Provider default',
|
||||
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
'ai.codex.title': 'Codex CLI',
|
||||
'ai.codex.description': 'Uses codex + codex-acp for ACP protocol streaming. Login with ChatGPT here, or enable an OpenAI-compatible provider API key and custom endpoint in Settings.',
|
||||
'ai.codex.detecting': 'Detecting...',
|
||||
'ai.codex.notFound': 'Not found',
|
||||
'ai.codex.awaitingLogin': 'Awaiting login',
|
||||
'ai.codex.connectedChatGPT': 'Connected via ChatGPT',
|
||||
'ai.codex.connectedApiKey': 'Connected via API key',
|
||||
'ai.codex.connectedCustomConfig': 'Connected via ~/.codex/config.toml',
|
||||
'ai.codex.customConfigIncomplete': 'Custom config detected (env var missing)',
|
||||
'ai.codex.customConfigHint': 'Using custom provider "{provider}" configured in ~/.codex/config.toml — no ChatGPT login needed.',
|
||||
'ai.codex.customConfigMissingEnvKey': 'Warning: {envKey} is not set in your shell environment. Export it (or launch netcatty from a shell that has it) so Codex can authenticate.',
|
||||
'ai.codex.notConnected': 'Not connected',
|
||||
'ai.codex.statusUnknown': 'Status unknown',
|
||||
'ai.codex.path': 'Path:',
|
||||
'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.openLogin': 'Open Login',
|
||||
'ai.codex.logout': 'Logout',
|
||||
'ai.codex.connectChatGPT': 'Connect ChatGPT',
|
||||
'ai.codex.refreshStatus': 'Refresh Status',
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
'ai.claude.description': "Anthropic's agentic coding assistant. Requires the system Claude Code CLI.",
|
||||
'ai.claude.detecting': 'Detecting...',
|
||||
'ai.claude.detected': 'Detected',
|
||||
'ai.claude.notFound': 'Not found',
|
||||
'ai.claude.path': 'Path:',
|
||||
'ai.claude.notFoundHint': 'Could not find claude in PATH. Install it or specify the executable path below.',
|
||||
'ai.claude.customPathPlaceholder': 'e.g. /usr/local/bin/claude',
|
||||
'ai.claude.configSection': 'Authentication & config (optional)',
|
||||
'ai.claude.configDir': 'Config directory',
|
||||
'ai.claude.configDir.placeholder': '~/.claude (leave blank for default)',
|
||||
'ai.claude.configDir.hint': 'Sets CLAUDE_CONFIG_DIR — point at a folder where you have run `claude` login (contains settings.json + credentials).',
|
||||
'ai.claude.envVars': 'Environment variables',
|
||||
'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 GitHub Copilot CLI
|
||||
'ai.copilot.title': 'GitHub Copilot CLI',
|
||||
'ai.copilot.description': 'Uses GitHub Copilot CLI via ACP over stdio (`copilot --acp --stdio`). Once detected, it can be selected as an external coding agent.',
|
||||
'ai.copilot.detecting': 'Detecting...',
|
||||
'ai.copilot.detected': 'Detected',
|
||||
'ai.copilot.notFound': 'Not found',
|
||||
'ai.copilot.path': 'Path:',
|
||||
'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 Default Agent
|
||||
'ai.defaultAgent': 'Default Agent',
|
||||
'ai.defaultAgent.description': 'Agent to use when starting a new AI session',
|
||||
'ai.defaultAgent.catty': 'Catty (Built-in)',
|
||||
'ai.toolAccess.title': 'Tool Access',
|
||||
'ai.toolAccess.mode': 'Netcatty Access Mode',
|
||||
'ai.toolAccess.description': 'Choose how external ACP agents access Netcatty sessions. MCP exposes the built-in server, while Skills + CLI points agents to the local Netcatty skill and CLI commands.',
|
||||
'ai.toolAccess.mode.mcp': 'MCP',
|
||||
'ai.toolAccess.mode.skills': 'Skills + CLI',
|
||||
'ai.userSkills.title': 'User Skills',
|
||||
'ai.userSkills.description': 'Open the Netcatty skills folder to add your own skill directories. Netcatty scans these skills automatically and injects only lightweight indexes unless a skill clearly matches the current request.',
|
||||
'ai.userSkills.openFolder': 'Open Skills Folder',
|
||||
'ai.userSkills.reload': 'Reload Skills',
|
||||
'ai.userSkills.location': 'Location',
|
||||
'ai.userSkills.loading': 'Scanning user skills...',
|
||||
'ai.userSkills.summary': '{ready} ready, {warnings} warnings',
|
||||
'ai.userSkills.empty': 'No user skills found yet. Open the folder to add skill directories with a SKILL.md file.',
|
||||
'ai.userSkills.unavailable': 'User skills are unavailable in this environment.',
|
||||
'ai.userSkills.status.ready': 'Ready',
|
||||
'ai.userSkills.status.warning': 'Warning',
|
||||
|
||||
// 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.',
|
||||
'ai.chat.toolApproved': 'Approved',
|
||||
'ai.chat.toolApprovalHint': 'Press Enter to approve, Escape to reject',
|
||||
'ai.chat.approve': 'Approve',
|
||||
'ai.chat.reject': 'Reject',
|
||||
'ai.chat.toolLabel': 'Tool',
|
||||
'ai.chat.targetLabel': 'Target',
|
||||
'ai.chat.permissionRequired': 'Permission Required',
|
||||
'ai.chat.permissionDescription': 'The AI agent wants to execute a tool call that requires your approval.',
|
||||
'ai.chat.commandBlocked': 'This command is blocked by your security policy and cannot be executed.',
|
||||
'ai.chat.recommendAllow': 'Allow',
|
||||
'ai.chat.recommendConfirm': 'Confirm',
|
||||
'ai.chat.recommendDeny': 'Deny',
|
||||
'ai.chat.exportConversation': 'Export conversation',
|
||||
'ai.chat.exportAs': 'Export As',
|
||||
'ai.chat.exportMarkdown': 'Markdown',
|
||||
'ai.chat.exportJSON': 'JSON',
|
||||
'ai.chat.exportPlainText': 'Plain Text',
|
||||
'ai.chat.thinking': 'Thinking',
|
||||
'ai.chat.thoughtFor': 'Thought for {duration}',
|
||||
'ai.chat.thought': 'Thought',
|
||||
'ai.chat.agents': 'Agents',
|
||||
'ai.chat.detectedOnMachine': 'Detected on this machine',
|
||||
'ai.chat.rescan': 'Re-scan',
|
||||
'ai.chat.permObserver': 'Observer',
|
||||
'ai.chat.permConfirm': 'Confirm',
|
||||
'ai.chat.permAuto': 'Auto',
|
||||
'ai.chat.permObserverDesc': 'Read only',
|
||||
'ai.chat.permConfirmDesc': 'Ask before actions',
|
||||
'ai.chat.permAutoDesc': 'Execute freely',
|
||||
'ai.chat.emptyHint': 'Ask about your servers, run commands, or get help with configurations.',
|
||||
'ai.chat.placeholder': 'Message {agent} — @ to include context, / for commands',
|
||||
'ai.chat.placeholderDefault': 'Message Catty Agent...',
|
||||
'ai.chat.noModel': 'No model',
|
||||
'ai.chat.noProviderModel': 'No default model — set one in Settings → AI → Providers.',
|
||||
'ai.chat.selectProvider': 'Select provider',
|
||||
'ai.chat.recent': 'Recent',
|
||||
'ai.chat.viewAll': 'View All',
|
||||
'ai.chat.untitled': 'Untitled',
|
||||
'ai.chat.justNow': 'Just now',
|
||||
'ai.chat.minutesAgo': '{n}m ago',
|
||||
'ai.chat.hoursAgo': '{n}h ago',
|
||||
'ai.chat.daysAgo': '{n}d ago',
|
||||
'ai.chat.newChat': 'New Chat',
|
||||
'ai.chat.allSessions': 'All Sessions',
|
||||
'ai.chat.noSessions': 'No previous sessions',
|
||||
'ai.chat.retryHint': 'You can retry by sending your message again.',
|
||||
'ai.chat.approvalTimeout': 'Tool approval timed out after 5 minutes. You can retry by sending your message again.',
|
||||
'ai.chat.menuHosts': 'Hosts',
|
||||
'ai.chat.menuContext': 'Context',
|
||||
'ai.chat.menuFiles': 'Files',
|
||||
'ai.chat.menuImage': 'Image',
|
||||
'ai.chat.menuMentionHost': 'Mention Host',
|
||||
'ai.chat.menuUserSkills': 'User Skills',
|
||||
|
||||
// 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.',
|
||||
|
||||
// AI Web Search
|
||||
'ai.webSearch.title': 'Web Search',
|
||||
'ai.webSearch.enable': 'Enable Web Search',
|
||||
'ai.webSearch.enable.description': 'Allow the AI agent to search the web for current information.',
|
||||
'ai.webSearch.provider': 'Search Provider',
|
||||
'ai.webSearch.provider.description': 'Choose a web search API provider.',
|
||||
'ai.webSearch.apiKey': 'API Key',
|
||||
'ai.webSearch.apiKey.description': 'API key for the selected search provider.',
|
||||
'ai.webSearch.apiKey.placeholder': 'Enter API key...',
|
||||
'ai.webSearch.apiHost': 'API Host',
|
||||
'ai.webSearch.apiHost.description': 'Custom API endpoint. Leave default unless you use a proxy.',
|
||||
'ai.webSearch.apiHost.searxngDescription': 'URL of your SearXNG instance (required).',
|
||||
'ai.webSearch.maxResults': 'Max Results',
|
||||
'ai.webSearch.maxResults.description': 'Maximum number of search results to return (1-20).',
|
||||
|
||||
// AI Safety Settings
|
||||
'ai.safety.title': 'Safety',
|
||||
'ai.safety.permissionMode': 'Permission Mode',
|
||||
'ai.safety.permissionMode.description': 'Controls how the AI interacts with your terminals. Observer mode blocks all write operations through Netcatty, enforced for both built-in and ACP agents. Confirm mode is advisory for ACP agents (they control their own tool approval flow).',
|
||||
'ai.safety.permissionMode.observer': 'Observer - Read only, no actions',
|
||||
'ai.safety.permissionMode.confirm': 'Confirm - Ask before actions',
|
||||
'ai.safety.permissionMode.autonomous': 'Autonomous - Execute freely',
|
||||
'ai.safety.commandTimeout': 'Command Timeout',
|
||||
'ai.safety.commandTimeout.description': 'Maximum seconds a command can run before being terminated. Applies to both built-in and ACP agents.',
|
||||
'ai.safety.commandTimeout.unit': 'sec',
|
||||
'ai.safety.maxIterations': 'Max Iterations',
|
||||
'ai.safety.maxIterations.description': 'Maximum number of AI tool-use loops to prevent runaway execution. ACP agents may have their own internal iteration limits that take precedence.',
|
||||
'ai.safety.blocklist': 'Command Blocklist',
|
||||
'ai.safety.blocklist.description': 'Regex patterns to block dangerous commands. Applies to both built-in and ACP agents through Netcatty execution.',
|
||||
'ai.safety.blocklist.placeholder': 'Regex pattern...',
|
||||
'ai.safety.blocklist.reset': 'Reset to defaults',
|
||||
'ai.safety.blocklist.add': 'Add pattern',
|
||||
'ai.safety.note': 'Command Blocklist, Command Timeout, and Observer mode are enforced at the MCP Server level, applying to all agent types. Confirm mode and Max Iterations are fully enforced for the built-in agent; ACP agents may have their own internal controls for these settings.',
|
||||
|
||||
// Unified tooltips for terminal workspace and top tabs (issue #954)
|
||||
'terminal.layer.addTerminal': 'Add Terminal',
|
||||
'terminal.layer.switchToSplitView': 'Switch to Split View',
|
||||
'terminal.layer.sftp': 'SFTP',
|
||||
'terminal.layer.scripts': 'Scripts',
|
||||
'terminal.layer.theme': 'Theme',
|
||||
'terminal.layer.aiChat': 'AI Chat',
|
||||
'terminal.layer.movePanelLeft': 'Move panel to left',
|
||||
'terminal.layer.movePanelRight': 'Move panel to right',
|
||||
'terminal.layer.closePanel': 'Close panel',
|
||||
'topTabs.openQuickSwitcher': 'Open quick switcher',
|
||||
'topTabs.moreTabs': 'More tabs',
|
||||
'topTabs.aiAssistant': 'AI Assistant',
|
||||
'topTabs.toggleTheme': 'Toggle theme',
|
||||
'topTabs.openSettings': 'Open Settings',
|
||||
'ai.chat.sessionHistory': 'Session history',
|
||||
'ai.chat.attach': 'Attach',
|
||||
'ai.chat.collapse': 'Collapse',
|
||||
'ai.chat.expand': 'Expand',
|
||||
'ai.chat.enableAgent': 'Enable {name}',
|
||||
'zmodem.waitingForRemote': 'Waiting for remote...',
|
||||
'zmodem.uploading': 'Uploading',
|
||||
'zmodem.downloading': 'Downloading',
|
||||
'zmodem.cancelTransfer': 'Cancel transfer (Ctrl+C)',
|
||||
'zmodem.overwrite.title': 'Remote file already exists',
|
||||
'zmodem.overwrite.applyToRest': 'Apply to remaining conflicts',
|
||||
'zmodem.overwrite.overwrite': 'Overwrite',
|
||||
'zmodem.overwrite.skip': 'Skip',
|
||||
'zmodem.overwrite.cancel': 'Cancel',
|
||||
'settings.shortcuts.resetToDefault': 'Reset to default',
|
||||
};
|
||||
651
application/i18n/locales/en/core.ts
Normal file
651
application/i18n/locales/en/core.ts
Normal file
@@ -0,0 +1,651 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enCoreMessages: Messages = {
|
||||
// Common
|
||||
'common.save': 'Save',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.close': 'Close',
|
||||
'common.reset': 'Reset',
|
||||
'common.zoomIn': 'Zoom in',
|
||||
'common.zoomOut': 'Zoom out',
|
||||
'common.settings': 'Settings',
|
||||
'common.search': 'Search',
|
||||
'common.searchPlaceholder': 'Search...',
|
||||
'common.connect': 'Connect',
|
||||
'common.terminal': 'Terminal',
|
||||
'common.create': 'Create',
|
||||
'common.import': 'Import',
|
||||
'common.generate': 'Generate',
|
||||
'common.delete': 'Delete',
|
||||
'common.edit': 'Edit',
|
||||
'common.clear': 'Clear',
|
||||
'common.optional': 'Optional',
|
||||
'common.selectPlaceholder': 'Select...',
|
||||
'common.add': 'Add',
|
||||
'common.rename': 'Rename',
|
||||
'common.refresh': 'Refresh',
|
||||
'common.continue': 'Continue',
|
||||
'common.enabled': 'Enabled',
|
||||
'common.disabled': 'Disabled',
|
||||
'common.error': 'Error',
|
||||
'common.validation': 'Validation',
|
||||
'common.unknownError': 'Unknown error',
|
||||
'common.noResultsFound': 'No results found',
|
||||
'common.back': 'Back',
|
||||
'common.apply': 'Apply',
|
||||
'common.use': 'Use',
|
||||
'common.useGlobal': 'Use global',
|
||||
'common.saveChanges': 'Save Changes',
|
||||
'common.advanced': 'Advanced',
|
||||
'common.left': 'Left',
|
||||
'common.right': 'Right',
|
||||
'common.more': 'More',
|
||||
'common.selectAHost': 'Select a host',
|
||||
'common.selectAHostPlaceholder': 'Select a host...',
|
||||
'sort.az': 'A-z',
|
||||
'sort.za': 'Z-a',
|
||||
'sort.newest': 'Newest to oldest',
|
||||
'sort.oldest': 'Oldest to newest',
|
||||
'sort.group': 'By group',
|
||||
'field.label': 'Label',
|
||||
'field.type': 'Type',
|
||||
'auth.keyType': 'Type {type}',
|
||||
'auth.showAllKeys': 'Show all keys',
|
||||
|
||||
// Dialogs / prompts
|
||||
'confirm.deleteHost': 'Delete Host "{name}"?',
|
||||
'confirm.deleteIdentity': 'Delete Identity "{name}"?',
|
||||
'confirm.removeProvider': 'Remove provider "{name}"?',
|
||||
'confirm.closeBusyTerminal.title': 'Confirm close',
|
||||
'confirm.closeBusyTerminal.message': 'Process "{command}" is still running and will be terminated.',
|
||||
'confirm.closeBusyTerminal.messageWithMore': 'Process "{command}" and {count} other running process(es) will be terminated.',
|
||||
'confirm.closeBusyTerminal.cancel': 'Cancel',
|
||||
'confirm.closeBusyTerminal.close': 'Close',
|
||||
'dialog.createWorkspace.title': 'Create Workspace',
|
||||
'dialog.renameWorkspace.title': 'Rename workspace',
|
||||
'dialog.renameSession.title': 'Rename session',
|
||||
'field.name': 'Name',
|
||||
'field.selectHosts': 'Select Hosts',
|
||||
'placeholder.workspaceName': 'Workspace name',
|
||||
'placeholder.sessionName': 'Session name',
|
||||
'placeholder.searchHosts': 'Search hosts...',
|
||||
'toast.settingsUnavailable': 'Settings window is unavailable on this platform.',
|
||||
'credentials.protectionUnavailable.title': 'Credential Protection Unavailable',
|
||||
'credentials.protectionUnavailable.message': 'Saved passwords and keys cannot be auto-decrypted on this device. Re-enter credentials before connecting.',
|
||||
'credentials.protectionUnavailable.action': 'Open Settings',
|
||||
|
||||
// Settings shell
|
||||
'settings.title': 'Settings',
|
||||
'settings.tab.application': 'Application',
|
||||
'settings.tab.appearance': 'Appearance',
|
||||
'settings.tab.terminal': 'Terminal',
|
||||
'settings.tab.shortcuts': 'Shortcuts',
|
||||
'settings.tab.syncCloud': 'Sync & Cloud',
|
||||
'settings.tab.system': 'System',
|
||||
|
||||
// Settings > System
|
||||
'settings.system.title': 'System',
|
||||
'settings.system.description': 'System information and temporary file management.',
|
||||
'settings.system.tempDirectory': 'Temporary Files',
|
||||
'settings.system.location': 'Location',
|
||||
'settings.system.fileCount': 'Files',
|
||||
'settings.system.totalSize': 'Size',
|
||||
'settings.system.openFolder': 'Open folder',
|
||||
'settings.system.refresh': 'Refresh',
|
||||
'settings.system.clearTempFiles': 'Clear temp files',
|
||||
'settings.system.clearing': 'Clearing...',
|
||||
'settings.system.clearResult': 'Deleted {deleted} file(s), {failed} failed.',
|
||||
'settings.system.tempDirectoryHint': 'Temporary files are created when opening remote files with external applications. They are automatically cleaned up when SFTP sessions close.',
|
||||
'settings.system.credentials.title': 'Credential Protection',
|
||||
'settings.system.credentials.status': 'Status',
|
||||
'settings.system.credentials.checking': 'Checking...',
|
||||
'settings.system.credentials.available': 'Available (OS keychain ready)',
|
||||
'settings.system.credentials.unavailable': 'Unavailable (cannot decrypt saved credentials)',
|
||||
'settings.system.credentials.unknown': 'Unknown (not supported in this environment)',
|
||||
'settings.system.credentials.unavailableHint': 'Credentials encrypted on another user profile or machine cannot be decrypted here. Re-enter and save credentials on this device.',
|
||||
'settings.system.credentials.portabilityHint': 'Cloud Sync is portable because it uses your master key encryption. Local safeStorage encryption is device/user scoped.',
|
||||
|
||||
// Settings > System > Crash Logs
|
||||
'settings.system.crashLogs.title': 'Crash Logs',
|
||||
'settings.system.crashLogs.description': 'View error logs from the main process to help diagnose unexpected behavior.',
|
||||
'settings.system.crashLogs.noLogs': 'No crash logs found.',
|
||||
'settings.system.crashLogs.entries': '{count} entries',
|
||||
'settings.system.crashLogs.clear': 'Clear all logs',
|
||||
'settings.system.crashLogs.cleared': 'Cleared {count} log file(s).',
|
||||
'settings.system.crashLogs.source': 'Source',
|
||||
'settings.system.crashLogs.time': 'Time',
|
||||
'settings.system.crashLogs.message': 'Message',
|
||||
'settings.system.crashLogs.stack': 'Stack Trace',
|
||||
'settings.system.crashLogs.hint': 'Crash logs are retained for 30 days and automatically rotated.',
|
||||
'settings.system.crashLogs.collapse': 'Collapse',
|
||||
'settings.system.crashLogs.expand': 'Show details',
|
||||
|
||||
// Settings > System > Software Update
|
||||
'settings.update.title': 'Software Update',
|
||||
'settings.update.currentVersion': 'Current version',
|
||||
'settings.update.checkForUpdates': 'Check for Updates',
|
||||
'settings.update.checking': 'Checking...',
|
||||
'settings.update.upToDate': 'You are using the latest version.',
|
||||
'settings.update.available': 'New version {version} is available.',
|
||||
'settings.update.download': 'Download Update',
|
||||
'settings.update.downloading': 'Downloading... {percent}%',
|
||||
'settings.update.readyToInstall': 'Update downloaded and ready to install.',
|
||||
'settings.update.restartNow': 'Restart to Update',
|
||||
'settings.update.error': 'Failed to check for updates.',
|
||||
'settings.update.downloadError': 'Download failed.',
|
||||
'settings.update.manualDownload': 'Download from GitHub',
|
||||
'settings.update.manualDownloadHint': 'Auto-update is not available on this platform. Download the latest version from GitHub.',
|
||||
'settings.update.hint': 'Netcatty checks for updates from GitHub Releases.',
|
||||
'settings.update.lastCheckedJustNow': 'just now',
|
||||
'settings.update.lastCheckedMinutesAgo': '{n} min ago',
|
||||
'settings.update.lastCheckedHoursAgo': '{n} hr ago',
|
||||
'settings.update.lastCheckedPrefix': 'Last checked: ',
|
||||
'settings.update.autoUpdateEnabled': 'Automatic Updates',
|
||||
'settings.update.autoUpdateEnabledDesc': 'Automatically check and download updates when available.',
|
||||
|
||||
// Settings > Session Logs
|
||||
'settings.sessionLogs.title': 'Session Logs',
|
||||
'settings.sessionLogs.description': 'Configure session log export and auto-save settings.',
|
||||
'settings.sessionLogs.autoSave': 'Auto-Save',
|
||||
'settings.sessionLogs.enableAutoSave': 'Enable auto-save',
|
||||
'settings.sessionLogs.enableAutoSaveDesc': 'Automatically save session logs when terminal sessions end.',
|
||||
'settings.sessionLogs.directory': 'Save Directory',
|
||||
'settings.sessionLogs.noDirectory': 'No directory selected',
|
||||
'settings.sessionLogs.browse': 'Browse',
|
||||
'settings.sessionLogs.openFolder': 'Open folder',
|
||||
'settings.sessionLogs.directoryHint': 'Logs will be organized by host in subdirectories.',
|
||||
'settings.sessionLogs.format': 'Log Format',
|
||||
'settings.sessionLogs.formatDesc': 'Choose the format for saved log files.',
|
||||
'settings.sessionLogs.formatTxt': 'Plain Text (.txt)',
|
||||
'settings.sessionLogs.formatRaw': 'Raw with ANSI (.log)',
|
||||
'settings.sessionLogs.formatHtml': 'HTML (.html)',
|
||||
'settings.sessionLogs.hint': 'Session logs capture all terminal output for troubleshooting and auditing purposes.',
|
||||
|
||||
// Settings > Global Hotkey (Quake Mode)
|
||||
'settings.globalHotkey.title': 'Global Hotkey',
|
||||
'settings.globalHotkey.toggleWindow': 'Toggle Window',
|
||||
'settings.globalHotkey.toggleWindowDesc': 'Press a key combination to set a global shortcut for showing/hiding the window.',
|
||||
'settings.globalHotkey.notSet': 'Not set',
|
||||
'settings.globalHotkey.reset': 'Reset to default',
|
||||
'settings.globalHotkey.closeToTray': 'Close to System Tray',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'When enabled, closing the window will minimize to the system tray instead of quitting.',
|
||||
'settings.globalHotkey.enabled': 'Enable Global Hotkey',
|
||||
'settings.globalHotkey.enabledDesc': 'Register system-wide keyboard shortcuts. When disabled, all global hotkeys are unregistered.',
|
||||
'settings.globalHotkey.hint': 'Global hotkey works system-wide to quickly show or hide the window (Quake-style terminal).',
|
||||
|
||||
// Tray Panel
|
||||
'tray.openMainWindow': 'Open Main Window',
|
||||
'tray.sessions': 'Sessions',
|
||||
'tray.portForwarding': 'Port Forwarding',
|
||||
'tray.status.connected': 'Connected',
|
||||
'tray.status.connecting': 'Connecting',
|
||||
'tray.status.disconnected': 'Disconnected',
|
||||
'tray.status.active': 'Active',
|
||||
'tray.status.inactive': 'Inactive',
|
||||
'tray.status.error': 'Error',
|
||||
'tray.recentHosts': 'Recent Hosts',
|
||||
'tray.empty.title': 'Nothing here yet',
|
||||
'tray.empty.subtitle': 'Go connect to a server, they miss you 🚀',
|
||||
'tray.quit': 'Quit Netcatty',
|
||||
|
||||
// Vault Sidebar
|
||||
'vault.sidebar.collapse': 'Collapse sidebar',
|
||||
'vault.sidebar.expand': 'Expand sidebar',
|
||||
'vault.sidebar.resize': 'Resize sidebar',
|
||||
|
||||
// Settings > Application
|
||||
'settings.application.checkUpdates': 'Check for updates',
|
||||
'settings.application.reportProblem': 'Report a problem',
|
||||
'settings.application.reportProblem.subtitle': 'Generate a pre-filled GitHub issue',
|
||||
'settings.application.community': 'Community',
|
||||
'settings.application.community.subtitle': 'On GitHub Discussions',
|
||||
'settings.application.github': 'GitHub',
|
||||
'settings.application.github.subtitle': 'Source code',
|
||||
'settings.application.whatsNew': "What's new",
|
||||
'settings.application.whatsNew.subtitle': 'Show release notes',
|
||||
'settings.application.openExternal.failedTitle': 'Cannot open link',
|
||||
'settings.application.openExternal.failedBody': 'The link could not be opened in either the system browser or the built-in browser window.',
|
||||
'settings.vault.title': 'Vault',
|
||||
'settings.vault.showRecentHosts': 'Show recently connected hosts',
|
||||
'settings.vault.showRecentHostsDesc': 'Display a section of recently connected hosts at the top of the vault',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': 'Only show ungrouped hosts at root',
|
||||
'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.',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': 'Update Available',
|
||||
'update.available.message': 'A new version {version} is available. Click to download.',
|
||||
'update.checking': 'Checking for updates...',
|
||||
'update.upToDate.title': 'Up to Date',
|
||||
'update.upToDate.message': 'You are running the latest version ({version}).',
|
||||
'update.error': 'Failed to check for updates',
|
||||
'update.downloadNow': 'Download Now',
|
||||
'update.viewInSettings': 'View in Settings',
|
||||
'update.readyToInstall.title': 'Update Ready',
|
||||
'update.readyToInstall.message': 'Version {version} downloaded and ready to install.',
|
||||
'update.restartNow': 'Restart Now',
|
||||
'update.downloadFailed.title': 'Update Failed',
|
||||
'update.downloadFailed.message': 'Failed to download update. You can download it manually.',
|
||||
'update.openReleases': 'Open Releases',
|
||||
'update.remindLater': 'Remind Later',
|
||||
'update.skipVersion': 'Skip This Version',
|
||||
|
||||
// Settings > Appearance
|
||||
'settings.appearance.uiTheme': 'UI Theme',
|
||||
'settings.appearance.theme': 'Theme',
|
||||
'settings.appearance.theme.desc': 'Choose light, dark, or follow system preference',
|
||||
'settings.appearance.theme.light': 'Light',
|
||||
'settings.appearance.theme.dark': 'Dark',
|
||||
'settings.appearance.theme.system': 'System',
|
||||
'settings.appearance.accentColor': 'Accent Color',
|
||||
'settings.appearance.customColor': 'Custom color',
|
||||
'settings.appearance.accentColor.mode': 'Use custom accent',
|
||||
'settings.appearance.accentColor.mode.desc': 'Override the theme accent color',
|
||||
'settings.appearance.accentColor.custom': 'Custom accent',
|
||||
'settings.appearance.themeColor': 'Theme Color',
|
||||
'settings.appearance.themeColor.desc': 'Pick a preset palette for each theme',
|
||||
'settings.appearance.themeColor.light': 'Light palette',
|
||||
'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, top-tabs.',
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Make snippet sidebar text larger */\n[data-section="snippets-panel"] {\n font-size: 14px !important;\n}\n\n/* Custom terminal background */\n.terminal { background: #1a1a2e !important; }\n\n/* Tweak global border radius */\n:root { --radius: 0.25rem; }',
|
||||
'settings.appearance.language': 'Language',
|
||||
'settings.appearance.language.desc': 'Choose the UI language',
|
||||
'settings.appearance.uiFont': 'Interface Font',
|
||||
'settings.appearance.uiFont.desc': 'Choose the font for the application interface',
|
||||
|
||||
// Settings > Terminal
|
||||
'settings.terminal.section.theme': 'Terminal Theme',
|
||||
'settings.terminal.themeModal.title': 'Select Theme',
|
||||
'settings.terminal.themeModal.darkThemes': 'Dark Themes',
|
||||
'settings.terminal.themeModal.lightThemes': 'Light Themes',
|
||||
'settings.terminal.theme.selectButton': 'Select Theme',
|
||||
'settings.terminal.theme.followApp': 'Follow Application Theme',
|
||||
'settings.terminal.theme.followApp.desc': 'Automatically match the terminal background to the current app theme for a seamless look.',
|
||||
'settings.terminal.theme.darkTheme': 'Dark mode terminal theme',
|
||||
'settings.terminal.theme.lightTheme': 'Light mode terminal theme',
|
||||
'settings.terminal.theme.auto': 'Auto (match app theme)',
|
||||
'settings.terminal.theme.autoDesc': 'Follows the active UI theme preset',
|
||||
'settings.terminal.section.font': 'Font',
|
||||
'settings.terminal.section.cursor': 'Cursor',
|
||||
'settings.terminal.section.keyboard': 'Keyboard',
|
||||
'settings.terminal.section.accessibility': 'Accessibility',
|
||||
'settings.terminal.section.behavior': 'Behavior',
|
||||
'settings.terminal.section.scrollback': 'Scrollback',
|
||||
'settings.terminal.section.keywordHighlight': 'Keyword highlighting',
|
||||
'settings.terminal.font.family': 'Font',
|
||||
'settings.terminal.font.family.desc': 'Terminal font family',
|
||||
'settings.terminal.font.cjk': 'CJK font',
|
||||
'settings.terminal.font.cjk.desc': 'Font used for Chinese / Japanese / Korean characters; "Auto" picks one based on the primary font',
|
||||
'settings.terminal.font.cjk.option.auto': 'Auto · paired with the primary font',
|
||||
'settings.terminal.font.cjk.option.sarasaSC': 'Sarasa Mono SC (Iosevka + Source Han SC)',
|
||||
'settings.terminal.font.cjk.option.sarasaTC': 'Sarasa Mono TC (Iosevka + Source Han TC)',
|
||||
'settings.terminal.font.cjk.option.mapleCN': 'Maple Mono CN',
|
||||
'settings.terminal.font.cjk.option.sourceHan': 'Source Han Mono SC',
|
||||
'settings.terminal.font.cjk.option.notoCJK': 'Noto Sans Mono CJK SC',
|
||||
'settings.terminal.font.cjk.option.lxgwWenkai': 'LXGW WenKai Mono',
|
||||
'settings.terminal.font.cjk.option.simSun': 'SimSun',
|
||||
'settings.terminal.font.cjk.option.legacy': '{font} · not recommended (proportional font)',
|
||||
'settings.terminal.font.size': 'Font size',
|
||||
'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.weightBold': 'Bold font weight',
|
||||
'settings.terminal.font.weightBold.desc': 'Weight for bold text (100-900)',
|
||||
'settings.terminal.font.linePadding': 'Line padding',
|
||||
'settings.terminal.font.linePadding.desc': 'Additional space between lines (0-10)',
|
||||
'settings.terminal.font.emulationType': 'Terminal emulation type',
|
||||
'settings.terminal.cursor.style': 'Cursor style',
|
||||
'settings.terminal.cursor.style.block': 'Block',
|
||||
'settings.terminal.cursor.style.bar': 'Bar',
|
||||
'settings.terminal.cursor.style.underline': 'Underline',
|
||||
'settings.terminal.cursor.blink': 'Cursor blink',
|
||||
'settings.terminal.keyboard.altAsMeta': 'Use Option as Meta key',
|
||||
'settings.terminal.keyboard.altAsMeta.desc':
|
||||
'Use Option (Alt) as the Meta key instead of for special characters',
|
||||
'settings.terminal.keyboard.optionArrowWordJump': 'Option+←/→ jumps by word',
|
||||
'settings.terminal.keyboard.optionArrowWordJump.desc':
|
||||
'Send Meta-b / Meta-f on Option+Left/Right so the shell moves by word, instead of the default ^[[1;3D / ^[[1;3C',
|
||||
'settings.terminal.accessibility.minimumContrastRatio': 'Minimum contrast ratio',
|
||||
'settings.terminal.accessibility.minimumContrastRatio.desc':
|
||||
'Adjust colors to meet contrast requirements (1 = disabled, 21 = max)',
|
||||
'settings.terminal.behavior.rightClick': 'Right-click behavior',
|
||||
'settings.terminal.behavior.rightClick.desc': 'Action when right-clicking in terminal',
|
||||
'settings.terminal.behavior.rightClick.menu': 'Show menu',
|
||||
'settings.terminal.behavior.rightClick.paste': 'Paste',
|
||||
'settings.terminal.behavior.rightClick.selectWord': 'Select word',
|
||||
'settings.terminal.behavior.copyOnSelect': 'Copy on select',
|
||||
'settings.terminal.behavior.copyOnSelect.desc': 'Automatically copy selected text. In tmux/vim with mouse mode, hold Option on macOS or Shift on Windows/Linux to select',
|
||||
'settings.terminal.behavior.middleClickPaste': 'Middle-click paste',
|
||||
'settings.terminal.behavior.middleClickPaste.desc':
|
||||
'Paste clipboard content on middle-click',
|
||||
'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.',
|
||||
'settings.terminal.behavior.clearWipesScrollback': '`clear` wipes scrollback',
|
||||
'settings.terminal.behavior.clearWipesScrollback.desc':
|
||||
'Make `clear` also wipe the scrollback buffer (POSIX default). Disable to keep history visible after `clear`.',
|
||||
'settings.terminal.behavior.preserveSelectionOnInput': 'Keep selection while typing',
|
||||
'settings.terminal.behavior.preserveSelectionOnInput.desc':
|
||||
'Don\'t clear mouse-selected text when typing — useful for selecting a path then pasting it after a command prefix like `sz `.',
|
||||
'settings.terminal.behavior.forcePromptNewLine': 'Prompt on a new line',
|
||||
'settings.terminal.behavior.forcePromptNewLine.desc':
|
||||
'When the final line of command output is not terminated by a newline, move the recognized shell prompt to the next visual line.',
|
||||
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 clipboard',
|
||||
'settings.terminal.behavior.osc52Clipboard.desc':
|
||||
'Allow remote programs (tmux, vim, etc.) to access the local clipboard via OSC-52 escape sequences.',
|
||||
'settings.terminal.behavior.osc52Clipboard.off': 'Disabled',
|
||||
'settings.terminal.behavior.osc52Clipboard.writeOnly': 'Write only',
|
||||
'settings.terminal.behavior.osc52Clipboard.readWrite': 'Read & Write',
|
||||
'settings.terminal.behavior.osc52Clipboard.prompt': 'Write + Prompt on Read',
|
||||
'terminal.osc52.readPrompt.title': 'Clipboard Read Request',
|
||||
'terminal.osc52.readPrompt.desc': 'A remote program is requesting to read your clipboard. Allow?',
|
||||
'terminal.osc52.readPrompt.allow': 'Allow',
|
||||
'terminal.osc52.readPrompt.deny': 'Deny',
|
||||
'settings.terminal.behavior.scrollOnInput': 'Scroll on input',
|
||||
'settings.terminal.behavior.scrollOnInput.desc': 'Scroll terminal to bottom when typing',
|
||||
'settings.terminal.behavior.scrollOnOutput': 'Scroll on output',
|
||||
'settings.terminal.behavior.scrollOnOutput.desc':
|
||||
'Scroll terminal to bottom when new output arrives',
|
||||
'settings.terminal.behavior.scrollOnKeyPress': 'Scroll on key press',
|
||||
'settings.terminal.behavior.scrollOnKeyPress.desc':
|
||||
'Scroll terminal to bottom when pressing a key (e.g., Enter)',
|
||||
'settings.terminal.behavior.scrollOnPaste': 'Scroll on paste',
|
||||
'settings.terminal.behavior.scrollOnPaste.desc':
|
||||
'Scroll terminal to bottom when pasting text',
|
||||
'settings.terminal.behavior.smoothScrolling': 'Smooth scrolling',
|
||||
'settings.terminal.behavior.smoothScrolling.desc':
|
||||
'Animate terminal viewport scrolling instead of jumping instantly',
|
||||
'settings.terminal.behavior.linkModifier': 'Link modifier key',
|
||||
'settings.terminal.behavior.linkModifier.desc': 'Hold this key to click on links in terminal',
|
||||
'settings.terminal.behavior.linkModifier.none': 'None (click directly)',
|
||||
'settings.terminal.behavior.linkModifier.ctrl': 'Ctrl',
|
||||
'settings.terminal.behavior.linkModifier.alt': 'Alt / Option',
|
||||
'settings.terminal.behavior.linkModifier.meta': 'Cmd / Win',
|
||||
'settings.terminal.scrollback.desc': 'Limit number of terminal rows. Set to 0 for no limit.',
|
||||
'settings.terminal.scrollback.rows': 'Number of rows *',
|
||||
'settings.terminal.section.startupCommand': 'Startup command',
|
||||
'settings.terminal.startupCommandDelay.label': 'Startup command delay (ms)',
|
||||
'settings.terminal.startupCommandDelay.desc': 'How long to wait after connecting before sending the startup command. Also used between lines when the startup command has multiple lines. Increase for slow connections.',
|
||||
'settings.terminal.keywordHighlight.title': 'Keyword highlighting',
|
||||
'settings.terminal.keywordHighlight.resetColors': 'Reset to default colors',
|
||||
'settings.terminal.keywordHighlight.resetDefaults': 'Reset built-ins to defaults',
|
||||
'settings.terminal.keywordHighlight.resetBuiltIn': 'Restore default label and patterns',
|
||||
'settings.terminal.keywordHighlight.addCustom': 'Add Custom Rule',
|
||||
'settings.terminal.keywordHighlight.editCustom': 'Edit Rule',
|
||||
'settings.terminal.keywordHighlight.editBuiltIn': 'Edit Built-in Rule',
|
||||
'settings.terminal.keywordHighlight.labelField': 'Label & Color',
|
||||
'settings.terminal.keywordHighlight.labelPlaceholder': 'Label (e.g., Down)',
|
||||
'settings.terminal.keywordHighlight.patternField': 'Regex Patterns',
|
||||
'settings.terminal.keywordHighlight.patternPlaceholder': 'One regex per line (e.g., \\bdown\\b)',
|
||||
'settings.terminal.keywordHighlight.patternHint': 'One regex per line. Patterns are matched case-insensitively with the global flag.',
|
||||
'settings.terminal.keywordHighlight.invalidPattern': 'Invalid regex pattern',
|
||||
'settings.terminal.keywordHighlight.preview': 'Preview',
|
||||
'settings.terminal.section.localShell': 'Local Shell',
|
||||
'settings.terminal.localShell.shell': 'Shell executable',
|
||||
'settings.terminal.localShell.shell.desc': 'Path to the shell executable (e.g., /bin/zsh, pwsh.exe). Leave empty for system default.',
|
||||
'settings.terminal.localShell.shell.placeholder': 'System default',
|
||||
'settings.terminal.localShell.shell.detected': 'Detected',
|
||||
'settings.terminal.localShell.shell.notFound': 'Shell executable not found',
|
||||
'settings.terminal.localShell.shell.isDirectory': 'Path is a directory, not an executable',
|
||||
'settings.terminal.localShell.shell.default': 'System Default',
|
||||
'settings.terminal.localShell.shell.custom': 'Custom...',
|
||||
'settings.terminal.localShell.shell.customPath': 'Shell executable path',
|
||||
'settings.terminal.localShell.shell.commonPaths': 'Common paths',
|
||||
'settings.terminal.localShell.shell.pathValid': 'Path valid',
|
||||
'settings.terminal.localShell.startDir': 'Starting directory',
|
||||
'settings.terminal.localShell.startDir.desc': 'Directory to start in when opening a local terminal. Leave empty for home directory.',
|
||||
'settings.terminal.localShell.startDir.placeholder': 'Home directory',
|
||||
'settings.terminal.localShell.startDir.notFound': 'Directory not found',
|
||||
'settings.terminal.localShell.startDir.isFile': 'Path is a file, not a directory',
|
||||
'settings.terminal.section.connection': 'Connection',
|
||||
'settings.terminal.connection.keepaliveInterval': 'Keepalive Interval',
|
||||
'settings.terminal.connection.keepaliveInterval.desc': 'How often (in seconds) to send SSH-level keepalive packets. Set to 0 to disable globally — note that individual hosts can override this in their own settings.',
|
||||
'settings.terminal.connection.keepaliveCountMax': 'Max unanswered keepalives',
|
||||
'settings.terminal.connection.keepaliveCountMax.desc': 'Unanswered keepalives before the connection is declared dead. Higher values are more forgiving of brief network glitches and SSH servers that respond slowly.',
|
||||
'settings.terminal.connection.x11Display': 'X11 display',
|
||||
'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.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',
|
||||
'settings.terminal.serverStats.refreshInterval.desc': 'How often to refresh server stats.',
|
||||
'settings.terminal.serverStats.seconds': 'seconds',
|
||||
|
||||
// Settings > Terminal > Rendering
|
||||
'settings.terminal.section.rendering': 'Rendering',
|
||||
'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 > Workspace Focus Indicator
|
||||
'settings.terminal.section.workspaceFocus': 'Workspace Focus Indicator',
|
||||
'settings.terminal.workspaceFocus.style': 'Focus indicator style',
|
||||
'settings.terminal.workspaceFocus.style.desc': 'How to indicate which pane is focused in split view.',
|
||||
'settings.terminal.workspaceFocus.dim': 'Dim unfocused panes',
|
||||
'settings.terminal.workspaceFocus.border': 'Border on focused pane',
|
||||
|
||||
// Settings > Terminal > Autocomplete
|
||||
'settings.terminal.section.autocomplete': 'Autocomplete',
|
||||
'settings.terminal.autocomplete.enabled': 'Enable autocomplete',
|
||||
'settings.terminal.autocomplete.enabled.desc': 'Show command suggestions based on history and command specs as you type.',
|
||||
'settings.terminal.autocomplete.ghostText': 'Ghost text',
|
||||
'settings.terminal.autocomplete.ghostText.desc': 'Show inline gray suggestion text after the cursor (like fish shell).',
|
||||
'settings.terminal.autocomplete.popupMenu': 'Popup menu',
|
||||
'settings.terminal.autocomplete.popupMenu.desc': 'Show a floating list of multiple suggestions.',
|
||||
|
||||
// Settings > Shortcuts
|
||||
'settings.shortcuts.section.scheme': 'Hotkey Scheme',
|
||||
'settings.shortcuts.scheme.label': 'Keyboard shortcuts',
|
||||
'settings.shortcuts.scheme.desc': 'Choose which keyboard layout to use for shortcuts',
|
||||
'settings.shortcuts.scheme.disabled': 'Disabled',
|
||||
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
|
||||
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
|
||||
'settings.shortcuts.section.custom': 'Custom Shortcuts',
|
||||
'settings.shortcuts.resetAll': 'Reset All',
|
||||
'settings.shortcuts.recording': 'Press keys...',
|
||||
'settings.shortcuts.none': 'None',
|
||||
'settings.shortcuts.setDisabled': 'Set to disabled',
|
||||
'settings.shortcuts.category.tabs': 'Tabs',
|
||||
'settings.shortcuts.category.terminal': 'Terminal',
|
||||
'settings.shortcuts.category.navigation': 'Navigation',
|
||||
'settings.shortcuts.category.app': 'App',
|
||||
'settings.shortcuts.category.sftp': 'SFTP',
|
||||
|
||||
// Context menus / common actions
|
||||
'action.newHost': 'New Host',
|
||||
'action.newSubfolder': 'New Subfolder',
|
||||
'action.copyPublicKey': 'Copy Public Key',
|
||||
'action.keyExport': 'Key Export',
|
||||
'action.edit': 'Edit',
|
||||
'action.delete': 'Delete',
|
||||
'action.duplicate': 'Duplicate',
|
||||
'action.open': 'Open',
|
||||
'action.copy': 'Copy',
|
||||
'action.run': 'Run',
|
||||
'action.start': 'Start',
|
||||
'action.stop': 'Stop',
|
||||
'action.remove': 'Remove',
|
||||
'action.convertToHost': 'Convert to Host',
|
||||
|
||||
// Sync
|
||||
'sync.cloudSync': 'Cloud Sync',
|
||||
'sync.settings': 'Sync Settings',
|
||||
'sync.active': 'Cloud Sync Active',
|
||||
'sync.syncing': 'Syncing...',
|
||||
'sync.error': 'Sync Error',
|
||||
'sync.notConfigured': 'Not Configured',
|
||||
'sync.failed': 'Sync failed',
|
||||
'sync.connected': 'Connected',
|
||||
'sync.syncNow': 'Sync Now',
|
||||
'sync.recentActivity': 'Recent activity',
|
||||
'sync.history.uploaded': 'Uploaded',
|
||||
'sync.history.downloaded': 'Downloaded',
|
||||
'sync.history.resolved': 'Resolved',
|
||||
'sync.toast.completedMessage': 'Sync completed successfully',
|
||||
'sync.toast.errorTitle': 'Sync Error',
|
||||
'sync.autoSync.failedTitle': 'Sync failed',
|
||||
'sync.autoSync.inspectFailedTitle': 'Sync paused',
|
||||
'sync.autoSync.inspectFailedMessage': 'Could not reach the cloud to check for changes. Auto-sync will retry when data changes or the app is restarted.',
|
||||
'sync.autoSync.syncedTitle': 'Synced from cloud',
|
||||
'sync.autoSync.syncedMessage': 'Your data has been updated from the cloud.',
|
||||
'sync.autoSync.noProvider': 'No cloud provider connected. Open Settings → Sync & Cloud to connect one.',
|
||||
'sync.autoSync.alreadySyncing': 'Sync is already in progress.',
|
||||
'sync.autoSync.restoreInProgress': 'A vault restore is in progress in another window. Please wait for it to finish.',
|
||||
'sync.autoSync.interruptedApplyTitle': 'Sync paused — previous restore interrupted',
|
||||
'sync.autoSync.interruptedApplyMessage': 'A previous restore did not finish cleanly, so the local vault may be inconsistent. Open Settings → Sync & Cloud → Restore and apply a protective backup before auto-sync resumes.',
|
||||
'sync.autoSync.vaultLocked': 'Vault is locked. Open Settings → Sync & Cloud to unlock.',
|
||||
'sync.autoSync.conflictDetected': 'Sync conflict detected. Open Settings → Sync & Cloud to resolve.',
|
||||
'sync.autoSync.syncFailed': 'Sync failed',
|
||||
'sync.autoSync.restoredTitle': 'Vault restored',
|
||||
'sync.autoSync.restoredMessage': 'Your vault has been restored from the cloud.',
|
||||
'sync.autoSync.keptLocalTitle': 'Kept local vault',
|
||||
'sync.autoSync.keptLocalMessage': 'Your empty local vault was kept. Cloud data was not applied.',
|
||||
'sync.autoSync.emptyVaultConflict.title': 'Empty Vault Detected',
|
||||
'sync.autoSync.emptyVaultConflict.description': 'Your local vault is empty, but the cloud has data. This usually happens after an update or storage reset. What would you like to do?',
|
||||
'sync.autoSync.emptyVaultConflict.cloudLabel': 'Cloud',
|
||||
'sync.autoSync.emptyVaultConflict.restore': 'Restore from Cloud',
|
||||
'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.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',
|
||||
'sync.blocked.reason.bulkShrink': 'Would delete {lost} of {baseCount} {entityType} from cloud ({percent}% reduction).',
|
||||
'sync.blocked.reason.largeShrink': 'Would delete {lost} {entityType} from cloud.',
|
||||
'sync.blocked.detail': 'This is usually caused by a degraded local state (keychain failure, partial data load). Restore from a local backup, or force-push if you truly meant to remove these entries.',
|
||||
'sync.blocked.restoreButton': 'Restore from local backup',
|
||||
'sync.blocked.forcePushButton': 'Force push anyway',
|
||||
|
||||
'sync.forcePush.title': 'Confirm force push',
|
||||
'sync.forcePush.body': 'You are about to remove {lost} {entityType} from the cloud. This cannot be undone. Proceed?',
|
||||
'sync.forcePush.confirm': 'Yes, push anyway',
|
||||
'sync.forcePush.cancel': 'Cancel',
|
||||
|
||||
'sync.entityType.hosts': 'hosts',
|
||||
'sync.entityType.keys': 'keys',
|
||||
'sync.entityType.identities': 'identities',
|
||||
'sync.entityType.proxyProfiles': 'proxy profiles',
|
||||
'sync.entityType.snippets': 'snippets',
|
||||
'sync.entityType.customGroups': 'groups',
|
||||
'sync.entityType.snippetPackages': 'snippet packages',
|
||||
'sync.entityType.knownHosts': 'known-host entries',
|
||||
'sync.entityType.portForwardingRules': 'port-forwarding rules',
|
||||
'sync.entityType.groupConfigs': 'group configs',
|
||||
|
||||
'sync.credentialsUnavailable': 'This device cannot decrypt some saved credentials. Re-enter credentials locally before syncing.',
|
||||
'time.never': 'Never',
|
||||
'time.justNow': 'Just now',
|
||||
'time.minutesAgo': '{minutes}m ago',
|
||||
|
||||
// Vault navigation
|
||||
'vault.nav.hosts': 'Hosts',
|
||||
'vault.nav.keychain': 'Keychain',
|
||||
'vault.nav.proxies': 'Proxies',
|
||||
'vault.nav.portForwarding': 'Port Forwarding',
|
||||
'vault.nav.snippets': 'Snippets',
|
||||
'vault.nav.knownHosts': 'Known Hosts',
|
||||
'vault.nav.logs': 'Logs',
|
||||
|
||||
'proxyProfiles.action.add': 'Add Proxy',
|
||||
'proxyProfiles.search.placeholder': 'Search proxies…',
|
||||
'proxyProfiles.section.proxies': 'Proxies',
|
||||
'proxyProfiles.count.items': '{count} items',
|
||||
'proxyProfiles.empty.title': 'No Proxies',
|
||||
'proxyProfiles.empty.desc': 'Create reusable HTTP or SOCKS5 proxies and select them from host details.',
|
||||
'proxyProfiles.usage': '{count} linked',
|
||||
'proxyProfiles.copyName': '{name} Copy',
|
||||
'proxyProfiles.panel.newTitle': 'New Proxy',
|
||||
'proxyProfiles.field.name': 'Proxy name',
|
||||
'proxyProfiles.error.required': 'Name, host, and port are required.',
|
||||
'proxyProfiles.error.port': 'Port must be between 1 and 65535.',
|
||||
'proxyProfiles.viewMode': 'Proxy view mode',
|
||||
'proxyProfiles.delete.title': 'Delete proxy?',
|
||||
'proxyProfiles.delete.desc': 'Deleting "{name}" will unlink it from {count} host or group settings.',
|
||||
|
||||
'vault.groups.title': 'Groups',
|
||||
'vault.groups.total': '{count} total',
|
||||
'vault.groups.hostsCount': '{count} Hosts',
|
||||
'vault.groups.newSubgroup': 'New Subgroup',
|
||||
'vault.groups.rename': 'Rename Group',
|
||||
'vault.groups.delete': 'Delete Group',
|
||||
'vault.groups.createSubfolder': 'Create Subfolder',
|
||||
'vault.groups.createRoot': 'Create Root Group',
|
||||
'vault.groups.createDialog.desc': 'Create a new group for organizing hosts.',
|
||||
'vault.groups.renameDialogTitle': 'Rename Group',
|
||||
'vault.groups.renameDialog.desc': 'Rename an existing group.',
|
||||
'vault.groups.deleteDialogTitle': 'Delete Group',
|
||||
'vault.groups.deleteDialog.desc': 'This will permanently delete the group and move all hosts to the root level.',
|
||||
'vault.groups.deleteDialog.managedDesc': 'This is a managed SSH config group. Deleting it will also delete all hosts and unlink from the source file.',
|
||||
'vault.groups.deleteDialog.deleteHosts': 'Also delete all hosts in this group',
|
||||
'vault.groups.ungrouped': 'Ungrouped',
|
||||
'vault.groups.field.name': 'Group Name',
|
||||
'vault.groups.placeholder.example': 'e.g. Production',
|
||||
'vault.groups.parentLabel': 'Parent',
|
||||
'vault.groups.pathLabel': 'Path',
|
||||
'vault.groups.settings': 'Group Settings',
|
||||
'vault.groups.details': 'Group Details',
|
||||
'vault.groups.details.general': 'General',
|
||||
'vault.groups.details.ssh': 'SSH',
|
||||
'vault.groups.details.telnet': 'Telnet',
|
||||
'vault.groups.details.advanced': 'Advanced',
|
||||
'vault.groups.details.appearance': 'Appearance',
|
||||
'vault.groups.details.mosh': 'Mosh',
|
||||
'vault.groups.details.parentGroup': 'Parent Group',
|
||||
'vault.groups.details.none': 'None',
|
||||
'vault.groups.details.inherited': 'Inherited from group',
|
||||
'vault.groups.details.addProtocol': 'Add Protocol',
|
||||
'vault.groups.details.removeProtocol': 'Remove Protocol',
|
||||
'vault.groups.details.fontFamily': 'Font Family',
|
||||
'vault.groups.details.fontSize': 'Font Size',
|
||||
'vault.groups.errors.required': 'Group name is required.',
|
||||
'vault.groups.errors.invalidChars': "Group name cannot include '/' or '\\\\'.",
|
||||
'vault.groups.errors.duplicatePath': 'A group with this name already exists at this location.',
|
||||
|
||||
'vault.managedSource.unmanage': 'Unmanage',
|
||||
'vault.managedSource.unmanageSuccess': 'Successfully unmanaged group',
|
||||
|
||||
'vault.hosts.header.entries': '{count} entries',
|
||||
'vault.hosts.header.live': '{count} live',
|
||||
|
||||
// Vault hosts header/actions
|
||||
'vault.hosts.search.placeholder': 'Find a host or ssh user@hostname / ssh -p 2222 user@hostname...',
|
||||
'vault.hosts.connect': 'Connect',
|
||||
'vault.view.grid': 'Grid',
|
||||
'vault.view.list': 'List',
|
||||
'vault.view.tree': 'Tree',
|
||||
'vault.tree.expandAll': 'Expand All',
|
||||
'vault.tree.collapseAll': 'Collapse All',
|
||||
'vault.hosts.newHost': 'New Host',
|
||||
'vault.hosts.newGroup': 'New Group',
|
||||
'vault.hosts.import': 'Import',
|
||||
'vault.hosts.export': 'Export',
|
||||
'vault.hosts.export.toast.success': 'Exported {count} hosts to CSV',
|
||||
'vault.hosts.export.toast.successWithSkipped': 'Exported {count} hosts to CSV ({skipped} unsupported hosts skipped)',
|
||||
'vault.hosts.export.toast.noHosts': 'No hosts to export',
|
||||
'vault.hosts.allHosts': 'All hosts',
|
||||
'vault.hosts.pinned': 'Pinned',
|
||||
'vault.hosts.recentlyConnected': 'Recently Connected',
|
||||
'vault.hosts.pinToTop': 'Pin to Top',
|
||||
'vault.hosts.unpin': 'Unpin',
|
||||
'vault.hosts.copyCredentials': 'Copy Credentials',
|
||||
'vault.hosts.copyCredentials.toast.success': 'Credentials copied to clipboard',
|
||||
'vault.hosts.copyCredentials.toast.noPassword': 'No password saved for this host',
|
||||
'vault.hosts.multiSelect': 'Multi-select',
|
||||
'vault.hosts.selected': '{count} selected',
|
||||
'vault.hosts.selectAll': 'Select All',
|
||||
'vault.hosts.deselectAll': 'Deselect All',
|
||||
'vault.hosts.deleteSelected': 'Delete ({count})',
|
||||
'vault.hosts.deleteMultiple.success': 'Deleted {count} hosts',
|
||||
'vault.hosts.connectSelected': 'Connect ({count})',
|
||||
'vault.hosts.connectMultiple.success': 'Connecting {count} hosts',
|
||||
'vault.hosts.moveToGroup.success': 'Moved {host} to {group}',
|
||||
'vault.hosts.empty.title': 'Set up your hosts',
|
||||
'vault.hosts.empty.desc': 'Save hosts to quickly connect to your servers, VMs, and containers.',
|
||||
|
||||
};
|
||||
655
application/i18n/locales/en/terminal.ts
Normal file
655
application/i18n/locales/en/terminal.ts
Normal file
@@ -0,0 +1,655 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enTerminalMessages: Messages = {
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': 'Open SFTP',
|
||||
'terminal.toolbar.availableAfterConnect': 'Available after connect',
|
||||
'terminal.toolbar.sftp': 'SFTP',
|
||||
'terminal.toolbar.more': 'More actions',
|
||||
'terminal.toolbar.scripts': 'Scripts',
|
||||
'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.broadcast': 'Broadcast',
|
||||
'terminal.toolbar.broadcastEnable': 'Enable Broadcast Mode',
|
||||
'terminal.toolbar.broadcastDisable': 'Disable Broadcast Mode',
|
||||
'terminal.toolbar.composeBar': 'Compose Bar',
|
||||
'terminal.composeBar.placeholder': 'Type command here, press Enter to send...',
|
||||
'terminal.composeBar.send': 'Send',
|
||||
'terminal.composeBar.close': 'Close compose bar',
|
||||
'terminal.composeBar.broadcasting': 'Broadcasting to all sessions',
|
||||
'terminal.toolbar.focus': 'Focus',
|
||||
'terminal.toolbar.focusMode': 'Focus Mode',
|
||||
'terminal.toolbar.encoding': 'Terminal Encoding',
|
||||
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
||||
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
||||
'terminal.toolbar.closeSession': 'Close session',
|
||||
'terminal.toolbar.hostHighlight.title': 'Host Keyword Highlighting',
|
||||
'terminal.toolbar.hostHighlight.noRules': 'No custom highlight rules defined for this host',
|
||||
'terminal.toolbar.hostHighlight.addRule': 'Add New Rule',
|
||||
'terminal.toolbar.hostHighlight.labelPlaceholder': 'Label (e.g., Error)',
|
||||
'terminal.toolbar.hostHighlight.patternPlaceholder': 'Regex pattern (e.g., \\bfailed\\b)',
|
||||
'terminal.toolbar.hostHighlight.invalidPattern': 'Invalid regex pattern',
|
||||
'terminal.toolbar.hostHighlight.clearAll': 'Clear All',
|
||||
'terminal.toolbar.hostHighlight.changeColor': 'Change highlight color for',
|
||||
'terminal.toolbar.hostHighlight.selectColor': 'Select color for new rule',
|
||||
'terminal.statusbar.copyHostname.label': 'Copy host address',
|
||||
'terminal.statusbar.copyHostname.tooltip': 'Copy host address ({hostname})',
|
||||
'terminal.statusbar.copyHostname.toast': 'Copied host address: {hostname}',
|
||||
'terminal.statusbar.copyHostname.error': 'Failed to copy host address to clipboard',
|
||||
'terminal.serverStats.cpu': 'CPU Usage',
|
||||
'terminal.serverStats.cpuCores': 'CPU Core Usage',
|
||||
'terminal.serverStats.memory': 'Memory Usage',
|
||||
'terminal.serverStats.memoryDetails': 'Memory Details',
|
||||
'terminal.serverStats.memUsed': 'Used',
|
||||
'terminal.serverStats.memBuffers': 'Buffers',
|
||||
'terminal.serverStats.memCached': 'Cache',
|
||||
'terminal.serverStats.memFree': 'Free',
|
||||
'terminal.serverStats.swap': 'Swap',
|
||||
'terminal.serverStats.swapUsed': 'Swap Used',
|
||||
'terminal.serverStats.swapFree': 'Swap Free',
|
||||
'terminal.serverStats.swapTotal': 'Total',
|
||||
'terminal.serverStats.topProcesses': 'Top Processes by Memory',
|
||||
'terminal.serverStats.disk': 'Disk Usage (Root)',
|
||||
'terminal.serverStats.diskDetails': 'Mounted Disks',
|
||||
'terminal.serverStats.network': 'Network Speed',
|
||||
'terminal.serverStats.networkDetails': 'Network Interfaces',
|
||||
'terminal.serverStats.noData': 'No data available',
|
||||
'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.notConnected': 'Cannot drop files - terminal is not connected',
|
||||
'terminal.dragDrop.errorTitle': 'Drop Error',
|
||||
'terminal.dragDrop.errorMessage': 'Failed to process dropped files',
|
||||
'terminal.search.placeholder': 'Search...',
|
||||
'terminal.search.noResults': 'No results',
|
||||
'terminal.search.prevMatch': 'Previous match (Shift+Enter)',
|
||||
'terminal.search.nextMatch': 'Next match (Enter)',
|
||||
'terminal.menu.copy': 'Copy',
|
||||
'terminal.menu.paste': 'Paste',
|
||||
'terminal.menu.pasteSelection': 'Paste Selection',
|
||||
'terminal.menu.selectAll': 'Select All',
|
||||
'terminal.menu.reconnect': 'Reconnect',
|
||||
'terminal.menu.splitHorizontal': 'Split Horizontal',
|
||||
'terminal.menu.splitVertical': 'Split Vertical',
|
||||
'terminal.menu.clearBuffer': 'Clear Buffer',
|
||||
'terminal.menu.closeTerminal': 'Close terminal',
|
||||
'terminal.auth.password': 'Password',
|
||||
'terminal.auth.sshKey': 'SSH Key',
|
||||
'terminal.auth.username': 'Username',
|
||||
'terminal.auth.username.placeholder': 'root',
|
||||
'terminal.auth.passwordLabel': 'Password',
|
||||
'terminal.auth.password.placeholder': 'Enter password',
|
||||
'terminal.auth.passphrase': 'Passphrase',
|
||||
'terminal.auth.passphrase.placeholder': 'Optional passphrase for the selected private key',
|
||||
'terminal.auth.certificate': 'Certificate',
|
||||
'terminal.auth.selectKey': 'Select Key',
|
||||
'terminal.auth.noKeysHint': 'No keys available. Add keys in Keychain.',
|
||||
'terminal.auth.continueSave': 'Continue & Save',
|
||||
'terminal.auth.credentialsUnavailable': 'Saved credentials cannot be decrypted on this device. Please re-enter and save them again.',
|
||||
'terminal.auth.jumpCredentialsUnavailable': 'A jump host has saved credentials that cannot be decrypted on this device. Open host settings and re-enter them.',
|
||||
'terminal.auth.proxyCredentialsUnavailable': 'Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.',
|
||||
'terminal.auth.keyUnavailableFallbackPassword': 'Saved SSH key is unavailable on this device. Falling back to password authentication.',
|
||||
'terminal.progress.timeoutIn': 'Timeout in {seconds}s',
|
||||
'terminal.progress.disconnected': 'Disconnected',
|
||||
'terminal.progress.cancelling': 'Cancelling...',
|
||||
'terminal.progress.startOver': 'Start over',
|
||||
'terminal.connection.dismissDisconnectedDialog': 'Dismiss disconnected notice',
|
||||
'terminal.connection.chainOf': 'Chain {current} of {total}',
|
||||
'terminal.connection.showLogs': 'Show logs',
|
||||
'terminal.connection.hideLogs': 'Hide logs',
|
||||
'terminal.connection.protocol.ssh': 'SSH',
|
||||
'terminal.connection.protocol.telnet': 'Telnet',
|
||||
'terminal.connection.protocol.mosh': 'Mosh',
|
||||
'terminal.connection.protocol.serial': 'Serial',
|
||||
'terminal.connection.protocol.local': 'Local Shell',
|
||||
'terminal.hostKey.unknownTitle': 'Confirm this host key',
|
||||
'terminal.hostKey.changedTitle': 'Host key changed',
|
||||
'terminal.hostKey.unknownDescription': 'The authenticity of {host} cannot be established yet.',
|
||||
'terminal.hostKey.changedDescription': 'The saved key for {host} no longer matches this server.',
|
||||
'terminal.hostKey.fingerprintLabel': '{keyType} fingerprint is SHA256:',
|
||||
'terminal.hostKey.savedFingerprintLabel': 'Saved fingerprint',
|
||||
'terminal.hostKey.unknownHint': 'Remember it if this fingerprint belongs to the server you expected.',
|
||||
'terminal.hostKey.changedHint': 'Only continue if you expected this host to change.',
|
||||
'terminal.hostKey.addAndContinue': 'Add and continue',
|
||||
'terminal.hostKey.updateAndContinue': 'Update and continue',
|
||||
'terminal.themeModal.title': 'Terminal Appearance',
|
||||
'terminal.themeModal.tab.theme': 'Theme',
|
||||
'terminal.themeModal.tab.font': 'Font',
|
||||
'terminal.themeModal.tab.custom': 'Custom',
|
||||
'terminal.themeModal.globalTheme': 'Global Theme',
|
||||
'terminal.themeModal.globalFont': 'Global Font',
|
||||
'terminal.themeModal.fontSize': 'Font Size',
|
||||
'terminal.themeModal.fontWeight': 'Font Weight',
|
||||
'terminal.themeModal.livePreview': 'Live Preview',
|
||||
'terminal.themeModal.themeType': '{type} theme',
|
||||
'terminal.hiddenTheme.title': 'Current hidden theme',
|
||||
'terminal.hiddenTheme.desc': 'This theme is hidden from manual picks and will be replaced when you choose another theme.',
|
||||
'topTabs.toggleTheme.systemExitTitle': 'System theme is active',
|
||||
'topTabs.toggleTheme.systemExitMessage': 'Open Settings to choose a fixed Light or Dark theme.',
|
||||
'topTabs.toggleTheme.openSettings': 'Open Settings',
|
||||
|
||||
// Custom Themes
|
||||
'terminal.customTheme.section': 'Custom Themes',
|
||||
'terminal.customTheme.yourThemes': 'Your Themes',
|
||||
'terminal.customTheme.new': 'New Theme',
|
||||
'terminal.customTheme.newDesc': 'Clone current theme and customize',
|
||||
'terminal.customTheme.newTitle': 'New Custom Theme',
|
||||
'terminal.customTheme.editTitle': 'Edit Theme',
|
||||
'terminal.customTheme.import': 'Import .itermcolors',
|
||||
'terminal.customTheme.importDesc': 'Import from iTerm2 color scheme file',
|
||||
'terminal.customTheme.importError': 'Failed to parse the selected file. Please ensure it is a valid .itermcolors XML file.',
|
||||
'terminal.customTheme.delete': 'Delete Theme',
|
||||
'terminal.customTheme.confirmDelete': 'Confirm Delete',
|
||||
'terminal.customTheme.name': 'Name',
|
||||
'terminal.customTheme.namePlaceholder': 'My Custom Theme',
|
||||
'terminal.customTheme.type': 'Type',
|
||||
'terminal.customTheme.group.general': 'General',
|
||||
'terminal.customTheme.group.normal': 'Normal Colors',
|
||||
'terminal.customTheme.group.bright': 'Bright Colors',
|
||||
'terminal.customTheme.color.background': 'Background',
|
||||
'terminal.customTheme.color.foreground': 'Foreground',
|
||||
'terminal.customTheme.color.cursor': 'Cursor',
|
||||
'terminal.customTheme.color.selection': 'Selection',
|
||||
'terminal.customTheme.color.black': 'Black',
|
||||
'terminal.customTheme.color.red': 'Red',
|
||||
'terminal.customTheme.color.green': 'Green',
|
||||
'terminal.customTheme.color.yellow': 'Yellow',
|
||||
'terminal.customTheme.color.blue': 'Blue',
|
||||
'terminal.customTheme.color.magenta': 'Magenta',
|
||||
'terminal.customTheme.color.cyan': 'Cyan',
|
||||
'terminal.customTheme.color.white': 'White',
|
||||
'terminal.customTheme.color.brightBlack': 'Bright Black',
|
||||
'terminal.customTheme.color.brightRed': 'Bright Red',
|
||||
'terminal.customTheme.color.brightGreen': 'Bright Green',
|
||||
'terminal.customTheme.color.brightYellow': 'Bright Yellow',
|
||||
'terminal.customTheme.color.brightBlue': 'Bright Blue',
|
||||
'terminal.customTheme.color.brightMagenta': 'Bright Magenta',
|
||||
'terminal.customTheme.color.brightCyan': 'Bright Cyan',
|
||||
'terminal.customTheme.color.brightWhite': 'Bright White',
|
||||
|
||||
// Cloud Sync Settings
|
||||
'cloudSync.gate.title': 'End-to-End Encrypted Sync',
|
||||
'cloudSync.gate.desc':
|
||||
'Your data is encrypted locally before syncing. Cloud providers never see your plaintext data. Set a master key to enable secure sync.',
|
||||
'cloudSync.gate.masterKey': 'Master Key',
|
||||
'cloudSync.gate.confirmMasterKey': 'Confirm Master Key',
|
||||
'cloudSync.gate.placeholder': 'Enter a strong password',
|
||||
'cloudSync.gate.confirmPlaceholder': 'Confirm your password',
|
||||
'cloudSync.gate.mismatch': 'Passwords do not match',
|
||||
'cloudSync.gate.warning':
|
||||
'I understand that if I forget my master key, my data cannot be recovered. There is no password reset.',
|
||||
'cloudSync.gate.enableVault': 'Enable Encrypted Vault',
|
||||
'cloudSync.gate.enabledToast': 'Encrypted vault enabled',
|
||||
'cloudSync.gate.setupFailed': 'Failed to set up master key',
|
||||
'cloudSync.passwordStrength.tooShort': 'Too short',
|
||||
'cloudSync.passwordStrength.weak': 'Weak',
|
||||
'cloudSync.passwordStrength.moderate': 'Moderate',
|
||||
'cloudSync.passwordStrength.strong': 'Strong',
|
||||
'cloudSync.passwordStrength.veryStrong': 'Very Strong',
|
||||
'cloudSync.provider.notConnected': 'Not connected',
|
||||
'cloudSync.provider.sync': 'Sync',
|
||||
'cloudSync.provider.connect': 'Connect',
|
||||
'cloudSync.provider.connecting': 'Connecting...',
|
||||
'cloudSync.provider.webdav': 'WebDAV',
|
||||
'cloudSync.provider.webdav.desc': 'Connect to a self-hosted WebDAV endpoint',
|
||||
'cloudSync.provider.s3': 'S3 Compatible',
|
||||
'cloudSync.provider.s3.desc': 'Connect to S3-compatible object storage',
|
||||
'cloudSync.provider.comingSoon': 'Coming soon',
|
||||
'cloudSync.webdav.title': 'WebDAV Settings',
|
||||
'cloudSync.webdav.desc': 'Configure a WebDAV endpoint for encrypted sync.',
|
||||
'cloudSync.webdav.endpoint': 'Endpoint URL',
|
||||
'cloudSync.webdav.authType': 'Auth Type',
|
||||
'cloudSync.webdav.auth.basic': 'Basic',
|
||||
'cloudSync.webdav.auth.digest': 'Digest',
|
||||
'cloudSync.webdav.auth.token': 'Token',
|
||||
'cloudSync.webdav.username': 'Username',
|
||||
'cloudSync.webdav.password': 'Password',
|
||||
'cloudSync.webdav.token': 'Token',
|
||||
'cloudSync.webdav.showSecret': 'Show secret',
|
||||
'cloudSync.webdav.allowInsecure': 'Allow insecure connection (ignore certificate errors)',
|
||||
'cloudSync.webdav.validation.endpoint': 'Enter a valid WebDAV endpoint.',
|
||||
'cloudSync.webdav.validation.credentials': 'Username and password are required.',
|
||||
'cloudSync.webdav.validation.token': 'Token is required.',
|
||||
'cloudSync.s3.title': 'S3 Settings',
|
||||
'cloudSync.s3.desc': 'Connect to S3-compatible object storage for encrypted sync.',
|
||||
'cloudSync.s3.endpoint': 'Endpoint URL',
|
||||
'cloudSync.s3.region': 'Region',
|
||||
'cloudSync.s3.bucket': 'Bucket',
|
||||
'cloudSync.s3.accessKeyId': 'Access Key ID',
|
||||
'cloudSync.s3.secretAccessKey': 'Secret Access Key',
|
||||
'cloudSync.s3.sessionToken': 'Session Token (optional)',
|
||||
'cloudSync.s3.prefix': 'Key Prefix (optional)',
|
||||
'cloudSync.s3.forcePathStyle': 'Force path-style URLs (for MinIO/R2, etc.)',
|
||||
'cloudSync.s3.showSecret': 'Show secrets',
|
||||
'cloudSync.s3.validation.required': 'Endpoint, region, bucket, access key, and secret are required.',
|
||||
'cloudSync.smb.title': 'SMB Settings',
|
||||
'cloudSync.smb.desc': 'Connect to an SMB/CIFS file share for encrypted sync.',
|
||||
'cloudSync.smb.share': 'Share Path',
|
||||
'cloudSync.smb.username': 'Username',
|
||||
'cloudSync.smb.password': 'Password',
|
||||
'cloudSync.smb.domain': 'Domain (optional)',
|
||||
'cloudSync.smb.domainPlaceholder': 'e.g., WORKGROUP',
|
||||
'cloudSync.smb.port': 'Port (optional)',
|
||||
'cloudSync.smb.showSecret': 'Show password',
|
||||
'cloudSync.smb.validation.share': 'Share path is required.',
|
||||
'cloudSync.smb.validation.port': 'Port must be a number between 1 and 65535.',
|
||||
'cloudSync.connect.smb.success': 'SMB connected successfully',
|
||||
'cloudSync.connect.smb.failedTitle': 'SMB connection failed',
|
||||
'cloudSync.provider.smb': 'SMB Share',
|
||||
'cloudSync.connect.webdav.success': 'WebDAV connected successfully',
|
||||
'cloudSync.connect.webdav.failedTitle': 'WebDAV connection failed',
|
||||
'cloudSync.connect.s3.success': 'S3 connected successfully',
|
||||
'cloudSync.connect.s3.failedTitle': 'S3 connection failed',
|
||||
'cloudSync.lastSync.never': 'Never',
|
||||
'cloudSync.lastSync.justNow': 'Just now',
|
||||
'cloudSync.lastSync.minutesAgo': '{minutes} min ago',
|
||||
'cloudSync.changeKey': 'Change Key',
|
||||
'cloudSync.providers.title': 'Cloud Providers',
|
||||
'cloudSync.syncAll': 'Sync All Connected Providers',
|
||||
'cloudSync.autoSync.title': 'Auto-sync',
|
||||
'cloudSync.autoSync.desc': 'Automatically sync when changes are made',
|
||||
'cloudSync.strategy.title': 'Sync strategy',
|
||||
'cloudSync.strategy.desc': 'Choose what happens when local and cloud data both changed.',
|
||||
'cloudSync.strategy.smartMerge': 'Smart merge (recommended)',
|
||||
'cloudSync.strategy.smartMergeDesc': 'Combine changes from both sides when possible; if Netcatty cannot decide safely, ask you to choose.',
|
||||
'cloudSync.strategy.preferCloud': 'Cloud wins',
|
||||
'cloudSync.strategy.preferCloudDesc': 'When both sides changed, download the cloud version and replace local changes.',
|
||||
'cloudSync.strategy.preferLocal': 'Local wins',
|
||||
'cloudSync.strategy.preferLocalDesc': 'When both sides changed, upload the local version and replace cloud changes.',
|
||||
'cloudSync.status.title': 'Sync Status',
|
||||
'cloudSync.status.localVersion': 'Local Version',
|
||||
'cloudSync.status.remoteVersion': 'Remote Version',
|
||||
'cloudSync.history.title': 'Sync History',
|
||||
'cloudSync.history.upload': 'Upload',
|
||||
'cloudSync.history.download': 'Download',
|
||||
'cloudSync.history.resolved': 'Resolved',
|
||||
'cloudSync.history.error': 'Error',
|
||||
'cloudSync.localBackups.title': 'Local Backup History',
|
||||
'cloudSync.localBackups.desc': 'Netcatty keeps local restore points before app version changes and before vault restores.',
|
||||
'cloudSync.localBackups.retentionTitle': 'Backup Retention',
|
||||
'cloudSync.localBackups.retentionDesc': 'Choose how many local backups Netcatty should keep.',
|
||||
'cloudSync.localBackups.maxCount': 'Max backups',
|
||||
'cloudSync.localBackups.maxSaved': 'Saved backup retention: {count}',
|
||||
'cloudSync.localBackups.maxInvalid': 'Please enter a number between 1 and 100.',
|
||||
'cloudSync.localBackups.empty': 'No local backups yet.',
|
||||
'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.restore': 'Restore',
|
||||
'cloudSync.localBackups.restoreSuccess': 'Local backup restored.',
|
||||
'cloudSync.localBackups.restoreFailedTitle': 'Restore failed',
|
||||
'cloudSync.localBackups.restoreMissing': 'Backup not found.',
|
||||
'cloudSync.localBackups.protectiveBackupFailed': 'Safety backup could not be created, so the restore was aborted to protect your current data. Resolve the underlying issue (e.g. keychain access) and try again. Details: {message}',
|
||||
'cloudSync.localBackups.restoreConfirmTitle': 'Restore this backup?',
|
||||
'cloudSync.localBackups.restoreConfirmDesc': 'Your current hosts, keys, snippets and settings will be replaced with the contents of this backup. A protective snapshot of your current data is taken automatically first.',
|
||||
'cloudSync.localBackups.restoreConfirmButton': 'Restore',
|
||||
'cloudSync.localBackups.restoreConfirmCancel': 'Cancel',
|
||||
'cloudSync.localBackups.unavailableTitle': 'Local backups unavailable',
|
||||
'cloudSync.localBackups.unavailableDesc': 'This platform does not expose a secure keychain to Netcatty, so local backups cannot be written safely. Install Netcatty on a system with a supported keychain to enable the local backup history.',
|
||||
'cloudSync.localBackups.lockedTitle': 'Master key required',
|
||||
'cloudSync.localBackups.lockedDesc': 'Set up or unlock your master key before restoring a backup, so restored credentials remain encrypted.',
|
||||
'cloudSync.revisionHistory.viewButton': 'History',
|
||||
'cloudSync.revisionHistory.title': 'Vault Version History',
|
||||
'cloudSync.revisionHistory.description': 'Browse and restore previous versions of your vault from the Gist revision history.',
|
||||
'cloudSync.revisionHistory.empty': 'No revisions found.',
|
||||
'cloudSync.revisionHistory.current': 'Current',
|
||||
'cloudSync.revisionHistory.revision': 'Revision',
|
||||
'cloudSync.revisionHistory.revisionPreview': 'Revision Contents',
|
||||
'cloudSync.revisionHistory.device': 'Device',
|
||||
'cloudSync.revisionHistory.hosts': 'Hosts',
|
||||
'cloudSync.revisionHistory.keys': 'Keys',
|
||||
'cloudSync.revisionHistory.snippets': 'Snippets',
|
||||
'cloudSync.revisionHistory.identities': 'Identities',
|
||||
'cloudSync.revisionHistory.restoreButton': 'Restore This Version',
|
||||
'cloudSync.revisionHistory.restored': 'Vault restored from selected revision.',
|
||||
'cloudSync.revisionHistory.revisionNotFound': 'Revision not found or does not contain vault data.',
|
||||
'cloudSync.revisionHistory.decryptFailed': 'Cannot decrypt this revision. It may have been encrypted with a different master password.',
|
||||
'cloudSync.changeKey.title': 'Change Master Key',
|
||||
'cloudSync.changeKey.current': 'Current Master Key',
|
||||
'cloudSync.changeKey.new': 'New Master Key',
|
||||
'cloudSync.changeKey.confirmNew': 'Confirm New Master Key',
|
||||
'cloudSync.changeKey.currentPlaceholder': 'Enter current master key',
|
||||
'cloudSync.changeKey.newPlaceholder': 'Enter new master key',
|
||||
'cloudSync.changeKey.confirmPlaceholder': 'Confirm new master key',
|
||||
'cloudSync.changeKey.fillAll': 'Please fill in all fields',
|
||||
'cloudSync.changeKey.minLength': 'New master key must be at least 8 characters',
|
||||
'cloudSync.changeKey.notMatch': 'New master keys do not match',
|
||||
'cloudSync.changeKey.incorrectCurrent': 'Incorrect current master key',
|
||||
'cloudSync.changeKey.failed': 'Failed to change master key',
|
||||
'cloudSync.changeKey.desc': 'This will re-encrypt your vault. Make sure you remember the new key.',
|
||||
'cloudSync.changeKey.showKeys': 'Show keys',
|
||||
'cloudSync.changeKey.updatedToast': 'Master key updated',
|
||||
'cloudSync.changeKey.updateButton': 'Update Key',
|
||||
'cloudSync.unlock.title': 'Enter Master Key',
|
||||
'cloudSync.unlock.masterKey': 'Master Key',
|
||||
'cloudSync.unlock.desc':
|
||||
'Enter your master key once to enable encrypted sync. It will be stored securely using your OS keychain.',
|
||||
'cloudSync.unlock.placeholder': 'Enter your master key',
|
||||
'cloudSync.unlock.empty': 'Please enter your master key',
|
||||
'cloudSync.unlock.incorrect': 'Incorrect master key',
|
||||
'cloudSync.unlock.failed': 'Failed to unlock vault',
|
||||
'cloudSync.unlock.showKey': 'Show key',
|
||||
'cloudSync.unlock.notNow': 'Not now',
|
||||
'cloudSync.unlock.readyToast': 'Vault ready',
|
||||
'cloudSync.unlock.unlockButton': 'Unlock',
|
||||
'cloudSync.header.vaultReady': 'Vault ready',
|
||||
'cloudSync.header.preparingVault': 'Preparing vault...',
|
||||
'cloudSync.header.providersConnected': '{count} provider(s) connected',
|
||||
'cloudSync.githubFlow.title': 'Connect to GitHub',
|
||||
'cloudSync.githubFlow.desc': 'Copy the code below and enter it on GitHub to authorize Netcatty.',
|
||||
'cloudSync.githubFlow.copyCode': 'Copy code',
|
||||
'cloudSync.githubFlow.copied': 'Copied!',
|
||||
'cloudSync.githubFlow.openGitHub': 'Open GitHub',
|
||||
'cloudSync.githubFlow.waiting': 'Waiting for authorization...',
|
||||
'cloudSync.conflict.title': 'Version conflict detected',
|
||||
'cloudSync.conflict.desc': 'Choose which version to keep',
|
||||
'cloudSync.conflict.local': 'LOCAL',
|
||||
'cloudSync.conflict.cloud': 'CLOUD',
|
||||
'cloudSync.conflict.detailsTitle': 'Changed data',
|
||||
'cloudSync.conflict.detailsCounts': 'Local {local} · Cloud {cloud} · Conflicts {conflicts}',
|
||||
'cloudSync.conflict.entity.hosts': 'Hosts',
|
||||
'cloudSync.conflict.entity.keys': 'Keys',
|
||||
'cloudSync.conflict.entity.identities': 'Identities',
|
||||
'cloudSync.conflict.entity.proxyProfiles': 'Proxy profiles',
|
||||
'cloudSync.conflict.entity.snippets': 'Snippets',
|
||||
'cloudSync.conflict.entity.customGroups': 'Groups',
|
||||
'cloudSync.conflict.entity.snippetPackages': 'Snippet packages',
|
||||
'cloudSync.conflict.entity.portForwardingRules': 'Port forwarding',
|
||||
'cloudSync.conflict.entity.groupConfigs': 'Group settings',
|
||||
'cloudSync.conflict.entity.settings': 'Settings',
|
||||
'cloudSync.conflict.keepLocal': 'Overwrite cloud (keep local)',
|
||||
'cloudSync.conflict.useCloud': 'Download cloud (overwrite local)',
|
||||
'cloudSync.connect.browserContinue': 'Complete authorization in browser',
|
||||
'cloudSync.connect.browserCancelled': 'Previous browser authorization was cancelled',
|
||||
'cloudSync.connect.github.success': 'GitHub connected successfully',
|
||||
'cloudSync.connect.github.failedTitle': 'GitHub connection failed',
|
||||
'cloudSync.connect.github.timeout': 'GitHub connection timed out. Check your network or proxy settings.',
|
||||
'cloudSync.connect.github.networkError': 'Unable to reach GitHub. Check your network or proxy settings.',
|
||||
'cloudSync.connect.google.failedTitle': 'Google connection failed',
|
||||
'cloudSync.connect.onedrive.failedTitle': 'OneDrive connection failed',
|
||||
'cloudSync.sync.success': 'Synced to {provider}',
|
||||
'cloudSync.sync.failed': 'Sync failed',
|
||||
'cloudSync.sync.failedTitle': 'Sync failed',
|
||||
'cloudSync.sync.errorTitle': 'Sync error',
|
||||
'cloudSync.resolve.downloaded': 'Downloaded cloud data',
|
||||
'cloudSync.resolve.uploaded': 'Uploaded local data',
|
||||
'cloudSync.resolve.failedTitle': 'Conflict resolution failed',
|
||||
'cloudSync.clearLocal.title': 'Clear Local Data',
|
||||
'cloudSync.clearLocal.desc': 'Reset local version and sync history. Next sync will download from cloud.',
|
||||
'cloudSync.clearLocal.button': 'Clear',
|
||||
'cloudSync.clearLocal.dialog.title': 'Clear Local Vault Data?',
|
||||
'cloudSync.clearLocal.dialog.desc': 'This will reset local version to 0 and clear sync history. Your next sync will download data from the cloud, replacing local data.',
|
||||
'cloudSync.clearLocal.dialog.cancel': 'Cancel',
|
||||
'cloudSync.clearLocal.dialog.confirm': 'Clear Local Data',
|
||||
'cloudSync.clearLocal.toast.title': 'Local data cleared',
|
||||
'cloudSync.clearLocal.toast.desc': 'Local version reset to 0. Sync to download from cloud.',
|
||||
|
||||
// Keychain
|
||||
'keychain.filter.key': 'KEY',
|
||||
'keychain.filter.certificate': 'CERTIFICATE',
|
||||
'keychain.action.generateKey': 'Generate Key',
|
||||
'keychain.action.importKey': 'Import Key',
|
||||
'keychain.action.newIdentity': 'New Identity',
|
||||
'keychain.action.importCertificate': 'Import Certificate',
|
||||
'keychain.view.grid': 'Grid',
|
||||
'keychain.view.list': 'List',
|
||||
'keychain.section.keys': 'Keys',
|
||||
'keychain.section.identities': 'Identities',
|
||||
'keychain.count.items': '{count} items',
|
||||
'keychain.empty.title': 'Set up your keys',
|
||||
'keychain.empty.desc': 'Import or generate SSH keys for secure authentication.',
|
||||
'keychain.panel.generateKey': 'Generate Key',
|
||||
'keychain.panel.newKey': 'New Key',
|
||||
'keychain.panel.keyDetails': 'Key Details',
|
||||
'keychain.panel.editKey': 'Edit Key',
|
||||
'keychain.panel.editIdentity': 'Edit Identity',
|
||||
'keychain.panel.newIdentity': 'New Identity',
|
||||
'keychain.panel.keyExport': 'Key Export',
|
||||
'keychain.validation.labelRequired': 'Please enter a label for the key',
|
||||
'keychain.validation.labelAndPrivateKeyRequired': 'Label and private key are required',
|
||||
'keychain.validation.labelAndUsernameRequired': 'Label and username are required',
|
||||
'keychain.error.generationUnavailable':
|
||||
'Key generation not available - please ensure the app is running in Electron',
|
||||
'keychain.error.generateKeyPairFailed': 'Failed to generate key pair',
|
||||
'keychain.error.generateKeyFailed': 'Failed to generate key',
|
||||
'keychain.error.keyGenerationTitle': 'Key Generation',
|
||||
'keychain.export.exportTo': 'Export to *',
|
||||
'keychain.export.selectHost': 'Select Host',
|
||||
'keychain.export.location': 'Location ~ $1 *',
|
||||
'keychain.export.filename': 'Filename ~ $2 *',
|
||||
'keychain.export.note':
|
||||
'Key export currently supports only {unix} systems. Use the {advanced} section to customize the export script.',
|
||||
'keychain.export.script': 'Script *',
|
||||
'keychain.export.scriptPlaceholder': 'Export script...',
|
||||
'keychain.export.missingCredentials':
|
||||
'Host has no saved password or key. Please add password credentials to the host first.',
|
||||
'keychain.export.successTitle': 'Export Successful',
|
||||
'keychain.export.successMessage': 'Public key exported and attached to {host}',
|
||||
'keychain.export.failedTitle': 'Export Failed',
|
||||
'keychain.export.failedMessage': 'Failed to export key: {error}',
|
||||
'keychain.export.failedPrefix': 'Export failed: {error}',
|
||||
'keychain.export.exitCode': 'Command exited with code {code}',
|
||||
'keychain.export.exporting': 'Exporting...',
|
||||
'keychain.export.exportAndAttach': 'Export and Attach',
|
||||
'keychain.export.title': 'Key export',
|
||||
'keychain.export.exportToRequired': 'Export to *',
|
||||
'keychain.export.selectHostPlaceholder': 'Select a host...',
|
||||
'keychain.export.locationLabel': 'Location ~ $1 *',
|
||||
'keychain.export.filenameLabel': 'Filename ~ $2 *',
|
||||
'keychain.export.advanced': 'Advanced',
|
||||
'keychain.export.note.supportsOnly': 'Key export currently supports only',
|
||||
'keychain.export.note.systems': 'systems.',
|
||||
'keychain.export.note.use': 'Use',
|
||||
'keychain.export.note.customize': 'section to customize the export script.',
|
||||
'keychain.export.scriptRequired': 'Script *',
|
||||
'keychain.export.exportToHost': 'Export to host',
|
||||
'keychain.export.failedGeneric': 'Export failed: {message}',
|
||||
'keychain.field.label': 'Label',
|
||||
'keychain.field.labelRequired': 'Label *',
|
||||
'keychain.field.labelPlaceholder': 'Key label',
|
||||
'keychain.field.privateKeyRequired': 'Private key *',
|
||||
'keychain.field.publicKey': 'Public key',
|
||||
'keychain.field.certificatePlaceholder': 'Certificate content (optional)',
|
||||
'keychain.generate.keyType': 'Key type',
|
||||
'keychain.generate.keySize': 'Key size',
|
||||
'keychain.generate.labelPlaceholder': 'Key label',
|
||||
'keychain.generate.passphrasePlaceholder': 'Passphrase (optional)',
|
||||
'keychain.generate.savePassphrase': 'Save passphrase',
|
||||
'keychain.generate.generate': 'Generate',
|
||||
'keychain.generate.generateSave': 'Generate & Save',
|
||||
'keychain.import.dropHint': 'Drop a key file here',
|
||||
'keychain.import.importFromFile': 'Import from file',
|
||||
'keychain.import.saveKey': 'Save Key',
|
||||
'keychain.import.importedKeyLabel': 'Imported Key',
|
||||
'keychain.identity.usernameRequired': 'Username *',
|
||||
'keychain.identity.method.passwordOnly': 'Password',
|
||||
'keychain.identity.summary.password': 'Auth password',
|
||||
'keychain.identity.summary.key': 'Auth key',
|
||||
'keychain.identity.summary.certificate': 'Auth certificate',
|
||||
'keychain.identity.summary.passwordAndKey': 'Auth password and key',
|
||||
'keychain.identity.summary.passwordAndCertificate': 'Auth password and certificate',
|
||||
'keychain.identity.summary.none': 'No credentials',
|
||||
'keychain.identity.selectCredential': 'Select {kind}',
|
||||
'keychain.identity.save': 'Save',
|
||||
'keychain.identity.update': 'Update',
|
||||
'keychain.keyDialog.newTitle': 'New Key',
|
||||
'keychain.keyDialog.newDesc': 'Add a new SSH key',
|
||||
'keychain.keyDialog.editTitle': 'Edit Key',
|
||||
'keychain.keyDialog.editDesc': 'Update this SSH key',
|
||||
'keychain.keyDialog.updateKey': 'Update Key',
|
||||
|
||||
// Tabs
|
||||
'tabs.closeSessionAria': 'Close session',
|
||||
'tabs.closeLogViewAria': 'Close log view',
|
||||
'tabs.logPrefix': 'Log:',
|
||||
'tabs.logLocal': 'Local',
|
||||
'tabs.copyTab': 'Copy Tab',
|
||||
'tabs.closeOthers': 'Close Others',
|
||||
'tabs.closeToRight': 'Close Tabs to the Right',
|
||||
'tabs.closeAll': 'Close All',
|
||||
'keychain.edit.labelRequired': 'Label *',
|
||||
'keychain.edit.keyLabelPlaceholder': 'Key label',
|
||||
'keychain.edit.privateKeyRequired': 'Private key *',
|
||||
'keychain.edit.publicKey': 'Public key',
|
||||
'keychain.edit.certificate': 'Certificate',
|
||||
'keychain.edit.certificatePlaceholder': 'Certificate content (optional)',
|
||||
'keychain.edit.filePath': 'File path',
|
||||
'keychain.edit.keyExport': 'Key export',
|
||||
'keychain.edit.exportToHost': 'Export to host',
|
||||
|
||||
// Snippets
|
||||
'snippets.searchPlaceholder': 'Search snippets...',
|
||||
'snippets.action.newSnippet': 'New Snippet',
|
||||
'snippets.action.newPackage': 'New Package',
|
||||
'snippets.panel.newTitle': 'New Snippet',
|
||||
'snippets.panel.editTitle': 'Edit Snippet',
|
||||
'snippets.field.description': 'Action description',
|
||||
'snippets.field.descriptionPlaceholder': 'Example: check network load',
|
||||
'snippets.field.package': 'Add a Package',
|
||||
'snippets.field.packagePlaceholder': 'Select or create package',
|
||||
'snippets.field.createPackage': 'Create Package',
|
||||
'snippets.field.scriptRequired': 'Script *',
|
||||
'snippets.scriptEditor.expand': 'Open in dialog',
|
||||
'snippets.scriptEditor.resize': 'Resize editor height',
|
||||
'snippets.scriptEditor.modalTitle': 'Edit script',
|
||||
'snippets.targets.title': 'Targets',
|
||||
'snippets.targets.add': 'Add targets',
|
||||
'snippets.history.title': 'Shell History',
|
||||
'snippets.history.subtitle': '{count} commands',
|
||||
'snippets.history.emptyTitle': 'No shell history yet',
|
||||
'snippets.history.emptyDesc': 'Commands you execute will appear here',
|
||||
'snippets.history.loadMore': 'Load more',
|
||||
'snippets.history.separator': '•',
|
||||
'snippets.history.labelPlaceholder': 'Set a label for this snippet',
|
||||
'snippets.history.saveAsSnippet': 'Save as Snippet',
|
||||
'snippets.history.time.justNow': 'just now',
|
||||
'snippets.history.time.minutesAgo': '{count}m ago',
|
||||
'snippets.history.time.hoursAgo': '{count}h ago',
|
||||
'snippets.history.time.daysAgo': '{count}d ago',
|
||||
'snippets.breadcrumb.allPackages': 'All packages',
|
||||
'snippets.breadcrumb.separator': '›',
|
||||
'snippets.empty.title': 'Create snippet',
|
||||
'snippets.empty.desc': 'Save your most used commands as snippets to reuse them in one click.',
|
||||
'snippets.search.noResults.title': 'No matches',
|
||||
'snippets.search.noResults.desc': 'No snippets or packages match "{query}". Try a different search term or clear the search to browse.',
|
||||
'snippets.section.packages': 'Packages',
|
||||
'snippets.section.snippets': 'Snippets',
|
||||
'snippets.package.count': '{count} snippet(s)',
|
||||
'snippets.commandFallback': 'Command',
|
||||
'snippets.view.grid': 'Grid',
|
||||
'snippets.view.list': 'List',
|
||||
'snippets.packageDialog.title': 'New Package',
|
||||
'snippets.packageDialog.parent': 'Parent: {parent}',
|
||||
'snippets.packageDialog.root': 'Root',
|
||||
'snippets.packageDialog.placeholder': 'e.g. ops/maintenance',
|
||||
'snippets.packageDialog.hint': 'Use "/" to create nested packages.',
|
||||
|
||||
// Snippets Rename Dialog
|
||||
'snippets.renameDialog.title': 'Rename Package',
|
||||
'snippets.renameDialog.currentPath': 'Current path: {path}',
|
||||
'snippets.renameDialog.placeholder': 'Enter new name',
|
||||
'snippets.renameDialog.error.empty': 'Package name cannot be empty',
|
||||
'snippets.renameDialog.error.duplicate': 'A package with this name already exists',
|
||||
'snippets.renameDialog.error.invalidChars': 'Package name can only contain letters, numbers, hyphens, and underscores',
|
||||
|
||||
'snippets.field.noAutoRun': 'Paste only (do not auto-execute)',
|
||||
// Snippet Shortkey
|
||||
'snippets.field.shortkey': 'Keyboard Shortcut',
|
||||
'snippets.shortkey.placeholder': 'Click to set shortcut',
|
||||
'snippets.shortkey.recording': 'Press a key combination...',
|
||||
'snippets.shortkey.hint': 'Press this shortcut in terminal to quickly send the command.',
|
||||
'snippets.shortkey.clear': 'Clear shortcut',
|
||||
'snippets.shortkey.error.systemConflict': 'This shortcut conflicts with a system shortcut',
|
||||
'snippets.shortkey.error.snippetConflict': 'This shortcut is already used by snippet: {name}',
|
||||
|
||||
'snippets.variables.dialogTitle': 'Snippet variables',
|
||||
'snippets.variables.dialogDesc': 'Fill in values for "{label}" before running.',
|
||||
'snippets.variables.hint': 'Values are inserted as-is into the script (not shell-escaped).',
|
||||
'snippets.variables.preview': 'Preview',
|
||||
'snippets.variables.placeholder': 'Enter a value',
|
||||
'snippets.variables.placeholderDefault': 'Default: {value}',
|
||||
'snippets.variables.required': 'This variable is required',
|
||||
'snippets.variables.run': 'Run',
|
||||
'snippets.field.variablesHelp': 'Use {{name}} or {{name:default}} for placeholders in the script.',
|
||||
'snippets.field.variablesDetected': 'Variables',
|
||||
'snippets.field.variableDefault': 'default {value}',
|
||||
|
||||
// Serial Port
|
||||
'serial.button': 'Serial',
|
||||
'serial.modal.title': 'Connect to Serial Port',
|
||||
'serial.modal.desc': 'Configure serial port connection settings',
|
||||
'serial.field.port': 'Serial Port',
|
||||
'serial.field.selectPort': 'Select a port...',
|
||||
'serial.field.baudRate': 'Baud Rate',
|
||||
'serial.field.dataBits': 'Data Bits',
|
||||
'serial.field.stopBits': 'Stop Bits',
|
||||
'serial.field.stopBits15Warning': '1.5 stop bits may not be supported on all Windows devices',
|
||||
'serial.field.parity': 'Parity',
|
||||
'serial.field.flowControl': 'Flow Control',
|
||||
'serial.noPorts': 'No serial ports detected. Connect a device and refresh.',
|
||||
'serial.field.customPort': 'Custom Port Path',
|
||||
'serial.field.customPortPlaceholder': 'e.g. /dev/ttys001 or COM1',
|
||||
'serial.type.hardware': 'Hardware',
|
||||
'serial.type.pseudo': 'Pseudo Terminal',
|
||||
'serial.type.custom': 'Custom',
|
||||
'serial.parity.none': 'None',
|
||||
'serial.parity.even': 'Even',
|
||||
'serial.parity.odd': 'Odd',
|
||||
'serial.parity.mark': 'Mark',
|
||||
'serial.parity.space': 'Space',
|
||||
'serial.flowControl.none': 'None',
|
||||
'serial.flowControl.xon/xoff': 'XON/XOFF (Software)',
|
||||
'serial.flowControl.rts/cts': 'RTS/CTS (Hardware)',
|
||||
'serial.field.localEcho': 'Force Local Echo',
|
||||
'serial.field.localEchoDesc': 'Echo typed characters locally (for devices without remote echo)',
|
||||
'serial.field.lineMode': 'Line Mode',
|
||||
'serial.field.lineModeDesc': 'Buffer input and send on Enter (instead of character-by-character)',
|
||||
'serial.field.charset': 'Charset',
|
||||
'serial.connectionError': 'Failed to connect to serial port',
|
||||
'serial.field.baudRatePlaceholder': 'Select or enter baud rate...',
|
||||
'serial.field.baudRateEmpty': 'Enter a custom baud rate',
|
||||
'serial.field.customBaudRate': 'Using custom baud rate',
|
||||
'serial.field.saveConfig': 'Save Configuration',
|
||||
'serial.field.saveConfigDesc': 'Save this serial configuration to hosts for quick access',
|
||||
'serial.field.configLabel': 'Configuration Name',
|
||||
'serial.field.configLabelPlaceholder': 'e.g. Arduino Uno',
|
||||
'serial.connectAndSave': 'Connect & Save',
|
||||
'serial.edit.title': 'Serial Port Settings',
|
||||
|
||||
// Keyboard Interactive Authentication (2FA/MFA)
|
||||
'keyboard.interactive.title': 'Authentication Required',
|
||||
'keyboard.interactive.desc': 'The server requires additional authentication.',
|
||||
'keyboard.interactive.descWithHost': 'The server {hostname} requires additional authentication.',
|
||||
'keyboard.interactive.response': 'Response',
|
||||
'keyboard.interactive.enterCode': 'Enter verification code',
|
||||
'keyboard.interactive.enterResponse': 'Enter response',
|
||||
'keyboard.interactive.submit': 'Submit',
|
||||
'keyboard.interactive.verifying': 'Verifying...',
|
||||
'keyboard.interactive.savePassword': 'Save password',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'SSH Key Passphrase',
|
||||
'passphrase.desc': 'Enter the passphrase for {keyName}',
|
||||
'passphrase.descWithHost': 'Enter the passphrase for {keyName} to connect to {hostname}',
|
||||
'passphrase.label': 'Passphrase',
|
||||
'passphrase.keyPath': 'Key',
|
||||
'passphrase.unlock': 'Unlock',
|
||||
'passphrase.unlocking': 'Unlocking...',
|
||||
'passphrase.skip': 'Skip',
|
||||
'passphrase.remember': 'Remember this passphrase',
|
||||
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': 'Word Wrap',
|
||||
'sftp.editor.maximize': 'Maximize',
|
||||
'sftp.editor.unsavedTitle': 'Unsaved changes',
|
||||
'sftp.editor.unsavedMessage': '{fileName} has unsaved changes. Save before closing?',
|
||||
'sftp.editor.discardChanges': 'Discard',
|
||||
'sftp.editor.saveAndClose': 'Save and close',
|
||||
'sftp.editor.quitBlockedByDirty': 'Unsaved editors — please save or discard before quitting',
|
||||
|
||||
};
|
||||
650
application/i18n/locales/en/vault.ts
Normal file
650
application/i18n/locales/en/vault.ts
Normal file
@@ -0,0 +1,650 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enVaultMessages: Messages = {
|
||||
// Vault import
|
||||
'vault.import.title': 'Add data to your vault',
|
||||
'vault.import.desc':
|
||||
'Transfer your connections from popular clients. Select a file format to start the migration.',
|
||||
'vault.import.chooseFormat': 'Select a file format',
|
||||
'vault.import.csv.tip': 'Bulk import: use the CSV template.',
|
||||
'vault.import.csv.downloadTemplate': 'Download CSV template',
|
||||
'vault.import.toast.start': 'Importing from {format}...',
|
||||
'vault.import.toast.completedTitle': 'Import completed',
|
||||
'vault.import.toast.failedTitle': 'Import failed',
|
||||
'vault.import.toast.noEntries': 'No importable entries found in {format}.',
|
||||
'vault.import.toast.noNewHosts': 'No new hosts imported from {format}.',
|
||||
'vault.import.toast.summary':
|
||||
'Imported {count} hosts (skipped {skipped}, duplicates {duplicates}).',
|
||||
'vault.import.toast.firstIssue': 'First issue: {issue}',
|
||||
'vault.import.sshConfig.chooseMode': 'Choose how to import your SSH config file.',
|
||||
'vault.import.sshConfig.modeQuestion': 'How would you like to import?',
|
||||
'vault.import.sshConfig.importOnly': 'Import Only',
|
||||
'vault.import.sshConfig.importOnlyDesc': 'One-time import. Changes won\'t sync back to the file.',
|
||||
'vault.import.sshConfig.managed': 'Managed Sync',
|
||||
'vault.import.sshConfig.managedDesc': 'Keep in sync. Changes will be saved back to the file.',
|
||||
'vault.import.sshConfig.managedGroup': 'ssh config',
|
||||
'vault.import.sshConfig.managedSuccess': 'Imported {count} hosts. File is now managed.',
|
||||
'vault.import.sshConfig.alreadyManaged': 'This file is already being managed.',
|
||||
'vault.import.sshConfig.alreadyManagedDesc': 'This file is already managed under group "{group}". Remove the existing managed source first if you want to re-import.',
|
||||
'vault.import.sshConfig.noFilePath': 'Cannot manage this file.',
|
||||
'vault.import.sshConfig.noFilePathDesc': 'Unable to determine the file path. Managed sync requires access to the file system.',
|
||||
|
||||
// Known Hosts
|
||||
'knownHosts.search.placeholder': 'Search known hosts...',
|
||||
'knownHosts.action.scanSystem': 'Scan System',
|
||||
'knownHosts.action.importFile': 'Import File',
|
||||
'knownHosts.action.browseFile': 'Browse File',
|
||||
'knownHosts.empty.title': 'No Known Hosts',
|
||||
'knownHosts.empty.desc':
|
||||
"Known hosts are SSH servers you've connected to before. Import from your system's known_hosts file to get started.",
|
||||
'knownHosts.results.showingLimited':
|
||||
'Showing {shown} of {total} hosts. Use search to find specific hosts.',
|
||||
'knownHosts.toast.scanUnavailable': 'System scan is unavailable on this platform.',
|
||||
'knownHosts.toast.scanNoFile': 'No system known_hosts file found.',
|
||||
'knownHosts.toast.scanNoEntries': 'No usable entries found in known_hosts.',
|
||||
'knownHosts.toast.scanImported': 'Imported {count} new hosts.',
|
||||
'knownHosts.toast.scanNoNew': 'No new hosts found.',
|
||||
'knownHosts.toast.scanFailed': 'Failed to scan system known_hosts.',
|
||||
|
||||
// Port Forwarding
|
||||
'pf.empty.title': 'Set up port forwarding',
|
||||
'pf.empty.desc': 'Save port forwarding to access databases, web apps, and other services.',
|
||||
'pf.title': 'Port Forwarding',
|
||||
'pf.rulesCount': '{count} rules',
|
||||
'pf.wizard.editTitle': 'Edit Port Forwarding',
|
||||
'pf.wizard.newTitle': 'New Port Forwarding',
|
||||
'pf.wizard.saveChanges': 'Save Changes',
|
||||
'pf.wizard.done': 'Done',
|
||||
'pf.wizard.continue': 'Continue',
|
||||
'pf.wizard.cancel': 'Cancel',
|
||||
'pf.wizard.skipWizard': 'Skip wizard',
|
||||
'pf.error.hostNotFound': 'Host not found',
|
||||
'pf.toast.titleWithLabel': 'Port Forwarding: {label}',
|
||||
'pf.type.local': 'Local',
|
||||
'pf.type.remote': 'Remote',
|
||||
'pf.type.dynamic': 'Dynamic',
|
||||
'pf.type.menu.local': 'Local Forwarding',
|
||||
'pf.type.menu.remote': 'Remote Forwarding',
|
||||
'pf.type.menu.dynamic': 'Dynamic Forwarding',
|
||||
'pf.type.local.desc': "Local forwarding lets you access a remote server's listening port as though it were local.",
|
||||
'pf.type.remote.desc': 'Remote forwarding opens a port on the remote machine and forwards connections to the local (current) host.',
|
||||
'pf.type.dynamic.desc': 'Dynamic port forwarding turns Netcatty into a SOCKS proxy server.',
|
||||
'pf.wizard.type.title': 'Select the port forwarding type:',
|
||||
'pf.wizard.localConfig.title': 'Set the local port and binding address:',
|
||||
'pf.wizard.localConfig.desc': 'This port will be open on the local (current) device, and it will receive the traffic.',
|
||||
'pf.wizard.localConfig.localPort': 'Local port number *',
|
||||
'pf.wizard.bindAddress': 'Bind address',
|
||||
'pf.wizard.remoteHost.title': 'Select the remote host:',
|
||||
'pf.wizard.remoteHost.desc': 'Select a host where the port will be open. Traffic from this port will be forwarded to the destination host.',
|
||||
'pf.wizard.remoteConfig.title': 'Set the port and binding address:',
|
||||
'pf.wizard.remoteConfig.desc': 'Traffic will be forwarded from the specified port and interface address of the selected host.',
|
||||
'pf.wizard.remoteConfig.remotePort': 'Remote port number *',
|
||||
'pf.wizard.destination.title': 'Select the destination host:',
|
||||
'pf.wizard.destination.desc.local': 'Enter the remote destination that you want to access through the tunnel.',
|
||||
'pf.wizard.destination.desc.remote': 'The destination address and port where the traffic will be forwarded.',
|
||||
'pf.wizard.destination.address': 'Destination address *',
|
||||
'pf.wizard.destination.addressPlaceholder': 'e.g. 127.0.0.1 or 192.168.1.100',
|
||||
'pf.wizard.destination.port': 'Destination port number *',
|
||||
'pf.wizard.sshServer.title': 'Select the SSH server:',
|
||||
'pf.wizard.sshServer.desc.dynamic': 'Select the SSH server that will act as your SOCKS proxy.',
|
||||
'pf.wizard.sshServer.desc.default': 'Select the SSH server that will tunnel your traffic to the destination.',
|
||||
'pf.wizard.label.title': 'Select the label:',
|
||||
'pf.wizard.label.placeholder.dynamic': 'e.g. SOCKS Proxy',
|
||||
'pf.wizard.label.placeholder.default': 'e.g. MySQL Production',
|
||||
'pf.wizard.label.placeholder.remoteRule': 'e.g. Remote Rule',
|
||||
'pf.wizard.placeholders.portExample': 'e.g. {port}',
|
||||
'pf.action.newForwarding': 'New Forwarding',
|
||||
'pf.form.labelPlaceholder': 'Rule label',
|
||||
'pf.form.intermediateHost': 'Intermediate host *',
|
||||
'pf.form.createRule': 'Create Rule',
|
||||
'pf.form.openWizard': 'Open Wizard',
|
||||
'pf.form.openWizardTitle': 'Open Port Forwarding Wizard',
|
||||
'pf.view.grid': 'Grid',
|
||||
'pf.view.list': 'List',
|
||||
'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',
|
||||
'pf.tooltip.noHost': 'No relay host configured',
|
||||
'pf.tooltip.localDesc': 'Local port forwarding: Access remote services through SSH tunnel',
|
||||
'pf.tooltip.remoteDesc': 'Remote port forwarding: Expose local services to remote host',
|
||||
'pf.tooltip.dynamicDesc': 'Dynamic SOCKS proxy: Route traffic through SSH tunnel',
|
||||
'pf.deleteActive.title': 'Delete Active Port Forwarding?',
|
||||
'pf.deleteActive.desc': 'This port forwarding rule "{label}" is currently active. Deleting it will stop the tunnel first.',
|
||||
'pf.deleteActive.confirm': 'Stop and Delete',
|
||||
'pf.form.autoStart': 'Auto Start',
|
||||
'pf.form.autoStartDesc': 'Automatically start this rule when the app launches',
|
||||
|
||||
// SFTP
|
||||
'sftp.newFolder': 'New Folder',
|
||||
'sftp.newFile': 'New File',
|
||||
'sftp.filter': 'Filter',
|
||||
'sftp.filter.placeholder': 'Filter by filename...',
|
||||
'sftp.bookmark.add': 'Bookmark this path',
|
||||
'sftp.bookmark.remove': 'Remove bookmark',
|
||||
'sftp.bookmark.addGlobal': '+Global',
|
||||
'sftp.bookmark.addGlobalTooltip': 'Save as global bookmark (shared across all hosts)',
|
||||
'sftp.bookmark.empty': 'No bookmarks yet',
|
||||
'sftp.columns.name': 'Name',
|
||||
'sftp.columns.modified': 'Modified',
|
||||
'sftp.columns.size': 'Size',
|
||||
'sftp.columns.kind': 'Kind',
|
||||
'sftp.columns.actions': 'Actions',
|
||||
'sftp.emptyDirectory': 'Empty directory',
|
||||
'sftp.nav.up': 'Go up',
|
||||
'sftp.nav.home': 'Go to home',
|
||||
'sftp.nav.refresh': 'Refresh',
|
||||
'sftp.upload': 'Upload',
|
||||
'sftp.uploadFiles': 'Upload files',
|
||||
'sftp.uploadFolder': 'Upload folder',
|
||||
'sftp.dragDropToUpload': 'Drag and drop files here to upload',
|
||||
'sftp.retry': 'Retry',
|
||||
'sftp.context.open': 'Open',
|
||||
'sftp.context.navigateTo': 'Navigate to',
|
||||
'sftp.context.moveTo': 'Move to...',
|
||||
'sftp.context.moveToParent': 'Move to parent directory',
|
||||
'sftp.moveTo.title': 'Move to directory',
|
||||
'sftp.moveTo.placeholder': 'Enter target directory path',
|
||||
'sftp.moveTo.confirm': 'Move',
|
||||
'sftp.moveTo.pathNotFound': 'Directory not found or inaccessible',
|
||||
'sftp.context.download': 'Download',
|
||||
'sftp.context.copyToOtherPane': 'Copy to other pane',
|
||||
'sftp.viewMode.label': 'View mode',
|
||||
'sftp.viewMode.list': 'List view',
|
||||
'sftp.viewMode.tree': 'Tree view',
|
||||
'sftp.tree.loadError': 'Failed to load directory',
|
||||
'sftp.tree.loading': 'Loading...',
|
||||
'sftp.kind.folder': 'Folder',
|
||||
'sftp.context.rename': 'Rename',
|
||||
'sftp.context.permissions': 'Permissions',
|
||||
'sftp.context.delete': 'Delete',
|
||||
'sftp.context.refresh': 'Refresh',
|
||||
'sftp.context.uploadFiles': 'Upload File(s)...',
|
||||
'sftp.context.uploadFilesHere': 'Upload File(s) Here...',
|
||||
'sftp.context.uploadFolder': 'Upload Folder...',
|
||||
'sftp.context.uploadFolderHere': 'Upload Folder Here...',
|
||||
'sftp.context.downloadSelected': 'Download selected ({count})',
|
||||
'sftp.context.deleteSelected': 'Delete selected ({count})',
|
||||
'sftp.dropFilesHere': 'Drop files here',
|
||||
'sftp.itemsCount': '{count} items',
|
||||
'sftp.selectedCount': '{count} selected',
|
||||
'sftp.path.doubleClickToEdit': 'Double-click to edit path',
|
||||
'sftp.showHiddenPaths': 'Hidden paths',
|
||||
'sftp.task.waiting': 'Waiting...',
|
||||
'sftp.transfer.preparing': 'preparing...',
|
||||
'sftp.status.loading': 'Loading...',
|
||||
'sftp.status.uploading': 'Uploading...',
|
||||
'sftp.status.ready': 'Ready',
|
||||
'sftp.transfers': 'Transfers',
|
||||
'sftp.transfers.active': '{count} active',
|
||||
'sftp.transfers.clearCompleted': 'Clear completed',
|
||||
'sftp.transfers.calculatingTotal': 'Calculating total size...',
|
||||
'sftp.transfers.filesCount': '{count} files',
|
||||
'sftp.transfers.filesProgress': '{current}/{total} files',
|
||||
'sftp.transfers.expandChildren': 'Show files',
|
||||
'sftp.transfers.collapseChildren': 'Hide files',
|
||||
'sftp.transfers.expandChildList': 'Show detail',
|
||||
'sftp.transfers.collapseChildList': 'Hide',
|
||||
'sftp.transfers.retryAction': 'Retry',
|
||||
'sftp.transfers.dismissAction': 'Dismiss',
|
||||
'sftp.transfers.openTargetFolder': 'Open target folder',
|
||||
'sftp.transfers.openTargetFolderError': 'Could not open target folder',
|
||||
'sftp.transfers.copyTargetPath': 'Copy target path',
|
||||
'sftp.transfers.copyTargetPathSuccess': 'Target path copied',
|
||||
'sftp.transfers.copyTargetPathError': 'Could not copy target path',
|
||||
'sftp.transfers.resizeNameColumn': 'Resize file name column',
|
||||
'sftp.transfers.dragToResize': 'Drag to resize',
|
||||
'sftp.goUp': 'Go up',
|
||||
'sftp.goToTerminalCwd': 'Go to terminal directory',
|
||||
'sftp.encoding.label': 'Filename Encoding',
|
||||
'sftp.encoding.auto': 'Auto',
|
||||
'sftp.encoding.utf8': 'UTF-8',
|
||||
'sftp.encoding.gb18030': 'GB18030',
|
||||
'sftp.goHome': 'Go to home',
|
||||
'sftp.folderName': 'Folder name',
|
||||
'sftp.folderName.placeholder': 'Enter folder name',
|
||||
'sftp.fileName': 'File name',
|
||||
'sftp.fileName.placeholder': 'Enter file name',
|
||||
'sftp.prompt.newFolderName': 'New folder name?',
|
||||
'sftp.rename.title': 'Rename',
|
||||
'sftp.rename.newName': 'New name',
|
||||
'sftp.rename.placeholder': 'Enter new name',
|
||||
'sftp.confirm.deleteOne': 'Delete "{name}"?',
|
||||
'sftp.deleteConfirm.single': 'Delete "{name}"?',
|
||||
'sftp.deleteConfirm.title': 'Delete {count} item(s)?',
|
||||
'sftp.deleteConfirm.desc': 'This action cannot be undone. The following will be deleted:',
|
||||
'sftp.deleteConfirm.descSingle': 'This action cannot be undone.',
|
||||
'sftp.deleteConfirm.host': 'Host',
|
||||
'sftp.deleteConfirm.path': 'Path',
|
||||
'sftp.error.loadFailed': 'Failed to load directory',
|
||||
'sftp.error.downloadFailed': 'Download failed',
|
||||
'sftp.error.uploadFailed': 'Upload failed',
|
||||
'sftp.error.deleteFailed': 'Delete failed',
|
||||
'sftp.error.createFolderFailed': 'Failed to create folder',
|
||||
'sftp.error.createFileFailed': 'Failed to create file',
|
||||
'sftp.error.invalidFileName': 'Filename contains invalid characters: {chars}',
|
||||
'sftp.error.reservedName': 'This filename is reserved by the system',
|
||||
'sftp.overwrite.title': 'File Already Exists',
|
||||
'sftp.overwrite.desc': 'A file named "{name}" already exists. Do you want to replace it?',
|
||||
'sftp.overwrite.confirm': 'Replace',
|
||||
'sftp.error.renameFailed': 'Failed to rename',
|
||||
'sftp.picker.title': 'Select Host',
|
||||
'sftp.picker.desc': 'Pick a host for the {side} pane',
|
||||
'sftp.picker.searchPlaceholder': 'Search hosts...',
|
||||
'sftp.picker.local.title': 'Local filesystem',
|
||||
'sftp.picker.local.desc': 'Browse local files',
|
||||
'sftp.picker.local.badge': 'Local',
|
||||
'sftp.picker.noMatch': 'No matching hosts',
|
||||
'sftp.permissions.title': 'Edit Permissions',
|
||||
'sftp.permissions.owner': 'Owner',
|
||||
'sftp.permissions.group': 'Group',
|
||||
'sftp.permissions.others': 'Others',
|
||||
'sftp.permissions.octal': 'Octal',
|
||||
'sftp.permissions.symbolic': 'Symbolic',
|
||||
'sftp.permissions.success': 'Permissions updated successfully',
|
||||
'sftp.permissions.failed': 'Failed to update permissions',
|
||||
'sftp.pane.local': 'Local',
|
||||
'sftp.pane.remote': 'Remote',
|
||||
'sftp.pane.selectHost': 'Select host',
|
||||
'sftp.pane.selectHostToStart': 'Select a host to start',
|
||||
'sftp.pane.chooseFilesystem': 'Choose a local or remote filesystem to browse',
|
||||
'sftp.tabs.addTab': 'Add new tab',
|
||||
'sftp.tabs.closeTab': 'Close tab',
|
||||
'sftp.tabs.newTab': 'New Tab',
|
||||
'sftp.conflict.title': 'File Conflict',
|
||||
'sftp.conflict.desc': 'A file with the same name already exists at the destination',
|
||||
'sftp.conflict.alreadyExistsSuffix': 'already exists',
|
||||
'sftp.conflict.existingFile': 'Existing file',
|
||||
'sftp.conflict.newFile': 'New file',
|
||||
'sftp.conflict.size': 'Size:',
|
||||
'sftp.conflict.modified': 'Modified:',
|
||||
'sftp.conflict.applyToAll': 'Apply this action to all {count} remaining conflicts',
|
||||
'sftp.conflict.action.stop': 'Stop',
|
||||
'sftp.conflict.action.skip': 'Skip',
|
||||
'sftp.conflict.action.keepBoth': 'Keep Both',
|
||||
'sftp.conflict.action.duplicate': 'Duplicate',
|
||||
'sftp.conflict.action.merge': 'Merge',
|
||||
'sftp.conflict.action.replace': 'Replace',
|
||||
|
||||
// SFTP Upload Phases
|
||||
'sftp.upload.phase.compressing': 'Compressing',
|
||||
'sftp.upload.phase.uploading': 'Uploading',
|
||||
'sftp.upload.phase.extracting': 'Extracting',
|
||||
'sftp.upload.phase.compressed': 'Compressed',
|
||||
|
||||
// SFTP File Opener
|
||||
'sftp.context.copyPath': 'Copy file path',
|
||||
'sftp.context.openWith': 'Open with...',
|
||||
'sftp.context.edit': 'Edit',
|
||||
'sftp.context.preview': 'Preview',
|
||||
'sftp.opener.title': 'Open with',
|
||||
'sftp.opener.desc': 'Choose an application to open this file',
|
||||
'sftp.opener.builtInEditor': 'Built-in Editor',
|
||||
'sftp.opener.editDescription': 'Edit text files',
|
||||
'sftp.opener.builtInImageViewer': 'Built-in Image Viewer',
|
||||
'sftp.opener.previewDescription': 'Preview images',
|
||||
'sftp.opener.systemApp': 'Choose Application...',
|
||||
'sftp.opener.systemAppDescription': 'Select an application from your computer',
|
||||
'sftp.opener.onlySystemApp': 'This file can only be opened with an external application',
|
||||
'sftp.opener.noAppsAvailable': 'No applications available',
|
||||
'sftp.opener.noExtension': 'files without extension',
|
||||
'sftp.opener.setDefault': 'Always use this for {ext} files',
|
||||
'sftp.opener.confirmTitle': 'Set as Default?',
|
||||
'sftp.opener.confirmDescription': 'Do you want to always use {app} for {ext} files?',
|
||||
'sftp.opener.yesRemember': 'Yes, remember this choice',
|
||||
'sftp.opener.justOnce': 'Just this once',
|
||||
'sftp.opener.confirm.title': 'Set Default Application',
|
||||
'sftp.opener.confirm.desc': 'Do you want to always open .{ext} files with this application?',
|
||||
'sftp.editor.title': 'Text Editor',
|
||||
'sftp.editor.save': 'Save to Remote',
|
||||
'sftp.editor.saving': 'Saving...',
|
||||
'sftp.editor.saved': 'Saved successfully',
|
||||
'sftp.editor.saveFailed': 'Failed to save file',
|
||||
'sftp.editor.unsavedChanges': 'You have unsaved changes. Close anyway?',
|
||||
'sftp.editor.syntaxHighlight': 'Syntax Highlighting',
|
||||
'sftp.preview.title': 'Image Preview',
|
||||
'sftp.preview.zoomIn': 'Zoom In',
|
||||
'sftp.preview.zoomOut': 'Zoom Out',
|
||||
'sftp.preview.resetZoom': 'Reset Zoom',
|
||||
'sftp.preview.fitToWindow': 'Fit to Window',
|
||||
|
||||
// Settings > SFTP File Associations
|
||||
'settings.tab.sftpFileAssociations': 'SFTP',
|
||||
'settings.sftp.transferConcurrency': 'Transfer Concurrency',
|
||||
'settings.sftp.transferConcurrency.desc': 'Number of files to transfer in parallel when uploading or downloading folders. Higher values may improve speed but can overwhelm some servers.',
|
||||
'settings.sftp.defaultOpener': 'Default File Opener',
|
||||
'settings.sftp.defaultOpener.desc': 'Choose the default application for opening files without a specific file association',
|
||||
'settings.sftp.defaultOpener.ask': 'Always ask',
|
||||
'settings.sftp.defaultOpener.askDesc': 'Show a dialog to choose an application each time',
|
||||
'settings.sftp.defaultOpener.builtInDesc': 'Open text files in the built-in editor by default',
|
||||
'settings.sftp.defaultOpener.systemApp': 'Choose Application...',
|
||||
'settings.sftp.defaultOpener.systemAppDesc': 'Open files with a specific application by default',
|
||||
'settings.sftpFileAssociations.title': 'SFTP File Associations',
|
||||
'settings.sftpFileAssociations.desc': 'Configure default applications for opening files by extension',
|
||||
'settings.sftpFileAssociations.extension': 'Extension',
|
||||
'settings.sftpFileAssociations.application': 'Application',
|
||||
'settings.sftpFileAssociations.noAssociations': 'No file associations configured',
|
||||
'settings.sftpFileAssociations.remove': 'Remove',
|
||||
'settings.sftpFileAssociations.removeConfirm': 'Remove association for .{ext}?',
|
||||
|
||||
// Settings > SFTP Behavior
|
||||
'settings.sftp.doubleClickBehavior': 'Double-click behavior',
|
||||
'settings.sftp.doubleClickBehavior.desc': 'Choose the action when double-clicking a file in SFTP View',
|
||||
'settings.sftp.doubleClickBehavior.open': 'Open file',
|
||||
'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 > SFTP Auto Sync
|
||||
'settings.sftp.autoSync': 'Auto-sync to remote',
|
||||
'settings.sftp.autoSync.desc': 'Automatically sync file changes back to the remote server when opening files with external applications',
|
||||
'settings.sftp.autoSync.enable': 'Enable auto-sync',
|
||||
'settings.sftp.autoSync.enableDesc': 'When you save a file in an external application, changes will be automatically uploaded to the remote server',
|
||||
|
||||
// Settings > SFTP Auto Open Sidebar
|
||||
'settings.sftp.autoOpenSidebar': 'Auto-open sidebar on connect',
|
||||
'settings.sftp.autoOpenSidebar.desc': 'Automatically open the SFTP file browser sidebar when connecting to a host',
|
||||
'settings.sftp.autoOpenSidebar.enable': 'Enable auto-open sidebar',
|
||||
'settings.sftp.autoOpenSidebar.enableDesc': 'The SFTP sidebar will open automatically when a terminal session connects to a remote host',
|
||||
|
||||
'settings.sftp.defaultViewMode': 'Default View Mode',
|
||||
'settings.sftp.defaultViewMode.desc': 'Choose the default view mode when opening a new SFTP tab. Per-host preferences override this setting.',
|
||||
'settings.sftp.defaultViewMode.list': 'List View',
|
||||
'settings.sftp.defaultViewMode.listDesc': 'Display files in a flat list for the current directory',
|
||||
'settings.sftp.defaultViewMode.tree': 'Tree View',
|
||||
'settings.sftp.defaultViewMode.treeDesc': 'Display files in a hierarchical tree structure',
|
||||
|
||||
'sftp.autoSync.success': 'File synced to remote: {fileName}',
|
||||
'sftp.autoSync.error': 'Failed to sync file: {error}',
|
||||
|
||||
// SFTP Folder Upload Progress
|
||||
'sftp.upload.progress': 'Uploading {current} of {total} files...',
|
||||
'sftp.upload.uploading': 'Uploading...',
|
||||
'sftp.upload.compressing': 'Compressing...',
|
||||
'sftp.upload.extracting': 'Extracting...',
|
||||
'sftp.upload.scanning': 'Scanning files...',
|
||||
'sftp.upload.completed': 'Completed',
|
||||
'sftp.upload.compressed': 'Compressed Transfer',
|
||||
'sftp.upload.currentFile': 'Current: {fileName}',
|
||||
'sftp.upload.cancelled': 'Upload cancelled',
|
||||
'sftp.upload.cancel': 'Cancel',
|
||||
'sftp.upload.completedToPath': 'Uploaded to {path}',
|
||||
|
||||
// SFTP Download
|
||||
'sftp.download.completed': 'Downloaded',
|
||||
'sftp.download.cancelled': 'Download cancelled',
|
||||
|
||||
// SFTP Reconnecting
|
||||
'sftp.reconnecting.title': 'Reconnecting...',
|
||||
'sftp.reconnecting.desc': 'Connection lost, attempting to reconnect',
|
||||
'sftp.reconnected': 'Connection restored',
|
||||
'sftp.error.reconnectFailed': 'Failed to reconnect. Please try again.',
|
||||
'sftp.error.connectionLostManual': 'Connection lost. Please reconnect manually.',
|
||||
'sftp.error.connectionLostReconnecting': 'Connection lost. Reconnecting...',
|
||||
'sftp.error.sessionLost': 'SFTP session lost. Please reconnect.',
|
||||
|
||||
// Settings > SFTP Show Hidden Files
|
||||
'settings.sftp.showHiddenFiles': 'Show hidden files',
|
||||
'settings.sftp.showHiddenFiles.desc': 'Display hidden files (dotfiles on Unix/macOS and files with the hidden attribute on Windows) in the SFTP file browser.',
|
||||
'settings.sftp.showHiddenFiles.enable': 'Show hidden files',
|
||||
'settings.sftp.showHiddenFiles.enableDesc': 'Display hidden files when browsing both local and remote filesystems',
|
||||
|
||||
// Settings > SFTP Compressed Upload
|
||||
'settings.sftp.compressedUpload': 'Folder Compression Transfer',
|
||||
'settings.sftp.compressedUpload.desc': 'Compress folders before uploading to significantly reduce transfer time.',
|
||||
'settings.sftp.compressedUpload.enable': 'Enable folder compression',
|
||||
'settings.sftp.compressedUpload.enableDesc': 'Automatically compress folders using tar before transfer. Requires tar support on the server. Falls back to regular transfer if not available.',
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': 'Search hosts or tabs',
|
||||
'qs.jumpTo': 'Jump To',
|
||||
'qs.localTerminal': 'Local Terminal',
|
||||
'qs.localShells': 'Local Shells',
|
||||
'qs.default': 'Default',
|
||||
|
||||
// Select Host panel
|
||||
'selectHost.title': 'Select Host',
|
||||
'selectHost.noHostsFound': 'No hosts found',
|
||||
'selectHost.newHost': 'New Host',
|
||||
'selectHost.continue': 'Continue',
|
||||
'selectHost.continueWithCount': 'Continue ({count} selected)',
|
||||
|
||||
// Quick Connect
|
||||
'quickConnect.knownHost.title': 'Are you sure you want to connect?',
|
||||
'quickConnect.knownHost.authenticity': 'The authenticity of {hostname} can not be established.',
|
||||
'quickConnect.knownHost.fingerprintLabel': '{keyType} fingerprint is SHA256:',
|
||||
'quickConnect.knownHost.addQuestion': 'Do you want to add it to the list of known hosts?',
|
||||
'quickConnect.knownHost.addAndContinue': 'Add and continue',
|
||||
'quickConnect.addKey': 'Add key',
|
||||
'quickConnect.warning.unparsedOptions': 'Some SSH arguments were ignored: {options}',
|
||||
|
||||
// Terminal
|
||||
'terminal.connectionErrorTitle': 'Connection Error',
|
||||
|
||||
// Protocol select dialog
|
||||
'protocolSelect.chooseProtocol': 'Choose protocol',
|
||||
'protocolSelect.port': 'port:',
|
||||
|
||||
// Host Details
|
||||
'hostDetails.title.details': 'Host Details',
|
||||
'hostDetails.title.new': 'New Host',
|
||||
'hostDetails.saveAria': 'Save',
|
||||
'hostDetails.section.address': 'Address',
|
||||
'hostDetails.hostname.placeholder': 'IP or Hostname',
|
||||
'hostDetails.section.general': 'General',
|
||||
'hostDetails.section.sftp': 'SFTP Settings',
|
||||
'hostDetails.sftp.sudo': 'Sudo Mode',
|
||||
'hostDetails.sftp.sudo.desc': 'Automatically acquire Root privileges using stored password',
|
||||
'hostDetails.sftp.sudo.passwordWarning': 'Sudo mode requires a password. Configure one above, or ensure the server allows passwordless sudo.',
|
||||
'hostDetails.sftp.encoding': 'Filename Encoding',
|
||||
'hostDetails.sftp.encoding.desc': 'Select the encoding used to decode and send SFTP filenames.',
|
||||
'hostDetails.label.placeholder': 'Label (e.g., Production Server)',
|
||||
'hostDetails.notes.label': 'Notes',
|
||||
'hostDetails.notes.placeholder': 'Hardware, project, customer, region, role...',
|
||||
'hostDetails.notes.help': 'Supports Markdown. Do not store passwords or private keys here.',
|
||||
'hostDetails.notes.tab.edit': 'Edit',
|
||||
'hostDetails.notes.tab.preview': 'Preview',
|
||||
'hostDetails.notes.preview.empty': 'Nothing to preview yet.',
|
||||
'hostDetails.group.placeholder': 'Parent Group',
|
||||
'hostDetails.section.credentials': 'Credentials',
|
||||
'hostDetails.section.portCredentials': 'Port & Credentials',
|
||||
'hostDetails.section.appearance': 'Appearance',
|
||||
'hostDetails.distro.title': 'Linux Distribution',
|
||||
'hostDetails.distro.desc': 'Auto-detect on connect, or override the distro icon manually.',
|
||||
'hostDetails.distro.mode': 'Source',
|
||||
'hostDetails.distro.mode.auto': 'Auto-detect',
|
||||
'hostDetails.distro.mode.manual': 'Manual override',
|
||||
'hostDetails.distro.detectedLabel': 'Current',
|
||||
'hostDetails.distro.manualLabel': 'Override',
|
||||
'hostDetails.distro.pending': 'Detect after first connection',
|
||||
'hostDetails.distro.unknown': 'Unknown',
|
||||
'hostDetails.distro.option.linux': 'Generic Linux',
|
||||
'hostDetails.distro.option.ubuntu': 'Ubuntu',
|
||||
'hostDetails.distro.option.debian': 'Debian',
|
||||
'hostDetails.distro.option.centos': 'CentOS',
|
||||
'hostDetails.distro.option.rocky': 'Rocky Linux',
|
||||
'hostDetails.distro.option.fedora': 'Fedora',
|
||||
'hostDetails.distro.option.arch': 'Arch Linux',
|
||||
'hostDetails.distro.option.alpine': 'Alpine',
|
||||
'hostDetails.distro.option.amazon': 'Amazon Linux',
|
||||
'hostDetails.distro.option.opensuse': 'openSUSE / SLES',
|
||||
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
|
||||
'hostDetails.distro.option.almalinux': 'AlmaLinux',
|
||||
'hostDetails.distro.option.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.mikrotik': 'MikroTik',
|
||||
'hostDetails.distro.option.fortinet': 'Fortinet',
|
||||
'hostDetails.distro.option.paloalto': 'Palo Alto Networks',
|
||||
'hostDetails.distro.option.zyxel': 'ZyXEL',
|
||||
'hostDetails.distro.option.ruijie': 'Ruijie',
|
||||
'hostDetails.section.mosh': 'Mosh',
|
||||
'hostDetails.username.placeholder': 'Username',
|
||||
'hostDetails.password.placeholder': 'Password',
|
||||
'hostDetails.password.show': 'Show password',
|
||||
'hostDetails.password.hide': 'Hide password',
|
||||
'hostDetails.password.save': 'Save password',
|
||||
'hostDetails.identity.suggestions': 'Identities',
|
||||
'hostDetails.identity.missing': 'Identity not found',
|
||||
'hostDetails.credential.keyCertificate': 'Key, Certificate, Local Key File',
|
||||
'hostDetails.credential.key': 'Key',
|
||||
'hostDetails.credential.certificate': 'Certificate',
|
||||
'hostDetails.credential.localKeyFile': 'Local Key File',
|
||||
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
|
||||
'hostDetails.credential.browseKeyFile': 'Browse...',
|
||||
'hostDetails.credential.missing': 'Credential not found',
|
||||
'hostDetails.keys.search': 'Search keys...',
|
||||
'hostDetails.keys.empty': 'No keys available',
|
||||
'hostDetails.certs.search': 'Search certificates...',
|
||||
'hostDetails.certs.empty': 'No certificates available',
|
||||
'hostDetails.agentForwarding': 'Forward SSH Agent',
|
||||
'hostDetails.agentForwarding.desc': 'Allow remote server to use your local SSH keys (e.g., for git operations)',
|
||||
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent is not available',
|
||||
'hostDetails.agentForwarding.agentNotRunningHint': 'No SSH agent detected. Enable OpenSSH Authentication Agent in Windows Services, or use a compatible agent such as Bitwarden, 1Password, or gpg-agent.',
|
||||
'hostDetails.section.agentForwarding': 'SSH Agent',
|
||||
'hostDetails.x11Forwarding': 'Forward X11 apps',
|
||||
'hostDetails.x11Forwarding.desc': 'Show remote graphical apps on your local desktop when a local X server is running.',
|
||||
'hostDetails.section.x11Forwarding': 'X11 Forwarding',
|
||||
'hostDetails.section.deviceType': 'Device Type',
|
||||
'hostDetails.deviceType': 'Network Device Mode',
|
||||
'hostDetails.deviceType.desc': 'Enable for network equipment (switches, routers, firewalls) connected via SSH. Commands are sent as-is without shell wrapping, compatible with vendor CLIs like Huawei VRP and Cisco IOS.',
|
||||
'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.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.',
|
||||
'hostDetails.skipEcdsaHostKey': 'Skip ECDSA host key',
|
||||
'hostDetails.skipEcdsaHostKey.desc': 'Some old Huawei / Cisco switches produce non-standard ECDSA host-key signatures that cause "signature verification failed". Turning this on drops every ecdsa-sha2-* from the client offer so negotiation falls back to RSA / Ed25519.',
|
||||
'hostDetails.algorithms.advanced': 'Advanced algorithm overrides',
|
||||
'hostDetails.algorithms.advanced.desc': 'Replace the offered algorithm list for any category on a per-host basis. Leaving a category untouched uses the default; selecting a subset fully replaces the default list. Incorrect values can make the host unreachable.',
|
||||
'hostDetails.algorithms.inheritedNotice': 'The current group has algorithm overrides set for: {categories}. The "Reset" button here falls back to the group\'s lists, not NetCatty\'s defaults. To ignore the group restriction, clear the override in the group\'s algorithm settings.',
|
||||
'hostDetails.algorithms.customized': 'customized',
|
||||
'hostDetails.algorithms.reset': 'Reset',
|
||||
'hostDetails.algorithms.category.kex': 'Key Exchange (KEX)',
|
||||
'hostDetails.algorithms.category.cipher': 'Cipher',
|
||||
'hostDetails.algorithms.category.hmac': 'MAC (HMAC)',
|
||||
'hostDetails.algorithms.category.serverHostKey': 'Host Key',
|
||||
'hostDetails.algorithms.category.compress': 'Compression',
|
||||
'hostDetails.section.keepalive': 'Keepalive',
|
||||
'hostDetails.keepalive.override': 'Override global keepalive',
|
||||
'hostDetails.keepalive.desc': 'Use a custom keepalive policy for this host instead of the global setting. Useful for older routers or switches whose SSH server does not reply to keepalive@openssh.com requests — set interval to 0 to disable keepalive entirely on this host.',
|
||||
'hostDetails.keepalive.interval': 'Interval (seconds)',
|
||||
'hostDetails.keepalive.countMax': 'Max unanswered keepalives',
|
||||
'hostDetails.keepalive.disabledHint': 'Interval = 0 disables keepalive for this host. The session will rely on TCP-level timeouts to detect a dead connection.',
|
||||
'hostDetails.backspaceBehavior': 'Backspace Behavior',
|
||||
'hostDetails.backspaceBehavior.default': 'Default',
|
||||
'hostDetails.jumpHosts': 'Proxy via Hosts',
|
||||
'hostDetails.jumpHosts.hops': '{count} hop(s)',
|
||||
'hostDetails.jumpHosts.direct': 'Direct',
|
||||
'hostDetails.jumpHosts.configure': 'Configure Proxy Hosts',
|
||||
'hostDetails.proxy': 'Proxy via HTTP/SOCKS5',
|
||||
'hostDetails.proxy.none': 'None',
|
||||
'hostDetails.proxy.edit': 'Edit Proxy',
|
||||
'hostDetails.proxy.configure': 'Configure Proxy',
|
||||
'hostDetails.proxyPanel.title': 'Proxy via HTTP/SOCKS5',
|
||||
'hostDetails.proxyPanel.hostPlaceholder': 'Proxy host',
|
||||
'hostDetails.proxyPanel.credentials': 'Credentials',
|
||||
'hostDetails.proxyPanel.usernamePlaceholder': 'Username',
|
||||
'hostDetails.proxyPanel.passwordPlaceholder': 'Password',
|
||||
'hostDetails.proxyPanel.identities': 'Identities',
|
||||
'hostDetails.proxyPanel.remove': 'Remove Proxy',
|
||||
'hostDetails.proxyPanel.savedProxy': 'Saved proxy',
|
||||
'hostDetails.proxyPanel.selectSaved': 'Select saved proxy',
|
||||
'hostDetails.proxyPanel.customProxy': 'Custom proxy',
|
||||
'hostDetails.proxyPanel.missing': 'Missing',
|
||||
'hostDetails.proxyPanel.missingSaved': 'Missing saved proxy',
|
||||
'hostDetails.proxyPanel.error.required': 'Proxy host and port are required.',
|
||||
'hostDetails.envVars': 'Environment Variables',
|
||||
'hostDetails.envVars.add': 'Add Environment Variable',
|
||||
'hostDetails.envVars.title': 'Environment Variables',
|
||||
'hostDetails.envVars.desc': 'Set an environment variable for {host}.',
|
||||
'hostDetails.envVars.note':
|
||||
'Some SSH servers by default only allow variables with prefix LC_ and LANG_.',
|
||||
'hostDetails.envVars.variable': 'Variable',
|
||||
'hostDetails.envVars.value': 'Value',
|
||||
'hostDetails.envVars.newVariable': 'New Variable',
|
||||
'hostDetails.envVars.variableName': 'Variable name',
|
||||
'hostDetails.chain.title': 'Edit Chain',
|
||||
'hostDetails.chain.desc': 'Adding another host will create a connection to {host}.',
|
||||
'hostDetails.chain.addHost': 'Add a Host',
|
||||
'hostDetails.chain.target': 'Target',
|
||||
'hostDetails.chain.availableHosts': 'Available Hosts',
|
||||
'hostDetails.chain.clear': 'Clear',
|
||||
'hostDetails.group.title': 'New Group',
|
||||
'hostDetails.group.general': 'General',
|
||||
'hostDetails.group.namePlaceholder': 'Group name',
|
||||
'hostDetails.group.parentPlaceholder': 'Parent Group',
|
||||
'hostDetails.group.cloudSync': 'Cloud Sync',
|
||||
'hostDetails.group.addProtocol': 'Add protocol',
|
||||
'hostDetails.startupCommand': 'Startup Command',
|
||||
'hostDetails.startupCommand.placeholder': 'Command to run on connect (e.g., cd /app && ls)',
|
||||
'hostDetails.startupCommand.help':
|
||||
'This command will be executed automatically after SSH connection is established.',
|
||||
'hostDetails.otherProtocols': 'Other Protocols',
|
||||
'hostDetails.telnetOn': 'Telnet on',
|
||||
'hostDetails.port': 'port',
|
||||
'hostDetails.telnet.credentials': 'Credentials',
|
||||
'hostDetails.telnet.username': 'Telnet Username',
|
||||
'hostDetails.telnet.password': 'Telnet Password',
|
||||
'hostDetails.charset.placeholder': 'Charset (e.g. UTF-8)',
|
||||
'hostDetails.telnet.add': 'Add Telnet Protocol',
|
||||
'hostDetails.telnet.setDefault': 'Connect with Telnet by default',
|
||||
'hostDetails.tags': 'Tags',
|
||||
'hostDetails.group': 'Group',
|
||||
'hostDetails.selectGroup': 'Select Group',
|
||||
'hostDetails.addTag': 'Add a tag...',
|
||||
'hostDetails.createTag': 'Create tag',
|
||||
'hostDetails.createGroup': 'Create group',
|
||||
|
||||
// Host form (legacy modal)
|
||||
'hostForm.title.edit': 'Edit Host',
|
||||
'hostForm.title.new': 'New Host',
|
||||
'hostForm.desc.edit': 'Update connection details for this host',
|
||||
'hostForm.desc.new': 'Create a new SSH host entry',
|
||||
'hostForm.field.label': 'Label',
|
||||
'hostForm.placeholder.label': 'My Production Server',
|
||||
'hostForm.field.hostname': 'Hostname / IP',
|
||||
'hostForm.placeholder.hostname': '192.168.1.1',
|
||||
'hostForm.field.port': 'Port',
|
||||
'hostForm.field.username': 'Username',
|
||||
'hostForm.field.osType': 'OS Type',
|
||||
'hostForm.placeholder.selectOs': 'Select OS',
|
||||
'hostForm.field.group': 'Group',
|
||||
'hostForm.placeholder.group': 'e.g. AWS, DigitalOcean',
|
||||
'hostForm.field.tags': 'Tags',
|
||||
'hostForm.placeholder.addTag': 'Add a tag...',
|
||||
'hostForm.auth.method': 'Authentication Method',
|
||||
'hostForm.auth.password': 'Password',
|
||||
'hostForm.auth.sshKey': 'SSH Key',
|
||||
'hostForm.auth.selectKey': 'Select an SSH Key',
|
||||
'hostForm.auth.noKeys': 'No keys available',
|
||||
'hostForm.auth.noKeysHint': 'No SSH keys found in Keychain. Please create one first.',
|
||||
'hostForm.saveHost': 'Save Host',
|
||||
|
||||
// Connection logs
|
||||
'logs.table.date': 'Date',
|
||||
'logs.table.user': 'User',
|
||||
'logs.table.host': 'Host',
|
||||
'logs.table.saved': 'Saved',
|
||||
'logs.empty.title': 'No Connection Logs',
|
||||
'logs.empty.desc':
|
||||
'Your connection history will appear here when you connect to hosts or open local terminals.',
|
||||
'logs.loadMore': 'Load {count} more logs',
|
||||
'logs.ongoing': 'ongoing',
|
||||
'logs.localTerminal': 'Local Terminal',
|
||||
'logs.action.save': 'Save',
|
||||
'logs.action.unsave': 'Unsave',
|
||||
'logs.action.delete': 'Delete',
|
||||
|
||||
// Log view
|
||||
'logView.customizeAppearance': 'Customize appearance',
|
||||
'logView.appearance': 'Appearance',
|
||||
'logView.readOnly': 'Read-only',
|
||||
'logView.export': 'Export',
|
||||
|
||||
};
|
||||
16
application/i18n/locales/ru.ts
Normal file
16
application/i18n/locales/ru.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { Messages } from './types';
|
||||
import { ruCoreMessages } from './ru/core';
|
||||
import { ruVaultMessages } from './ru/vault';
|
||||
import { ruTerminalMessages } from './ru/terminal';
|
||||
import { ruAiMessages } from './ru/ai';
|
||||
|
||||
export type { Messages } from './types';
|
||||
|
||||
const ru: Messages = {
|
||||
...ruCoreMessages,
|
||||
...ruVaultMessages,
|
||||
...ruTerminalMessages,
|
||||
...ruAiMessages,
|
||||
};
|
||||
|
||||
export default ru;
|
||||
247
application/i18n/locales/ru/ai.ts
Normal file
247
application/i18n/locales/ru/ai.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const ruAiMessages: Messages = {
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Настройки агента',
|
||||
'ai.title': 'AI',
|
||||
'ai.description': 'Настройка AI-провайдеров, агентов и параметров безопасности',
|
||||
'ai.providers': 'Провайдеры',
|
||||
'ai.providers.empty': 'Провайдеры не настроены. Добавьте провайдера, чтобы начать.',
|
||||
'ai.providers.add': 'Добавить провайдера',
|
||||
'ai.providers.active': 'Активен',
|
||||
'ai.providers.apiKeyConfigured': 'API-ключ настроен',
|
||||
'ai.providers.noApiKey': 'Нет API-ключа',
|
||||
'ai.providers.configure': 'Настроить',
|
||||
'ai.providers.remove': 'Удалить',
|
||||
'ai.providers.name': 'Отображаемое имя',
|
||||
'ai.providers.name.placeholder': 'например, Мой провайдер',
|
||||
'ai.providers.style': 'Стиль протокола',
|
||||
'ai.providers.style.anthropic': 'Совместимый с Anthropic',
|
||||
'ai.providers.style.openai': 'Совместимый с OpenAI',
|
||||
'ai.providers.style.google': 'Совместимый с Google',
|
||||
'ai.providers.style.inherited': 'авто',
|
||||
'ai.providers.style.help': 'Определяет, какой формат API используется для запросов. Переопределите, если стороннее API использует другой диалект.',
|
||||
'ai.providers.icon.change': 'Изменить иконку',
|
||||
'ai.providers.icon.upload': 'Загрузить изображение',
|
||||
'ai.providers.icon.reset': 'Сбросить',
|
||||
'ai.providers.icon.close': 'Свернуть',
|
||||
'ai.providers.icon.uploadedNote': 'Своя иконка (64×64 WebP)',
|
||||
'ai.providers.icon.errorType': 'Пожалуйста, выберите файл изображения.',
|
||||
'ai.providers.apiKey': 'API-ключ',
|
||||
'ai.providers.apiKey.placeholder': 'Введите API-ключ',
|
||||
'ai.providers.apiKey.decrypting': 'Расшифровка...',
|
||||
'ai.providers.baseUrl': 'Базовый URL',
|
||||
'ai.providers.skipTLSVerify': 'Пропустить проверку TLS-сертификата (для самоподписанных сертификатов)',
|
||||
'ai.providers.defaultModel': 'Модель по умолчанию',
|
||||
'ai.providers.defaultModel.placeholder': 'например, gpt-4o, claude-sonnet-4-20250514',
|
||||
'ai.providers.refreshModels': 'Обновить модели',
|
||||
'ai.providers.searchModel': 'Искать или ввести ID модели...',
|
||||
'ai.providers.filterModels': 'Фильтровать модели...',
|
||||
'ai.providers.loadingModels': 'Загрузка моделей...',
|
||||
'ai.providers.noMatchingModels': 'Нет подходящих моделей',
|
||||
'ai.providers.clickToLoadModels': 'Нажмите, чтобы загрузить модели',
|
||||
'ai.providers.showingModels': 'Показаны первые 100 из {count} моделей. Введите текст для фильтрации.',
|
||||
'ai.providers.advancedParams': 'Дополнительные параметры',
|
||||
'ai.providers.advancedParams.hint': 'Оставьте пустым, чтобы использовать настройки провайдера по умолчанию.',
|
||||
'ai.providers.advancedParams.maxTokens.placeholder': 'например, 4096',
|
||||
'ai.providers.advancedParams.default': 'По умолчанию у провайдера',
|
||||
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
'ai.codex.title': 'Codex CLI',
|
||||
'ai.codex.description': 'Использует codex + codex-acp для потоковой передачи по протоколу ACP. Здесь можно войти через ChatGPT или включить API-ключ OpenAI-совместимого провайдера и пользовательский endpoint в настройках.',
|
||||
'ai.codex.detecting': 'Обнаружение...',
|
||||
'ai.codex.notFound': 'Не найден',
|
||||
'ai.codex.awaitingLogin': 'Ожидание входа',
|
||||
'ai.codex.connectedChatGPT': 'Подключено через ChatGPT',
|
||||
'ai.codex.connectedApiKey': 'Подключено через API-ключ',
|
||||
'ai.codex.connectedCustomConfig': 'Подключено через ~/.codex/config.toml',
|
||||
'ai.codex.customConfigIncomplete': 'Обнаружен пользовательский конфиг (отсутствует переменная окружения)',
|
||||
'ai.codex.customConfigHint': 'Используется пользовательский провайдер "{provider}", настроенный в ~/.codex/config.toml — вход через ChatGPT не требуется.',
|
||||
'ai.codex.customConfigMissingEnvKey': 'Предупреждение: {envKey} не задана в переменных окружения вашей оболочки. Экспортируйте её (или запустите netcatty из оболочки, где она задана), чтобы Codex мог пройти аутентификацию.',
|
||||
'ai.codex.notConnected': 'Не подключено',
|
||||
'ai.codex.statusUnknown': 'Статус неизвестен',
|
||||
'ai.codex.path': 'Путь:',
|
||||
'ai.codex.notFoundHint': 'Не удалось найти codex в PATH. Установите его или укажите путь к исполняемому файлу ниже.',
|
||||
'ai.codex.customPathPlaceholder': 'например, /usr/local/bin/codex',
|
||||
'ai.codex.check': 'Проверить',
|
||||
'ai.codex.openLogin': 'Открыть вход',
|
||||
'ai.codex.logout': 'Выйти',
|
||||
'ai.codex.connectChatGPT': 'Подключить ChatGPT',
|
||||
'ai.codex.refreshStatus': 'Обновить статус',
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
'ai.claude.description': 'Агентный помощник для программирования от Anthropic. Требует установленный в системе Claude Code CLI.',
|
||||
'ai.claude.detecting': 'Обнаружение...',
|
||||
'ai.claude.detected': 'Обнаружен',
|
||||
'ai.claude.notFound': 'Не найден',
|
||||
'ai.claude.path': 'Путь:',
|
||||
'ai.claude.notFoundHint': 'Не удалось найти claude в PATH. Установите его или укажите путь к исполняемому файлу ниже.',
|
||||
'ai.claude.customPathPlaceholder': 'например, /usr/local/bin/claude',
|
||||
'ai.claude.configSection': 'Аутентификация и конфигурация (опционально)',
|
||||
'ai.claude.configDir': 'Каталог конфигурации',
|
||||
'ai.claude.configDir.placeholder': '~/.claude (пусто — по умолчанию)',
|
||||
'ai.claude.configDir.hint': 'Задаёт CLAUDE_CONFIG_DIR — укажите папку, где выполнен вход `claude` (содержит settings.json и учётные данные).',
|
||||
'ai.claude.envVars': 'Переменные окружения',
|
||||
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
|
||||
'ai.claude.envVars.hint': 'По одному KEY=VALUE в строке, передаётся агенту Claude. Хранится локально в открытом виде — для API-ключей и учётных данных используйте «Каталог конфигурации» выше (вход `claude`).',
|
||||
'ai.claude.check': 'Проверить',
|
||||
|
||||
// AI GitHub Copilot CLI
|
||||
'ai.copilot.title': 'GitHub Copilot CLI',
|
||||
'ai.copilot.description': 'Использует GitHub Copilot CLI через ACP по stdio (`copilot --acp --stdio`). После обнаружения может быть выбран как внешний агент для программирования.',
|
||||
'ai.copilot.detecting': 'Обнаружение...',
|
||||
'ai.copilot.detected': 'Обнаружен',
|
||||
'ai.copilot.notFound': 'Не найден',
|
||||
'ai.copilot.path': 'Путь:',
|
||||
'ai.copilot.notFoundHint': 'Не удалось найти copilot в PATH. Установите его или укажите путь к исполняемому файлу ниже.',
|
||||
'ai.copilot.customPathPlaceholder': 'например, /usr/local/bin/copilot',
|
||||
'ai.copilot.check': 'Проверить',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': 'Агент по умолчанию',
|
||||
'ai.defaultAgent.description': 'Агент, который будет использоваться при запуске новой AI-сессии',
|
||||
'ai.defaultAgent.catty': 'Catty (встроенный)',
|
||||
'ai.toolAccess.title': 'Доступ к инструментам',
|
||||
'ai.toolAccess.mode': 'Режим доступа Netcatty',
|
||||
'ai.toolAccess.description': 'Выберите, как внешние ACP-агенты получают доступ к сессиям Netcatty. MCP предоставляет встроенный сервер, а Skills + CLI указывает агентам на локальный skill Netcatty и команды CLI.',
|
||||
'ai.toolAccess.mode.mcp': 'MCP',
|
||||
'ai.toolAccess.mode.skills': 'Skills + CLI',
|
||||
'ai.userSkills.title': 'Пользовательские skills',
|
||||
'ai.userSkills.description': 'Откройте папку skills Netcatty, чтобы добавить свои каталоги skills. Netcatty автоматически сканирует их и добавляет только лёгкие индексы, если skill явно не соответствует текущему запросу.',
|
||||
'ai.userSkills.openFolder': 'Открыть папку skills',
|
||||
'ai.userSkills.reload': 'Перезагрузить skills',
|
||||
'ai.userSkills.location': 'Расположение',
|
||||
'ai.userSkills.loading': 'Сканирование пользовательских skills...',
|
||||
'ai.userSkills.summary': '{ready} готово, {warnings} предупреждений',
|
||||
'ai.userSkills.empty': 'Пользовательские skills пока не найдены. Откройте папку, чтобы добавить каталоги skills с файлом SKILL.md.',
|
||||
'ai.userSkills.unavailable': 'Пользовательские skills недоступны в этой среде.',
|
||||
'ai.userSkills.status.ready': 'Готово',
|
||||
'ai.userSkills.status.warning': 'Предупреждение',
|
||||
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': 'AI-провайдер не настроен. Перейдите в **Настройки → AI → Провайдеры**, чтобы добавить и включить провайдера.',
|
||||
'ai.chat.toolDenied': 'Действие было отклонено пользователем.',
|
||||
'ai.chat.toolApproved': 'Одобрено',
|
||||
'ai.chat.toolApprovalHint': 'Нажмите Enter для одобрения, Escape для отклонения',
|
||||
'ai.chat.approve': 'Одобрить',
|
||||
'ai.chat.reject': 'Отклонить',
|
||||
'ai.chat.toolLabel': 'Инструмент',
|
||||
'ai.chat.targetLabel': 'Цель',
|
||||
'ai.chat.permissionRequired': 'Требуется разрешение',
|
||||
'ai.chat.permissionDescription': 'AI-агент хочет выполнить вызов инструмента, для которого требуется ваше одобрение.',
|
||||
'ai.chat.commandBlocked': 'Эта команда заблокирована вашей политикой безопасности и не может быть выполнена.',
|
||||
'ai.chat.recommendAllow': 'Разрешить',
|
||||
'ai.chat.recommendConfirm': 'Подтвердить',
|
||||
'ai.chat.recommendDeny': 'Запретить',
|
||||
'ai.chat.exportConversation': 'Экспортировать разговор',
|
||||
'ai.chat.exportAs': 'Экспортировать как',
|
||||
'ai.chat.exportMarkdown': 'Markdown',
|
||||
'ai.chat.exportJSON': 'JSON',
|
||||
'ai.chat.exportPlainText': 'Обычный текст',
|
||||
'ai.chat.thinking': 'Размышляет',
|
||||
'ai.chat.thoughtFor': 'Размышлял {duration}',
|
||||
'ai.chat.thought': 'Мысль',
|
||||
'ai.chat.agents': 'Агенты',
|
||||
'ai.chat.detectedOnMachine': 'Обнаружено на этом устройстве',
|
||||
'ai.chat.rescan': 'Пересканировать',
|
||||
'ai.chat.permObserver': 'Наблюдатель',
|
||||
'ai.chat.permConfirm': 'Подтверждение',
|
||||
'ai.chat.permAuto': 'Авто',
|
||||
'ai.chat.permObserverDesc': 'Только чтение',
|
||||
'ai.chat.permConfirmDesc': 'Спрашивать перед действиями',
|
||||
'ai.chat.permAutoDesc': 'Выполнять свободно',
|
||||
'ai.chat.emptyHint': 'Спрашивайте о ваших серверах, запускайте команды или получайте помощь с конфигурациями.',
|
||||
'ai.chat.placeholder': 'Сообщение {agent} — @ для добавления контекста, / для команд',
|
||||
'ai.chat.placeholderDefault': 'Сообщение агенту Catty...',
|
||||
'ai.chat.noModel': 'Нет модели',
|
||||
'ai.chat.noProviderModel': 'Модель по умолчанию не задана — настройте её в Настройки → AI → Провайдеры.',
|
||||
'ai.chat.selectProvider': 'Выберите провайдера',
|
||||
'ai.chat.recent': 'Недавние',
|
||||
'ai.chat.viewAll': 'Показать всё',
|
||||
'ai.chat.untitled': 'Без названия',
|
||||
'ai.chat.justNow': 'Только что',
|
||||
'ai.chat.minutesAgo': '{n}м назад',
|
||||
'ai.chat.hoursAgo': '{n}ч назад',
|
||||
'ai.chat.daysAgo': '{n}д назад',
|
||||
'ai.chat.newChat': 'Новый чат',
|
||||
'ai.chat.allSessions': 'Все сессии',
|
||||
'ai.chat.noSessions': 'Предыдущих сессий нет',
|
||||
'ai.chat.retryHint': 'Вы можете повторить попытку, отправив сообщение ещё раз.',
|
||||
'ai.chat.approvalTimeout': 'Время ожидания одобрения инструмента истекло через 5 минут. Вы можете повторить попытку, отправив сообщение ещё раз.',
|
||||
'ai.chat.menuHosts': 'Хосты',
|
||||
'ai.chat.menuContext': 'Контекст',
|
||||
'ai.chat.menuFiles': 'Файлы',
|
||||
'ai.chat.menuImage': 'Изображение',
|
||||
'ai.chat.menuMentionHost': 'Упомянуть хост',
|
||||
'ai.chat.menuUserSkills': 'Пользовательские skills',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Обработчики главного процесса Codex ещё не загружены. Полностью перезапустите Netcatty или dev-процесс Electron и попробуйте снова.',
|
||||
|
||||
// AI Web Search
|
||||
'ai.webSearch.title': 'Веб-поиск',
|
||||
'ai.webSearch.enable': 'Включить веб-поиск',
|
||||
'ai.webSearch.enable.description': 'Разрешить AI-агенту искать в интернете актуальную информацию.',
|
||||
'ai.webSearch.provider': 'Провайдер поиска',
|
||||
'ai.webSearch.provider.description': 'Выберите провайдера API веб-поиска.',
|
||||
'ai.webSearch.apiKey': 'API-ключ',
|
||||
'ai.webSearch.apiKey.description': 'API-ключ для выбранного провайдера поиска.',
|
||||
'ai.webSearch.apiKey.placeholder': 'Введите API-ключ...',
|
||||
'ai.webSearch.apiHost': 'API Host',
|
||||
'ai.webSearch.apiHost.description': 'Пользовательский API endpoint. Оставьте по умолчанию, если не используете прокси.',
|
||||
'ai.webSearch.apiHost.searxngDescription': 'URL вашего экземпляра SearXNG (обязательно).',
|
||||
'ai.webSearch.maxResults': 'Макс. число результатов',
|
||||
'ai.webSearch.maxResults.description': 'Максимальное количество результатов поиска для возврата (1-20).',
|
||||
|
||||
// AI Safety Settings
|
||||
'ai.safety.title': 'Безопасность',
|
||||
'ai.safety.permissionMode': 'Режим разрешений',
|
||||
'ai.safety.permissionMode.description': 'Управляет тем, как AI взаимодействует с вашими терминалами. Режим наблюдателя блокирует все операции записи через Netcatty и применяется как к встроенным, так и к ACP-агентам. Режим подтверждения носит рекомендательный характер для ACP-агентов (они управляют собственным потоком одобрения инструментов).',
|
||||
'ai.safety.permissionMode.observer': 'Наблюдатель — только чтение, без действий',
|
||||
'ai.safety.permissionMode.confirm': 'Подтверждение — спрашивать перед действиями',
|
||||
'ai.safety.permissionMode.autonomous': 'Автономный — выполнять свободно',
|
||||
'ai.safety.commandTimeout': 'Тайм-аут команды',
|
||||
'ai.safety.commandTimeout.description': 'Максимальное число секунд, которое команда может выполняться до принудительного завершения. Применяется как к встроенным, так и к ACP-агентам.',
|
||||
'ai.safety.commandTimeout.unit': 'с',
|
||||
'ai.safety.maxIterations': 'Макс. число итераций',
|
||||
'ai.safety.maxIterations.description': 'Максимальное число циклов использования инструментов AI, чтобы предотвратить бесконтрольное выполнение. У ACP-агентов могут быть собственные внутренние лимиты итераций, имеющие приоритет.',
|
||||
'ai.safety.blocklist': 'Чёрный список команд',
|
||||
'ai.safety.blocklist.description': 'Regex-шаблоны для блокировки опасных команд. Применяется как к встроенным, так и к ACP-агентам через механизм выполнения Netcatty.',
|
||||
'ai.safety.blocklist.placeholder': 'Regex-шаблон...',
|
||||
'ai.safety.blocklist.reset': 'Сбросить по умолчанию',
|
||||
'ai.safety.blocklist.add': 'Добавить шаблон',
|
||||
'ai.safety.note': 'Чёрный список команд, тайм-аут команд и режим наблюдателя применяются на уровне MCP Server ко всем типам агентов. Режим подтверждения и максимальное число итераций полностью применяются к встроенному агенту; у ACP-агентов могут быть свои внутренние механизмы управления этими настройками.',
|
||||
|
||||
// Unified tooltips for terminal workspace and top tabs (issue #954)
|
||||
'terminal.layer.addTerminal': 'Добавить терминал',
|
||||
'terminal.layer.switchToSplitView': 'Переключить в режим разделения',
|
||||
'terminal.layer.sftp': 'SFTP',
|
||||
'terminal.layer.scripts': 'Скрипты',
|
||||
'terminal.layer.theme': 'Тема',
|
||||
'terminal.layer.aiChat': 'AI-чат',
|
||||
'terminal.layer.movePanelLeft': 'Переместить панель влево',
|
||||
'terminal.layer.movePanelRight': 'Переместить панель вправо',
|
||||
'terminal.layer.closePanel': 'Закрыть панель',
|
||||
'topTabs.openQuickSwitcher': 'Открыть быстрый переключатель',
|
||||
'topTabs.moreTabs': 'Больше вкладок',
|
||||
'topTabs.aiAssistant': 'AI-помощник',
|
||||
'topTabs.toggleTheme': 'Переключить тему',
|
||||
'topTabs.openSettings': 'Открыть настройки',
|
||||
'ai.chat.sessionHistory': 'История сессий',
|
||||
'ai.chat.attach': 'Прикрепить',
|
||||
'ai.chat.collapse': 'Свернуть',
|
||||
'ai.chat.expand': 'Развернуть',
|
||||
'ai.chat.enableAgent': 'Включить {name}',
|
||||
'zmodem.waitingForRemote': 'Ожидание удалённой стороны...',
|
||||
'zmodem.uploading': 'Загрузка',
|
||||
'zmodem.downloading': 'Скачивание',
|
||||
'zmodem.cancelTransfer': 'Отменить передачу (Ctrl+C)',
|
||||
'zmodem.overwrite.title': 'Remote file already exists',
|
||||
'zmodem.overwrite.applyToRest': 'Apply to remaining conflicts',
|
||||
'zmodem.overwrite.overwrite': 'Overwrite',
|
||||
'zmodem.overwrite.skip': 'Skip',
|
||||
'zmodem.overwrite.cancel': 'Cancel',
|
||||
'settings.shortcuts.resetToDefault': 'Сбросить по умолчанию',
|
||||
};
|
||||
653
application/i18n/locales/ru/core.ts
Normal file
653
application/i18n/locales/ru/core.ts
Normal file
@@ -0,0 +1,653 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const ruCoreMessages: Messages = {
|
||||
// Common
|
||||
'common.save': 'Сохранить',
|
||||
'common.cancel': 'Отмена',
|
||||
'common.close': 'Закрыть',
|
||||
'common.reset': 'Сбросить',
|
||||
'common.zoomIn': 'Увеличить',
|
||||
'common.zoomOut': 'Уменьшить',
|
||||
'common.settings': 'Настройки',
|
||||
'common.search': 'Поиск',
|
||||
'common.searchPlaceholder': 'Поиск...',
|
||||
'common.connect': 'Подключиться',
|
||||
'common.terminal': 'Терминал',
|
||||
'common.create': 'Создать',
|
||||
'common.import': 'Импорт',
|
||||
'common.generate': 'Сгенерировать',
|
||||
'common.delete': 'Удалить',
|
||||
'common.edit': 'Редактировать',
|
||||
'common.clear': 'Очистить',
|
||||
'common.optional': 'Необязательно',
|
||||
'common.selectPlaceholder': 'Выбрать...',
|
||||
'common.add': 'Добавить',
|
||||
'common.rename': 'Переименовать',
|
||||
'common.refresh': 'Обновить',
|
||||
'common.continue': 'Продолжить',
|
||||
'common.enabled': 'Включено',
|
||||
'common.disabled': 'Отключено',
|
||||
'common.error': 'Ошибка',
|
||||
'common.validation': 'Проверка',
|
||||
'common.unknownError': 'Неизвестная ошибка',
|
||||
'common.noResultsFound': 'Ничего не найдено',
|
||||
'common.back': 'Назад',
|
||||
'common.apply': 'Применить',
|
||||
'common.use': 'Использовать',
|
||||
'common.useGlobal': 'Использовать глобальное',
|
||||
'common.saveChanges': 'Сохранить изменения',
|
||||
'common.advanced': 'Дополнительно',
|
||||
'common.left': 'Слева',
|
||||
'common.right': 'Справа',
|
||||
'common.more': 'Ещё',
|
||||
'common.selectAHost': 'Выберите хост',
|
||||
'common.selectAHostPlaceholder': 'Выберите хост...',
|
||||
'sort.az': 'А-Я',
|
||||
'sort.za': 'Я-А',
|
||||
'sort.newest': 'Сначала новые',
|
||||
'sort.oldest': 'Сначала старые',
|
||||
'sort.group': 'По группе',
|
||||
'field.label': 'Метка',
|
||||
'field.type': 'Тип',
|
||||
'auth.keyType': 'Тип {type}',
|
||||
'auth.showAllKeys': 'Показать все ключи',
|
||||
|
||||
// Dialogs / prompts
|
||||
'confirm.deleteHost': 'Удалить хост "{name}"?',
|
||||
'confirm.deleteIdentity': 'Удалить идентификатор "{name}"?',
|
||||
'confirm.removeProvider': 'Удалить провайдера "{name}"?',
|
||||
'confirm.closeBusyTerminal.title': 'Подтвердите закрытие',
|
||||
'confirm.closeBusyTerminal.message': 'Процесс "{command}" всё ещё выполняется и будет завершён.',
|
||||
'confirm.closeBusyTerminal.messageWithMore': 'Процесс "{command}" и ещё {count} выполняющихся процесс(ов) будут завершены.',
|
||||
'confirm.closeBusyTerminal.cancel': 'Отмена',
|
||||
'confirm.closeBusyTerminal.close': 'Закрыть',
|
||||
'dialog.createWorkspace.title': 'Создать рабочее пространство',
|
||||
'dialog.renameWorkspace.title': 'Переименовать рабочее пространство',
|
||||
'dialog.renameSession.title': 'Переименовать сессию',
|
||||
'field.name': 'Имя',
|
||||
'field.selectHosts': 'Выбрать хосты',
|
||||
'placeholder.workspaceName': 'Имя рабочего пространства',
|
||||
'placeholder.sessionName': 'Имя сессии',
|
||||
'placeholder.searchHosts': 'Поиск хостов...',
|
||||
'toast.settingsUnavailable': 'Окно настроек недоступно на этой платформе.',
|
||||
'credentials.protectionUnavailable.title': 'Защита учётных данных недоступна',
|
||||
'credentials.protectionUnavailable.message': 'Сохранённые пароли и ключи не могут быть автоматически расшифрованы на этом устройстве. Перед подключением введите учётные данные заново.',
|
||||
'credentials.protectionUnavailable.action': 'Открыть настройки',
|
||||
|
||||
// Settings shell
|
||||
'settings.title': 'Настройки',
|
||||
'settings.tab.application': 'Приложение',
|
||||
'settings.tab.appearance': 'Внешний вид',
|
||||
'settings.tab.terminal': 'Терминал',
|
||||
'settings.tab.shortcuts': 'Горячие клавиши',
|
||||
'settings.tab.syncCloud': 'Синхронизация и облако',
|
||||
'settings.tab.system': 'Система',
|
||||
|
||||
// Settings > System
|
||||
'settings.system.title': 'Система',
|
||||
'settings.system.description': 'Системная информация и управление временными файлами.',
|
||||
'settings.system.tempDirectory': 'Временные файлы',
|
||||
'settings.system.location': 'Расположение',
|
||||
'settings.system.fileCount': 'Файлы',
|
||||
'settings.system.totalSize': 'Размер',
|
||||
'settings.system.openFolder': 'Открыть папку',
|
||||
'settings.system.refresh': 'Обновить',
|
||||
'settings.system.clearTempFiles': 'Очистить временные файлы',
|
||||
'settings.system.clearing': 'Очистка...',
|
||||
'settings.system.clearResult': 'Удалено файлов: {deleted}, ошибок: {failed}.',
|
||||
'settings.system.tempDirectoryHint': 'Временные файлы создаются при открытии удалённых файлов во внешних приложениях. Они автоматически очищаются при закрытии SFTP-сессий.',
|
||||
'settings.system.credentials.title': 'Защита учётных данных',
|
||||
'settings.system.credentials.status': 'Статус',
|
||||
'settings.system.credentials.checking': 'Проверка...',
|
||||
'settings.system.credentials.available': 'Доступно (системное хранилище ключей готово)',
|
||||
'settings.system.credentials.unavailable': 'Недоступно (невозможно расшифровать сохранённые учётные данные)',
|
||||
'settings.system.credentials.unknown': 'Неизвестно (не поддерживается в этой среде)',
|
||||
'settings.system.credentials.unavailableHint': 'Учётные данные, зашифрованные в другом профиле пользователя или на другой машине, здесь расшифровать нельзя. Повторно введите и сохраните их на этом устройстве.',
|
||||
'settings.system.credentials.portabilityHint': 'Облачная синхронизация переносима, потому что использует шифрование вашим мастер-ключом. Локальное шифрование safeStorage привязано к устройству и пользователю.',
|
||||
|
||||
// Settings > System > Crash Logs
|
||||
'settings.system.crashLogs.title': 'Журналы сбоев',
|
||||
'settings.system.crashLogs.description': 'Просмотр журналов ошибок основного процесса для диагностики неожиданного поведения.',
|
||||
'settings.system.crashLogs.noLogs': 'Журналы сбоев не найдены.',
|
||||
'settings.system.crashLogs.entries': 'Записей: {count}',
|
||||
'settings.system.crashLogs.clear': 'Очистить все журналы',
|
||||
'settings.system.crashLogs.cleared': 'Очищено файлов журналов: {count}.',
|
||||
'settings.system.crashLogs.source': 'Источник',
|
||||
'settings.system.crashLogs.time': 'Время',
|
||||
'settings.system.crashLogs.message': 'Сообщение',
|
||||
'settings.system.crashLogs.stack': 'Трассировка стека',
|
||||
'settings.system.crashLogs.hint': 'Журналы сбоев хранятся 30 дней и автоматически ротируются.',
|
||||
'settings.system.crashLogs.collapse': 'Свернуть',
|
||||
'settings.system.crashLogs.expand': 'Показать детали',
|
||||
|
||||
// Settings > System > Software Update
|
||||
'settings.update.title': 'Обновление программы',
|
||||
'settings.update.currentVersion': 'Текущая версия',
|
||||
'settings.update.checkForUpdates': 'Проверить обновления',
|
||||
'settings.update.checking': 'Проверка...',
|
||||
'settings.update.upToDate': 'Вы используете последнюю версию.',
|
||||
'settings.update.available': 'Доступна новая версия {version}.',
|
||||
'settings.update.download': 'Скачать обновление',
|
||||
'settings.update.downloading': 'Загрузка... {percent}%',
|
||||
'settings.update.readyToInstall': 'Обновление загружено и готово к установке.',
|
||||
'settings.update.restartNow': 'Перезапустить для обновления',
|
||||
'settings.update.error': 'Не удалось проверить наличие обновлений.',
|
||||
'settings.update.downloadError': 'Не удалось загрузить обновление.',
|
||||
'settings.update.manualDownload': 'Скачать с GitHub',
|
||||
'settings.update.manualDownloadHint': 'Автообновление недоступно на этой платформе. Скачайте последнюю версию с GitHub.',
|
||||
'settings.update.hint': 'Netcatty проверяет обновления через GitHub Releases.',
|
||||
'settings.update.lastCheckedJustNow': 'только что',
|
||||
'settings.update.lastCheckedMinutesAgo': '{n} мин назад',
|
||||
'settings.update.lastCheckedHoursAgo': '{n} ч назад',
|
||||
'settings.update.lastCheckedPrefix': 'Последняя проверка: ',
|
||||
'settings.update.autoUpdateEnabled': 'Автоматические обновления',
|
||||
'settings.update.autoUpdateEnabledDesc': 'Автоматически проверять и загружать обновления, когда они доступны.',
|
||||
|
||||
// Settings > Session Logs
|
||||
'settings.sessionLogs.title': 'Журналы сессий',
|
||||
'settings.sessionLogs.description': 'Настройка экспорта журналов сессий и параметров автосохранения.',
|
||||
'settings.sessionLogs.autoSave': 'Автосохранение',
|
||||
'settings.sessionLogs.enableAutoSave': 'Включить автосохранение',
|
||||
'settings.sessionLogs.enableAutoSaveDesc': 'Автоматически сохранять журналы сессий после завершения терминальных сессий.',
|
||||
'settings.sessionLogs.directory': 'Папка сохранения',
|
||||
'settings.sessionLogs.noDirectory': 'Папка не выбрана',
|
||||
'settings.sessionLogs.browse': 'Обзор',
|
||||
'settings.sessionLogs.openFolder': 'Открыть папку',
|
||||
'settings.sessionLogs.directoryHint': 'Журналы будут организованы по хостам во вложенных папках.',
|
||||
'settings.sessionLogs.format': 'Формат журнала',
|
||||
'settings.sessionLogs.formatDesc': 'Выберите формат сохраняемых файлов журналов.',
|
||||
'settings.sessionLogs.formatTxt': 'Обычный текст (.txt)',
|
||||
'settings.sessionLogs.formatRaw': 'Сырые данные с ANSI (.log)',
|
||||
'settings.sessionLogs.formatHtml': 'HTML (.html)',
|
||||
'settings.sessionLogs.hint': 'Журналы сессий сохраняют весь вывод терминала для диагностики и аудита.',
|
||||
|
||||
// Settings > Global Hotkey (Quake Mode)
|
||||
'settings.globalHotkey.title': 'Глобальная горячая клавиша',
|
||||
'settings.globalHotkey.toggleWindow': 'Переключение окна',
|
||||
'settings.globalHotkey.toggleWindowDesc': 'Нажмите сочетание клавиш, чтобы задать глобальную горячую клавишу для показа или скрытия окна.',
|
||||
'settings.globalHotkey.notSet': 'Не задано',
|
||||
'settings.globalHotkey.reset': 'Сбросить по умолчанию',
|
||||
'settings.globalHotkey.closeToTray': 'Сворачивать в системный трей',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'Если включено, при закрытии окно будет сворачиваться в системный трей вместо выхода из приложения.',
|
||||
'settings.globalHotkey.enabled': 'Включить глобальную горячую клавишу',
|
||||
'settings.globalHotkey.enabledDesc': 'Регистрировать системные сочетания клавиш. Когда отключено, все глобальные горячие клавиши снимаются с регистрации.',
|
||||
'settings.globalHotkey.hint': 'Глобальная горячая клавиша работает на уровне всей системы и позволяет быстро показывать или скрывать окно (терминал в стиле Quake).',
|
||||
|
||||
// Tray Panel
|
||||
'tray.openMainWindow': 'Открыть главное окно',
|
||||
'tray.sessions': 'Сессии',
|
||||
'tray.portForwarding': 'Проброс портов',
|
||||
'tray.status.connected': 'Подключено',
|
||||
'tray.status.connecting': 'Подключение',
|
||||
'tray.status.disconnected': 'Отключено',
|
||||
'tray.status.active': 'Активно',
|
||||
'tray.status.inactive': 'Неактивно',
|
||||
'tray.status.error': 'Ошибка',
|
||||
'tray.recentHosts': 'Недавние хосты',
|
||||
'tray.empty.title': 'Пока здесь ничего нет',
|
||||
'tray.empty.subtitle': 'Подключитесь к серверу, они по вам скучают 🚀',
|
||||
'tray.quit': 'Выйти из Netcatty',
|
||||
|
||||
// Vault Sidebar
|
||||
'vault.sidebar.collapse': 'Свернуть боковую панель',
|
||||
'vault.sidebar.expand': 'Развернуть боковую панель',
|
||||
'vault.sidebar.resize': 'Изменить ширину боковой панели',
|
||||
|
||||
// Settings > Application
|
||||
'settings.application.checkUpdates': 'Проверить обновления',
|
||||
'settings.application.reportProblem': 'Сообщить о проблеме',
|
||||
'settings.application.reportProblem.subtitle': 'Создать заранее заполненную задачу на GitHub',
|
||||
'settings.application.community': 'Сообщество',
|
||||
'settings.application.community.subtitle': 'На GitHub Discussions',
|
||||
'settings.application.github': 'GitHub',
|
||||
'settings.application.github.subtitle': 'Исходный код',
|
||||
'settings.application.whatsNew': 'Что нового',
|
||||
'settings.application.whatsNew.subtitle': 'Показать примечания к релизу',
|
||||
'settings.application.openExternal.failedTitle': 'Не удалось открыть ссылку',
|
||||
'settings.application.openExternal.failedBody': 'Не удалось открыть ссылку ни в системном браузере, ни во встроенном окне браузера.',
|
||||
'settings.vault.title': 'Хранилище',
|
||||
'settings.vault.showRecentHosts': 'Показывать недавно подключённые хосты',
|
||||
'settings.vault.showRecentHostsDesc': 'Показывать раздел недавно подключённых хостов в верхней части хранилища',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': 'Показывать в корне только хосты без группы',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'Если включено, в корневом списке хостов будут показаны только хосты без группы. Откройте группу на боковой панели, чтобы увидеть сгруппированные хосты.',
|
||||
'settings.vault.showSftpTab': 'Показывать вкладку SFTP',
|
||||
'settings.vault.showSftpTabDesc': 'Показывать отдельный SFTP-вид в верхней панели вкладок. Если скрыто, используйте боковую панель SFTP внутри сессии.',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': 'Доступно обновление',
|
||||
'update.available.message': 'Доступна новая версия {version}. Нажмите, чтобы скачать.',
|
||||
'update.checking': 'Проверка обновлений...',
|
||||
'update.upToDate.title': 'Актуальная версия',
|
||||
'update.upToDate.message': 'У вас установлена последняя версия ({version}).',
|
||||
'update.error': 'Не удалось проверить наличие обновлений',
|
||||
'update.downloadNow': 'Скачать сейчас',
|
||||
'update.viewInSettings': 'Открыть в настройках',
|
||||
'update.readyToInstall.title': 'Обновление готово',
|
||||
'update.readyToInstall.message': 'Версия {version} загружена и готова к установке.',
|
||||
'update.restartNow': 'Перезапустить сейчас',
|
||||
'update.downloadFailed.title': 'Ошибка обновления',
|
||||
'update.downloadFailed.message': 'Не удалось скачать обновление. Вы можете скачать его вручную.',
|
||||
'update.openReleases': 'Открыть релизы',
|
||||
'update.remindLater': 'Напомнить позже',
|
||||
'update.skipVersion': 'Пропустить эту версию',
|
||||
|
||||
// Settings > Appearance
|
||||
'settings.appearance.uiTheme': 'Тема интерфейса',
|
||||
'settings.appearance.theme': 'Тема',
|
||||
'settings.appearance.theme.desc': 'Выберите светлую, тёмную тему или следование системным настройкам',
|
||||
'settings.appearance.theme.light': 'Светлая',
|
||||
'settings.appearance.theme.dark': 'Тёмная',
|
||||
'settings.appearance.theme.system': 'Системная',
|
||||
'settings.appearance.accentColor': 'Акцентный цвет',
|
||||
'settings.appearance.customColor': 'Пользовательский цвет',
|
||||
'settings.appearance.accentColor.mode': 'Использовать свой акцент',
|
||||
'settings.appearance.accentColor.mode.desc': 'Переопределить акцентный цвет темы',
|
||||
'settings.appearance.accentColor.custom': 'Пользовательский акцент',
|
||||
'settings.appearance.themeColor': 'Цвет темы',
|
||||
'settings.appearance.themeColor.desc': 'Выберите готовую палитру для каждой темы',
|
||||
'settings.appearance.themeColor.light': 'Палитра светлой темы',
|
||||
'settings.appearance.themeColor.dark': 'Палитра тёмной темы',
|
||||
'settings.appearance.customCss': 'Пользовательский CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'Добавьте пользовательский CSS, чтобы настроить внешний вид приложения. Изменения применяются сразу. Основные области интерфейса имеют атрибут [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, top-tabs.',
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\n\n/* Сделать текст в боковой панели сниппетов крупнее */\n[data-section="snippets-panel"] {\n font-size: 14px !important;\n}\n\n/* Пользовательский фон терминала */\n.terminal { background: #1a1a2e !important; }\n\n/* Настройка глобального радиуса скругления */\n:root { --radius: 0.25rem; }',
|
||||
'settings.appearance.language': 'Язык',
|
||||
'settings.appearance.language.desc': 'Выберите язык интерфейса',
|
||||
'settings.appearance.uiFont': 'Шрифт интерфейса',
|
||||
'settings.appearance.uiFont.desc': 'Выберите шрифт для интерфейса приложения',
|
||||
|
||||
// Settings > Terminal
|
||||
'settings.terminal.section.theme': 'Тема терминала',
|
||||
'settings.terminal.themeModal.title': 'Выберите тему',
|
||||
'settings.terminal.themeModal.darkThemes': 'Тёмные темы',
|
||||
'settings.terminal.themeModal.lightThemes': 'Светлые темы',
|
||||
'settings.terminal.theme.selectButton': 'Выбрать тему',
|
||||
'settings.terminal.theme.followApp': 'Следовать теме приложения',
|
||||
'settings.terminal.theme.followApp.desc': 'Автоматически подбирать фон терминала под текущую тему приложения для более цельного вида.',
|
||||
'settings.terminal.theme.darkTheme': 'Тема терминала для тёмного режима',
|
||||
'settings.terminal.theme.lightTheme': 'Тема терминала для светлого режима',
|
||||
'settings.terminal.theme.auto': 'Авто (как тема приложения)',
|
||||
'settings.terminal.theme.autoDesc': 'Следует активному пресету темы интерфейса',
|
||||
'settings.terminal.section.font': 'Шрифт',
|
||||
'settings.terminal.section.cursor': 'Курсор',
|
||||
'settings.terminal.section.keyboard': 'Клавиатура',
|
||||
'settings.terminal.section.accessibility': 'Доступность',
|
||||
'settings.terminal.section.behavior': 'Поведение',
|
||||
'settings.terminal.section.scrollback': 'Буфер прокрутки',
|
||||
'settings.terminal.section.keywordHighlight': 'Подсветка ключевых слов',
|
||||
'settings.terminal.font.family': 'Шрифт',
|
||||
'settings.terminal.font.family.desc': 'Семейство шрифта терминала',
|
||||
'settings.terminal.font.cjk': 'Шрифт CJK',
|
||||
'settings.terminal.font.cjk.desc': 'Шрифт для китайских, японских и корейских символов; вариант "Авто" выбирает подходящий шрифт на основе основного',
|
||||
'settings.terminal.font.cjk.option.auto': 'Авто · в паре с основным шрифтом',
|
||||
'settings.terminal.font.cjk.option.sarasaSC': 'Sarasa Mono SC (Iosevka + Source Han SC)',
|
||||
'settings.terminal.font.cjk.option.sarasaTC': 'Sarasa Mono TC (Iosevka + Source Han TC)',
|
||||
'settings.terminal.font.cjk.option.mapleCN': 'Maple Mono CN',
|
||||
'settings.terminal.font.cjk.option.sourceHan': 'Source Han Mono SC',
|
||||
'settings.terminal.font.cjk.option.notoCJK': 'Noto Sans Mono CJK SC',
|
||||
'settings.terminal.font.cjk.option.lxgwWenkai': 'LXGW WenKai Mono',
|
||||
'settings.terminal.font.cjk.option.simSun': 'SimSun',
|
||||
'settings.terminal.font.cjk.option.legacy': '{font} · не рекомендуется (пропорциональный шрифт)',
|
||||
'settings.terminal.font.size': 'Размер шрифта',
|
||||
'settings.terminal.font.size.desc': 'Размер текста терминала',
|
||||
'settings.terminal.font.weight': 'Толщина шрифта',
|
||||
'settings.terminal.font.weight.desc': 'Толщина обычного текста (100-900)',
|
||||
'settings.terminal.font.weightBold': 'Толщина жирного шрифта',
|
||||
'settings.terminal.font.weightBold.desc': 'Толщина жирного текста (100-900)',
|
||||
'settings.terminal.font.linePadding': 'Межстрочный отступ',
|
||||
'settings.terminal.font.linePadding.desc': 'Дополнительное пространство между строками (0-10)',
|
||||
'settings.terminal.font.emulationType': 'Тип эмуляции терминала',
|
||||
'settings.terminal.cursor.style': 'Стиль курсора',
|
||||
'settings.terminal.cursor.style.block': 'Блок',
|
||||
'settings.terminal.cursor.style.bar': 'Полоса',
|
||||
'settings.terminal.cursor.style.underline': 'Подчёркивание',
|
||||
'settings.terminal.cursor.blink': 'Мигание курсора',
|
||||
'settings.terminal.keyboard.altAsMeta': 'Использовать Option как клавишу Meta',
|
||||
'settings.terminal.keyboard.altAsMeta.desc':
|
||||
'Использовать Option (Alt) как клавишу Meta вместо ввода специальных символов',
|
||||
'settings.terminal.keyboard.optionArrowWordJump': 'Option+←/→ переход по словам',
|
||||
'settings.terminal.keyboard.optionArrowWordJump.desc':
|
||||
'Отправлять Meta-b / Meta-f при Option+Влево/Вправо, чтобы оболочка перемещалась по словам, вместо стандартного ^[[1;3D / ^[[1;3C',
|
||||
'settings.terminal.accessibility.minimumContrastRatio': 'Минимальный коэффициент контрастности',
|
||||
'settings.terminal.accessibility.minimumContrastRatio.desc':
|
||||
'Подстраивать цвета под требования контрастности (1 = отключено, 21 = максимум)',
|
||||
'settings.terminal.behavior.rightClick': 'Поведение правой кнопки мыши',
|
||||
'settings.terminal.behavior.rightClick.desc': 'Действие при щелчке правой кнопкой в терминале',
|
||||
'settings.terminal.behavior.rightClick.menu': 'Показать меню',
|
||||
'settings.terminal.behavior.rightClick.paste': 'Вставить',
|
||||
'settings.terminal.behavior.rightClick.selectWord': 'Выбрать слово',
|
||||
'settings.terminal.behavior.copyOnSelect': 'Копировать при выделении',
|
||||
'settings.terminal.behavior.copyOnSelect.desc': 'Автоматически копировать выделенный текст. В tmux/vim с режимом мыши удерживайте Option на macOS или Shift на Windows/Linux для выделения',
|
||||
'settings.terminal.behavior.middleClickPaste': 'Вставка средней кнопкой мыши',
|
||||
'settings.terminal.behavior.middleClickPaste.desc':
|
||||
'Вставлять содержимое буфера обмена по щелчку средней кнопкой',
|
||||
'settings.terminal.behavior.bracketedPaste': 'Режим bracketed paste',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'Оборачивать вставляемый текст escape-последовательностями, чтобы оболочка отличала вставку от обычного ввода. Отключите, если видите артефакты вида ^[[200~.',
|
||||
'settings.terminal.behavior.clearWipesScrollback': '`clear` очищает буфер прокрутки',
|
||||
'settings.terminal.behavior.clearWipesScrollback.desc':
|
||||
'Команда `clear` также будет очищать буфер прокрутки (поведение POSIX по умолчанию). Отключите, чтобы история оставалась видимой после `clear`.',
|
||||
'settings.terminal.behavior.preserveSelectionOnInput': 'Сохранять выделение при вводе',
|
||||
'settings.terminal.behavior.preserveSelectionOnInput.desc':
|
||||
'Не сбрасывать выделенный мышью текст при вводе. Это удобно, например, чтобы выделить путь и вставить его после префикса команды вроде `sz `.',
|
||||
'settings.terminal.behavior.forcePromptNewLine': 'Переносить приглашение на новую строку',
|
||||
'settings.terminal.behavior.forcePromptNewLine.desc':
|
||||
'Если последняя строка вывода команды не завершена переводом строки, переносить распознанное приглашение оболочки на следующую визуальную строку.',
|
||||
'settings.terminal.behavior.osc52Clipboard': 'Буфер обмена OSC-52',
|
||||
'settings.terminal.behavior.osc52Clipboard.desc':
|
||||
'Разрешить удалённым программам (tmux, vim и т. д.) доступ к локальному буферу обмена через escape-последовательности OSC-52.',
|
||||
'settings.terminal.behavior.osc52Clipboard.off': 'Отключено',
|
||||
'settings.terminal.behavior.osc52Clipboard.writeOnly': 'Только запись',
|
||||
'settings.terminal.behavior.osc52Clipboard.readWrite': 'Чтение и запись',
|
||||
'settings.terminal.behavior.osc52Clipboard.prompt': 'Запись + запрос при чтении',
|
||||
'terminal.osc52.readPrompt.title': 'Запрос чтения буфера обмена',
|
||||
'terminal.osc52.readPrompt.desc': 'Удалённая программа запрашивает чтение вашего буфера обмена. Разрешить?',
|
||||
'terminal.osc52.readPrompt.allow': 'Разрешить',
|
||||
'terminal.osc52.readPrompt.deny': 'Запретить',
|
||||
'settings.terminal.behavior.scrollOnInput': 'Прокручивать при вводе',
|
||||
'settings.terminal.behavior.scrollOnInput.desc': 'Прокручивать терминал вниз при наборе текста',
|
||||
'settings.terminal.behavior.scrollOnOutput': 'Прокручивать при выводе',
|
||||
'settings.terminal.behavior.scrollOnOutput.desc':
|
||||
'Прокручивать терминал вниз при появлении нового вывода',
|
||||
'settings.terminal.behavior.scrollOnKeyPress': 'Прокручивать при нажатии клавиш',
|
||||
'settings.terminal.behavior.scrollOnKeyPress.desc':
|
||||
'Прокручивать терминал вниз при нажатии клавиши (например, Enter)',
|
||||
'settings.terminal.behavior.scrollOnPaste': 'Прокручивать при вставке',
|
||||
'settings.terminal.behavior.scrollOnPaste.desc':
|
||||
'Прокручивать терминал вниз при вставке текста',
|
||||
'settings.terminal.behavior.smoothScrolling': 'Плавная прокрутка',
|
||||
'settings.terminal.behavior.smoothScrolling.desc':
|
||||
'Анимировать прокрутку области терминала вместо мгновенного перехода',
|
||||
'settings.terminal.behavior.linkModifier': 'Клавиша-модификатор для ссылок',
|
||||
'settings.terminal.behavior.linkModifier.desc': 'Удерживайте эту клавишу, чтобы нажимать на ссылки в терминале',
|
||||
'settings.terminal.behavior.linkModifier.none': 'Нет (нажимать напрямую)',
|
||||
'settings.terminal.behavior.linkModifier.ctrl': 'Ctrl',
|
||||
'settings.terminal.behavior.linkModifier.alt': 'Alt / Option',
|
||||
'settings.terminal.behavior.linkModifier.meta': 'Cmd / Win',
|
||||
'settings.terminal.scrollback.desc': 'Ограничение количества строк терминала. Установите 0, чтобы снять ограничение.',
|
||||
'settings.terminal.scrollback.rows': 'Количество строк *',
|
||||
'settings.terminal.section.startupCommand': 'Команда запуска',
|
||||
'settings.terminal.startupCommandDelay.label': 'Задержка команды запуска (мс)',
|
||||
'settings.terminal.startupCommandDelay.desc': 'Сколько ждать после подключения перед отправкой команды запуска. Также используется между строками, если команда запуска многострочная. Увеличьте для медленных соединений.',
|
||||
'settings.terminal.keywordHighlight.title': 'Подсветка ключевых слов',
|
||||
'settings.terminal.keywordHighlight.resetColors': 'Сбросить цвета по умолчанию',
|
||||
'settings.terminal.keywordHighlight.resetDefaults': 'Сбросить встроенные правила по умолчанию',
|
||||
'settings.terminal.keywordHighlight.resetBuiltIn': 'Восстановить стандартную метку и шаблоны',
|
||||
'settings.terminal.keywordHighlight.addCustom': 'Добавить своё правило',
|
||||
'settings.terminal.keywordHighlight.editCustom': 'Редактировать правило',
|
||||
'settings.terminal.keywordHighlight.editBuiltIn': 'Редактировать встроенное правило',
|
||||
'settings.terminal.keywordHighlight.labelField': 'Метка и цвет',
|
||||
'settings.terminal.keywordHighlight.labelPlaceholder': 'Метка (например, Down)',
|
||||
'settings.terminal.keywordHighlight.patternField': 'Шаблоны Regex',
|
||||
'settings.terminal.keywordHighlight.patternPlaceholder': 'Один regex на строку (например, \\bdown\\b)',
|
||||
'settings.terminal.keywordHighlight.patternHint': 'Один regex на строку. Шаблоны сопоставляются без учёта регистра с глобальным флагом.',
|
||||
'settings.terminal.keywordHighlight.invalidPattern': 'Некорректный regex-шаблон',
|
||||
'settings.terminal.keywordHighlight.preview': 'Предпросмотр',
|
||||
'settings.terminal.section.localShell': 'Локальная оболочка',
|
||||
'settings.terminal.localShell.shell': 'Исполняемый файл оболочки',
|
||||
'settings.terminal.localShell.shell.desc': 'Путь к исполняемому файлу оболочки (например, /bin/zsh, pwsh.exe). Оставьте пустым, чтобы использовать системную оболочку по умолчанию.',
|
||||
'settings.terminal.localShell.shell.placeholder': 'Системная по умолчанию',
|
||||
'settings.terminal.localShell.shell.detected': 'Обнаружено',
|
||||
'settings.terminal.localShell.shell.notFound': 'Исполняемый файл оболочки не найден',
|
||||
'settings.terminal.localShell.shell.isDirectory': 'Путь указывает на каталог, а не на исполняемый файл',
|
||||
'settings.terminal.localShell.shell.default': 'Системная по умолчанию',
|
||||
'settings.terminal.localShell.shell.custom': 'Пользовательская...',
|
||||
'settings.terminal.localShell.shell.customPath': 'Путь к исполняемому файлу оболочки',
|
||||
'settings.terminal.localShell.shell.commonPaths': 'Частые пути',
|
||||
'settings.terminal.localShell.shell.pathValid': 'Путь корректен',
|
||||
'settings.terminal.localShell.startDir': 'Начальный каталог',
|
||||
'settings.terminal.localShell.startDir.desc': 'Каталог, в котором будет открываться локальный терминал. Оставьте пустым, чтобы использовать домашний каталог.',
|
||||
'settings.terminal.localShell.startDir.placeholder': 'Домашний каталог',
|
||||
'settings.terminal.localShell.startDir.notFound': 'Каталог не найден',
|
||||
'settings.terminal.localShell.startDir.isFile': 'Путь указывает на файл, а не на каталог',
|
||||
'settings.terminal.section.connection': 'Подключение',
|
||||
'settings.terminal.connection.keepaliveInterval': 'Интервал keepalive',
|
||||
'settings.terminal.connection.keepaliveInterval.desc': 'Как часто (в секундах) отправлять keepalive-пакеты на уровне SSH. Установите 0, чтобы отключить глобально. Учтите, что отдельные хосты могут переопределять это значение в своих настройках.',
|
||||
'settings.terminal.connection.keepaliveCountMax': 'Макс. число пропущенных keepalive',
|
||||
'settings.terminal.connection.keepaliveCountMax.desc': 'Количество пропущенных keepalive, после которого соединение считается мёртвым. Более высокие значения лучше переносят краткие сетевые сбои и медленные ответы SSH-серверов.',
|
||||
'settings.terminal.connection.x11Display': 'Дисплей X11',
|
||||
'settings.terminal.connection.x11Display.desc': 'Необязательный адрес локального дисплея для перенаправления X11. Оставьте пустым, чтобы использовать системное значение по умолчанию.',
|
||||
'settings.terminal.connection.x11Display.placeholder': 'Авто (:0 или DISPLAY)',
|
||||
'settings.terminal.section.serverStats': 'Статистика сервера (Linux)',
|
||||
'settings.terminal.serverStats.show': 'Показывать статистику сервера',
|
||||
'settings.terminal.serverStats.show.desc': 'Показывать загрузку CPU, памяти и диска в строке состояния терминала (только для Linux-серверов).',
|
||||
'settings.terminal.serverStats.refreshInterval': 'Интервал обновления',
|
||||
'settings.terminal.serverStats.refreshInterval.desc': 'Как часто обновлять статистику сервера.',
|
||||
'settings.terminal.serverStats.seconds': 'секунд',
|
||||
|
||||
// Settings > Terminal > Rendering
|
||||
'settings.terminal.section.rendering': 'Рендеринг',
|
||||
'settings.terminal.rendering.renderer': 'Рендерер',
|
||||
'settings.terminal.rendering.renderer.desc': 'Выберите технологию рендеринга терминала. В режиме "Авто" на устройствах с малым объёмом памяти будет использоваться DOM. Изменения применяются к новым терминальным сессиям.',
|
||||
'settings.terminal.rendering.auto': 'Авто',
|
||||
|
||||
// Settings > Terminal > Workspace Focus Indicator
|
||||
'settings.terminal.section.workspaceFocus': 'Индикатор фокуса рабочей области',
|
||||
'settings.terminal.workspaceFocus.style': 'Стиль индикатора фокуса',
|
||||
'settings.terminal.workspaceFocus.style.desc': 'Как показывать, какая панель активна в режиме разделённого вида.',
|
||||
'settings.terminal.workspaceFocus.dim': 'Затемнять неактивные панели',
|
||||
'settings.terminal.workspaceFocus.border': 'Рамка вокруг активной панели',
|
||||
|
||||
// Settings > Terminal > Autocomplete
|
||||
'settings.terminal.section.autocomplete': 'Автодополнение',
|
||||
'settings.terminal.autocomplete.enabled': 'Включить автодополнение',
|
||||
'settings.terminal.autocomplete.enabled.desc': 'Показывать подсказки команд на основе истории и описаний команд во время ввода.',
|
||||
'settings.terminal.autocomplete.ghostText': 'Призрачный текст',
|
||||
'settings.terminal.autocomplete.ghostText.desc': 'Показывать серую встроенную подсказку после курсора (как в fish shell).',
|
||||
'settings.terminal.autocomplete.popupMenu': 'Всплывающее меню',
|
||||
'settings.terminal.autocomplete.popupMenu.desc': 'Показывать плавающий список из нескольких подсказок.',
|
||||
|
||||
// Settings > Shortcuts
|
||||
'settings.shortcuts.section.scheme': 'Схема горячих клавиш',
|
||||
'settings.shortcuts.scheme.label': 'Сочетания клавиш',
|
||||
'settings.shortcuts.scheme.desc': 'Выберите раскладку клавиш для использования в сочетаниях',
|
||||
'settings.shortcuts.scheme.disabled': 'Отключено',
|
||||
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
|
||||
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
|
||||
'settings.shortcuts.section.custom': 'Пользовательские сочетания',
|
||||
'settings.shortcuts.resetAll': 'Сбросить все',
|
||||
'settings.shortcuts.recording': 'Нажмите клавиши...',
|
||||
'settings.shortcuts.none': 'Нет',
|
||||
'settings.shortcuts.setDisabled': 'Отключить',
|
||||
'settings.shortcuts.category.tabs': 'Вкладки',
|
||||
'settings.shortcuts.category.terminal': 'Терминал',
|
||||
'settings.shortcuts.category.navigation': 'Навигация',
|
||||
'settings.shortcuts.category.app': 'Приложение',
|
||||
'settings.shortcuts.category.sftp': 'SFTP',
|
||||
// Settings > Shortcuts -> key bings
|
||||
'settings.shortcuts.binding.switch-tab-1-9': 'Переключиться на вкладку [1...9]',
|
||||
'settings.shortcuts.binding.next-tab': 'Следующая вкладка',
|
||||
'settings.shortcuts.binding.prev-tab': 'Предыдущая вкладка',
|
||||
'settings.shortcuts.binding.close-tab': 'Закрыть вкладку',
|
||||
'settings.shortcuts.binding.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.move-focus': 'Переместить фокус между разделёнными окнами',
|
||||
'settings.shortcuts.binding.split-horizontal': 'Горизонтальное разделение',
|
||||
'settings.shortcuts.binding.split-vertical': 'Вертикальное разделение',
|
||||
'settings.shortcuts.binding.open-hosts': 'Открыть список хостов',
|
||||
'settings.shortcuts.binding.open-local': 'Открыть локальный терминал',
|
||||
'settings.shortcuts.binding.open-sftp': 'Открыть SFTP',
|
||||
'settings.shortcuts.binding.open-settings': 'Открыть настройки',
|
||||
'settings.shortcuts.binding.port-forwarding': 'Открыть перенаправление портов',
|
||||
'settings.shortcuts.binding.command-palette': 'Открыть палитру команд',
|
||||
'settings.shortcuts.binding.quick-switch': 'Быстрое переключение',
|
||||
'settings.shortcuts.binding.new-workspace': 'Новая рабочая область',
|
||||
'settings.shortcuts.binding.snippets': 'Открыть сниппеты',
|
||||
'settings.shortcuts.binding.broadcast': 'Переключить режим трансляции',
|
||||
'settings.shortcuts.binding.toggle-side-panel': 'Переключить боковую панель',
|
||||
'settings.shortcuts.binding.sftp-copy': 'Копировать файл',
|
||||
'settings.shortcuts.binding.sftp-cut': 'Вырезать файл',
|
||||
'settings.shortcuts.binding.sftp-paste': 'Вставить файл',
|
||||
'settings.shortcuts.binding.sftp-select-all': 'Выделить все файлы',
|
||||
'settings.shortcuts.binding.sftp-rename': 'Переименовать файл',
|
||||
'settings.shortcuts.binding.sftp-delete': 'Удалить файл',
|
||||
'settings.shortcuts.binding.sftp-refresh': 'Обновить',
|
||||
'settings.shortcuts.binding.sftp-new-folder': 'Создать новую папку',
|
||||
'settings.shortcuts.binding.sftp-open': 'Открыть файл / Войти в директорию',
|
||||
'settings.shortcuts.binding.sftp-go-parent': 'Перейти в родительскую директорию',
|
||||
'settings.shortcuts.binding.sftp-navigate-to': 'Перейти в выбранную директорию',
|
||||
|
||||
// Context menus / common actions
|
||||
'action.newHost': 'Новый хост',
|
||||
'action.newSubfolder': 'Новая подпапка',
|
||||
'action.copyPublicKey': 'Копировать публичный ключ',
|
||||
'action.keyExport': 'Экспорт ключа',
|
||||
'action.edit': 'Редактировать',
|
||||
'action.delete': 'Удалить',
|
||||
'action.duplicate': 'Дублировать',
|
||||
'action.open': 'Открыть',
|
||||
'action.copy': 'Копировать',
|
||||
'action.run': 'Запустить',
|
||||
'action.start': 'Старт',
|
||||
'action.stop': 'Остановить',
|
||||
'action.remove': 'Убрать',
|
||||
'action.convertToHost': 'Преобразовать в хост',
|
||||
|
||||
// Sync
|
||||
'sync.cloudSync': 'Облачная синхронизация',
|
||||
'sync.settings': 'Настройки синхронизации',
|
||||
'sync.active': 'Облачная синхронизация активна',
|
||||
'sync.syncing': 'Синхронизация...',
|
||||
'sync.error': 'Ошибка синхронизации',
|
||||
'sync.notConfigured': 'Не настроено',
|
||||
'sync.failed': 'Синхронизация не удалась',
|
||||
'sync.connected': 'Подключено',
|
||||
'sync.syncNow': 'Синхронизировать сейчас',
|
||||
'sync.recentActivity': 'Недавняя активность',
|
||||
'sync.history.uploaded': 'Загружено',
|
||||
'sync.history.downloaded': 'Скачано',
|
||||
'sync.history.resolved': 'Разрешено',
|
||||
'sync.toast.completedMessage': 'Синхронизация успешно завершена',
|
||||
'sync.toast.errorTitle': 'Ошибка синхронизации',
|
||||
'sync.autoSync.failedTitle': 'Синхронизация не удалась',
|
||||
'sync.autoSync.inspectFailedTitle': 'Синхронизация приостановлена',
|
||||
'sync.autoSync.inspectFailedMessage': 'Не удалось подключиться к облаку для проверки изменений. Автосинхронизация повторит попытку при изменении данных или после перезапуска приложения.',
|
||||
'sync.autoSync.syncedTitle': 'Синхронизировано из облака',
|
||||
'sync.autoSync.syncedMessage': 'Ваши данные были обновлены из облака.',
|
||||
'sync.autoSync.noProvider': 'Облачный провайдер не подключён. Откройте Настройки → Синхронизация и облако, чтобы подключить его.',
|
||||
'sync.autoSync.alreadySyncing': 'Синхронизация уже выполняется.',
|
||||
'sync.autoSync.restoreInProgress': 'В другом окне уже выполняется восстановление хранилища. Подождите, пока оно завершится.',
|
||||
'sync.autoSync.interruptedApplyTitle': 'Синхронизация приостановлена — предыдущее восстановление прервано',
|
||||
'sync.autoSync.interruptedApplyMessage': 'Предыдущее восстановление завершилось некорректно, поэтому локальное хранилище может быть в несогласованном состоянии. Откройте Настройки → Синхронизация и облако → Восстановление и примените защитную резервную копию перед возобновлением автосинхронизации.',
|
||||
'sync.autoSync.vaultLocked': 'Хранилище заблокировано. Откройте Настройки → Синхронизация и облако, чтобы разблокировать его.',
|
||||
'sync.autoSync.conflictDetected': 'Обнаружен конфликт синхронизации. Откройте Настройки → Синхронизация и облако, чтобы разрешить его.',
|
||||
'sync.autoSync.syncFailed': 'Синхронизация не удалась',
|
||||
'sync.autoSync.restoredTitle': 'Хранилище восстановлено',
|
||||
'sync.autoSync.restoredMessage': 'Ваше хранилище было восстановлено из облака.',
|
||||
'sync.autoSync.keptLocalTitle': 'Локальное хранилище сохранено',
|
||||
'sync.autoSync.keptLocalMessage': 'Ваше пустое локальное хранилище было сохранено. Облачные данные не применялись.',
|
||||
'sync.autoSync.emptyVaultConflict.title': 'Обнаружено пустое хранилище',
|
||||
'sync.autoSync.emptyVaultConflict.description': 'Ваше локальное хранилище пусто, но в облаке есть данные. Обычно это происходит после обновления или сброса хранилища. Что вы хотите сделать?',
|
||||
'sync.autoSync.emptyVaultConflict.cloudLabel': 'Облако',
|
||||
'sync.autoSync.emptyVaultConflict.restore': 'Восстановить из облака',
|
||||
'sync.autoSync.emptyVaultConflict.restoreDesc': 'Рекомендуется — восстановить ваши хосты, ключи и сниппеты из облачной резервной копии',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmpty': 'Оставить пустым',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': 'Начать заново с пустым хранилищем',
|
||||
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} хостов, {keys} ключей, {snippets} сниппетов, {proxyProfiles} прокси',
|
||||
'sync.autoSync.emptyVaultManual': 'Синхронизация невозможна: локальное хранилище пусто. Сначала восстановите его из локальной резервной копии или включите принудительную отправку в панели синхронизации.',
|
||||
|
||||
'sync.blocked.title': 'Синхронизация приостановлена',
|
||||
'sync.blocked.reason.bulkShrink': 'Будет удалено {lost} из {baseCount} сущностей типа {entityType} из облака (сокращение на {percent}%).',
|
||||
'sync.blocked.reason.largeShrink': 'Будет удалено {lost} сущностей типа {entityType} из облака.',
|
||||
'sync.blocked.detail': 'Обычно это вызвано повреждённым локальным состоянием (сбой keychain, частичная загрузка данных). Восстановите данные из локальной резервной копии или выполните принудительную отправку, если вы действительно хотели удалить эти записи.',
|
||||
'sync.blocked.restoreButton': 'Восстановить из локальной резервной копии',
|
||||
'sync.blocked.forcePushButton': 'Всё равно отправить принудительно',
|
||||
|
||||
'sync.forcePush.title': 'Подтвердите принудительную отправку',
|
||||
'sync.forcePush.body': 'Вы собираетесь удалить {lost} сущностей типа {entityType} из облака. Это действие нельзя отменить. Продолжить?',
|
||||
'sync.forcePush.confirm': 'Да, всё равно отправить',
|
||||
'sync.forcePush.cancel': 'Отмена',
|
||||
|
||||
'sync.entityType.hosts': 'хостов',
|
||||
'sync.entityType.keys': 'ключей',
|
||||
'sync.entityType.identities': 'идентификаторов',
|
||||
'sync.entityType.proxyProfiles': 'профилей прокси',
|
||||
'sync.entityType.snippets': 'сниппетов',
|
||||
'sync.entityType.customGroups': 'групп',
|
||||
'sync.entityType.snippetPackages': 'пакетов сниппетов',
|
||||
'sync.entityType.knownHosts': 'записей known_hosts',
|
||||
'sync.entityType.portForwardingRules': 'правил проброса портов',
|
||||
'sync.entityType.groupConfigs': 'конфигураций групп',
|
||||
|
||||
'sync.credentialsUnavailable': 'Это устройство не может расшифровать некоторые сохранённые учётные данные. Перед синхронизацией повторно введите их локально.',
|
||||
'time.never': 'Никогда',
|
||||
'time.justNow': 'Только что',
|
||||
'time.minutesAgo': '{minutes} мин назад',
|
||||
|
||||
// Vault navigation
|
||||
'vault.nav.hosts': 'Хосты',
|
||||
'vault.nav.keychain': 'Связка ключей',
|
||||
'vault.nav.proxies': 'Прокси',
|
||||
'vault.nav.portForwarding': 'Проброс портов',
|
||||
'vault.nav.snippets': 'Сниппеты',
|
||||
'vault.nav.knownHosts': 'Известные хосты',
|
||||
'vault.nav.logs': 'Журналы',
|
||||
|
||||
'proxyProfiles.action.add': 'Добавить прокси',
|
||||
'proxyProfiles.search.placeholder': 'Поиск прокси…',
|
||||
'proxyProfiles.section.proxies': 'Прокси',
|
||||
'proxyProfiles.count.items': 'Элементов: {count}',
|
||||
'proxyProfiles.empty.title': 'Нет прокси',
|
||||
'proxyProfiles.empty.desc': 'Создавайте переиспользуемые HTTP- или SOCKS5-прокси и выбирайте их в настройках хоста.',
|
||||
'proxyProfiles.usage': 'Связано: {count}',
|
||||
'proxyProfiles.copyName': '{name} Копия',
|
||||
'proxyProfiles.panel.newTitle': 'Новый прокси',
|
||||
'proxyProfiles.field.name': 'Имя прокси',
|
||||
'proxyProfiles.error.required': 'Имя, хост и порт обязательны.',
|
||||
'proxyProfiles.error.port': 'Порт должен быть в диапазоне от 1 до 65535.',
|
||||
'proxyProfiles.viewMode': 'Режим просмотра прокси',
|
||||
'proxyProfiles.delete.title': 'Удалить прокси?',
|
||||
'proxyProfiles.delete.desc': 'Удаление "{name}" отвяжет его от {count} настроек хостов или групп.',
|
||||
|
||||
'vault.groups.title': 'Группы',
|
||||
'vault.groups.total': 'Всего: {count}',
|
||||
'vault.groups.hostsCount': 'Хостов: {count}',
|
||||
'vault.groups.newSubgroup': 'Новая подгруппа',
|
||||
'vault.groups.rename': 'Переименовать группу',
|
||||
'vault.groups.delete': 'Удалить группу',
|
||||
'vault.groups.createSubfolder': 'Создать подпапку',
|
||||
'vault.groups.createRoot': 'Создать корневую группу',
|
||||
'vault.groups.createDialog.desc': 'Создайте новую группу для организации хостов.',
|
||||
'vault.groups.renameDialogTitle': 'Переименовать группу',
|
||||
'vault.groups.renameDialog.desc': 'Переименуйте существующую группу.',
|
||||
'vault.groups.deleteDialogTitle': 'Удалить группу',
|
||||
'vault.groups.deleteDialog.desc': 'Группа будет безвозвратно удалена, а все хосты будут перемещены в корень.',
|
||||
'vault.groups.deleteDialog.managedDesc': 'Это управляемая группа SSH-конфига. При её удалении также будут удалены все хосты и снята связь с исходным файлом.',
|
||||
'vault.groups.deleteDialog.deleteHosts': 'Также удалить все хосты в этой группе',
|
||||
'vault.groups.ungrouped': 'Без группы',
|
||||
'vault.groups.field.name': 'Имя группы',
|
||||
'vault.groups.placeholder.example': 'например, Production',
|
||||
'vault.groups.parentLabel': 'Родитель',
|
||||
'vault.groups.pathLabel': 'Путь',
|
||||
'vault.groups.settings': 'Настройки группы',
|
||||
'vault.groups.details': 'Сведения о группе',
|
||||
'vault.groups.details.general': 'Общие',
|
||||
'vault.groups.details.ssh': 'SSH',
|
||||
'vault.groups.details.telnet': 'Telnet',
|
||||
'vault.groups.details.advanced': 'Дополнительно',
|
||||
'vault.groups.details.appearance': 'Внешний вид',
|
||||
'vault.groups.details.mosh': 'Mosh',
|
||||
'vault.groups.details.parentGroup': 'Родительская группа',
|
||||
'vault.groups.details.none': 'Нет',
|
||||
'vault.groups.details.inherited': 'Унаследовано от группы',
|
||||
'vault.groups.details.addProtocol': 'Добавить протокол',
|
||||
'vault.groups.details.removeProtocol': 'Удалить протокол',
|
||||
'vault.groups.details.fontFamily': 'Семейство шрифта',
|
||||
'vault.groups.details.fontSize': 'Размер шрифта',
|
||||
'vault.groups.errors.required': 'Имя группы обязательно.',
|
||||
'vault.groups.errors.invalidChars': "Имя группы не может содержать '/' или '\\\\'.",
|
||||
'vault.groups.errors.duplicatePath': 'Группа с таким именем уже существует в этом расположении.',
|
||||
|
||||
'vault.managedSource.unmanage': 'Снять управление',
|
||||
'vault.managedSource.unmanageSuccess': 'Управление группой успешно снято',
|
||||
|
||||
'vault.hosts.header.entries': 'Записей: {count}',
|
||||
'vault.hosts.header.live': 'Активных: {count}',
|
||||
|
||||
};
|
||||
672
application/i18n/locales/ru/terminal.ts
Normal file
672
application/i18n/locales/ru/terminal.ts
Normal file
@@ -0,0 +1,672 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const ruTerminalMessages: Messages = {
|
||||
// Connection logs
|
||||
'logs.table.date': 'Дата',
|
||||
'logs.table.user': 'Пользователь',
|
||||
'logs.table.host': 'Хост',
|
||||
'logs.table.saved': 'Сохранено',
|
||||
'logs.empty.title': 'Нет журналов подключений',
|
||||
'logs.empty.desc':
|
||||
'История ваших подключений будет отображаться здесь, когда вы подключаетесь к хостам или открываете локальные терминалы.',
|
||||
'logs.loadMore': 'Загрузить ещё {count} журналов',
|
||||
'logs.ongoing': 'в процессе',
|
||||
'logs.localTerminal': 'Локальный терминал',
|
||||
'logs.action.save': 'Сохранить',
|
||||
'logs.action.unsave': 'Убрать из сохранённых',
|
||||
'logs.action.delete': 'Удалить',
|
||||
|
||||
// Log view
|
||||
'logView.customizeAppearance': 'Настроить внешний вид',
|
||||
'logView.appearance': 'Внешний вид',
|
||||
'logView.readOnly': 'Только чтение',
|
||||
'logView.export': 'Экспорт',
|
||||
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': 'Открыть SFTP',
|
||||
'terminal.toolbar.availableAfterConnect': 'Доступно после подключения',
|
||||
'terminal.toolbar.sftp': 'SFTP',
|
||||
'terminal.toolbar.more': 'Другие действия',
|
||||
'terminal.toolbar.scripts': 'Скрипты',
|
||||
'terminal.toolbar.library': 'Библиотека',
|
||||
'terminal.toolbar.noSnippets': 'Нет доступных сниппетов',
|
||||
'terminal.toolbar.terminalSettings': 'Настройки терминала',
|
||||
'terminal.toolbar.searchTerminal': 'Поиск по терминалу',
|
||||
'terminal.toolbar.search': 'Поиск',
|
||||
'terminal.toolbar.broadcast': 'Трансляция',
|
||||
'terminal.toolbar.broadcastEnable': 'Включить режим трансляции',
|
||||
'terminal.toolbar.broadcastDisable': 'Отключить режим трансляции',
|
||||
'terminal.toolbar.composeBar': 'Строка ввода',
|
||||
'terminal.composeBar.placeholder': 'Введите команду здесь и нажмите Enter для отправки...',
|
||||
'terminal.composeBar.send': 'Отправить',
|
||||
'terminal.composeBar.close': 'Закрыть строку ввода',
|
||||
'terminal.composeBar.broadcasting': 'Трансляция во все сессии',
|
||||
'terminal.toolbar.focus': 'Фокус',
|
||||
'terminal.toolbar.focusMode': 'Режим фокуса',
|
||||
'terminal.toolbar.encoding': 'Кодировка терминала',
|
||||
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
||||
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
||||
'terminal.toolbar.closeSession': 'Закрыть сессию',
|
||||
'terminal.toolbar.hostHighlight.title': 'Подсветка ключевых слов хоста',
|
||||
'terminal.toolbar.hostHighlight.noRules': 'Для этого хоста не задано пользовательских правил подсветки',
|
||||
'terminal.toolbar.hostHighlight.addRule': 'Добавить новое правило',
|
||||
'terminal.toolbar.hostHighlight.labelPlaceholder': 'Метка (например, Error)',
|
||||
'terminal.toolbar.hostHighlight.patternPlaceholder': 'Regex-шаблон (например, \\bfailed\\b)',
|
||||
'terminal.toolbar.hostHighlight.invalidPattern': 'Некорректный regex-шаблон',
|
||||
'terminal.toolbar.hostHighlight.clearAll': 'Очистить все',
|
||||
'terminal.toolbar.hostHighlight.changeColor': 'Изменить цвет подсветки для',
|
||||
'terminal.toolbar.hostHighlight.selectColor': 'Выбрать цвет для нового правила',
|
||||
'terminal.statusbar.copyHostname.label': 'Копировать адрес хоста',
|
||||
'terminal.statusbar.copyHostname.tooltip': 'Копировать адрес хоста ({hostname})',
|
||||
'terminal.statusbar.copyHostname.toast': 'Адрес хоста скопирован: {hostname}',
|
||||
'terminal.statusbar.copyHostname.error': 'Не удалось скопировать адрес хоста в буфер обмена',
|
||||
'terminal.serverStats.cpu': 'Использование CPU',
|
||||
'terminal.serverStats.cpuCores': 'Использование ядер CPU',
|
||||
'terminal.serverStats.memory': 'Использование памяти',
|
||||
'terminal.serverStats.memoryDetails': 'Сведения о памяти',
|
||||
'terminal.serverStats.memUsed': 'Использовано',
|
||||
'terminal.serverStats.memBuffers': 'Буферы',
|
||||
'terminal.serverStats.memCached': 'Кэш',
|
||||
'terminal.serverStats.memFree': 'Свободно',
|
||||
'terminal.serverStats.swap': 'Swap',
|
||||
'terminal.serverStats.swapUsed': 'Использовано swap',
|
||||
'terminal.serverStats.swapFree': 'Свободный swap',
|
||||
'terminal.serverStats.swapTotal': 'Всего',
|
||||
'terminal.serverStats.topProcesses': 'Топ процессов по памяти',
|
||||
'terminal.serverStats.disk': 'Использование диска (корень)',
|
||||
'terminal.serverStats.diskDetails': 'Смонтированные диски',
|
||||
'terminal.serverStats.network': 'Скорость сети',
|
||||
'terminal.serverStats.networkDetails': 'Сетевые интерфейсы',
|
||||
'terminal.serverStats.noData': 'Данные недоступны',
|
||||
'terminal.dragDrop.localTitle': 'Перетащите для вставки путей',
|
||||
'terminal.dragDrop.localMessage': 'Пути к файлам будут вставлены в терминал',
|
||||
'terminal.dragDrop.remoteTitle': 'Перетащите для загрузки файлов',
|
||||
'terminal.dragDrop.remoteMessage': 'Файлы будут загружены через SFTP',
|
||||
'terminal.dragDrop.notConnected': 'Нельзя перетащить файлы — терминал не подключён',
|
||||
'terminal.dragDrop.errorTitle': 'Ошибка перетаскивания',
|
||||
'terminal.dragDrop.errorMessage': 'Не удалось обработать перетащенные файлы',
|
||||
'terminal.search.placeholder': 'Поиск...',
|
||||
'terminal.search.noResults': 'Ничего не найдено',
|
||||
'terminal.search.prevMatch': 'Предыдущее совпадение (Shift+Enter)',
|
||||
'terminal.search.nextMatch': 'Следующее совпадение (Enter)',
|
||||
'terminal.menu.copy': 'Копировать',
|
||||
'terminal.menu.paste': 'Вставить',
|
||||
'terminal.menu.pasteSelection': 'Вставить выделенное',
|
||||
'terminal.menu.selectAll': 'Выбрать всё',
|
||||
'terminal.menu.reconnect': 'Переподключиться',
|
||||
'terminal.menu.splitHorizontal': 'Разделить по горизонтали',
|
||||
'terminal.menu.splitVertical': 'Разделить по вертикали',
|
||||
'terminal.menu.clearBuffer': 'Очистить буфер',
|
||||
'terminal.menu.closeTerminal': 'Закрыть терминал',
|
||||
'terminal.auth.password': 'Пароль',
|
||||
'terminal.auth.sshKey': 'SSH-ключ',
|
||||
'terminal.auth.username': 'Имя пользователя',
|
||||
'terminal.auth.username.placeholder': 'root',
|
||||
'terminal.auth.passwordLabel': 'Пароль',
|
||||
'terminal.auth.password.placeholder': 'Введите пароль',
|
||||
'terminal.auth.passphrase': 'Парольная фраза',
|
||||
'terminal.auth.passphrase.placeholder': 'Необязательная парольная фраза для выбранного приватного ключа',
|
||||
'terminal.auth.certificate': 'Сертификат',
|
||||
'terminal.auth.selectKey': 'Выбрать ключ',
|
||||
'terminal.auth.noKeysHint': 'Нет доступных ключей. Добавьте ключи в связке ключей.',
|
||||
'terminal.auth.continueSave': 'Продолжить и сохранить',
|
||||
'terminal.auth.credentialsUnavailable': 'Сохранённые учётные данные не могут быть расшифрованы на этом устройстве. Пожалуйста, введите и сохраните их заново.',
|
||||
'terminal.auth.jumpCredentialsUnavailable': 'У jump-хоста сохранены учётные данные, которые нельзя расшифровать на этом устройстве. Откройте настройки хоста и введите их заново.',
|
||||
'terminal.auth.proxyCredentialsUnavailable': 'Учётные данные прокси не могут быть расшифрованы на этом устройстве. Откройте настройки хоста и заново введите пароль прокси.',
|
||||
'terminal.auth.keyUnavailableFallbackPassword': 'Сохранённый SSH-ключ недоступен на этом устройстве. Выполняется переход на аутентификацию по паролю.',
|
||||
'terminal.progress.timeoutIn': 'Тайм-аут через {seconds}с',
|
||||
'terminal.progress.disconnected': 'Отключено',
|
||||
'terminal.progress.cancelling': 'Отмена...',
|
||||
'terminal.progress.startOver': 'Начать заново',
|
||||
'terminal.connection.dismissDisconnectedDialog': 'Закрыть уведомление об отключении',
|
||||
'terminal.connection.chainOf': 'Цепочка {current} из {total}',
|
||||
'terminal.connection.showLogs': 'Показать журналы',
|
||||
'terminal.connection.hideLogs': 'Скрыть журналы',
|
||||
'terminal.connection.protocol.ssh': 'SSH',
|
||||
'terminal.connection.protocol.telnet': 'Telnet',
|
||||
'terminal.connection.protocol.mosh': 'Mosh',
|
||||
'terminal.connection.protocol.serial': 'Serial',
|
||||
'terminal.connection.protocol.local': 'Локальная оболочка',
|
||||
'terminal.hostKey.unknownTitle': 'Подтвердите этот ключ хоста',
|
||||
'terminal.hostKey.changedTitle': 'Ключ хоста изменился',
|
||||
'terminal.hostKey.unknownDescription': 'Подлинность {host} пока не может быть установлена.',
|
||||
'terminal.hostKey.changedDescription': 'Сохранённый ключ для {host} больше не совпадает с этим сервером.',
|
||||
'terminal.hostKey.fingerprintLabel': 'Отпечаток {keyType} — SHA256:',
|
||||
'terminal.hostKey.savedFingerprintLabel': 'Сохранённый отпечаток',
|
||||
'terminal.hostKey.unknownHint': 'Запомните его, если этот отпечаток принадлежит серверу, к которому вы ожидали подключиться.',
|
||||
'terminal.hostKey.changedHint': 'Продолжайте только если вы ожидали, что этот хост изменится.',
|
||||
'terminal.hostKey.addAndContinue': 'Добавить и продолжить',
|
||||
'terminal.hostKey.updateAndContinue': 'Обновить и продолжить',
|
||||
'terminal.themeModal.title': 'Внешний вид терминала',
|
||||
'terminal.themeModal.tab.theme': 'Тема',
|
||||
'terminal.themeModal.tab.font': 'Шрифт',
|
||||
'terminal.themeModal.tab.custom': 'Пользовательское',
|
||||
'terminal.themeModal.globalTheme': 'Глобальная тема',
|
||||
'terminal.themeModal.globalFont': 'Глобальный шрифт',
|
||||
'terminal.themeModal.fontSize': 'Размер шрифта',
|
||||
'terminal.themeModal.fontWeight': 'Толщина шрифта',
|
||||
'terminal.themeModal.livePreview': 'Предпросмотр в реальном времени',
|
||||
'terminal.themeModal.themeType': 'Тема {type}',
|
||||
'terminal.hiddenTheme.title': 'Текущая скрытая тема',
|
||||
'terminal.hiddenTheme.desc': 'Эта тема скрыта из ручного выбора и будет заменена, когда вы выберете другую тему.',
|
||||
'topTabs.toggleTheme.systemExitTitle': 'Активна системная тема',
|
||||
'topTabs.toggleTheme.systemExitMessage': 'Откройте настройки, чтобы выбрать фиксированную светлую или тёмную тему.',
|
||||
'topTabs.toggleTheme.openSettings': 'Открыть настройки',
|
||||
|
||||
// Custom Themes
|
||||
'terminal.customTheme.section': 'Пользовательские темы',
|
||||
'terminal.customTheme.yourThemes': 'Ваши темы',
|
||||
'terminal.customTheme.new': 'Новая тема',
|
||||
'terminal.customTheme.newDesc': 'Клонировать текущую тему и настроить её',
|
||||
'terminal.customTheme.newTitle': 'Новая пользовательская тема',
|
||||
'terminal.customTheme.editTitle': 'Редактировать тему',
|
||||
'terminal.customTheme.import': 'Импорт .itermcolors',
|
||||
'terminal.customTheme.importDesc': 'Импорт из файла цветовой схемы iTerm2',
|
||||
'terminal.customTheme.importError': 'Не удалось разобрать выбранный файл. Убедитесь, что это корректный XML-файл .itermcolors.',
|
||||
'terminal.customTheme.delete': 'Удалить тему',
|
||||
'terminal.customTheme.confirmDelete': 'Подтвердить удаление',
|
||||
'terminal.customTheme.name': 'Название',
|
||||
'terminal.customTheme.namePlaceholder': 'Моя пользовательская тема',
|
||||
'terminal.customTheme.type': 'Тип',
|
||||
'terminal.customTheme.group.general': 'Общие',
|
||||
'terminal.customTheme.group.normal': 'Обычные цвета',
|
||||
'terminal.customTheme.group.bright': 'Яркие цвета',
|
||||
'terminal.customTheme.color.background': 'Фон',
|
||||
'terminal.customTheme.color.foreground': 'Текст',
|
||||
'terminal.customTheme.color.cursor': 'Курсор',
|
||||
'terminal.customTheme.color.selection': 'Выделение',
|
||||
'terminal.customTheme.color.black': 'Чёрный',
|
||||
'terminal.customTheme.color.red': 'Красный',
|
||||
'terminal.customTheme.color.green': 'Зелёный',
|
||||
'terminal.customTheme.color.yellow': 'Жёлтый',
|
||||
'terminal.customTheme.color.blue': 'Синий',
|
||||
'terminal.customTheme.color.magenta': 'Пурпурный',
|
||||
'terminal.customTheme.color.cyan': 'Голубой',
|
||||
'terminal.customTheme.color.white': 'Белый',
|
||||
'terminal.customTheme.color.brightBlack': 'Яркий чёрный',
|
||||
'terminal.customTheme.color.brightRed': 'Яркий красный',
|
||||
'terminal.customTheme.color.brightGreen': 'Яркий зелёный',
|
||||
'terminal.customTheme.color.brightYellow': 'Яркий жёлтый',
|
||||
'terminal.customTheme.color.brightBlue': 'Яркий синий',
|
||||
'terminal.customTheme.color.brightMagenta': 'Яркий пурпурный',
|
||||
'terminal.customTheme.color.brightCyan': 'Яркий голубой',
|
||||
'terminal.customTheme.color.brightWhite': 'Яркий белый',
|
||||
|
||||
// Cloud Sync Settings
|
||||
'cloudSync.gate.title': 'Синхронизация с end-to-end шифрованием',
|
||||
'cloudSync.gate.desc':
|
||||
'Ваши данные шифруются локально перед синхронизацией. Облачные провайдеры никогда не видят ваши данные в открытом виде. Задайте мастер-ключ, чтобы включить безопасную синхронизацию.',
|
||||
'cloudSync.gate.masterKey': 'Мастер-ключ',
|
||||
'cloudSync.gate.confirmMasterKey': 'Подтвердите мастер-ключ',
|
||||
'cloudSync.gate.placeholder': 'Введите надёжный пароль',
|
||||
'cloudSync.gate.confirmPlaceholder': 'Подтвердите пароль',
|
||||
'cloudSync.gate.mismatch': 'Пароли не совпадают',
|
||||
'cloudSync.gate.warning':
|
||||
'Я понимаю, что если забуду мастер-ключ, мои данные нельзя будет восстановить. Сброс пароля невозможен.',
|
||||
'cloudSync.gate.enableVault': 'Включить зашифрованное хранилище',
|
||||
'cloudSync.gate.enabledToast': 'Зашифрованное хранилище включено',
|
||||
'cloudSync.gate.setupFailed': 'Не удалось настроить мастер-ключ',
|
||||
'cloudSync.passwordStrength.tooShort': 'Слишком короткий',
|
||||
'cloudSync.passwordStrength.weak': 'Слабый',
|
||||
'cloudSync.passwordStrength.moderate': 'Средний',
|
||||
'cloudSync.passwordStrength.strong': 'Сильный',
|
||||
'cloudSync.passwordStrength.veryStrong': 'Очень сильный',
|
||||
'cloudSync.provider.notConnected': 'Не подключено',
|
||||
'cloudSync.provider.sync': 'Синхронизация',
|
||||
'cloudSync.provider.connect': 'Подключить',
|
||||
'cloudSync.provider.connecting': 'Подключение...',
|
||||
'cloudSync.provider.webdav': 'WebDAV',
|
||||
'cloudSync.provider.webdav.desc': 'Подключение к самостоятельно размещённому WebDAV endpoint',
|
||||
'cloudSync.provider.s3': 'Совместимое с S3',
|
||||
'cloudSync.provider.s3.desc': 'Подключение к объектному хранилищу, совместимому с S3',
|
||||
'cloudSync.provider.comingSoon': 'Скоро',
|
||||
'cloudSync.webdav.title': 'Настройки WebDAV',
|
||||
'cloudSync.webdav.desc': 'Настройка WebDAV endpoint для зашифрованной синхронизации.',
|
||||
'cloudSync.webdav.endpoint': 'URL endpoint',
|
||||
'cloudSync.webdav.authType': 'Тип аутентификации',
|
||||
'cloudSync.webdav.auth.basic': 'Basic',
|
||||
'cloudSync.webdav.auth.digest': 'Digest',
|
||||
'cloudSync.webdav.auth.token': 'Токен',
|
||||
'cloudSync.webdav.username': 'Имя пользователя',
|
||||
'cloudSync.webdav.password': 'Пароль',
|
||||
'cloudSync.webdav.token': 'Токен',
|
||||
'cloudSync.webdav.showSecret': 'Показать секрет',
|
||||
'cloudSync.webdav.allowInsecure': 'Разрешить небезопасное соединение (игнорировать ошибки сертификата)',
|
||||
'cloudSync.webdav.validation.endpoint': 'Введите корректный WebDAV endpoint.',
|
||||
'cloudSync.webdav.validation.credentials': 'Имя пользователя и пароль обязательны.',
|
||||
'cloudSync.webdav.validation.token': 'Токен обязателен.',
|
||||
'cloudSync.s3.title': 'Настройки S3',
|
||||
'cloudSync.s3.desc': 'Подключение к объектному хранилищу, совместимому с S3, для зашифрованной синхронизации.',
|
||||
'cloudSync.s3.endpoint': 'URL endpoint',
|
||||
'cloudSync.s3.region': 'Регион',
|
||||
'cloudSync.s3.bucket': 'Бакет',
|
||||
'cloudSync.s3.accessKeyId': 'ID ключа доступа',
|
||||
'cloudSync.s3.secretAccessKey': 'Секретный ключ доступа',
|
||||
'cloudSync.s3.sessionToken': 'Токен сессии (необязательно)',
|
||||
'cloudSync.s3.prefix': 'Префикс ключа (необязательно)',
|
||||
'cloudSync.s3.forcePathStyle': 'Принудительно использовать path-style URL (для MinIO/R2 и т. д.)',
|
||||
'cloudSync.s3.showSecret': 'Показать секреты',
|
||||
'cloudSync.s3.validation.required': 'Endpoint, регион, бакет, access key и secret обязательны.',
|
||||
'cloudSync.smb.title': 'Настройки SMB',
|
||||
'cloudSync.smb.desc': 'Подключение к файловой SMB/CIFS-шаре для зашифрованной синхронизации.',
|
||||
'cloudSync.smb.share': 'Путь к шаре',
|
||||
'cloudSync.smb.username': 'Имя пользователя',
|
||||
'cloudSync.smb.password': 'Пароль',
|
||||
'cloudSync.smb.domain': 'Домен (необязательно)',
|
||||
'cloudSync.smb.domainPlaceholder': 'например, WORKGROUP',
|
||||
'cloudSync.smb.port': 'Порт (необязательно)',
|
||||
'cloudSync.smb.showSecret': 'Показать пароль',
|
||||
'cloudSync.smb.validation.share': 'Путь к шаре обязателен.',
|
||||
'cloudSync.smb.validation.port': 'Порт должен быть числом от 1 до 65535.',
|
||||
'cloudSync.connect.smb.success': 'SMB успешно подключён',
|
||||
'cloudSync.connect.smb.failedTitle': 'Ошибка подключения SMB',
|
||||
'cloudSync.provider.smb': 'SMB-шара',
|
||||
'cloudSync.connect.webdav.success': 'WebDAV успешно подключён',
|
||||
'cloudSync.connect.webdav.failedTitle': 'Ошибка подключения WebDAV',
|
||||
'cloudSync.connect.s3.success': 'S3 успешно подключён',
|
||||
'cloudSync.connect.s3.failedTitle': 'Ошибка подключения S3',
|
||||
'cloudSync.lastSync.never': 'Никогда',
|
||||
'cloudSync.lastSync.justNow': 'Только что',
|
||||
'cloudSync.lastSync.minutesAgo': '{minutes} мин назад',
|
||||
'cloudSync.changeKey': 'Изменить ключ',
|
||||
'cloudSync.providers.title': 'Облачные провайдеры',
|
||||
'cloudSync.syncAll': 'Синхронизировать всех подключённых провайдеров',
|
||||
'cloudSync.autoSync.title': 'Автосинхронизация',
|
||||
'cloudSync.autoSync.desc': 'Автоматически синхронизировать при внесении изменений',
|
||||
'cloudSync.strategy.title': 'Стратегия синхронизации',
|
||||
'cloudSync.strategy.desc': 'Выберите, что делать, когда изменились и локальные, и облачные данные.',
|
||||
'cloudSync.strategy.smartMerge': 'Умное объединение (рекомендуется)',
|
||||
'cloudSync.strategy.smartMergeDesc': 'По возможности объединять изменения с обеих сторон; если Netcatty не сможет безопасно выбрать, он попросит вас решить вручную.',
|
||||
'cloudSync.strategy.preferCloud': 'Приоритет облака',
|
||||
'cloudSync.strategy.preferCloudDesc': 'Когда изменились обе стороны, скачать облачную версию и заменить локальные изменения.',
|
||||
'cloudSync.strategy.preferLocal': 'Приоритет локальных данных',
|
||||
'cloudSync.strategy.preferLocalDesc': 'Когда изменились обе стороны, загрузить локальную версию и заменить облачные изменения.',
|
||||
'cloudSync.status.title': 'Статус синхронизации',
|
||||
'cloudSync.status.localVersion': 'Локальная версия',
|
||||
'cloudSync.status.remoteVersion': 'Удалённая версия',
|
||||
'cloudSync.history.title': 'История синхронизации',
|
||||
'cloudSync.history.upload': 'Загрузка',
|
||||
'cloudSync.history.download': 'Скачивание',
|
||||
'cloudSync.history.resolved': 'Разрешено',
|
||||
'cloudSync.history.error': 'Ошибка',
|
||||
'cloudSync.localBackups.title': 'История локальных резервных копий',
|
||||
'cloudSync.localBackups.desc': 'Netcatty сохраняет локальные точки восстановления перед сменой версии приложения и перед восстановлением хранилища.',
|
||||
'cloudSync.localBackups.retentionTitle': 'Хранение резервных копий',
|
||||
'cloudSync.localBackups.retentionDesc': 'Выберите, сколько локальных резервных копий должен хранить Netcatty.',
|
||||
'cloudSync.localBackups.maxCount': 'Макс. число копий',
|
||||
'cloudSync.localBackups.maxSaved': 'Хранение резервных копий: {count}',
|
||||
'cloudSync.localBackups.maxInvalid': 'Введите число от 1 до 100.',
|
||||
'cloudSync.localBackups.empty': 'Локальных резервных копий пока нет.',
|
||||
'cloudSync.localBackups.reason.appVersionChange': 'Перед сменой версии приложения',
|
||||
'cloudSync.localBackups.reason.beforeRestore': 'Перед восстановлением',
|
||||
'cloudSync.localBackups.versionChange': '{from} -> {to}',
|
||||
'cloudSync.localBackups.counts': '{hosts} хостов, {keys} ключей, {snippets} сниппетов',
|
||||
'cloudSync.localBackups.restore': 'Восстановить',
|
||||
'cloudSync.localBackups.restoreSuccess': 'Локальная резервная копия восстановлена.',
|
||||
'cloudSync.localBackups.restoreFailedTitle': 'Ошибка восстановления',
|
||||
'cloudSync.localBackups.restoreMissing': 'Резервная копия не найдена.',
|
||||
'cloudSync.localBackups.protectiveBackupFailed': 'Не удалось создать защитную резервную копию, поэтому восстановление было прервано для защиты ваших текущих данных. Устраните основную проблему (например, доступ к keychain) и попробуйте снова. Подробности: {message}',
|
||||
'cloudSync.localBackups.restoreConfirmTitle': 'Восстановить эту резервную копию?',
|
||||
'cloudSync.localBackups.restoreConfirmDesc': 'Ваши текущие хосты, ключи, сниппеты и настройки будут заменены содержимым этой резервной копии. Перед этим автоматически создаётся защитный снимок текущих данных.',
|
||||
'cloudSync.localBackups.restoreConfirmButton': 'Восстановить',
|
||||
'cloudSync.localBackups.restoreConfirmCancel': 'Отмена',
|
||||
'cloudSync.localBackups.unavailableTitle': 'Локальные резервные копии недоступны',
|
||||
'cloudSync.localBackups.unavailableDesc': 'Эта платформа не предоставляет Netcatty безопасное хранилище ключей, поэтому локальные резервные копии нельзя записывать безопасно. Установите Netcatty в систему с поддерживаемым keychain, чтобы включить историю локальных резервных копий.',
|
||||
'cloudSync.localBackups.lockedTitle': 'Требуется мастер-ключ',
|
||||
'cloudSync.localBackups.lockedDesc': 'Настройте или разблокируйте мастер-ключ перед восстановлением резервной копии, чтобы восстановленные учётные данные оставались зашифрованными.',
|
||||
'cloudSync.revisionHistory.viewButton': 'История',
|
||||
'cloudSync.revisionHistory.title': 'История версий хранилища',
|
||||
'cloudSync.revisionHistory.description': 'Просматривайте и восстанавливайте предыдущие версии вашего хранилища из истории ревизий Gist.',
|
||||
'cloudSync.revisionHistory.empty': 'Ревизии не найдены.',
|
||||
'cloudSync.revisionHistory.current': 'Текущая',
|
||||
'cloudSync.revisionHistory.revision': 'Ревизия',
|
||||
'cloudSync.revisionHistory.revisionPreview': 'Содержимое ревизии',
|
||||
'cloudSync.revisionHistory.device': 'Устройство',
|
||||
'cloudSync.revisionHistory.hosts': 'Хосты',
|
||||
'cloudSync.revisionHistory.keys': 'Ключи',
|
||||
'cloudSync.revisionHistory.snippets': 'Сниппеты',
|
||||
'cloudSync.revisionHistory.identities': 'Идентификаторы',
|
||||
'cloudSync.revisionHistory.restoreButton': 'Восстановить эту версию',
|
||||
'cloudSync.revisionHistory.restored': 'Хранилище восстановлено из выбранной ревизии.',
|
||||
'cloudSync.revisionHistory.revisionNotFound': 'Ревизия не найдена или не содержит данных хранилища.',
|
||||
'cloudSync.revisionHistory.decryptFailed': 'Не удалось расшифровать эту ревизию. Возможно, она была зашифрована другим мастер-паролем.',
|
||||
'cloudSync.changeKey.title': 'Изменить мастер-ключ',
|
||||
'cloudSync.changeKey.current': 'Текущий мастер-ключ',
|
||||
'cloudSync.changeKey.new': 'Новый мастер-ключ',
|
||||
'cloudSync.changeKey.confirmNew': 'Подтвердите новый мастер-ключ',
|
||||
'cloudSync.changeKey.currentPlaceholder': 'Введите текущий мастер-ключ',
|
||||
'cloudSync.changeKey.newPlaceholder': 'Введите новый мастер-ключ',
|
||||
'cloudSync.changeKey.confirmPlaceholder': 'Подтвердите новый мастер-ключ',
|
||||
'cloudSync.changeKey.fillAll': 'Пожалуйста, заполните все поля',
|
||||
'cloudSync.changeKey.minLength': 'Новый мастер-ключ должен содержать не менее 8 символов',
|
||||
'cloudSync.changeKey.notMatch': 'Новые мастер-ключи не совпадают',
|
||||
'cloudSync.changeKey.incorrectCurrent': 'Неверный текущий мастер-ключ',
|
||||
'cloudSync.changeKey.failed': 'Не удалось изменить мастер-ключ',
|
||||
'cloudSync.changeKey.desc': 'Это заново зашифрует ваше хранилище. Убедитесь, что вы помните новый ключ.',
|
||||
'cloudSync.changeKey.showKeys': 'Показать ключи',
|
||||
'cloudSync.changeKey.updatedToast': 'Мастер-ключ обновлён',
|
||||
'cloudSync.changeKey.updateButton': 'Обновить ключ',
|
||||
'cloudSync.unlock.title': 'Введите мастер-ключ',
|
||||
'cloudSync.unlock.masterKey': 'Мастер-ключ',
|
||||
'cloudSync.unlock.desc':
|
||||
'Введите мастер-ключ один раз, чтобы включить зашифрованную синхронизацию. Он будет безопасно сохранён в системном keychain.',
|
||||
'cloudSync.unlock.placeholder': 'Введите мастер-ключ',
|
||||
'cloudSync.unlock.empty': 'Пожалуйста, введите мастер-ключ',
|
||||
'cloudSync.unlock.incorrect': 'Неверный мастер-ключ',
|
||||
'cloudSync.unlock.failed': 'Не удалось разблокировать хранилище',
|
||||
'cloudSync.unlock.showKey': 'Показать ключ',
|
||||
'cloudSync.unlock.notNow': 'Не сейчас',
|
||||
'cloudSync.unlock.readyToast': 'Хранилище готово',
|
||||
'cloudSync.unlock.unlockButton': 'Разблокировать',
|
||||
'cloudSync.header.vaultReady': 'Хранилище готово',
|
||||
'cloudSync.header.preparingVault': 'Подготовка хранилища...',
|
||||
'cloudSync.header.providersConnected': 'Подключено провайдеров: {count}',
|
||||
'cloudSync.githubFlow.title': 'Подключить GitHub',
|
||||
'cloudSync.githubFlow.desc': 'Скопируйте код ниже и введите его на GitHub, чтобы авторизовать Netcatty.',
|
||||
'cloudSync.githubFlow.copyCode': 'Скопировать код',
|
||||
'cloudSync.githubFlow.copied': 'Скопировано!',
|
||||
'cloudSync.githubFlow.openGitHub': 'Открыть GitHub',
|
||||
'cloudSync.githubFlow.waiting': 'Ожидание авторизации...',
|
||||
'cloudSync.conflict.title': 'Обнаружен конфликт версий',
|
||||
'cloudSync.conflict.desc': 'Выберите, какую версию сохранить',
|
||||
'cloudSync.conflict.local': 'ЛОКАЛЬНАЯ',
|
||||
'cloudSync.conflict.cloud': 'ОБЛАЧНАЯ',
|
||||
'cloudSync.conflict.detailsTitle': 'Изменённые данные',
|
||||
'cloudSync.conflict.detailsCounts': 'Локально {local} · Облако {cloud} · Конфликты {conflicts}',
|
||||
'cloudSync.conflict.entity.hosts': 'Хосты',
|
||||
'cloudSync.conflict.entity.keys': 'Ключи',
|
||||
'cloudSync.conflict.entity.identities': 'Идентификаторы',
|
||||
'cloudSync.conflict.entity.proxyProfiles': 'Профили прокси',
|
||||
'cloudSync.conflict.entity.snippets': 'Сниппеты',
|
||||
'cloudSync.conflict.entity.customGroups': 'Группы',
|
||||
'cloudSync.conflict.entity.snippetPackages': 'Пакеты сниппетов',
|
||||
'cloudSync.conflict.entity.portForwardingRules': 'Проброс портов',
|
||||
'cloudSync.conflict.entity.groupConfigs': 'Настройки групп',
|
||||
'cloudSync.conflict.entity.settings': 'Настройки',
|
||||
'cloudSync.conflict.keepLocal': 'Перезаписать облако (сохранить локальную)',
|
||||
'cloudSync.conflict.useCloud': 'Скачать из облака (перезаписать локальную)',
|
||||
'cloudSync.connect.browserContinue': 'Завершите авторизацию в браузере',
|
||||
'cloudSync.connect.browserCancelled': 'Предыдущая авторизация в браузере была отменена',
|
||||
'cloudSync.connect.github.success': 'GitHub успешно подключён',
|
||||
'cloudSync.connect.github.failedTitle': 'Ошибка подключения GitHub',
|
||||
'cloudSync.connect.github.timeout': 'Время подключения к GitHub истекло. Проверьте сеть или настройки прокси.',
|
||||
'cloudSync.connect.github.networkError': 'Не удалось связаться с GitHub. Проверьте сеть или настройки прокси.',
|
||||
'cloudSync.connect.google.failedTitle': 'Ошибка подключения Google',
|
||||
'cloudSync.connect.onedrive.failedTitle': 'Ошибка подключения OneDrive',
|
||||
'cloudSync.sync.success': 'Синхронизировано с {provider}',
|
||||
'cloudSync.sync.failed': 'Синхронизация не удалась',
|
||||
'cloudSync.sync.failedTitle': 'Синхронизация не удалась',
|
||||
'cloudSync.sync.errorTitle': 'Ошибка синхронизации',
|
||||
'cloudSync.resolve.downloaded': 'Скачаны данные из облака',
|
||||
'cloudSync.resolve.uploaded': 'Загружены локальные данные',
|
||||
'cloudSync.resolve.failedTitle': 'Не удалось разрешить конфликт',
|
||||
'cloudSync.clearLocal.title': 'Очистить локальные данные',
|
||||
'cloudSync.clearLocal.desc': 'Сбросить локальную версию и историю синхронизации. При следующей синхронизации данные будут скачаны из облака.',
|
||||
'cloudSync.clearLocal.button': 'Очистить',
|
||||
'cloudSync.clearLocal.dialog.title': 'Очистить локальные данные хранилища?',
|
||||
'cloudSync.clearLocal.dialog.desc': 'Локальная версия будет сброшена до 0, а история синхронизации очищена. При следующей синхронизации данные будут скачаны из облака и заменят локальные.',
|
||||
'cloudSync.clearLocal.dialog.cancel': 'Отмена',
|
||||
'cloudSync.clearLocal.dialog.confirm': 'Очистить локальные данные',
|
||||
'cloudSync.clearLocal.toast.title': 'Локальные данные очищены',
|
||||
'cloudSync.clearLocal.toast.desc': 'Локальная версия сброшена до 0. Выполните синхронизацию для загрузки из облака.',
|
||||
|
||||
// Keychain
|
||||
'keychain.filter.key': 'Ключ',
|
||||
'keychain.filter.certificate': 'Сертификат',
|
||||
'keychain.action.generateKey': 'Создать ключ',
|
||||
'keychain.action.importKey': 'Импорт. ключ',
|
||||
'keychain.action.newIdentity': 'Новый ид-катор',
|
||||
'keychain.action.importCertificate': 'Импорт. сертификат',
|
||||
'keychain.view.grid': 'Сетка',
|
||||
'keychain.view.list': 'Список',
|
||||
'keychain.section.keys': 'Ключи',
|
||||
'keychain.section.identities': 'Идентификаторы',
|
||||
'keychain.count.items': '{count} запис(ей)',
|
||||
'keychain.empty.title': 'Настройте свои ключи',
|
||||
'keychain.empty.desc': 'Импортируйте или создайте SSH-ключи для безопасной аутентификации.',
|
||||
'keychain.panel.generateKey': 'Сгенерировать ключ',
|
||||
'keychain.panel.newKey': 'Новый ключ',
|
||||
'keychain.panel.keyDetails': 'Сведения о ключе',
|
||||
'keychain.panel.editKey': 'Редактировать ключ',
|
||||
'keychain.panel.editIdentity': 'Редактировать идентификатор',
|
||||
'keychain.panel.newIdentity': 'Новый идентификатор',
|
||||
'keychain.panel.keyExport': 'Экспорт ключа',
|
||||
'keychain.validation.labelRequired': 'Пожалуйста, введите метку для ключа',
|
||||
'keychain.validation.labelAndPrivateKeyRequired': 'Метка и приватный ключ обязательны',
|
||||
'keychain.validation.labelAndUsernameRequired': 'Метка и имя пользователя обязательны',
|
||||
'keychain.error.generationUnavailable': 'Генератор ключей не работает - пожалуйста, убедитесь, что приложение работает в Electron',
|
||||
'keychain.error.generateKeyPairFailed': 'Не удалось сгенерировать пару ключей',
|
||||
'keychain.error.generateKeyFailed': 'Не удалось сгенерировать ключ',
|
||||
'keychain.error.keyGenerationTitle': 'Генерация ключа',
|
||||
'keychain.export.exportTo': 'Экспортировать в *',
|
||||
'keychain.export.selectHost': 'Выберите хост',
|
||||
'keychain.export.location': 'Расположение ~ $1 *',
|
||||
'keychain.export.filename': 'Имя файла ~ $2 *',
|
||||
'keychain.export.note': 'Экспорт ключей сейчас поддерживается только в системах {unix}. Используйте раздел {advanced} для настройки скрипта экспорта.',
|
||||
'keychain.export.script': 'Скрипт *',
|
||||
'keychain.export.scriptPlaceholder': 'Скрипт экспорта...',
|
||||
'keychain.export.missingCredentials': 'У хоста нет сохранённого пароля или ключа. Сначала добавьте в хост учётные данные с паролем.',
|
||||
'keychain.export.successTitle': 'Экспорт выполнен успешно',
|
||||
'keychain.export.successMessage': 'Публичный ключ экспортирован и привязан к {host}',
|
||||
'keychain.export.failedTitle': 'Ошибка экспорта',
|
||||
'keychain.export.failedMessage': 'Не удалось экспортировать ключ: {error}',
|
||||
'keychain.export.failedPrefix': 'Ошибка экспорта: {error}',
|
||||
'keychain.export.exitCode': 'Команда завершилась с кодом {code}',
|
||||
'keychain.export.exporting': 'Экспорт...',
|
||||
'keychain.export.exportAndAttach': 'Экспортировать и привязать',
|
||||
'keychain.export.title': 'Экспорт ключа',
|
||||
'keychain.export.exportToRequired': 'Экспортировать в *',
|
||||
'keychain.export.selectHostPlaceholder': 'Выберите хост...',
|
||||
'keychain.export.locationLabel': 'Расположение ~ $1 *',
|
||||
'keychain.export.filenameLabel': 'Имя файла ~ $2 *',
|
||||
'keychain.export.advanced': 'Дополнительно',
|
||||
'keychain.export.note.supportsOnly': 'Экспорт ключей сейчас поддерживается только в',
|
||||
'keychain.export.note.systems': 'системах.',
|
||||
'keychain.export.note.use': 'Используйте',
|
||||
'keychain.export.note.customize': 'раздел для настройки скрипта экспорта.',
|
||||
'keychain.export.scriptRequired': 'Скрипт *',
|
||||
'keychain.export.exportToHost': 'Экспортировать на хост',
|
||||
'keychain.export.failedGeneric': 'Ошибка экспорта: {message}',
|
||||
'keychain.field.label': 'Метка',
|
||||
'keychain.field.labelRequired': 'Метка *',
|
||||
'keychain.field.labelPlaceholder': 'Метка ключа',
|
||||
'keychain.field.privateKeyRequired': 'Приватный ключ *',
|
||||
'keychain.field.publicKey': 'Публичный ключ',
|
||||
'keychain.field.certificatePlaceholder': 'Содержимое сертификата (необязательно)',
|
||||
'keychain.generate.keyType': 'Тип ключа',
|
||||
'keychain.generate.keySize': 'Размер ключа',
|
||||
'keychain.generate.labelPlaceholder': 'Метка ключа',
|
||||
'keychain.generate.passphrasePlaceholder': 'Парольная фраза (необязательно)',
|
||||
'keychain.generate.savePassphrase': 'Сохранить парольную фразу',
|
||||
'keychain.generate.generate': 'Сгенерировать',
|
||||
'keychain.generate.generateSave': 'Сгенерировать и сохранить',
|
||||
'keychain.import.dropHint': 'Перетащите сюда файл ключа',
|
||||
'keychain.import.importFromFile': 'Импортировать из файла',
|
||||
'keychain.import.saveKey': 'Сохранить ключ',
|
||||
'keychain.import.importedKeyLabel': 'Импортированный ключ',
|
||||
'keychain.identity.usernameRequired': 'Имя пользователя *',
|
||||
'keychain.identity.method.passwordOnly': 'Пароль',
|
||||
'keychain.identity.summary.password': 'Пароль аутентификации',
|
||||
'keychain.identity.summary.key': 'Ключ аутентификации',
|
||||
'keychain.identity.summary.certificate': 'Сертификат аутентификации',
|
||||
'keychain.identity.summary.passwordAndKey': 'Пароль и ключ аутентификации',
|
||||
'keychain.identity.summary.passwordAndCertificate': 'Пароль и сертификат аутентификации',
|
||||
'keychain.identity.summary.none': 'Нет учётных данных',
|
||||
'keychain.identity.selectCredential': 'Выберите {kind}',
|
||||
'keychain.identity.save': 'Сохранить',
|
||||
'keychain.identity.update': 'Обновить',
|
||||
'keychain.keyDialog.newTitle': 'Новый ключ',
|
||||
'keychain.keyDialog.newDesc': 'Добавить новый SSH-ключ',
|
||||
'keychain.keyDialog.editTitle': 'Редактировать ключ',
|
||||
'keychain.keyDialog.editDesc': 'Обновить этот SSH-ключ',
|
||||
'keychain.keyDialog.updateKey': 'Обновить ключ',
|
||||
|
||||
// Tabs
|
||||
'tabs.closeSessionAria': 'Закрыть сессию',
|
||||
'tabs.closeLogViewAria': 'Закрыть просмотр журнала',
|
||||
'tabs.logPrefix': 'Журнал:',
|
||||
'tabs.logLocal': 'Локальный',
|
||||
'tabs.copyTab': 'Копировать вкладку',
|
||||
'tabs.closeOthers': 'Закрыть остальные',
|
||||
'tabs.closeToRight': 'Закрыть вкладки справа',
|
||||
'tabs.closeAll': 'Закрыть все',
|
||||
'keychain.edit.labelRequired': 'Метка *',
|
||||
'keychain.edit.keyLabelPlaceholder': 'Метка ключа',
|
||||
'keychain.edit.privateKeyRequired': 'Приватный ключ *',
|
||||
'keychain.edit.publicKey': 'Публичный ключ',
|
||||
'keychain.edit.certificate': 'Сертификат',
|
||||
'keychain.edit.certificatePlaceholder': 'Содержимое сертификата (необязательно)',
|
||||
'keychain.edit.filePath': 'Путь к файлу',
|
||||
'keychain.edit.keyExport': 'Экспорт ключа',
|
||||
'keychain.edit.exportToHost': 'Экспортировать на хост',
|
||||
|
||||
// Snippets
|
||||
'snippets.searchPlaceholder': 'Поиск сниппетов...',
|
||||
'snippets.action.newSnippet': 'Новый сниппет',
|
||||
'snippets.action.newPackage': 'Новый пакет',
|
||||
'snippets.panel.newTitle': 'Новый сниппет',
|
||||
'snippets.panel.editTitle': 'Редактировать сниппет',
|
||||
'snippets.field.description': 'Описание действия',
|
||||
'snippets.field.descriptionPlaceholder': 'Например: проверить сетевую нагрузку',
|
||||
'snippets.field.package': 'Добавить пакет',
|
||||
'snippets.field.packagePlaceholder': 'Выберите или создайте пакет',
|
||||
'snippets.field.createPackage': 'Создать пакет',
|
||||
'snippets.field.scriptRequired': 'Скрипт *',
|
||||
'snippets.scriptEditor.expand': 'Открыть в окне',
|
||||
'snippets.scriptEditor.resize': 'Изменить высоту редактора',
|
||||
'snippets.scriptEditor.modalTitle': 'Редактировать скрипт',
|
||||
'snippets.variables.dialogTitle': 'Переменные сниппета',
|
||||
'snippets.variables.dialogDesc': 'Заполните значения для "{label}" перед запуском.',
|
||||
'snippets.variables.hint': 'Значения вставляются в скрипт как есть (без shell-экранирования).',
|
||||
'snippets.variables.preview': 'Предпросмотр',
|
||||
'snippets.variables.placeholder': 'Введите значение',
|
||||
'snippets.variables.placeholderDefault': 'По умолчанию: {value}',
|
||||
'snippets.variables.required': 'Эта переменная обязательна',
|
||||
'snippets.variables.run': 'Запустить',
|
||||
'snippets.field.variablesHelp': 'Используйте {{name}} или {{name:default}} для плейсхолдеров в скрипте.',
|
||||
'snippets.field.variablesDetected': 'Переменные',
|
||||
'snippets.field.variableDefault': 'по умолчанию {value}',
|
||||
'snippets.targets.title': 'Цели',
|
||||
'snippets.targets.add': 'Добавить цели',
|
||||
'snippets.history.title': 'История оболочки',
|
||||
'snippets.history.subtitle': '{count} команд',
|
||||
'snippets.history.emptyTitle': 'История оболочки пока пуста',
|
||||
'snippets.history.emptyDesc': 'Здесь будут появляться выполненные вами команды',
|
||||
'snippets.history.loadMore': 'Загрузить ещё',
|
||||
'snippets.history.separator': '•',
|
||||
'snippets.history.labelPlaceholder': 'Задайте метку для этого сниппета',
|
||||
'snippets.history.saveAsSnippet': 'Сохранить как сниппет',
|
||||
'snippets.history.time.justNow': 'только что',
|
||||
'snippets.history.time.minutesAgo': '{count}м назад',
|
||||
'snippets.history.time.hoursAgo': '{count}ч назад',
|
||||
'snippets.history.time.daysAgo': '{count}д назад',
|
||||
'snippets.breadcrumb.allPackages': 'Все пакеты',
|
||||
'snippets.breadcrumb.separator': '›',
|
||||
'snippets.empty.title': 'Создать сниппет',
|
||||
'snippets.empty.desc': 'Сохраняйте самые используемые команды как сниппеты, чтобы повторно использовать их в один клик.',
|
||||
'snippets.search.noResults.title': 'Нет совпадений',
|
||||
'snippets.search.noResults.desc': 'Ни один сниппет или пакет не соответствует запросу "{query}". Попробуйте другой поисковый запрос или очистите поиск для просмотра.',
|
||||
'snippets.section.packages': 'Пакеты',
|
||||
'snippets.section.snippets': 'Сниппеты',
|
||||
'snippets.package.count': '{count} сниппет(ов)',
|
||||
'snippets.commandFallback': 'Команда',
|
||||
'snippets.view.grid': 'Сетка',
|
||||
'snippets.view.list': 'Список',
|
||||
'snippets.packageDialog.title': 'Новый пакет',
|
||||
'snippets.packageDialog.parent': 'Родитель: {parent}',
|
||||
'snippets.packageDialog.root': 'Корень',
|
||||
'snippets.packageDialog.placeholder': 'например, ops/maintenance',
|
||||
'snippets.packageDialog.hint': 'Используйте "/" для создания вложенных пакетов.',
|
||||
|
||||
// Snippets Rename Dialog
|
||||
'snippets.renameDialog.title': 'Переименовать пакет',
|
||||
'snippets.renameDialog.currentPath': 'Текущий путь: {path}',
|
||||
'snippets.renameDialog.placeholder': 'Введите новое имя',
|
||||
'snippets.renameDialog.error.empty': 'Имя пакета не может быть пустым',
|
||||
'snippets.renameDialog.error.duplicate': 'Пакет с таким именем уже существует',
|
||||
'snippets.renameDialog.error.invalidChars': 'Имя пакета может содержать только буквы, цифры, дефисы и подчёркивания',
|
||||
|
||||
'snippets.field.noAutoRun': 'Только вставить (не выполнять автоматически)',
|
||||
// Snippet Shortkey
|
||||
'snippets.field.shortkey': 'Сочетание клавиш',
|
||||
'snippets.shortkey.placeholder': 'Нажмите, чтобы задать сочетание',
|
||||
'snippets.shortkey.recording': 'Нажмите сочетание клавиш...',
|
||||
'snippets.shortkey.hint': 'Нажмите это сочетание в терминале, чтобы быстро отправить команду.',
|
||||
'snippets.shortkey.clear': 'Очистить сочетание',
|
||||
'snippets.shortkey.error.systemConflict': 'Это сочетание конфликтует с системным сочетанием',
|
||||
'snippets.shortkey.error.snippetConflict': 'Это сочетание уже используется сниппетом: {name}',
|
||||
|
||||
// Serial Port
|
||||
'serial.button': 'Серийный',
|
||||
'serial.modal.title': 'Подключение к последовательному порту',
|
||||
'serial.modal.desc': 'Настройте параметры подключения к последовательному порту',
|
||||
'serial.field.port': 'Последовательный порт',
|
||||
'serial.field.selectPort': 'Выберите порт...',
|
||||
'serial.field.baudRate': 'Скорость передачи',
|
||||
'serial.field.dataBits': 'Биты данных',
|
||||
'serial.field.stopBits': 'Стоп-биты',
|
||||
'serial.field.stopBits15Warning': '1.5 stop bits may not be supported on all Windows devices',
|
||||
'serial.field.parity': 'Чётность',
|
||||
'serial.field.flowControl': 'Управление потоком',
|
||||
'serial.noPorts': 'Последовательные порты не обнаружены. Подключите устройство и обновите список.',
|
||||
'serial.field.customPort': 'Путь к пользовательскому порту',
|
||||
'serial.field.customPortPlaceholder': 'например, /dev/ttys001 или COM1',
|
||||
'serial.type.hardware': 'Аппаратный',
|
||||
'serial.type.pseudo': 'Псевдотерминал',
|
||||
'serial.type.custom': 'Пользовательский',
|
||||
'serial.parity.none': 'Нет',
|
||||
'serial.parity.even': 'Чётная',
|
||||
'serial.parity.odd': 'Нечётная',
|
||||
'serial.parity.mark': 'Mark',
|
||||
'serial.parity.space': 'Space',
|
||||
'serial.flowControl.none': 'Нет',
|
||||
'serial.flowControl.xon/xoff': 'XON/XOFF (программный)',
|
||||
'serial.flowControl.rts/cts': 'RTS/CTS (аппаратный)',
|
||||
'serial.field.localEcho': 'Принудительное локальное эхо',
|
||||
'serial.field.localEchoDesc': 'Локально отображать вводимые символы (для устройств без удалённого эха)',
|
||||
'serial.field.lineMode': 'Построчный режим',
|
||||
'serial.field.lineModeDesc': 'Буферизовать ввод и отправлять по Enter (вместо посимвольной отправки)',
|
||||
'serial.field.charset': 'Кодировка',
|
||||
'serial.connectionError': 'Не удалось подключиться к последовательному порту',
|
||||
'serial.field.baudRatePlaceholder': 'Выберите или введите скорость...',
|
||||
'serial.field.baudRateEmpty': 'Введите пользовательскую скорость передачи',
|
||||
'serial.field.customBaudRate': 'Используется пользовательская скорость передачи',
|
||||
'serial.field.saveConfig': 'Сохранить конфигурацию',
|
||||
'serial.field.saveConfigDesc': 'Сохраните эту последовательную конфигурацию в хостах для быстрого доступа',
|
||||
'serial.field.configLabel': 'Имя конфигурации',
|
||||
'serial.field.configLabelPlaceholder': 'например, Arduino Uno',
|
||||
'serial.connectAndSave': 'Подключить и сохранить',
|
||||
'serial.edit.title': 'Настройки последовательного порта',
|
||||
|
||||
// Keyboard Interactive Authentication (2FA/MFA)
|
||||
'keyboard.interactive.title': 'Требуется аутентификация',
|
||||
'keyboard.interactive.desc': 'Сервер требует дополнительную аутентификацию.',
|
||||
'keyboard.interactive.descWithHost': 'Сервер {hostname} требует дополнительную аутентификацию.',
|
||||
'keyboard.interactive.response': 'Ответ',
|
||||
'keyboard.interactive.enterCode': 'Введите код подтверждения',
|
||||
'keyboard.interactive.enterResponse': 'Введите ответ',
|
||||
'keyboard.interactive.submit': 'Отправить',
|
||||
'keyboard.interactive.verifying': 'Проверка...',
|
||||
'keyboard.interactive.savePassword': 'Сохранить пароль',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'Парольная фраза SSH-ключа',
|
||||
'passphrase.desc': 'Введите парольную фразу для {keyName}',
|
||||
'passphrase.descWithHost': 'Введите парольную фразу для {keyName}, чтобы подключиться к {hostname}',
|
||||
'passphrase.label': 'Парольная фраза',
|
||||
'passphrase.keyPath': 'Ключ',
|
||||
'passphrase.unlock': 'Разблокировать',
|
||||
'passphrase.unlocking': 'Разблокировка...',
|
||||
'passphrase.skip': 'Пропустить',
|
||||
'passphrase.remember': 'Запомнить эту парольную фразу',
|
||||
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': 'Перенос строк',
|
||||
'sftp.editor.maximize': 'Развернуть',
|
||||
'sftp.editor.unsavedTitle': 'Несохранённые изменения',
|
||||
'sftp.editor.unsavedMessage': 'В файле {fileName} есть несохранённые изменения. Сохранить перед закрытием?',
|
||||
'sftp.editor.discardChanges': 'Отбросить',
|
||||
'sftp.editor.saveAndClose': 'Сохранить и закрыть',
|
||||
'sftp.editor.quitBlockedByDirty': 'Есть несохранённые редакторы — перед выходом сохраните изменения или отбросьте их',
|
||||
|
||||
};
|
||||
663
application/i18n/locales/ru/vault.ts
Normal file
663
application/i18n/locales/ru/vault.ts
Normal file
@@ -0,0 +1,663 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const ruVaultMessages: Messages = {
|
||||
// Vault hosts header/actions
|
||||
'vault.hosts.search.placeholder': 'Найти хост или ssh user@hostname / ssh -p 2222 user@hostname...',
|
||||
'vault.hosts.connect': 'Подключиться',
|
||||
'vault.view.grid': 'Сетка',
|
||||
'vault.view.list': 'Список',
|
||||
'vault.view.tree': 'Дерево',
|
||||
'vault.tree.expandAll': 'Развернуть все',
|
||||
'vault.tree.collapseAll': 'Свернуть все',
|
||||
'vault.hosts.newHost': 'Новый хост',
|
||||
'vault.hosts.newGroup': 'Новая группа',
|
||||
'vault.hosts.import': 'Импорт',
|
||||
'vault.hosts.export': 'Экспорт',
|
||||
'vault.hosts.export.toast.success': 'Экспортировано {count} хостов в CSV',
|
||||
'vault.hosts.export.toast.successWithSkipped': 'Экспортировано {count} хостов в CSV ({skipped} неподдерживаемых хостов пропущено)',
|
||||
'vault.hosts.export.toast.noHosts': 'Нет хостов для экспорта',
|
||||
'vault.hosts.allHosts': 'Все хосты',
|
||||
'vault.hosts.pinned': 'Закреплённые',
|
||||
'vault.hosts.recentlyConnected': 'Недавно подключённые',
|
||||
'vault.hosts.pinToTop': 'Закрепить сверху',
|
||||
'vault.hosts.unpin': 'Открепить',
|
||||
'vault.hosts.copyCredentials': 'Копировать учётные данные',
|
||||
'vault.hosts.copyCredentials.toast.success': 'Учётные данные скопированы в буфер обмена',
|
||||
'vault.hosts.copyCredentials.toast.noPassword': 'Для этого хоста нет сохранённого пароля',
|
||||
'vault.hosts.multiSelect': 'Множественный выбор',
|
||||
'vault.hosts.selected': 'Выбрано: {count}',
|
||||
'vault.hosts.selectAll': 'Выбрать все',
|
||||
'vault.hosts.deselectAll': 'Снять выделение',
|
||||
'vault.hosts.deleteSelected': 'Удалить ({count})',
|
||||
'vault.hosts.deleteMultiple.success': 'Удалено хостов: {count}',
|
||||
'vault.hosts.connectSelected': 'Подключить ({count})',
|
||||
'vault.hosts.connectMultiple.success': 'Подключение хостов: {count}',
|
||||
'vault.hosts.moveToGroup.success': 'Хост {host} перемещён в {group}',
|
||||
'vault.hosts.empty.title': 'Настройте свои хосты',
|
||||
'vault.hosts.empty.desc': 'Сохраняйте хосты, чтобы быстро подключаться к серверам, виртуальным машинам и контейнерам.',
|
||||
|
||||
// Vault import
|
||||
'vault.import.title': 'Добавить данные в хранилище',
|
||||
'vault.import.desc':
|
||||
'Перенесите свои подключения из популярных клиентов. Выберите формат файла, чтобы начать миграцию.',
|
||||
'vault.import.chooseFormat': 'Выберите формат файла',
|
||||
'vault.import.csv.tip': 'Массовый импорт: используйте шаблон CSV.',
|
||||
'vault.import.csv.downloadTemplate': 'Скачать шаблон CSV',
|
||||
'vault.import.toast.start': 'Импорт из {format}...',
|
||||
'vault.import.toast.completedTitle': 'Импорт завершён',
|
||||
'vault.import.toast.failedTitle': 'Ошибка импорта',
|
||||
'vault.import.toast.noEntries': 'В {format} не найдено импортируемых записей.',
|
||||
'vault.import.toast.noNewHosts': 'Из {format} не импортировано новых хостов.',
|
||||
'vault.import.toast.summary':
|
||||
'Импортировано {count} хостов (пропущено {skipped}, дубликатов {duplicates}).',
|
||||
'vault.import.toast.firstIssue': 'Первая проблема: {issue}',
|
||||
'vault.import.sshConfig.chooseMode': 'Выберите, как импортировать ваш файл SSH-конфига.',
|
||||
'vault.import.sshConfig.modeQuestion': 'Как вы хотите выполнить импорт?',
|
||||
'vault.import.sshConfig.importOnly': 'Только импорт',
|
||||
'vault.import.sshConfig.importOnlyDesc': 'Одноразовый импорт. Изменения не будут синхронизироваться обратно в файл.',
|
||||
'vault.import.sshConfig.managed': 'Управляемая синхронизация',
|
||||
'vault.import.sshConfig.managedDesc': 'Поддерживать синхронизацию. Изменения будут сохраняться обратно в файл.',
|
||||
'vault.import.sshConfig.managedGroup': 'ssh config',
|
||||
'vault.import.sshConfig.managedSuccess': 'Импортировано {count} хостов. Файл теперь находится под управлением.',
|
||||
'vault.import.sshConfig.alreadyManaged': 'Этот файл уже находится под управлением.',
|
||||
'vault.import.sshConfig.alreadyManagedDesc': 'Этот файл уже управляется в группе "{group}". Если хотите импортировать его заново, сначала удалите существующий управляемый источник.',
|
||||
'vault.import.sshConfig.noFilePath': 'Невозможно управлять этим файлом.',
|
||||
'vault.import.sshConfig.noFilePathDesc': 'Не удалось определить путь к файлу. Для управляемой синхронизации нужен доступ к файловой системе.',
|
||||
|
||||
// Known Hosts
|
||||
'knownHosts.search.placeholder': 'Поиск известных хостов...',
|
||||
'knownHosts.action.scanSystem': 'Сканировать систему',
|
||||
'knownHosts.action.importFile': 'Импортировать файл',
|
||||
'knownHosts.action.browseFile': 'Выбрать файл',
|
||||
'knownHosts.empty.title': 'Нет известных хостов',
|
||||
'knownHosts.empty.desc':
|
||||
'Известные хосты — это SSH-серверы, к которым вы подключались раньше. Импортируйте системный файл known_hosts, чтобы начать.',
|
||||
'knownHosts.results.showingLimited':
|
||||
'Показано {shown} из {total} хостов. Используйте поиск, чтобы найти нужные хосты.',
|
||||
'knownHosts.toast.scanUnavailable': 'Сканирование системы недоступно на этой платформе.',
|
||||
'knownHosts.toast.scanNoFile': 'Системный файл known_hosts не найден.',
|
||||
'knownHosts.toast.scanNoEntries': 'В known_hosts не найдено пригодных записей.',
|
||||
'knownHosts.toast.scanImported': 'Импортировано новых хостов: {count}.',
|
||||
'knownHosts.toast.scanNoNew': 'Новых хостов не найдено.',
|
||||
'knownHosts.toast.scanFailed': 'Не удалось просканировать системный known_hosts.',
|
||||
|
||||
// Port Forwarding
|
||||
'pf.empty.title': 'Настройте проброс портов',
|
||||
'pf.empty.desc': 'Сохраняйте правила проброса портов для доступа к базам данных, веб-приложениям и другим сервисам.',
|
||||
'pf.title': 'Проброс портов',
|
||||
'pf.rulesCount': 'Правил: {count}',
|
||||
'pf.wizard.editTitle': 'Редактировать проброс портов',
|
||||
'pf.wizard.newTitle': 'Новый проброс портов',
|
||||
'pf.wizard.saveChanges': 'Сохранить изменения',
|
||||
'pf.wizard.done': 'Готово',
|
||||
'pf.wizard.continue': 'Продолжить',
|
||||
'pf.wizard.cancel': 'Отмена',
|
||||
'pf.wizard.skipWizard': 'Пропустить мастер',
|
||||
'pf.error.hostNotFound': 'Хост не найден',
|
||||
'pf.toast.titleWithLabel': 'Проброс портов: {label}',
|
||||
'pf.type.local': 'Локальный',
|
||||
'pf.type.remote': 'Удалённый',
|
||||
'pf.type.dynamic': 'Динамический',
|
||||
'pf.type.menu.local': 'Локальный проброс',
|
||||
'pf.type.menu.remote': 'Удалённый проброс',
|
||||
'pf.type.menu.dynamic': 'Динамический проброс',
|
||||
'pf.type.local.desc': 'Локальный проброс позволяет обращаться к прослушиваемому порту удалённого сервера так, как будто он локальный.',
|
||||
'pf.type.remote.desc': 'Удалённый проброс открывает порт на удалённой машине и перенаправляет подключения на локальный (текущий) хост.',
|
||||
'pf.type.dynamic.desc': 'Динамический проброс портов превращает Netcatty в SOCKS-прокси-сервер.',
|
||||
'pf.wizard.type.title': 'Выберите тип проброса портов:',
|
||||
'pf.wizard.localConfig.title': 'Укажите локальный порт и адрес привязки:',
|
||||
'pf.wizard.localConfig.desc': 'Этот порт будет открыт на локальном (текущем) устройстве и будет принимать трафик.',
|
||||
'pf.wizard.localConfig.localPort': 'Номер локального порта *',
|
||||
'pf.wizard.bindAddress': 'Адрес привязки',
|
||||
'pf.wizard.remoteHost.title': 'Выберите удалённый хост:',
|
||||
'pf.wizard.remoteHost.desc': 'Выберите хост, на котором будет открыт порт. Трафик с этого порта будет перенаправляться на конечный хост.',
|
||||
'pf.wizard.remoteConfig.title': 'Укажите порт и адрес привязки:',
|
||||
'pf.wizard.remoteConfig.desc': 'Трафик будет перенаправляться с указанного порта и адреса интерфейса выбранного хоста.',
|
||||
'pf.wizard.remoteConfig.remotePort': 'Номер удалённого порта *',
|
||||
'pf.wizard.destination.title': 'Выберите конечный хост:',
|
||||
'pf.wizard.destination.desc.local': 'Введите удалённый адрес назначения, к которому вы хотите получить доступ через туннель.',
|
||||
'pf.wizard.destination.desc.remote': 'Адрес назначения и порт, на которые будет перенаправляться трафик.',
|
||||
'pf.wizard.destination.address': 'Адрес назначения *',
|
||||
'pf.wizard.destination.addressPlaceholder': 'например, 127.0.0.1 или 192.168.1.100',
|
||||
'pf.wizard.destination.port': 'Номер порта назначения *',
|
||||
'pf.wizard.sshServer.title': 'Выберите SSH-сервер:',
|
||||
'pf.wizard.sshServer.desc.dynamic': 'Выберите SSH-сервер, который будет работать как SOCKS-прокси.',
|
||||
'pf.wizard.sshServer.desc.default': 'Выберите SSH-сервер, который будет туннелировать ваш трафик к адресу назначения.',
|
||||
'pf.wizard.label.title': 'Выберите метку:',
|
||||
'pf.wizard.label.placeholder.dynamic': 'например, SOCKS Proxy',
|
||||
'pf.wizard.label.placeholder.default': 'например, MySQL Production',
|
||||
'pf.wizard.label.placeholder.remoteRule': 'например, Remote Rule',
|
||||
'pf.wizard.placeholders.portExample': 'например, {port}',
|
||||
'pf.action.newForwarding': 'Новое правило',
|
||||
'pf.form.labelPlaceholder': 'Метка правила',
|
||||
'pf.form.intermediateHost': 'Промежуточный хост *',
|
||||
'pf.form.createRule': 'Создать правило',
|
||||
'pf.form.openWizard': 'Открыть мастер',
|
||||
'pf.form.openWizardTitle': 'Открыть мастер проброса портов',
|
||||
'pf.view.grid': 'Сетка',
|
||||
'pf.view.list': 'Список',
|
||||
'pf.rule.summary.dynamic': 'SOCKS на {bindAddress}:{localPort}',
|
||||
'pf.rule.summary.default': '{bindAddress}:{localPort} -> {remoteHost}:{remotePort}',
|
||||
'pf.tooltip.relayHost': 'Промежуточный хост',
|
||||
'pf.tooltip.hostLabel': 'Хост',
|
||||
'pf.tooltip.hostAddress': 'Адрес',
|
||||
'pf.tooltip.noHost': 'Промежуточный хост не настроен',
|
||||
'pf.tooltip.localDesc': 'Локальный проброс портов: доступ к удалённым сервисам через SSH-туннель',
|
||||
'pf.tooltip.remoteDesc': 'Удалённый проброс портов: публикация локальных сервисов на удалённом хосте',
|
||||
'pf.tooltip.dynamicDesc': 'Динамический SOCKS-прокси: маршрутизация трафика через SSH-туннель',
|
||||
'pf.deleteActive.title': 'Удалить активное правило проброса портов?',
|
||||
'pf.deleteActive.desc': 'Правило проброса портов "{label}" сейчас активно. При удалении туннель будет сначала остановлен.',
|
||||
'pf.deleteActive.confirm': 'Остановить и удалить',
|
||||
'pf.form.autoStart': 'Автозапуск',
|
||||
'pf.form.autoStartDesc': 'Автоматически запускать это правило при запуске приложения',
|
||||
|
||||
// SFTP
|
||||
'sftp.newFolder': 'Новая папка',
|
||||
'sftp.newFile': 'Новый файл',
|
||||
'sftp.filter': 'Фильтр',
|
||||
'sftp.filter.placeholder': 'Фильтр по имени файла...',
|
||||
'sftp.bookmark.add': 'Добавить путь в закладки',
|
||||
'sftp.bookmark.remove': 'Удалить закладку',
|
||||
'sftp.bookmark.addGlobal': '+Глобальная',
|
||||
'sftp.bookmark.addGlobalTooltip': 'Сохранить как глобальную закладку (общую для всех хостов)',
|
||||
'sftp.bookmark.empty': 'Пока нет закладок',
|
||||
'sftp.columns.name': 'Имя',
|
||||
'sftp.columns.modified': 'Изменён',
|
||||
'sftp.columns.size': 'Размер',
|
||||
'sftp.columns.kind': 'Тип',
|
||||
'sftp.columns.actions': 'Действия',
|
||||
'sftp.emptyDirectory': 'Пустой каталог',
|
||||
'sftp.nav.up': 'Наверх',
|
||||
'sftp.nav.home': 'Перейти в домашний каталог',
|
||||
'sftp.nav.refresh': 'Обновить',
|
||||
'sftp.upload': 'Загрузить',
|
||||
'sftp.uploadFiles': 'Загрузить файлы',
|
||||
'sftp.uploadFolder': 'Загрузить папку',
|
||||
'sftp.dragDropToUpload': 'Перетащите сюда файлы для загрузки',
|
||||
'sftp.retry': 'Повторить',
|
||||
'sftp.context.open': 'Открыть',
|
||||
'sftp.context.navigateTo': 'Перейти к',
|
||||
'sftp.context.moveTo': 'Переместить в...',
|
||||
'sftp.context.moveToParent': 'Переместить в родительский каталог',
|
||||
'sftp.moveTo.title': 'Переместить в каталог',
|
||||
'sftp.moveTo.placeholder': 'Введите путь к целевому каталогу',
|
||||
'sftp.moveTo.confirm': 'Переместить',
|
||||
'sftp.moveTo.pathNotFound': 'Каталог не найден или недоступен',
|
||||
'sftp.context.download': 'Скачать',
|
||||
'sftp.context.copyToOtherPane': 'Копировать в другую панель',
|
||||
'sftp.viewMode.label': 'Режим просмотра',
|
||||
'sftp.viewMode.list': 'Список',
|
||||
'sftp.viewMode.tree': 'Дерево',
|
||||
'sftp.tree.loadError': 'Не удалось загрузить каталог',
|
||||
'sftp.tree.loading': 'Загрузка...',
|
||||
'sftp.kind.folder': 'Папка',
|
||||
'sftp.context.rename': 'Переименовать',
|
||||
'sftp.context.permissions': 'Права доступа',
|
||||
'sftp.context.delete': 'Удалить',
|
||||
'sftp.context.refresh': 'Обновить',
|
||||
'sftp.context.uploadFiles': 'Загрузить файл(ы)...',
|
||||
'sftp.context.uploadFilesHere': 'Загрузить файлы сюда...',
|
||||
'sftp.context.uploadFolder': 'Загрузить папку...',
|
||||
'sftp.context.uploadFolderHere': 'Загрузить папку сюда...',
|
||||
'sftp.context.downloadSelected': 'Скачать выбранное ({count})',
|
||||
'sftp.context.deleteSelected': 'Удалить выбранное ({count})',
|
||||
'sftp.dropFilesHere': 'Перетащите сюда файлы',
|
||||
'sftp.itemsCount': '{count} записей',
|
||||
'sftp.selectedCount': '{count} выбрано',
|
||||
'sftp.path.doubleClickToEdit': 'Дважды щёлкните, чтобы изменить путь',
|
||||
'sftp.showHiddenPaths': 'Скрытые пути',
|
||||
'sftp.task.waiting': 'Ожидание...',
|
||||
'sftp.transfer.preparing': 'подготовка...',
|
||||
'sftp.status.loading': 'Загрузка...',
|
||||
'sftp.status.uploading': 'Загрузка...',
|
||||
'sftp.status.ready': 'Готово',
|
||||
'sftp.transfers': 'Передачи',
|
||||
'sftp.transfers.active': '{count} активн(ый/ых)',
|
||||
'sftp.transfers.clearCompleted': 'Очистить завершённые',
|
||||
'sftp.transfers.calculatingTotal': 'Вычисление общего размера...',
|
||||
'sftp.transfers.filesCount': '{count} файл(ов)',
|
||||
'sftp.transfers.filesProgress': '{current}/{total} файл(ов)',
|
||||
'sftp.transfers.expandChildren': 'Показать файлы',
|
||||
'sftp.transfers.collapseChildren': 'Скрыть файлы',
|
||||
'sftp.transfers.expandChildList': 'Показать детали',
|
||||
'sftp.transfers.collapseChildList': 'Скрыть',
|
||||
'sftp.transfers.retryAction': 'Повторить',
|
||||
'sftp.transfers.dismissAction': 'Скрыть',
|
||||
'sftp.transfers.openTargetFolder': 'Открыть целевую папку',
|
||||
'sftp.transfers.openTargetFolderError': 'Не удалось открыть целевую папку',
|
||||
'sftp.transfers.copyTargetPath': 'Копировать целевой путь',
|
||||
'sftp.transfers.copyTargetPathSuccess': 'Целевой путь скопирован',
|
||||
'sftp.transfers.copyTargetPathError': 'Не удалось скопировать целевой путь',
|
||||
'sftp.transfers.resizeNameColumn': 'Изменить ширину столбца имени файла',
|
||||
'sftp.transfers.dragToResize': 'Перетащите для изменения размера',
|
||||
'sftp.goUp': 'Наверх',
|
||||
'sftp.goToTerminalCwd': 'Перейти в каталог терминала',
|
||||
'sftp.encoding.label': 'Кодировка имён файлов',
|
||||
'sftp.encoding.auto': 'Авто',
|
||||
'sftp.encoding.utf8': 'UTF-8',
|
||||
'sftp.encoding.gb18030': 'GB18030',
|
||||
'sftp.goHome': 'Перейти в домашний каталог',
|
||||
'sftp.folderName': 'Имя папки',
|
||||
'sftp.folderName.placeholder': 'Введите имя папки',
|
||||
'sftp.fileName': 'Имя файла',
|
||||
'sftp.fileName.placeholder': 'Введите имя файла',
|
||||
'sftp.prompt.newFolderName': 'Имя новой папки?',
|
||||
'sftp.rename.title': 'Переименовать',
|
||||
'sftp.rename.newName': 'Новое имя',
|
||||
'sftp.rename.placeholder': 'Введите новое имя',
|
||||
'sftp.confirm.deleteOne': 'Удалить "{name}"?',
|
||||
'sftp.deleteConfirm.single': 'Удалить "{name}"?',
|
||||
'sftp.deleteConfirm.title': 'Удалить {count} элемент(ов)?',
|
||||
'sftp.deleteConfirm.desc': 'Это действие нельзя отменить. Будет удалено следующее:',
|
||||
'sftp.deleteConfirm.descSingle': 'Это действие нельзя отменить.',
|
||||
'sftp.deleteConfirm.host': 'Хост',
|
||||
'sftp.deleteConfirm.path': 'Путь',
|
||||
'sftp.error.loadFailed': 'Не удалось загрузить каталог',
|
||||
'sftp.error.downloadFailed': 'Ошибка скачивания',
|
||||
'sftp.error.uploadFailed': 'Ошибка загрузки',
|
||||
'sftp.error.deleteFailed': 'Ошибка удаления',
|
||||
'sftp.error.createFolderFailed': 'Не удалось создать папку',
|
||||
'sftp.error.createFileFailed': 'Не удалось создать файл',
|
||||
'sftp.error.invalidFileName': 'Имя файла содержит недопустимые символы: {chars}',
|
||||
'sftp.error.reservedName': 'Это имя файла зарезервировано системой',
|
||||
'sftp.overwrite.title': 'Файл уже существует',
|
||||
'sftp.overwrite.desc': 'Файл с именем "{name}" уже существует. Хотите заменить его?',
|
||||
'sftp.overwrite.confirm': 'Заменить',
|
||||
'sftp.error.renameFailed': 'Не удалось переименовать',
|
||||
'sftp.picker.title': 'Выберите хост',
|
||||
'sftp.picker.desc': 'Выберите хост для панели {side}',
|
||||
'sftp.picker.searchPlaceholder': 'Поиск хостов...',
|
||||
'sftp.picker.local.title': 'Локальная файловая система',
|
||||
'sftp.picker.local.desc': 'Просмотр локальных файлов',
|
||||
'sftp.picker.local.badge': 'Локально',
|
||||
'sftp.picker.noMatch': 'Подходящие хосты не найдены',
|
||||
'sftp.permissions.title': 'Изменить права доступа',
|
||||
'sftp.permissions.owner': 'Владелец',
|
||||
'sftp.permissions.group': 'Группа',
|
||||
'sftp.permissions.others': 'Остальные',
|
||||
'sftp.permissions.octal': 'Восьмеричный',
|
||||
'sftp.permissions.symbolic': 'Символьный',
|
||||
'sftp.permissions.success': 'Права доступа успешно обновлены',
|
||||
'sftp.permissions.failed': 'Не удалось обновить права доступа',
|
||||
'sftp.pane.local': 'Локально',
|
||||
'sftp.pane.remote': 'Удалённо',
|
||||
'sftp.pane.selectHost': 'Выберите хост',
|
||||
'sftp.pane.selectHostToStart': 'Выберите хост для начала',
|
||||
'sftp.pane.chooseFilesystem': 'Выберите локальную или удалённую файловую систему для просмотра',
|
||||
'sftp.tabs.addTab': 'Добавить новую вкладку',
|
||||
'sftp.tabs.closeTab': 'Закрыть вкладку',
|
||||
'sftp.tabs.newTab': 'Новая вкладка',
|
||||
'sftp.conflict.title': 'Конфликт файлов',
|
||||
'sftp.conflict.desc': 'В месте назначения уже существует файл с таким именем',
|
||||
'sftp.conflict.alreadyExistsSuffix': 'уже существует',
|
||||
'sftp.conflict.existingFile': 'Существующий файл',
|
||||
'sftp.conflict.newFile': 'Новый файл',
|
||||
'sftp.conflict.size': 'Размер:',
|
||||
'sftp.conflict.modified': 'Изменён:',
|
||||
'sftp.conflict.applyToAll': 'Применить это действие ко всем оставшимся конфликтам ({count})',
|
||||
'sftp.conflict.action.stop': 'Остановить',
|
||||
'sftp.conflict.action.skip': 'Пропустить',
|
||||
'sftp.conflict.action.keepBoth': 'Сохранить оба',
|
||||
'sftp.conflict.action.duplicate': 'Дублировать',
|
||||
'sftp.conflict.action.merge': 'Объединить',
|
||||
'sftp.conflict.action.replace': 'Заменить',
|
||||
|
||||
// SFTP Upload Phases
|
||||
'sftp.upload.phase.compressing': 'Сжатие',
|
||||
'sftp.upload.phase.uploading': 'Загрузка',
|
||||
'sftp.upload.phase.extracting': 'Распаковка',
|
||||
'sftp.upload.phase.compressed': 'Сжато',
|
||||
|
||||
// SFTP File Opener
|
||||
'sftp.context.copyPath': 'Копировать путь к файлу',
|
||||
'sftp.context.openWith': 'Открыть с помощью...',
|
||||
'sftp.context.edit': 'Редактировать',
|
||||
'sftp.context.preview': 'Предпросмотр',
|
||||
'sftp.opener.title': 'Открыть с помощью',
|
||||
'sftp.opener.desc': 'Выберите приложение для открытия этого файла',
|
||||
'sftp.opener.builtInEditor': 'Встроенный редактор',
|
||||
'sftp.opener.editDescription': 'Редактировать текстовые файлы',
|
||||
'sftp.opener.builtInImageViewer': 'Встроенный просмотрщик изображений',
|
||||
'sftp.opener.previewDescription': 'Просмотр изображений',
|
||||
'sftp.opener.systemApp': 'Выбрать приложение...',
|
||||
'sftp.opener.systemAppDescription': 'Выберите приложение на вашем компьютере',
|
||||
'sftp.opener.onlySystemApp': 'Этот файл можно открыть только во внешнем приложении',
|
||||
'sftp.opener.noAppsAvailable': 'Нет доступных приложений',
|
||||
'sftp.opener.noExtension': 'файлы без расширения',
|
||||
'sftp.opener.setDefault': 'Всегда использовать это для файлов {ext}',
|
||||
'sftp.opener.confirmTitle': 'Установить по умолчанию?',
|
||||
'sftp.opener.confirmDescription': 'Хотите всегда использовать {app} для файлов {ext}?',
|
||||
'sftp.opener.yesRemember': 'Да, запомнить выбор',
|
||||
'sftp.opener.justOnce': 'Только один раз',
|
||||
'sftp.opener.confirm.title': 'Установить приложение по умолчанию',
|
||||
'sftp.opener.confirm.desc': 'Хотите всегда открывать файлы .{ext} этим приложением?',
|
||||
'sftp.editor.title': 'Текстовый редактор',
|
||||
'sftp.editor.save': 'Сохранить на удалённый сервер',
|
||||
'sftp.editor.saving': 'Сохранение...',
|
||||
'sftp.editor.saved': 'Успешно сохранено',
|
||||
'sftp.editor.saveFailed': 'Не удалось сохранить файл',
|
||||
'sftp.editor.unsavedChanges': 'У вас есть несохранённые изменения. Всё равно закрыть?',
|
||||
'sftp.editor.syntaxHighlight': 'Подсветка синтаксиса',
|
||||
'sftp.preview.title': 'Просмотр изображения',
|
||||
'sftp.preview.zoomIn': 'Увеличить',
|
||||
'sftp.preview.zoomOut': 'Уменьшить',
|
||||
'sftp.preview.resetZoom': 'Сбросить масштаб',
|
||||
'sftp.preview.fitToWindow': 'Подогнать по окну',
|
||||
|
||||
// Settings > SFTP File Associations
|
||||
'settings.tab.sftpFileAssociations': 'SFTP',
|
||||
'settings.sftp.transferConcurrency': 'Параллелизм передачи',
|
||||
'settings.sftp.transferConcurrency.desc': 'Количество файлов, передаваемых параллельно при загрузке или скачивании папок. Более высокие значения могут ускорить работу, но способны перегрузить некоторые серверы.',
|
||||
'settings.sftp.defaultOpener': 'Приложение для открытия по умолчанию',
|
||||
'settings.sftp.defaultOpener.desc': 'Выберите приложение по умолчанию для открытия файлов без конкретной ассоциации',
|
||||
'settings.sftp.defaultOpener.ask': 'Всегда спрашивать',
|
||||
'settings.sftp.defaultOpener.askDesc': 'Каждый раз показывать диалог выбора приложения',
|
||||
'settings.sftp.defaultOpener.builtInDesc': 'По умолчанию открывать текстовые файлы во встроенном редакторе',
|
||||
'settings.sftp.defaultOpener.systemApp': 'Выбрать приложение...',
|
||||
'settings.sftp.defaultOpener.systemAppDesc': 'По умолчанию открывать файлы в конкретном приложении',
|
||||
'settings.sftpFileAssociations.title': 'Ассоциации файлов SFTP',
|
||||
'settings.sftpFileAssociations.desc': 'Настройка приложений по умолчанию для открытия файлов по расширению',
|
||||
'settings.sftpFileAssociations.extension': 'Расширение',
|
||||
'settings.sftpFileAssociations.application': 'Приложение',
|
||||
'settings.sftpFileAssociations.noAssociations': 'Ассоциации файлов не настроены',
|
||||
'settings.sftpFileAssociations.remove': 'Удалить',
|
||||
'settings.sftpFileAssociations.removeConfirm': 'Удалить ассоциацию для .{ext}?',
|
||||
|
||||
// Settings > SFTP Behavior
|
||||
'settings.sftp.doubleClickBehavior': 'Поведение двойного щелчка',
|
||||
'settings.sftp.doubleClickBehavior.desc': 'Выберите действие при двойном щелчке по файлу в SFTP-режиме',
|
||||
'settings.sftp.doubleClickBehavior.open': 'Открыть файл',
|
||||
'settings.sftp.doubleClickBehavior.transfer': 'Передать в другую панель',
|
||||
'settings.sftp.doubleClickBehavior.openDesc': 'Открыть файл в приложении по умолчанию',
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': 'Передать файл на активный хост другой панели',
|
||||
|
||||
// Settings > SFTP Auto Sync
|
||||
'settings.sftp.autoSync': 'Автосинхронизация с удалённым сервером',
|
||||
'settings.sftp.autoSync.desc': 'Автоматически синхронизировать изменения файлов обратно на удалённый сервер при открытии файлов во внешних приложениях',
|
||||
'settings.sftp.autoSync.enable': 'Включить автосинхронизацию',
|
||||
'settings.sftp.autoSync.enableDesc': 'Когда вы сохраняете файл во внешнем приложении, изменения автоматически загружаются на удалённый сервер',
|
||||
|
||||
// Settings > SFTP Auto Open Sidebar
|
||||
'settings.sftp.autoOpenSidebar': 'Автооткрытие боковой панели при подключении',
|
||||
'settings.sftp.autoOpenSidebar.desc': 'Автоматически открывать боковую панель файлового браузера SFTP при подключении к хосту',
|
||||
'settings.sftp.autoOpenSidebar.enable': 'Включить автооткрытие боковой панели',
|
||||
'settings.sftp.autoOpenSidebar.enableDesc': 'Боковая панель SFTP будет автоматически открываться при подключении терминальной сессии к удалённому хосту',
|
||||
|
||||
'settings.sftp.defaultViewMode': 'Режим просмотра по умолчанию',
|
||||
'settings.sftp.defaultViewMode.desc': 'Выберите режим просмотра по умолчанию при открытии новой вкладки SFTP. Настройки конкретного хоста имеют приоритет.',
|
||||
'settings.sftp.defaultViewMode.list': 'Список',
|
||||
'settings.sftp.defaultViewMode.listDesc': 'Показывать файлы в виде плоского списка для текущего каталога',
|
||||
'settings.sftp.defaultViewMode.tree': 'Дерево',
|
||||
'settings.sftp.defaultViewMode.treeDesc': 'Показывать файлы в иерархической древовидной структуре',
|
||||
|
||||
'sftp.autoSync.success': 'Файл синхронизирован с удалённым сервером: {fileName}',
|
||||
'sftp.autoSync.error': 'Не удалось синхронизировать файл: {error}',
|
||||
|
||||
// SFTP Folder Upload Progress
|
||||
'sftp.upload.progress': 'Загрузка файлов {current} из {total}...',
|
||||
'sftp.upload.uploading': 'Загрузка...',
|
||||
'sftp.upload.compressing': 'Сжатие...',
|
||||
'sftp.upload.extracting': 'Распаковка...',
|
||||
'sftp.upload.scanning': 'Сканирование файлов...',
|
||||
'sftp.upload.completed': 'Завершено',
|
||||
'sftp.upload.compressed': 'Сжатая передача',
|
||||
'sftp.upload.currentFile': 'Текущий: {fileName}',
|
||||
'sftp.upload.cancelled': 'Загрузка отменена',
|
||||
'sftp.upload.cancel': 'Отмена',
|
||||
'sftp.upload.completedToPath': 'Загружено в {path}',
|
||||
|
||||
// SFTP Download
|
||||
'sftp.download.completed': 'Скачано',
|
||||
'sftp.download.cancelled': 'Скачивание отменено',
|
||||
|
||||
// SFTP Reconnecting
|
||||
'sftp.reconnecting.title': 'Переподключение...',
|
||||
'sftp.reconnecting.desc': 'Соединение потеряно, выполняется попытка переподключения',
|
||||
'sftp.reconnected': 'Соединение восстановлено',
|
||||
'sftp.error.reconnectFailed': 'Не удалось переподключиться. Попробуйте ещё раз.',
|
||||
'sftp.error.connectionLostManual': 'Соединение потеряно. Пожалуйста, переподключитесь вручную.',
|
||||
'sftp.error.connectionLostReconnecting': 'Соединение потеряно. Переподключение...',
|
||||
'sftp.error.sessionLost': 'SFTP-сессия потеряна. Пожалуйста, переподключитесь.',
|
||||
|
||||
// Settings > SFTP Show Hidden Files
|
||||
'settings.sftp.showHiddenFiles': 'Показывать скрытые файлы',
|
||||
'settings.sftp.showHiddenFiles.desc': 'Показывать скрытые файлы (dotfiles в Unix/macOS и файлы с атрибутом hidden в Windows) в файловом браузере SFTP.',
|
||||
'settings.sftp.showHiddenFiles.enable': 'Показывать скрытые файлы',
|
||||
'settings.sftp.showHiddenFiles.enableDesc': 'Показывать скрытые файлы при просмотре как локальной, так и удалённой файловой системы',
|
||||
|
||||
// Settings > SFTP Compressed Upload
|
||||
'settings.sftp.compressedUpload': 'Передача со сжатием папок',
|
||||
'settings.sftp.compressedUpload.desc': 'Сжимать папки перед загрузкой, чтобы значительно сократить время передачи.',
|
||||
'settings.sftp.compressedUpload.enable': 'Включить сжатие папок',
|
||||
'settings.sftp.compressedUpload.enableDesc': 'Автоматически сжимать папки с помощью tar перед передачей. Требует поддержки tar на сервере. Если она недоступна, будет использована обычная передача.',
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': 'Поиск хостов или вкладок',
|
||||
'qs.jumpTo': 'Перейти к',
|
||||
'qs.localTerminal': 'Локальный терминал',
|
||||
'qs.localShells': 'Локальные оболочки',
|
||||
'qs.default': 'По умолчанию',
|
||||
|
||||
// Select Host panel
|
||||
'selectHost.title': 'Выберите хост',
|
||||
'selectHost.noHostsFound': 'Хосты не найдены',
|
||||
'selectHost.newHost': 'Новый хост',
|
||||
'selectHost.continue': 'Продолжить',
|
||||
'selectHost.continueWithCount': 'Продолжить (выбрано: {count})',
|
||||
|
||||
// Quick Connect
|
||||
'quickConnect.knownHost.title': 'Вы уверены, что хотите подключиться?',
|
||||
'quickConnect.knownHost.authenticity': 'Подлинность {hostname} не может быть установлена.',
|
||||
'quickConnect.knownHost.fingerprintLabel': '{keyType} fingerprint is SHA256:',
|
||||
'quickConnect.knownHost.addQuestion': 'Хотите добавить его в список известных хостов?',
|
||||
'quickConnect.knownHost.addAndContinue': 'Добавить и продолжить',
|
||||
'quickConnect.addKey': 'Добавить ключ',
|
||||
'quickConnect.warning.unparsedOptions': 'Некоторые аргументы SSH были проигнорированы: {options}',
|
||||
|
||||
// Terminal
|
||||
'terminal.connectionErrorTitle': 'Ошибка подключения',
|
||||
|
||||
// Protocol select dialog
|
||||
'protocolSelect.chooseProtocol': 'Выберите протокол',
|
||||
'protocolSelect.port': 'порт:',
|
||||
|
||||
// Host Details
|
||||
'hostDetails.title.details': 'Сведения о хосте',
|
||||
'hostDetails.title.new': 'Новый хост',
|
||||
'hostDetails.saveAria': 'Сохранить',
|
||||
'hostDetails.section.address': 'Адрес',
|
||||
'hostDetails.hostname.placeholder': 'IP или имя хоста',
|
||||
'hostDetails.section.general': 'Общие',
|
||||
'hostDetails.section.sftp': 'Настройки SFTP',
|
||||
'hostDetails.sftp.sudo': 'Режим sudo',
|
||||
'hostDetails.sftp.sudo.desc': 'Автоматически получать привилегии Root с помощью сохранённого пароля',
|
||||
'hostDetails.sftp.sudo.passwordWarning': 'Для режима sudo требуется пароль. Укажите его выше или убедитесь, что сервер разрешает sudo без пароля.',
|
||||
'hostDetails.sftp.encoding': 'Кодировка имён файлов',
|
||||
'hostDetails.sftp.encoding.desc': 'Выберите кодировку, используемую для декодирования и отправки имён файлов SFTP.',
|
||||
'hostDetails.label.placeholder': 'Метка (например, Production Server)',
|
||||
'hostDetails.notes.label': 'Заметки',
|
||||
'hostDetails.notes.placeholder': 'Оборудование, проект, клиент, регион, роль...',
|
||||
'hostDetails.notes.help': 'Поддерживается Markdown. Не храните здесь пароли и закрытые ключи.',
|
||||
'hostDetails.notes.tab.edit': 'Редактировать',
|
||||
'hostDetails.notes.tab.preview': 'Просмотр',
|
||||
'hostDetails.notes.preview.empty': 'Пока нечего просматривать.',
|
||||
'hostDetails.group.placeholder': 'Родительская группа',
|
||||
'hostDetails.section.credentials': 'Учётные данные',
|
||||
'hostDetails.section.portCredentials': 'Порт и учётные данные',
|
||||
'hostDetails.section.appearance': 'Внешний вид',
|
||||
'hostDetails.distro.title': 'Дистрибутив Linux',
|
||||
'hostDetails.distro.desc': 'Автоопределение при подключении или ручное переопределение значка дистрибутива.',
|
||||
'hostDetails.distro.mode': 'Источник',
|
||||
'hostDetails.distro.mode.auto': 'Автоопределение',
|
||||
'hostDetails.distro.mode.manual': 'Ручное переопределение',
|
||||
'hostDetails.distro.detectedLabel': 'Текущий',
|
||||
'hostDetails.distro.manualLabel': 'Переопределить',
|
||||
'hostDetails.distro.pending': 'Определится после первого подключения',
|
||||
'hostDetails.distro.unknown': 'Неизвестно',
|
||||
'hostDetails.distro.option.linux': 'Обычный Linux',
|
||||
'hostDetails.distro.option.ubuntu': 'Ubuntu',
|
||||
'hostDetails.distro.option.debian': 'Debian',
|
||||
'hostDetails.distro.option.centos': 'CentOS',
|
||||
'hostDetails.distro.option.rocky': 'Rocky Linux',
|
||||
'hostDetails.distro.option.fedora': 'Fedora',
|
||||
'hostDetails.distro.option.arch': 'Arch Linux',
|
||||
'hostDetails.distro.option.alpine': 'Alpine',
|
||||
'hostDetails.distro.option.amazon': 'Amazon Linux',
|
||||
'hostDetails.distro.option.opensuse': 'openSUSE / SLES',
|
||||
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
|
||||
'hostDetails.distro.option.almalinux': 'AlmaLinux',
|
||||
'hostDetails.distro.option.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.mikrotik': 'MikroTik',
|
||||
'hostDetails.distro.option.fortinet': 'Fortinet',
|
||||
'hostDetails.distro.option.paloalto': 'Palo Alto Networks',
|
||||
'hostDetails.distro.option.zyxel': 'ZyXEL',
|
||||
'hostDetails.distro.option.ruijie': 'Ruijie',
|
||||
'hostDetails.section.mosh': 'Mosh',
|
||||
'hostDetails.username.placeholder': 'Имя пользователя',
|
||||
'hostDetails.password.placeholder': 'Пароль',
|
||||
'hostDetails.password.show': 'Показать пароль',
|
||||
'hostDetails.password.hide': 'Скрыть пароль',
|
||||
'hostDetails.password.save': 'Сохранить пароль',
|
||||
'hostDetails.identity.suggestions': 'Идентификаторы',
|
||||
'hostDetails.identity.missing': 'Идентификатор не найден',
|
||||
'hostDetails.credential.keyCertificate': 'Ключ, сертификат, локальный файл ключа',
|
||||
'hostDetails.credential.key': 'Ключ',
|
||||
'hostDetails.credential.certificate': 'Сертификат',
|
||||
'hostDetails.credential.localKeyFile': 'Локальный файл ключа',
|
||||
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
|
||||
'hostDetails.credential.browseKeyFile': 'Обзор...',
|
||||
'hostDetails.credential.missing': 'Учётные данные не найдены',
|
||||
'hostDetails.keys.search': 'Поиск ключей...',
|
||||
'hostDetails.keys.empty': 'Нет доступных ключей',
|
||||
'hostDetails.certs.search': 'Поиск сертификатов...',
|
||||
'hostDetails.certs.empty': 'Нет доступных сертификатов',
|
||||
'hostDetails.agentForwarding': 'Проброс SSH Agent',
|
||||
'hostDetails.agentForwarding.desc': 'Разрешить удалённому серверу использовать ваши локальные SSH-ключи (например, для операций git)',
|
||||
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent недоступен',
|
||||
'hostDetails.agentForwarding.agentNotRunningHint': 'SSH Agent не обнаружен. Включите OpenSSH Authentication Agent в службах Windows или используйте совместимый агент, например Bitwarden, 1Password или gpg-agent.',
|
||||
'hostDetails.section.agentForwarding': 'SSH Agent',
|
||||
'hostDetails.x11Forwarding': 'Проброс X11-приложений',
|
||||
'hostDetails.x11Forwarding.desc': 'Показывать удалённые графические приложения на вашем локальном рабочем столе, если запущен локальный X-сервер.',
|
||||
'hostDetails.section.x11Forwarding': 'Проброс X11',
|
||||
'hostDetails.section.deviceType': 'Тип устройства',
|
||||
'hostDetails.deviceType': 'Режим сетевого устройства',
|
||||
'hostDetails.deviceType.desc': 'Включайте для сетевого оборудования (коммутаторов, маршрутизаторов, межсетевых экранов), подключённого по SSH. Команды отправляются как есть, без обёртки оболочки, что совместимо с CLI вендоров вроде Huawei VRP и Cisco IOS.',
|
||||
'hostDetails.deviceType.warning': 'Команды AI-агента будут отправляться напрямую без отслеживания кода выхода. Включайте только для устройств, на которых нет стандартной оболочки.',
|
||||
'hostDetails.section.sshAlgorithms': 'SSH-алгоритмы',
|
||||
'hostDetails.section.terminalBehavior': 'Поведение терминала',
|
||||
'hostDetails.legacyAlgorithms': 'Разрешить устаревшие алгоритмы',
|
||||
'hostDetails.legacyAlgorithms.desc': 'Включить устаревшие SSH-алгоритмы (diffie-hellman-group1, ssh-dss, 3des-cbc и т. д.) для подключения к старому сетевому оборудованию.',
|
||||
'hostDetails.legacyAlgorithms.warning': 'У этих алгоритмов есть известные слабые места безопасности. Включайте только для устаревших устройств, которые не поддерживают современную криптографию.',
|
||||
'hostDetails.skipEcdsaHostKey': 'Пропустить ECDSA host key',
|
||||
'hostDetails.skipEcdsaHostKey.desc': 'Некоторые старые коммутаторы Huawei / Cisco выдают нестандартные подписи ECDSA host-key, из-за чего соединение падает с "signature verification failed". Включение этой опции убирает все ecdsa-sha2-* из предложения клиента, и согласование переходит к RSA / Ed25519.',
|
||||
'hostDetails.algorithms.advanced': 'Дополнительные настройки алгоритмов',
|
||||
'hostDetails.algorithms.advanced.desc': 'Заменить предлагаемый список алгоритмов для любой категории для конкретного хоста. Не трогать категорию = использовать значение по умолчанию; выбранное подмножество полностью заменяет список по умолчанию. Неверные значения могут сделать хост недоступным.',
|
||||
'hostDetails.algorithms.inheritedNotice': 'В текущей группе заданы переопределения алгоритмов для: {categories}. Кнопка «Сбросить» здесь возвращает к спискам группы, а не к значениям NetCatty по умолчанию. Чтобы игнорировать ограничение группы, очистите переопределение в настройках алгоритмов группы.',
|
||||
'hostDetails.algorithms.customized': 'настроено',
|
||||
'hostDetails.algorithms.reset': 'Сбросить',
|
||||
'hostDetails.algorithms.category.kex': 'Обмен ключами (KEX)',
|
||||
'hostDetails.algorithms.category.cipher': 'Шифр',
|
||||
'hostDetails.algorithms.category.hmac': 'MAC (HMAC)',
|
||||
'hostDetails.algorithms.category.serverHostKey': 'Host Key',
|
||||
'hostDetails.algorithms.category.compress': 'Сжатие',
|
||||
'hostDetails.section.keepalive': 'Keepalive',
|
||||
'hostDetails.keepalive.override': 'Переопределить глобальный keepalive',
|
||||
'hostDetails.keepalive.desc': 'Использовать для этого хоста собственную политику keepalive вместо глобальной настройки. Полезно для старых маршрутизаторов и коммутаторов, чей SSH-сервер не отвечает на запросы keepalive@openssh.com. Установите интервал 0, чтобы полностью отключить keepalive для этого хоста.',
|
||||
'hostDetails.keepalive.interval': 'Интервал (секунды)',
|
||||
'hostDetails.keepalive.countMax': 'Макс. число пропущенных keepalive',
|
||||
'hostDetails.keepalive.disabledHint': 'Интервал = 0 отключает keepalive для этого хоста. Для определения разорванного соединения сессия будет полагаться на TCP-таймауты.',
|
||||
'hostDetails.backspaceBehavior': 'Поведение Backspace',
|
||||
'hostDetails.backspaceBehavior.default': 'По умолчанию',
|
||||
'hostDetails.jumpHosts': 'Прокси через хосты',
|
||||
'hostDetails.jumpHosts.hops': '{count} hop(s)',
|
||||
'hostDetails.jumpHosts.direct': 'Напрямую',
|
||||
'hostDetails.jumpHosts.configure': 'Настроить прокси-хосты',
|
||||
'hostDetails.proxy': 'Прокси через HTTP/SOCKS5',
|
||||
'hostDetails.proxy.none': 'Нет',
|
||||
'hostDetails.proxy.edit': 'Редактировать прокси',
|
||||
'hostDetails.proxy.configure': 'Настроить прокси',
|
||||
'hostDetails.proxyPanel.title': 'Прокси через HTTP/SOCKS5',
|
||||
'hostDetails.proxyPanel.hostPlaceholder': 'Прокси-хост',
|
||||
'hostDetails.proxyPanel.credentials': 'Учётные данные',
|
||||
'hostDetails.proxyPanel.usernamePlaceholder': 'Имя пользователя',
|
||||
'hostDetails.proxyPanel.passwordPlaceholder': 'Пароль',
|
||||
'hostDetails.proxyPanel.identities': 'Идентификаторы',
|
||||
'hostDetails.proxyPanel.remove': 'Удалить прокси',
|
||||
'hostDetails.proxyPanel.savedProxy': 'Сохранённый прокси',
|
||||
'hostDetails.proxyPanel.selectSaved': 'Выбрать сохранённый прокси',
|
||||
'hostDetails.proxyPanel.customProxy': 'Пользовательский прокси',
|
||||
'hostDetails.proxyPanel.missing': 'Отсутствует',
|
||||
'hostDetails.proxyPanel.missingSaved': 'Сохранённый прокси отсутствует',
|
||||
'hostDetails.proxyPanel.error.required': 'Прокси-хост и порт обязательны.',
|
||||
'hostDetails.envVars': 'Переменные окружения',
|
||||
'hostDetails.envVars.add': 'Добавить переменную окружения',
|
||||
'hostDetails.envVars.title': 'Переменные окружения',
|
||||
'hostDetails.envVars.desc': 'Задайте переменную окружения для {host}.',
|
||||
'hostDetails.envVars.note': 'Некоторые SSH-серверы по умолчанию разрешают только переменные с префиксом LC_ и LANG_.',
|
||||
'hostDetails.envVars.variable': 'Переменная',
|
||||
'hostDetails.envVars.value': 'Значение',
|
||||
'hostDetails.envVars.newVariable': 'Новая переменная',
|
||||
'hostDetails.envVars.variableName': 'Имя переменной',
|
||||
'hostDetails.chain.title': 'Редактировать цепочку',
|
||||
'hostDetails.chain.desc': 'Добавление ещё одного хоста создаст подключение к {host}.',
|
||||
'hostDetails.chain.addHost': 'Добавить хост',
|
||||
'hostDetails.chain.target': 'Цель',
|
||||
'hostDetails.chain.availableHosts': 'Доступные хосты',
|
||||
'hostDetails.chain.clear': 'Очистить',
|
||||
'hostDetails.group.title': 'Новая группа',
|
||||
'hostDetails.group.general': 'Общие',
|
||||
'hostDetails.group.namePlaceholder': 'Имя группы',
|
||||
'hostDetails.group.parentPlaceholder': 'Родительская группа',
|
||||
'hostDetails.group.cloudSync': 'Облачная синхронизация',
|
||||
'hostDetails.group.addProtocol': 'Добавить протокол',
|
||||
'hostDetails.startupCommand': 'Команда запуска',
|
||||
'hostDetails.startupCommand.placeholder': 'Команда для запуска при подключении (например, cd /app && ls)',
|
||||
'hostDetails.startupCommand.help':
|
||||
'This command will be executed automatically after SSH connection is established.',
|
||||
'hostDetails.otherProtocols': 'Другие протоколы',
|
||||
'hostDetails.telnetOn': 'Telnet на',
|
||||
'hostDetails.port': 'порт',
|
||||
'hostDetails.telnet.credentials': 'Учётные данные',
|
||||
'hostDetails.telnet.username': 'Имя пользователя Telnet',
|
||||
'hostDetails.telnet.password': 'Пароль Telnet',
|
||||
'hostDetails.charset.placeholder': 'Кодировка (например, UTF-8)',
|
||||
'hostDetails.telnet.add': 'Добавить протокол Telnet',
|
||||
'hostDetails.telnet.setDefault': 'Подключаться по Telnet по умолчанию',
|
||||
'hostDetails.tags': 'Теги',
|
||||
'hostDetails.group': 'Группа',
|
||||
'hostDetails.selectGroup': 'Выберите группу',
|
||||
'hostDetails.addTag': 'Добавить тег...',
|
||||
'hostDetails.createTag': 'Создать тег',
|
||||
'hostDetails.createGroup': 'Создать группу',
|
||||
|
||||
// Host form (legacy modal)
|
||||
'hostForm.title.edit': 'Редактировать хост',
|
||||
'hostForm.title.new': 'Новый хост',
|
||||
'hostForm.desc.edit': 'Обновите параметры подключения для этого хоста',
|
||||
'hostForm.desc.new': 'Создайте новую запись SSH-хоста',
|
||||
'hostForm.field.label': 'Метка',
|
||||
'hostForm.placeholder.label': 'Мой production-сервер',
|
||||
'hostForm.field.hostname': 'Имя хоста / IP',
|
||||
'hostForm.placeholder.hostname': '192.168.1.1',
|
||||
'hostForm.field.port': 'Порт',
|
||||
'hostForm.field.username': 'Имя пользователя',
|
||||
'hostForm.field.osType': 'Тип ОС',
|
||||
'hostForm.placeholder.selectOs': 'Выберите ОС',
|
||||
'hostForm.field.group': 'Группа',
|
||||
'hostForm.placeholder.group': 'например, AWS, DigitalOcean',
|
||||
'hostForm.field.tags': 'Теги',
|
||||
'hostForm.placeholder.addTag': 'Добавить тег...',
|
||||
'hostForm.auth.method': 'Метод аутентификации',
|
||||
'hostForm.auth.password': 'Пароль',
|
||||
'hostForm.auth.sshKey': 'SSH-ключ',
|
||||
'hostForm.auth.selectKey': 'Выберите SSH-ключ',
|
||||
'hostForm.auth.noKeys': 'Нет доступных ключей',
|
||||
'hostForm.auth.noKeysHint': 'В связке ключей не найдено SSH-ключей. Сначала создайте один.',
|
||||
'hostForm.saveHost': 'Сохранить хост',
|
||||
|
||||
};
|
||||
1
application/i18n/locales/types.ts
Normal file
1
application/i18n/locales/types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type Messages = Record<string, string>;
|
||||
File diff suppressed because it is too large
Load Diff
247
application/i18n/locales/zh-CN/ai.ts
Normal file
247
application/i18n/locales/zh-CN/ai.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCNAiMessages: Messages = {
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent 设置',
|
||||
'ai.title': 'AI',
|
||||
'ai.description': '配置 AI 提供商、Agent 和安全设置',
|
||||
'ai.providers': '提供商',
|
||||
'ai.providers.empty': '尚未配置提供商。添加一个提供商以开始使用。',
|
||||
'ai.providers.add': '添加提供商',
|
||||
'ai.providers.active': '活跃',
|
||||
'ai.providers.apiKeyConfigured': 'API Key 已配置',
|
||||
'ai.providers.noApiKey': '未设置 API Key',
|
||||
'ai.providers.configure': '配置',
|
||||
'ai.providers.remove': '移除',
|
||||
'ai.providers.name': '显示名称',
|
||||
'ai.providers.name.placeholder': '例如 我的提供商',
|
||||
'ai.providers.style': '协议风格',
|
||||
'ai.providers.style.anthropic': 'Anthropic 兼容',
|
||||
'ai.providers.style.openai': 'OpenAI 兼容',
|
||||
'ai.providers.style.google': 'Google 兼容',
|
||||
'ai.providers.style.inherited': '默认',
|
||||
'ai.providers.style.help': '决定请求使用哪种 API 格式。当第三方端点的协议与其提供商类型不一致时,可手动覆盖。',
|
||||
'ai.providers.icon.change': '修改图标',
|
||||
'ai.providers.icon.upload': '上传图片',
|
||||
'ai.providers.icon.reset': '恢复默认',
|
||||
'ai.providers.icon.close': '收起',
|
||||
'ai.providers.icon.uploadedNote': '自定义图标(64×64 WebP)',
|
||||
'ai.providers.icon.errorType': '请选择图片文件。',
|
||||
'ai.providers.apiKey': 'API Key',
|
||||
'ai.providers.apiKey.placeholder': '输入 API Key',
|
||||
'ai.providers.apiKey.decrypting': '解密中...',
|
||||
'ai.providers.baseUrl': 'Base URL',
|
||||
'ai.providers.skipTLSVerify': '跳过 TLS 证书验证(用于自签名证书)',
|
||||
'ai.providers.defaultModel': '默认模型',
|
||||
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
|
||||
'ai.providers.refreshModels': '刷新模型列表',
|
||||
'ai.providers.searchModel': '搜索或输入模型 ID...',
|
||||
'ai.providers.filterModels': '筛选模型...',
|
||||
'ai.providers.loadingModels': '加载模型中...',
|
||||
'ai.providers.noMatchingModels': '没有匹配的模型',
|
||||
'ai.providers.clickToLoadModels': '点击加载模型',
|
||||
'ai.providers.showingModels': '显示前 100 个,共 {count} 个模型。输入以筛选。',
|
||||
'ai.providers.advancedParams': '高级参数',
|
||||
'ai.providers.advancedParams.hint': '留空则使用提供商默认值。',
|
||||
'ai.providers.advancedParams.maxTokens.placeholder': '例如 4096',
|
||||
'ai.providers.advancedParams.default': '提供商默认',
|
||||
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
'ai.codex.title': 'Codex CLI',
|
||||
'ai.codex.description': '使用 codex + codex-acp 进行 ACP 协议流式传输。可以在这里连接 ChatGPT,也可以在设置里启用兼容 OpenAI 的 API Key 和自定义接口地址。',
|
||||
'ai.codex.detecting': '检测中...',
|
||||
'ai.codex.notFound': '未找到',
|
||||
'ai.codex.awaitingLogin': '等待登录',
|
||||
'ai.codex.connectedChatGPT': '已通过 ChatGPT 连接',
|
||||
'ai.codex.connectedApiKey': '已通过 API Key 连接',
|
||||
'ai.codex.connectedCustomConfig': '使用 ~/.codex/config.toml 自定义 provider',
|
||||
'ai.codex.customConfigIncomplete': '检测到自定义配置(缺少环境变量)',
|
||||
'ai.codex.customConfigHint': '使用 ~/.codex/config.toml 中配置的自定义 provider "{provider}",无需 ChatGPT 登录。',
|
||||
'ai.codex.customConfigMissingEnvKey': '警告:环境变量 {envKey} 未在当前 shell 中设置。请 export 它(或从包含该变量的 shell 启动 netcatty),否则 Codex 无法鉴权。',
|
||||
'ai.codex.notConnected': '未连接',
|
||||
'ai.codex.statusUnknown': '状态未知',
|
||||
'ai.codex.path': '路径:',
|
||||
'ai.codex.notFoundHint': '在 PATH 中未找到 codex。请安装或在下方指定可执行文件路径。',
|
||||
'ai.codex.customPathPlaceholder': '例如 /usr/local/bin/codex',
|
||||
'ai.codex.check': '检查',
|
||||
'ai.codex.openLogin': '打开登录',
|
||||
'ai.codex.logout': '退出登录',
|
||||
'ai.codex.connectChatGPT': '连接 ChatGPT',
|
||||
'ai.codex.refreshStatus': '刷新状态',
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
'ai.claude.description': 'Anthropic 的智能编程助手。需要系统中已安装 Claude Code CLI。',
|
||||
'ai.claude.detecting': '检测中...',
|
||||
'ai.claude.detected': '已检测到',
|
||||
'ai.claude.notFound': '未找到',
|
||||
'ai.claude.path': '路径:',
|
||||
'ai.claude.notFoundHint': '在 PATH 中未找到 claude。请安装或在下方指定可执行文件路径。',
|
||||
'ai.claude.customPathPlaceholder': '例如 /usr/local/bin/claude',
|
||||
'ai.claude.configSection': '认证与配置(可选)',
|
||||
'ai.claude.configDir': '配置目录',
|
||||
'ai.claude.configDir.placeholder': '~/.claude(留空用默认)',
|
||||
'ai.claude.configDir.hint': '设置 CLAUDE_CONFIG_DIR —— 指向你已运行 `claude` 登录的目录(含 settings.json 和凭据)。',
|
||||
'ai.claude.envVars': '环境变量',
|
||||
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
|
||||
'ai.claude.envVars.hint': '每行一个 KEY=VALUE,传给 Claude agent。明文存在本地——API key/凭据建议用上面的「配置目录」(claude 登录),不要放这里。',
|
||||
'ai.claude.check': '检查',
|
||||
|
||||
// AI GitHub Copilot CLI
|
||||
'ai.copilot.title': 'GitHub Copilot CLI',
|
||||
'ai.copilot.description': '通过 ACP over stdio(`copilot --acp --stdio`)接入 GitHub Copilot CLI。检测到后即可作为外部编程 Agent 使用。',
|
||||
'ai.copilot.detecting': '检测中...',
|
||||
'ai.copilot.detected': '已检测到',
|
||||
'ai.copilot.notFound': '未找到',
|
||||
'ai.copilot.path': '路径:',
|
||||
'ai.copilot.notFoundHint': '在 PATH 中未找到 copilot。请安装或在下方指定可执行文件路径。',
|
||||
'ai.copilot.customPathPlaceholder': '例如 /usr/local/bin/copilot',
|
||||
'ai.copilot.check': '检查',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': '默认 Agent',
|
||||
'ai.defaultAgent.description': '创建新 AI 会话时使用的 Agent',
|
||||
'ai.defaultAgent.catty': 'Catty(内置)',
|
||||
'ai.toolAccess.title': '工具接入',
|
||||
'ai.toolAccess.mode': 'Netcatty 接入模式',
|
||||
'ai.toolAccess.description': '选择外部 ACP Agent 访问 Netcatty 会话的方式。MCP 会暴露内置服务器,Skills + CLI 会引导 Agent 读取本地 Skill 并调用 Netcatty CLI。',
|
||||
'ai.toolAccess.mode.mcp': 'MCP',
|
||||
'ai.toolAccess.mode.skills': 'Skills + CLI',
|
||||
'ai.userSkills.title': '用户 Skills',
|
||||
'ai.userSkills.description': '打开 Netcatty 的 Skills 文件夹以添加你自己的技能目录。Netcatty 会自动扫描这些 skills,默认只注入轻量索引,只有在请求明显命中某个 skill 时才展开正文。',
|
||||
'ai.userSkills.openFolder': '打开 Skills 文件夹',
|
||||
'ai.userSkills.reload': '重新加载 Skills',
|
||||
'ai.userSkills.location': '位置',
|
||||
'ai.userSkills.loading': '正在扫描用户 skills...',
|
||||
'ai.userSkills.summary': '已就绪 {ready} 个,警告 {warnings} 个',
|
||||
'ai.userSkills.empty': '暂未发现用户 skills。打开文件夹后可添加包含 SKILL.md 的技能目录。',
|
||||
'ai.userSkills.unavailable': '当前环境不支持用户 skills。',
|
||||
'ai.userSkills.status.ready': '正常',
|
||||
'ai.userSkills.status.warning': '警告',
|
||||
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': '尚未配置 AI 提供商。请前往 **设置 → AI → 提供商** 添加并启用一个提供商。',
|
||||
'ai.chat.toolDenied': '操作已被用户拒绝。',
|
||||
'ai.chat.toolApproved': '已批准',
|
||||
'ai.chat.toolApprovalHint': '按回车批准,按 Esc 拒绝',
|
||||
'ai.chat.approve': '批准',
|
||||
'ai.chat.reject': '拒绝',
|
||||
'ai.chat.toolLabel': '工具',
|
||||
'ai.chat.targetLabel': '目标',
|
||||
'ai.chat.permissionRequired': '需要权限',
|
||||
'ai.chat.permissionDescription': 'AI Agent 希望执行一个需要你批准的工具调用。',
|
||||
'ai.chat.commandBlocked': '此命令已被安全策略拦截,无法执行。',
|
||||
'ai.chat.recommendAllow': '允许',
|
||||
'ai.chat.recommendConfirm': '确认',
|
||||
'ai.chat.recommendDeny': '拒绝',
|
||||
'ai.chat.exportConversation': '导出对话',
|
||||
'ai.chat.exportAs': '导出为',
|
||||
'ai.chat.exportMarkdown': 'Markdown',
|
||||
'ai.chat.exportJSON': 'JSON',
|
||||
'ai.chat.exportPlainText': '纯文本',
|
||||
'ai.chat.thinking': '思考中',
|
||||
'ai.chat.thoughtFor': '思考了 {duration}',
|
||||
'ai.chat.thought': '思考',
|
||||
'ai.chat.agents': 'Agents',
|
||||
'ai.chat.detectedOnMachine': '在本机检测到',
|
||||
'ai.chat.rescan': '重新扫描',
|
||||
'ai.chat.permObserver': '观察',
|
||||
'ai.chat.permConfirm': '确认',
|
||||
'ai.chat.permAuto': '自主',
|
||||
'ai.chat.permObserverDesc': '只读模式',
|
||||
'ai.chat.permConfirmDesc': '操作前询问',
|
||||
'ai.chat.permAutoDesc': '自由执行',
|
||||
'ai.chat.emptyHint': '询问服务器相关问题、执行命令或获取配置帮助。',
|
||||
'ai.chat.placeholder': '向 {agent} 发送消息 — @ 引用上下文,/ 使用命令',
|
||||
'ai.chat.placeholderDefault': '向 Catty Agent 发送消息...',
|
||||
'ai.chat.noModel': '未选择模型',
|
||||
'ai.chat.noProviderModel': '未配置默认模型——前往 设置 → AI → 提供商 设置。',
|
||||
'ai.chat.selectProvider': '选择提供商',
|
||||
'ai.chat.recent': '最近',
|
||||
'ai.chat.viewAll': '查看全部',
|
||||
'ai.chat.untitled': '无标题',
|
||||
'ai.chat.justNow': '刚刚',
|
||||
'ai.chat.minutesAgo': '{n}分钟前',
|
||||
'ai.chat.hoursAgo': '{n}小时前',
|
||||
'ai.chat.daysAgo': '{n}天前',
|
||||
'ai.chat.newChat': '新对话',
|
||||
'ai.chat.allSessions': '所有会话',
|
||||
'ai.chat.noSessions': '没有历史会话',
|
||||
'ai.chat.retryHint': '你可以重新发送消息来重试。',
|
||||
'ai.chat.approvalTimeout': '工具审批已超时(5 分钟)。你可以重新发送消息来重试。',
|
||||
'ai.chat.menuHosts': '主机',
|
||||
'ai.chat.menuContext': '上下文',
|
||||
'ai.chat.menuFiles': '文件',
|
||||
'ai.chat.menuImage': '图片',
|
||||
'ai.chat.menuMentionHost': '提及主机',
|
||||
'ai.chat.menuUserSkills': '用户 Skills',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Codex 主进程处理器尚未加载。请完全重启 Netcatty 或重启 Electron 开发进程,然后重试。',
|
||||
|
||||
// AI Web Search
|
||||
'ai.webSearch.title': '网络搜索',
|
||||
'ai.webSearch.enable': '启用网络搜索',
|
||||
'ai.webSearch.enable.description': '允许 AI 代理搜索互联网获取最新信息。',
|
||||
'ai.webSearch.provider': '搜索供应商',
|
||||
'ai.webSearch.provider.description': '选择一个网络搜索 API 供应商。',
|
||||
'ai.webSearch.apiKey': 'API 密钥',
|
||||
'ai.webSearch.apiKey.description': '所选搜索供应商的 API 密钥。',
|
||||
'ai.webSearch.apiKey.placeholder': '输入 API 密钥...',
|
||||
'ai.webSearch.apiHost': 'API 地址',
|
||||
'ai.webSearch.apiHost.description': '自定义 API 端点。除非使用代理,否则保持默认值。',
|
||||
'ai.webSearch.apiHost.searxngDescription': 'SearXNG 实例的 URL(必填)。',
|
||||
'ai.webSearch.maxResults': '最大结果数',
|
||||
'ai.webSearch.maxResults.description': '搜索返回的最大结果数(1-20)。',
|
||||
|
||||
// AI Safety Settings
|
||||
'ai.safety.title': '安全',
|
||||
'ai.safety.permissionMode': '权限模式',
|
||||
'ai.safety.permissionMode.description': '控制 AI 与终端的交互方式。观察者模式会通过 Netcatty 阻止所有写操作,对内置和 ACP Agent 均生效。确认模式对 ACP Agent 仅为建议性(ACP Agent 有自己的工具审批流程)。',
|
||||
'ai.safety.permissionMode.observer': '观察者 - 只读,禁止操作',
|
||||
'ai.safety.permissionMode.confirm': '确认 - 操作前询问',
|
||||
'ai.safety.permissionMode.autonomous': '自主 - 自由执行',
|
||||
'ai.safety.commandTimeout': '命令超时',
|
||||
'ai.safety.commandTimeout.description': '命令执行的最大秒数,超时将被终止。对内置和 ACP Agent 均生效。',
|
||||
'ai.safety.commandTimeout.unit': '秒',
|
||||
'ai.safety.maxIterations': '最大迭代次数',
|
||||
'ai.safety.maxIterations.description': '防止 AI 失控执行的最大工具调用循环次数。ACP Agent 可能有自己的内部迭代限制,以其为准。',
|
||||
'ai.safety.blocklist': '命令黑名单',
|
||||
'ai.safety.blocklist.description': '用于拦截危险命令的正则表达式。通过 Netcatty 执行层对内置和 ACP Agent 均生效。',
|
||||
'ai.safety.blocklist.placeholder': '正则表达式...',
|
||||
'ai.safety.blocklist.reset': '恢复默认',
|
||||
'ai.safety.blocklist.add': '添加规则',
|
||||
'ai.safety.note': '命令黑名单、命令超时和观察者模式通过 MCP Server 层强制执行,对所有 Agent 类型生效。确认模式和最大迭代次数对内置 Agent 完全强制执行;ACP Agent 可能有自己的内部控制。',
|
||||
|
||||
// 统一终端工作区和顶部标签的 tooltip 文案 (issue #954)
|
||||
'terminal.layer.addTerminal': '添加终端',
|
||||
'terminal.layer.switchToSplitView': '切换到分屏视图',
|
||||
'terminal.layer.sftp': '文件传输',
|
||||
'terminal.layer.scripts': '脚本',
|
||||
'terminal.layer.theme': '主题',
|
||||
'terminal.layer.aiChat': 'AI 助手',
|
||||
'terminal.layer.movePanelLeft': '面板移至左侧',
|
||||
'terminal.layer.movePanelRight': '面板移至右侧',
|
||||
'terminal.layer.closePanel': '关闭面板',
|
||||
'topTabs.openQuickSwitcher': '打开快速切换',
|
||||
'topTabs.moreTabs': '更多标签页',
|
||||
'topTabs.aiAssistant': 'AI 助手',
|
||||
'topTabs.toggleTheme': '切换主题',
|
||||
'topTabs.openSettings': '打开设置',
|
||||
'ai.chat.sessionHistory': '会话历史',
|
||||
'ai.chat.attach': '附件',
|
||||
'ai.chat.collapse': '收起',
|
||||
'ai.chat.expand': '展开',
|
||||
'ai.chat.enableAgent': '启用 {name}',
|
||||
'zmodem.waitingForRemote': '等待远端...',
|
||||
'zmodem.uploading': '上传中',
|
||||
'zmodem.downloading': '下载中',
|
||||
'zmodem.cancelTransfer': '取消传输 (Ctrl+C)',
|
||||
'zmodem.overwrite.title': '远端已存在同名文件',
|
||||
'zmodem.overwrite.applyToRest': '应用到其余冲突文件',
|
||||
'zmodem.overwrite.overwrite': '覆盖',
|
||||
'zmodem.overwrite.skip': '跳过',
|
||||
'zmodem.overwrite.cancel': '取消',
|
||||
'settings.shortcuts.resetToDefault': '重置为默认',
|
||||
};
|
||||
656
application/i18n/locales/zh-CN/core.ts
Normal file
656
application/i18n/locales/zh-CN/core.ts
Normal file
@@ -0,0 +1,656 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCNCoreMessages: Messages = {
|
||||
// Common
|
||||
'common.save': '保存',
|
||||
'common.cancel': '取消',
|
||||
'common.close': '关闭',
|
||||
'common.reset': '重置',
|
||||
'common.zoomIn': '放大',
|
||||
'common.zoomOut': '缩小',
|
||||
'common.settings': '设置',
|
||||
'common.search': '搜索',
|
||||
'common.connect': '连接',
|
||||
'common.terminal': '终端',
|
||||
'common.create': '创建',
|
||||
'common.add': '添加',
|
||||
'common.rename': '重命名',
|
||||
'common.refresh': '刷新',
|
||||
'common.continue': '继续',
|
||||
'common.enabled': '已启用',
|
||||
'common.disabled': '已禁用',
|
||||
'common.unknownError': '未知错误',
|
||||
'common.noResultsFound': '没有匹配结果',
|
||||
'common.back': '返回',
|
||||
'common.apply': '应用',
|
||||
'common.use': '使用',
|
||||
'common.useGlobal': '跟随全局',
|
||||
'common.left': '左侧',
|
||||
'common.right': '右侧',
|
||||
'common.more': '更多',
|
||||
'common.selectAHost': '选择主机',
|
||||
'sort.az': 'A-z',
|
||||
'sort.za': 'Z-a',
|
||||
'sort.newest': '从新到旧',
|
||||
'sort.oldest': '从旧到新',
|
||||
'sort.group': '按分组',
|
||||
'field.label': 'Label',
|
||||
'field.type': '类型',
|
||||
'auth.keyType': '类型 {type}',
|
||||
'auth.showAllKeys': '显示全部 keys',
|
||||
|
||||
// Dialogs / prompts
|
||||
'confirm.deleteHost': '删除主机 "{name}"?',
|
||||
'confirm.deleteIdentity': '删除身份 "{name}"?',
|
||||
'confirm.removeProvider': '移除提供商 "{name}"?',
|
||||
'confirm.closeBusyTerminal.title': '确认关闭',
|
||||
'confirm.closeBusyTerminal.message': '进程 "{command}" 仍在运行,关闭后会被终止。',
|
||||
'confirm.closeBusyTerminal.messageWithMore': '进程 "{command}" 及其他 {count} 个正在运行的进程将被终止。',
|
||||
'confirm.closeBusyTerminal.cancel': '取消',
|
||||
'confirm.closeBusyTerminal.close': '关闭',
|
||||
'dialog.renameWorkspace.title': '重命名工作区',
|
||||
'dialog.renameSession.title': '重命名会话',
|
||||
'field.name': '名称',
|
||||
'placeholder.workspaceName': '工作区名称',
|
||||
'placeholder.sessionName': '会话名称',
|
||||
'toast.settingsUnavailable': '当前平台无法打开设置窗口。',
|
||||
'credentials.protectionUnavailable.title': '凭据保护不可用',
|
||||
'credentials.protectionUnavailable.message': '当前设备无法自动解密已保存的密码和密钥。连接前请重新输入凭据。',
|
||||
'credentials.protectionUnavailable.action': '打开设置',
|
||||
|
||||
// Settings shell
|
||||
'settings.title': '设置',
|
||||
'settings.tab.application': '应用',
|
||||
'settings.tab.appearance': '外观',
|
||||
'settings.tab.terminal': '终端',
|
||||
'settings.tab.shortcuts': '快捷键',
|
||||
'settings.tab.syncCloud': '同步与云',
|
||||
'settings.tab.system': '系统',
|
||||
|
||||
// Settings > System
|
||||
'settings.system.title': '系统',
|
||||
'settings.system.description': '系统信息与临时文件管理。',
|
||||
'settings.system.tempDirectory': '临时文件',
|
||||
'settings.system.location': '位置',
|
||||
'settings.system.fileCount': '文件数量',
|
||||
'settings.system.totalSize': '占用空间',
|
||||
'settings.system.openFolder': '打开文件夹',
|
||||
'settings.system.refresh': '刷新',
|
||||
'settings.system.clearTempFiles': '清理临时文件',
|
||||
'settings.system.clearing': '清理中...',
|
||||
'settings.system.clearResult': '已删除 {deleted} 个文件,{failed} 个失败。',
|
||||
'settings.system.tempDirectoryHint': '临时文件在使用外部应用打开远程文件时创建。SFTP 会话关闭时会自动清理。',
|
||||
'settings.system.credentials.title': '凭据保护',
|
||||
'settings.system.credentials.status': '状态',
|
||||
'settings.system.credentials.checking': '检查中...',
|
||||
'settings.system.credentials.available': '可用(系统钥匙串正常)',
|
||||
'settings.system.credentials.unavailable': '不可用(无法解密已保存凭据)',
|
||||
'settings.system.credentials.unknown': '未知(当前环境不支持)',
|
||||
'settings.system.credentials.unavailableHint': '在其他用户或机器上加密的凭据无法在此处解密。请在当前设备重新输入并保存凭据。',
|
||||
'settings.system.credentials.portabilityHint': '云同步可跨设备,因为使用主密钥加密;本地 safeStorage 加密仅绑定当前系统用户/设备。',
|
||||
|
||||
// Settings > System > Crash Logs
|
||||
'settings.system.crashLogs.title': '崩溃日志',
|
||||
'settings.system.crashLogs.description': '查看主进程错误日志,帮助诊断异常行为。',
|
||||
'settings.system.crashLogs.noLogs': '未找到崩溃日志。',
|
||||
'settings.system.crashLogs.entries': '{count} 条记录',
|
||||
'settings.system.crashLogs.clear': '清除所有日志',
|
||||
'settings.system.crashLogs.cleared': '已清除 {count} 个日志文件。',
|
||||
'settings.system.crashLogs.source': '来源',
|
||||
'settings.system.crashLogs.time': '时间',
|
||||
'settings.system.crashLogs.message': '消息',
|
||||
'settings.system.crashLogs.stack': '堆栈跟踪',
|
||||
'settings.system.crashLogs.hint': '崩溃日志保留 30 天,超期自动清理。',
|
||||
'settings.system.crashLogs.collapse': '收起',
|
||||
'settings.system.crashLogs.expand': '查看详情',
|
||||
|
||||
// Settings > System > Software Update
|
||||
'settings.update.title': '软件更新',
|
||||
'settings.update.currentVersion': '当前版本',
|
||||
'settings.update.checkForUpdates': '检查更新',
|
||||
'settings.update.checking': '检查中...',
|
||||
'settings.update.upToDate': '当前已是最新版本。',
|
||||
'settings.update.available': '新版本 {version} 已发布。',
|
||||
'settings.update.download': '下载更新',
|
||||
'settings.update.downloading': '正在下载... {percent}%',
|
||||
'settings.update.readyToInstall': '更新已下载,准备安装。',
|
||||
'settings.update.restartNow': '重启并更新',
|
||||
'settings.update.error': '检查更新失败。',
|
||||
'settings.update.downloadError': '下载失败。',
|
||||
'settings.update.manualDownload': '前往 GitHub 下载',
|
||||
'settings.update.manualDownloadHint': '当前平台不支持自动更新,请前往 GitHub 下载最新版本。',
|
||||
'settings.update.hint': 'Netcatty 从 GitHub Releases 检查更新。',
|
||||
'settings.update.lastCheckedJustNow': '刚刚',
|
||||
'settings.update.lastCheckedMinutesAgo': '{n} 分钟前',
|
||||
'settings.update.lastCheckedHoursAgo': '{n} 小时前',
|
||||
'settings.update.lastCheckedPrefix': '上次检查:',
|
||||
'settings.update.autoUpdateEnabled': '自动更新',
|
||||
'settings.update.autoUpdateEnabledDesc': '有新版本时自动检查并下载更新。',
|
||||
|
||||
// Settings > Session Logs
|
||||
'settings.sessionLogs.title': '会话日志',
|
||||
'settings.sessionLogs.description': '配置会话日志导出和自动保存设置。',
|
||||
'settings.sessionLogs.autoSave': '自动保存',
|
||||
'settings.sessionLogs.enableAutoSave': '启用自动保存',
|
||||
'settings.sessionLogs.enableAutoSaveDesc': '在终端会话结束时自动保存会话日志。',
|
||||
'settings.sessionLogs.directory': '保存目录',
|
||||
'settings.sessionLogs.noDirectory': '未选择目录',
|
||||
'settings.sessionLogs.browse': '浏览',
|
||||
'settings.sessionLogs.openFolder': '打开文件夹',
|
||||
'settings.sessionLogs.directoryHint': '日志将按主机名组织在子目录中。',
|
||||
'settings.sessionLogs.format': '日志格式',
|
||||
'settings.sessionLogs.formatDesc': '选择保存日志文件的格式。',
|
||||
'settings.sessionLogs.formatTxt': '纯文本 (.txt)',
|
||||
'settings.sessionLogs.formatRaw': '原始格式 (.log)',
|
||||
'settings.sessionLogs.formatHtml': 'HTML (.html)',
|
||||
'settings.sessionLogs.hint': '会话日志用于记录终端输出,便于故障排查和审计。',
|
||||
|
||||
// Settings > Global Hotkey (Quake Mode)
|
||||
'settings.globalHotkey.title': '全局快捷键',
|
||||
'settings.globalHotkey.toggleWindow': '切换窗口',
|
||||
'settings.globalHotkey.toggleWindowDesc': '按下组合键以设置显示/隐藏窗口的全局快捷键。',
|
||||
'settings.globalHotkey.notSet': '未设置',
|
||||
'settings.globalHotkey.reset': '恢复默认',
|
||||
'settings.globalHotkey.closeToTray': '关闭时最小化到托盘',
|
||||
'settings.globalHotkey.closeToTrayDesc': '启用后,关闭窗口将最小化到系统托盘而不是退出程序。',
|
||||
'settings.globalHotkey.enabled': '启用全局快捷键',
|
||||
'settings.globalHotkey.enabledDesc': '注册系统级键盘快捷键。禁用后将取消所有全局快捷键注册。',
|
||||
'settings.globalHotkey.hint': '全局快捷键在系统范围内工作,可快速显示或隐藏窗口(下拉式终端风格)。',
|
||||
|
||||
// Tray Panel
|
||||
'tray.openMainWindow': '打开主窗口',
|
||||
'tray.sessions': '会话',
|
||||
'tray.portForwarding': '端口转发',
|
||||
'tray.status.connected': '已连接',
|
||||
'tray.status.connecting': '连接中',
|
||||
'tray.status.disconnected': '已断开',
|
||||
'tray.status.active': '已启用',
|
||||
'tray.status.inactive': '未启用',
|
||||
'tray.status.error': '错误',
|
||||
'tray.recentHosts': '最近连接的主机',
|
||||
'tray.empty.title': '一切都很安静',
|
||||
'tray.empty.subtitle': '去连接个服务器吧,它们想念你了 🚀',
|
||||
'tray.quit': '退出 Netcatty',
|
||||
|
||||
// Vault Sidebar
|
||||
'vault.sidebar.collapse': '收起侧边栏',
|
||||
'vault.sidebar.expand': '展开侧边栏',
|
||||
'vault.sidebar.resize': '调整侧边栏宽度',
|
||||
|
||||
// Settings > Application
|
||||
'settings.application.checkUpdates': '检查更新',
|
||||
'settings.application.reportProblem': '反馈问题',
|
||||
'settings.application.reportProblem.subtitle': '生成预填的 GitHub issue',
|
||||
'settings.application.community': '社区',
|
||||
'settings.application.community.subtitle': 'GitHub Discussions',
|
||||
'settings.application.github': 'GitHub',
|
||||
'settings.application.github.subtitle': '源代码',
|
||||
'settings.application.whatsNew': '更新内容',
|
||||
'settings.application.whatsNew.subtitle': '查看发布说明',
|
||||
'settings.application.openExternal.failedTitle': '无法打开链接',
|
||||
'settings.application.openExternal.failedBody': '系统浏览器和内置浏览器窗口都无法打开该链接。',
|
||||
'settings.vault.title': '主机库',
|
||||
'settings.vault.showRecentHosts': '显示最近连接的主机',
|
||||
'settings.vault.showRecentHostsDesc': '在主机列表顶部显示最近连接过的主机',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': '根目录只显示未分组主机',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': '开启后,主机库根目录的主机列表只显示没有分组的主机,已分组主机请从左侧分组进入查看。',
|
||||
'settings.vault.showSftpTab': '显示 SFTP 标签页',
|
||||
'settings.vault.showSftpTabDesc': '在顶部标签栏显示独立的 SFTP 视图。关闭后可改用会话内左侧的 SFTP 侧栏。',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': '发现新版本',
|
||||
'update.available.message': '新版本 {version} 已发布,点击前往下载。',
|
||||
'update.checking': '正在检查更新...',
|
||||
'update.upToDate.title': '已是最新版本',
|
||||
'update.upToDate.message': '当前版本 ({version}) 已是最新。',
|
||||
'update.error': '检查更新失败',
|
||||
'update.downloadNow': '立即下载',
|
||||
'update.viewInSettings': '在设置中查看',
|
||||
'update.readyToInstall.title': '更新已就绪',
|
||||
'update.readyToInstall.message': '版本 {version} 已下载完成,准备安装。',
|
||||
'update.restartNow': '立即重启',
|
||||
'update.downloadFailed.title': '更新失败',
|
||||
'update.downloadFailed.message': '下载更新失败,可前往 GitHub 手动下载。',
|
||||
'update.openReleases': '打开 Releases',
|
||||
'update.remindLater': '稍后提醒',
|
||||
'update.skipVersion': '跳过此版本',
|
||||
|
||||
// Settings > Appearance
|
||||
'settings.appearance.uiTheme': '界面主题',
|
||||
'settings.appearance.theme': '主题',
|
||||
'settings.appearance.theme.desc': '选择浅色、深色或跟随系统设置',
|
||||
'settings.appearance.theme.light': '浅色',
|
||||
'settings.appearance.theme.dark': '深色',
|
||||
'settings.appearance.theme.system': '系统',
|
||||
'settings.appearance.accentColor': '强调色',
|
||||
'settings.appearance.customColor': '自定义颜色',
|
||||
'settings.appearance.accentColor.mode': '使用自定义强调色',
|
||||
'settings.appearance.accentColor.mode.desc': '覆盖主题自带的强调色',
|
||||
'settings.appearance.accentColor.custom': '自定义强调色',
|
||||
'settings.appearance.themeColor': '主题色',
|
||||
'settings.appearance.themeColor.desc': '为浅色与深色主题选择预设配色',
|
||||
'settings.appearance.themeColor.light': '浅色主题',
|
||||
'settings.appearance.themeColor.dark': '深色主题',
|
||||
'settings.appearance.customCss': '自定义 CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'使用自定义 CSS 个性化界面,修改会立即生效。主要 UI 区块都暴露了 [data-section="..."] 属性供你定位,比如:snippets-panel、host-details-panel、group-details-panel、serial-host-details-panel、ai-chat-panel、vault-sidebar、vault-main、vault-hosts-header、vault-host-list、vault-view、terminal-workspace、terminal-workspace-sidebar、top-tabs。',
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* 放大代码片段侧边栏字号 */\n[data-section="snippets-panel"] {\n font-size: 14px !important;\n}\n\n/* 自定义终端背景色 */\n.terminal { background: #1a1a2e !important; }\n\n/* 调整全局圆角 */\n:root { --radius: 0.25rem; }',
|
||||
'settings.appearance.language': '语言',
|
||||
'settings.appearance.language.desc': '选择界面语言',
|
||||
'settings.appearance.uiFont': '界面字体',
|
||||
'settings.appearance.uiFont.desc': '选择软件界面使用的字体',
|
||||
|
||||
// Context menus / common actions
|
||||
'action.newHost': '新建主机',
|
||||
'action.newSubfolder': '新建文件夹',
|
||||
'action.copyPublicKey': '复制公钥',
|
||||
'action.keyExport': '导出密钥',
|
||||
'action.edit': '编辑',
|
||||
'action.delete': '删除',
|
||||
'action.remove': '移除',
|
||||
'action.convertToHost': '转换为主机',
|
||||
|
||||
// Sync
|
||||
'sync.cloudSync': '云同步',
|
||||
'sync.settings': '同步设置',
|
||||
'sync.active': '云同步已启用',
|
||||
'sync.syncing': '正在同步…',
|
||||
'sync.error': '同步错误',
|
||||
'sync.notConfigured': '未配置',
|
||||
'sync.failed': '同步失败',
|
||||
'sync.connected': '已连接',
|
||||
'sync.syncNow': '立即同步',
|
||||
'sync.recentActivity': '最近活动',
|
||||
'sync.history.uploaded': '已 Upload',
|
||||
'sync.history.downloaded': '已 Download',
|
||||
'sync.history.resolved': '已处理',
|
||||
'sync.toast.completedMessage': '同步完成',
|
||||
'sync.toast.errorTitle': '同步错误',
|
||||
'sync.autoSync.failedTitle': '同步失败',
|
||||
'sync.autoSync.inspectFailedTitle': '同步已暂停',
|
||||
'sync.autoSync.inspectFailedMessage': '无法访问云端以检查变更。数据改动或下次启动时会自动重试。',
|
||||
'sync.autoSync.syncedTitle': '已从云端同步',
|
||||
'sync.autoSync.syncedMessage': '你的数据已从云端更新。',
|
||||
'sync.autoSync.noProvider': '未连接云同步 provider。请打开 设置 → Sync & Cloud 进行连接。',
|
||||
'sync.autoSync.alreadySyncing': '同步正在进行中。',
|
||||
'sync.autoSync.restoreInProgress': '另一个窗口中的本地备份恢复正在进行中,请等待其完成。',
|
||||
'sync.autoSync.interruptedApplyTitle': '同步已暂停 — 上次恢复未完成',
|
||||
'sync.autoSync.interruptedApplyMessage': '上次本地恢复过程未正常结束,本地数据可能处于半应用状态。请打开「设置 → Sync & Cloud → 恢复」,从保护性备份中恢复后再让自动同步继续。',
|
||||
'sync.autoSync.vaultLocked': 'Vault 处于锁定状态。请打开 设置 → Sync & Cloud 解锁。',
|
||||
'sync.autoSync.conflictDetected': '检测到同步冲突。请打开 设置 → Sync & Cloud 处理。',
|
||||
'sync.autoSync.syncFailed': '同步失败',
|
||||
'sync.autoSync.restoredTitle': '已恢复',
|
||||
'sync.autoSync.restoredMessage': '已从云端恢复主机库数据。',
|
||||
'sync.autoSync.keptLocalTitle': '已保留本地数据',
|
||||
'sync.autoSync.keptLocalMessage': '保留了空的本地主机库,未应用云端数据。',
|
||||
'sync.autoSync.emptyVaultConflict.title': '检测到空主机库',
|
||||
'sync.autoSync.emptyVaultConflict.description': '本地主机库为空,但云端有数据。这通常发生在应用更新或存储重置之后。请选择如何处理:',
|
||||
'sync.autoSync.emptyVaultConflict.cloudLabel': '云端',
|
||||
'sync.autoSync.emptyVaultConflict.restore': '从云端恢复',
|
||||
'sync.autoSync.emptyVaultConflict.restoreDesc': '推荐 — 从云端备份恢复主机、密钥和代码片段',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmpty': '保持为空',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': '从头开始,使用空的主机库',
|
||||
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} 台主机,{keys} 个密钥,{snippets} 个代码片段,{proxyProfiles} 个代理',
|
||||
'sync.autoSync.emptyVaultManual': '无法同步:本地 vault 为空。请先从本地备份恢复,或在同步面板里使用"强制推送"。',
|
||||
|
||||
'sync.blocked.title': '同步已暂停',
|
||||
'sync.blocked.reason.bulkShrink': '即将从云端删除 {baseCount} 条 {entityType} 中的 {lost} 条(缩减 {percent}%)。',
|
||||
'sync.blocked.reason.largeShrink': '即将从云端删除 {lost} 条 {entityType}。',
|
||||
'sync.blocked.detail': '通常是本地状态异常(钥匙串故障、数据加载不全)导致。请从本地备份恢复,如果确实要删这些条目请使用强制推送。',
|
||||
'sync.blocked.restoreButton': '从本地备份恢复',
|
||||
'sync.blocked.forcePushButton': '强制推送',
|
||||
|
||||
'sync.forcePush.title': '确认强制推送',
|
||||
'sync.forcePush.body': '你将从云端移除 {lost} 条 {entityType},此操作不可撤销。继续?',
|
||||
'sync.forcePush.confirm': '确认推送',
|
||||
'sync.forcePush.cancel': '取消',
|
||||
|
||||
'sync.entityType.hosts': '主机',
|
||||
'sync.entityType.keys': '密钥',
|
||||
'sync.entityType.identities': '身份',
|
||||
'sync.entityType.proxyProfiles': '代理配置',
|
||||
'sync.entityType.snippets': '代码片段',
|
||||
'sync.entityType.customGroups': '分组',
|
||||
'sync.entityType.snippetPackages': '片段包',
|
||||
'sync.entityType.knownHosts': '主机密钥记录',
|
||||
'sync.entityType.portForwardingRules': '端口转发规则',
|
||||
'sync.entityType.groupConfigs': '分组配置',
|
||||
|
||||
'sync.credentialsUnavailable': '当前设备无法解密部分已保存凭据。请先在本地重新输入凭据后再同步。',
|
||||
'time.never': '从未',
|
||||
'time.justNow': '刚刚',
|
||||
'time.minutesAgo': '{minutes} 分钟前',
|
||||
|
||||
// Vault navigation
|
||||
'vault.nav.hosts': '主机',
|
||||
'vault.nav.keychain': '钥匙串',
|
||||
'vault.nav.proxies': '代理',
|
||||
'vault.nav.portForwarding': '端口转发',
|
||||
'vault.nav.snippets': '代码片段',
|
||||
'vault.nav.knownHosts': '已知主机',
|
||||
'vault.nav.logs': '日志',
|
||||
|
||||
'proxyProfiles.action.add': '添加代理',
|
||||
'proxyProfiles.search.placeholder': '搜索代理…',
|
||||
'proxyProfiles.section.proxies': '代理',
|
||||
'proxyProfiles.count.items': '{count} 项',
|
||||
'proxyProfiles.empty.title': '暂无代理',
|
||||
'proxyProfiles.empty.desc': '创建可复用的 HTTP 或 SOCKS5 代理,然后在主机详情里选择。',
|
||||
'proxyProfiles.usage': '已关联 {count} 处',
|
||||
'proxyProfiles.copyName': '{name} 副本',
|
||||
'proxyProfiles.panel.newTitle': '新建代理',
|
||||
'proxyProfiles.field.name': '代理名称',
|
||||
'proxyProfiles.error.required': '名称、主机和端口不能为空。',
|
||||
'proxyProfiles.error.port': '端口必须在 1 到 65535 之间。',
|
||||
'proxyProfiles.viewMode': '代理显示方式',
|
||||
'proxyProfiles.delete.title': '删除代理?',
|
||||
'proxyProfiles.delete.desc': '删除 "{name}" 会同时从 {count} 个主机或分组设置中解除关联。',
|
||||
|
||||
'vault.groups.title': '分组',
|
||||
'vault.groups.total': '共 {count} 个',
|
||||
'vault.groups.hostsCount': '{count} 台主机',
|
||||
'vault.groups.newSubgroup': '新建子分组',
|
||||
'vault.groups.rename': '重命名分组',
|
||||
'vault.groups.delete': '删除分组',
|
||||
'vault.groups.createSubfolder': '创建子分组',
|
||||
'vault.groups.createRoot': '创建根分组',
|
||||
'vault.groups.createDialog.desc': '创建新的分组用于组织主机。',
|
||||
'vault.groups.renameDialogTitle': '重命名分组',
|
||||
'vault.groups.renameDialog.desc': '重命名已有分组。',
|
||||
'vault.groups.deleteDialogTitle': '删除分组',
|
||||
'vault.groups.deleteDialog.desc': '这将永久删除该分组并将所有主机移动到根级别。',
|
||||
'vault.groups.deleteDialog.managedDesc': '这是一个托管的 SSH config 分组。删除后将同时删除所有主机并断开与源文件的连接。',
|
||||
'vault.groups.deleteDialog.deleteHosts': '同时删除该分组下的所有主机',
|
||||
'vault.groups.ungrouped': '未分组',
|
||||
'vault.groups.field.name': '分组名称',
|
||||
'vault.groups.placeholder.example': '例如:Production',
|
||||
'vault.groups.parentLabel': '父级',
|
||||
'vault.groups.pathLabel': '路径',
|
||||
'vault.groups.settings': '分组设置',
|
||||
'vault.groups.details': '分组详情',
|
||||
'vault.groups.details.general': '常规',
|
||||
'vault.groups.details.ssh': 'SSH',
|
||||
'vault.groups.details.telnet': 'Telnet',
|
||||
'vault.groups.details.advanced': '高级',
|
||||
'vault.groups.details.appearance': '外观',
|
||||
'vault.groups.details.mosh': 'Mosh',
|
||||
'vault.groups.details.parentGroup': '父分组',
|
||||
'vault.groups.details.none': '无',
|
||||
'vault.groups.details.inherited': '继承自分组',
|
||||
'vault.groups.details.addProtocol': '添加协议',
|
||||
'vault.groups.details.removeProtocol': '移除协议',
|
||||
'vault.groups.details.fontFamily': '字体',
|
||||
'vault.groups.details.fontSize': '字号',
|
||||
'vault.groups.errors.required': '分组名称不能为空。',
|
||||
'vault.groups.errors.invalidChars': "分组名称不能包含 '/' 或 '\\\\'.",
|
||||
'vault.groups.errors.duplicatePath': '该位置已存在同名分组。',
|
||||
|
||||
'vault.managedSource.unmanage': '取消托管',
|
||||
'vault.managedSource.unmanageSuccess': '已取消托管分组',
|
||||
|
||||
'vault.hosts.header.entries': '{count} 条',
|
||||
'vault.hosts.header.live': '{count} 个在线',
|
||||
|
||||
// Vault hosts header/actions
|
||||
'vault.hosts.search.placeholder': '查找主机或 ssh user@hostname / ssh -p 2222 user@hostname…',
|
||||
'vault.hosts.connect': '连接',
|
||||
'vault.view.grid': '网格',
|
||||
'vault.view.list': '列表',
|
||||
'vault.view.tree': '树形',
|
||||
'vault.tree.expandAll': '展开全部',
|
||||
'vault.tree.collapseAll': '折叠全部',
|
||||
'vault.hosts.newHost': '新建主机',
|
||||
'vault.hosts.newGroup': '新建分组',
|
||||
'vault.hosts.import': '导入',
|
||||
'vault.hosts.export': '导出',
|
||||
'vault.hosts.export.toast.success': '已导出 {count} 个主机到 CSV',
|
||||
'vault.hosts.export.toast.successWithSkipped': '已导出 {count} 个主机到 CSV(跳过 {skipped} 个不支持的主机)',
|
||||
'vault.hosts.export.toast.noHosts': '没有主机可导出',
|
||||
'vault.hosts.allHosts': '全部主机',
|
||||
'vault.hosts.pinned': '已置顶',
|
||||
'vault.hosts.recentlyConnected': '最近连接',
|
||||
'vault.hosts.pinToTop': '置顶',
|
||||
'vault.hosts.unpin': '取消置顶',
|
||||
'vault.hosts.copyCredentials': '复制账密信息',
|
||||
'vault.hosts.copyCredentials.toast.success': '账密信息已复制到剪贴板',
|
||||
'vault.hosts.copyCredentials.toast.noPassword': '该主机未保存密码',
|
||||
'vault.hosts.multiSelect': '多选',
|
||||
'vault.hosts.selected': '已选择 {count} 项',
|
||||
'vault.hosts.selectAll': '全选',
|
||||
'vault.hosts.deselectAll': '取消全选',
|
||||
'vault.hosts.deleteSelected': '删除 ({count})',
|
||||
'vault.hosts.deleteMultiple.success': '已删除 {count} 个主机',
|
||||
'vault.hosts.connectSelected': '连接 ({count})',
|
||||
'vault.hosts.connectMultiple.success': '正在连接 {count} 个主机',
|
||||
'vault.hosts.moveToGroup.success': '已将 {host} 移动到 {group}',
|
||||
'vault.hosts.empty.title': '设置你的主机',
|
||||
'vault.hosts.empty.desc': '保存主机以快速连接到你的服务器、虚拟机和容器。',
|
||||
|
||||
// Vault import
|
||||
'vault.import.title': '添加数据到你的 Vault',
|
||||
'vault.import.desc': '从常见工具迁移连接信息。选择一种格式开始导入。',
|
||||
'vault.import.chooseFormat': '选择文件格式',
|
||||
'vault.import.csv.tip': '批量导入:可使用 CSV 模板填写后导入。',
|
||||
'vault.import.csv.downloadTemplate': '下载 CSV 模板',
|
||||
'vault.import.toast.start': '正在从 {format} 导入...',
|
||||
'vault.import.toast.completedTitle': '导入完成',
|
||||
'vault.import.toast.failedTitle': '导入失败',
|
||||
'vault.import.toast.noEntries': '{format} 文件中没有可导入的条目。',
|
||||
'vault.import.toast.noNewHosts': '从 {format} 没有导入到新的主机。',
|
||||
'vault.import.toast.summary': '已导入 {count} 个主机(跳过 {skipped},重复 {duplicates})。',
|
||||
'vault.import.toast.firstIssue': '首个问题:{issue}',
|
||||
'vault.import.sshConfig.chooseMode': '选择如何导入你的 SSH config 文件。',
|
||||
'vault.import.sshConfig.modeQuestion': '你希望如何导入?',
|
||||
'vault.import.sshConfig.importOnly': '仅导入',
|
||||
'vault.import.sshConfig.importOnlyDesc': '一次性导入,修改不会同步回文件。',
|
||||
'vault.import.sshConfig.managed': '托管同步',
|
||||
'vault.import.sshConfig.managedDesc': '保持同步,修改会自动保存回文件。',
|
||||
'vault.import.sshConfig.managedGroup': 'ssh config',
|
||||
'vault.import.sshConfig.managedSuccess': '已导入 {count} 个主机,文件已托管。',
|
||||
'vault.import.sshConfig.alreadyManaged': '该文件已被托管。',
|
||||
'vault.import.sshConfig.alreadyManagedDesc': '该文件已在分组 "{group}" 下托管。如需重新导入,请先移除现有的托管源。',
|
||||
'vault.import.sshConfig.noFilePath': '无法托管此文件。',
|
||||
'vault.import.sshConfig.noFilePathDesc': '无法确定文件路径。托管同步需要访问文件系统。',
|
||||
|
||||
// Known Hosts
|
||||
'knownHosts.search.placeholder': '搜索已知主机...',
|
||||
'knownHosts.action.scanSystem': '扫描系统',
|
||||
'knownHosts.action.importFile': '导入文件',
|
||||
'knownHosts.action.browseFile': '浏览文件',
|
||||
'knownHosts.empty.title': '暂无已知主机',
|
||||
'knownHosts.empty.desc':
|
||||
'Known Hosts 是你之前连接过的 SSH server。导入系统的 known_hosts 文件以开始。',
|
||||
'knownHosts.results.showingLimited': '显示 {shown}/{total} 个主机。使用搜索查找特定主机。',
|
||||
'knownHosts.toast.scanUnavailable': '当前平台无法扫描系统 known_hosts。',
|
||||
'knownHosts.toast.scanNoFile': '未找到系统 known_hosts 文件。',
|
||||
'knownHosts.toast.scanNoEntries': 'known_hosts 中没有可用条目。',
|
||||
'knownHosts.toast.scanImported': '已导入 {count} 个新主机。',
|
||||
'knownHosts.toast.scanNoNew': '没有发现新的主机。',
|
||||
'knownHosts.toast.scanFailed': '扫描系统 known_hosts 失败。',
|
||||
|
||||
// Port Forwarding
|
||||
'pf.empty.title': '配置端口转发规则',
|
||||
'pf.empty.desc': '保存端口转发规则,用于访问数据库、Web 应用等服务。',
|
||||
'pf.title': '端口转发规则',
|
||||
'pf.rulesCount': '{count} 条规则',
|
||||
'pf.wizard.editTitle': '编辑端口转发规则',
|
||||
'pf.wizard.newTitle': '新建端口转发规则',
|
||||
'pf.wizard.saveChanges': '保存修改',
|
||||
'pf.wizard.done': '完成',
|
||||
'pf.wizard.continue': '继续',
|
||||
'pf.wizard.cancel': '取消',
|
||||
'pf.wizard.skipWizard': '跳过向导',
|
||||
'pf.error.hostNotFound': '未找到主机',
|
||||
'pf.toast.titleWithLabel': '端口转发规则: {label}',
|
||||
'pf.type.local': '本地转发',
|
||||
'pf.type.remote': '远程转发',
|
||||
'pf.type.dynamic': '动态转发',
|
||||
'pf.type.menu.local': '本地转发',
|
||||
'pf.type.menu.remote': '远程转发',
|
||||
'pf.type.menu.dynamic': '动态转发',
|
||||
'pf.type.local.desc': '本地转发让你像访问本地一样访问远程服务端口。',
|
||||
'pf.type.remote.desc': '远程转发在远端开启端口,并将连接转发到本地(当前)主机。',
|
||||
'pf.type.dynamic.desc': '动态转发将 Netcatty 作为 SOCKS 代理使用。',
|
||||
'pf.wizard.type.title': '选择端口转发类型:',
|
||||
'pf.wizard.localConfig.title': '设置本地端口与绑定地址:',
|
||||
'pf.wizard.localConfig.desc': '该端口会在本地(当前设备)打开,并接收流量。',
|
||||
'pf.wizard.localConfig.localPort': '本地端口 *',
|
||||
'pf.wizard.bindAddress': '绑定地址',
|
||||
'pf.wizard.remoteHost.title': '选择远端主机:',
|
||||
'pf.wizard.remoteHost.desc': '选择要打开端口的远端主机。该端口的流量将转发到目标地址。',
|
||||
'pf.wizard.remoteConfig.title': '设置端口与绑定地址:',
|
||||
'pf.wizard.remoteConfig.desc': '将从所选主机的指定端口与网卡地址转发流量。',
|
||||
'pf.wizard.remoteConfig.remotePort': '远端端口 *',
|
||||
'pf.wizard.destination.title': '设置目标地址:',
|
||||
'pf.wizard.destination.desc.local': '输入你希望通过 tunnel 访问的远端目标地址。',
|
||||
'pf.wizard.destination.desc.remote': '要转发流量到的目标地址与端口。',
|
||||
'pf.wizard.destination.address': '目标地址 *',
|
||||
'pf.wizard.destination.addressPlaceholder': '例如:127.0.0.1 或 192.168.1.100',
|
||||
'pf.wizard.destination.port': '目标端口 *',
|
||||
'pf.wizard.sshServer.title': '选择 SSH server:',
|
||||
'pf.wizard.sshServer.desc.dynamic': '选择作为 SOCKS proxy 的 SSH server。',
|
||||
'pf.wizard.sshServer.desc.default': '选择用于将流量 tunnel 到目标地址的 SSH server。',
|
||||
'pf.wizard.label.title': '设置 Label:',
|
||||
'pf.wizard.label.placeholder.dynamic': '例如:SOCKS Proxy',
|
||||
'pf.wizard.label.placeholder.default': '例如:MySQL Production',
|
||||
'pf.wizard.label.placeholder.remoteRule': '例如:Remote Rule',
|
||||
'pf.wizard.placeholders.portExample': '例如:{port}',
|
||||
|
||||
// SFTP
|
||||
'sftp.newFolder': '新建文件夹',
|
||||
'sftp.newFile': '新建文件',
|
||||
'sftp.filter': '筛选',
|
||||
'sftp.filter.placeholder': '按文件名筛选...',
|
||||
'sftp.bookmark.add': '收藏此路径',
|
||||
'sftp.bookmark.remove': '取消收藏',
|
||||
'sftp.bookmark.addGlobal': '+全局',
|
||||
'sftp.bookmark.addGlobalTooltip': '保存为全局收藏(所有主机共享)',
|
||||
'sftp.bookmark.empty': '暂无收藏路径',
|
||||
'sftp.columns.name': '名称',
|
||||
'sftp.columns.modified': '修改时间',
|
||||
'sftp.columns.size': '大小',
|
||||
'sftp.columns.kind': '类型',
|
||||
'sftp.columns.actions': '操作',
|
||||
'sftp.emptyDirectory': '空目录',
|
||||
'sftp.nav.up': '返回上层',
|
||||
'sftp.nav.home': '返回主目录',
|
||||
'sftp.nav.refresh': '刷新',
|
||||
'sftp.upload': '上传',
|
||||
'sftp.uploadFiles': '上传文件',
|
||||
'sftp.uploadFolder': '上传文件夹',
|
||||
'sftp.dragDropToUpload': '拖拽文件到这里上传',
|
||||
'sftp.retry': '重试',
|
||||
'sftp.context.open': '打开',
|
||||
'sftp.context.navigateTo': '跳转到这里',
|
||||
'sftp.context.moveTo': '移动到...',
|
||||
'sftp.context.moveToParent': '移动到上级目录',
|
||||
'sftp.moveTo.title': '移动到目录',
|
||||
'sftp.moveTo.placeholder': '输入目标目录路径',
|
||||
'sftp.moveTo.confirm': '移动',
|
||||
'sftp.moveTo.pathNotFound': '目录不存在或无法访问',
|
||||
'sftp.context.download': '下载',
|
||||
'sftp.context.copyToOtherPane': '复制到另一侧',
|
||||
'sftp.viewMode.label': '视图模式',
|
||||
'sftp.viewMode.list': '列表视图',
|
||||
'sftp.viewMode.tree': '树形视图',
|
||||
'sftp.tree.loadError': '加载目录失败',
|
||||
'sftp.tree.loading': '加载中...',
|
||||
'sftp.kind.folder': '文件夹',
|
||||
'sftp.context.rename': '重命名',
|
||||
'sftp.context.permissions': '权限',
|
||||
'sftp.context.delete': '删除',
|
||||
'sftp.context.refresh': '刷新',
|
||||
'sftp.context.uploadFiles': '上传文件...',
|
||||
'sftp.context.uploadFilesHere': '上传文件到这里...',
|
||||
'sftp.context.uploadFolder': '上传文件夹...',
|
||||
'sftp.context.uploadFolderHere': '上传文件夹到这里...',
|
||||
'sftp.context.downloadSelected': '下载选中项({count})',
|
||||
'sftp.context.deleteSelected': '删除选中项({count})',
|
||||
'sftp.dropFilesHere': '拖拽文件到这里',
|
||||
'sftp.itemsCount': '{count} 个项目',
|
||||
'sftp.selectedCount': '已选 {count} 个',
|
||||
'sftp.path.doubleClickToEdit': '双击编辑路径',
|
||||
'sftp.showHiddenPaths': '隐藏的路径',
|
||||
'sftp.task.waiting': '等待中...',
|
||||
'sftp.transfer.preparing': '准备中...',
|
||||
'sftp.status.loading': '加载中...',
|
||||
'sftp.status.uploading': '上传中...',
|
||||
'sftp.status.ready': '就绪',
|
||||
'sftp.transfers': '传输',
|
||||
'sftp.transfers.active': '{count} 个进行中',
|
||||
'sftp.transfers.clearCompleted': '清除已完成',
|
||||
'sftp.transfers.calculatingTotal': '正在统计总大小...',
|
||||
'sftp.transfers.filesCount': '{count} 个文件',
|
||||
'sftp.transfers.filesProgress': '{current}/{total} 个文件',
|
||||
'sftp.transfers.expandChildren': '展开文件',
|
||||
'sftp.transfers.collapseChildren': '收起文件',
|
||||
'sftp.transfers.expandChildList': '展开详情',
|
||||
'sftp.transfers.collapseChildList': '收起',
|
||||
'sftp.transfers.retryAction': '重试',
|
||||
'sftp.transfers.dismissAction': '移除',
|
||||
'sftp.transfers.openTargetFolder': '打开目标目录',
|
||||
'sftp.transfers.openTargetFolderError': '无法打开目标目录',
|
||||
'sftp.transfers.copyTargetPath': '复制目标路径',
|
||||
'sftp.transfers.copyTargetPathSuccess': '已复制目标路径',
|
||||
'sftp.transfers.copyTargetPathError': '无法复制目标路径',
|
||||
'sftp.transfers.resizeNameColumn': '调整文件名列宽',
|
||||
'sftp.transfers.dragToResize': '拖拽调整高度',
|
||||
'sftp.goUp': '上一级',
|
||||
'sftp.goToTerminalCwd': '定位到终端当前目录',
|
||||
'sftp.encoding.label': '文件名编码',
|
||||
'sftp.encoding.auto': '自动',
|
||||
'sftp.encoding.utf8': 'UTF-8',
|
||||
'sftp.encoding.gb18030': 'GB18030',
|
||||
'sftp.goHome': '返回主目录',
|
||||
'sftp.folderName': '文件夹名称',
|
||||
'sftp.folderName.placeholder': '输入文件夹名称',
|
||||
'sftp.fileName': '文件名称',
|
||||
'sftp.fileName.placeholder': '输入文件名称',
|
||||
'sftp.prompt.newFolderName': '新建文件夹名称?',
|
||||
'sftp.rename.title': '重命名',
|
||||
'sftp.rename.newName': '新名称',
|
||||
'sftp.rename.placeholder': '输入新名称',
|
||||
'sftp.confirm.deleteOne': '删除 "{name}"?',
|
||||
'sftp.deleteConfirm.single': '删除 "{name}"?',
|
||||
'sftp.deleteConfirm.title': '删除 {count} 个项目?',
|
||||
'sftp.deleteConfirm.desc': '此操作不可撤销,将删除以下内容:',
|
||||
'sftp.deleteConfirm.descSingle': '此操作不可撤销。',
|
||||
'sftp.deleteConfirm.host': '主机',
|
||||
'sftp.deleteConfirm.path': '路径',
|
||||
'sftp.error.loadFailed': '加载目录失败',
|
||||
'sftp.error.downloadFailed': '下载失败',
|
||||
'sftp.error.uploadFailed': '上传失败',
|
||||
'sftp.error.deleteFailed': '删除失败',
|
||||
'sftp.error.createFolderFailed': '创建文件夹失败',
|
||||
'sftp.error.createFileFailed': '创建文件失败',
|
||||
'sftp.error.invalidFileName': '文件名包含非法字符:{chars}',
|
||||
'sftp.error.reservedName': '此文件名是系统保留名称',
|
||||
'sftp.overwrite.title': '文件已存在',
|
||||
'sftp.overwrite.desc': '名为"{name}"的文件已存在。是否要替换它?',
|
||||
'sftp.overwrite.confirm': '替换',
|
||||
'sftp.error.renameFailed': '重命名失败',
|
||||
'sftp.picker.title': '选择主机',
|
||||
'sftp.picker.desc': '为{side}窗格选择主机',
|
||||
'sftp.picker.searchPlaceholder': '搜索主机...',
|
||||
'sftp.picker.local.title': '本地文件系统',
|
||||
'sftp.picker.local.desc': '浏览本地文件',
|
||||
'sftp.picker.local.badge': '本地',
|
||||
'sftp.picker.noMatch': '没有匹配的主机',
|
||||
'sftp.permissions.title': '编辑权限',
|
||||
'sftp.permissions.owner': '所有者',
|
||||
'sftp.permissions.group': '群组',
|
||||
'sftp.permissions.others': '其他',
|
||||
'sftp.permissions.octal': '八进制',
|
||||
'sftp.permissions.symbolic': '符号',
|
||||
'sftp.permissions.success': '权限已更新',
|
||||
'sftp.permissions.failed': '权限更新失败',
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': '搜索主机或标签页',
|
||||
'qs.jumpTo': '跳转到',
|
||||
'qs.localTerminal': '本地终端',
|
||||
'qs.localShells': '本地 Shell',
|
||||
'qs.default': '默认',
|
||||
|
||||
};
|
||||
636
application/i18n/locales/zh-CN/terminal.ts
Normal file
636
application/i18n/locales/zh-CN/terminal.ts
Normal file
@@ -0,0 +1,636 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCNTerminalMessages: Messages = {
|
||||
// SFTP File Opener
|
||||
'sftp.context.copyPath': '复制文件路径',
|
||||
'sftp.context.openWith': '打开方式...',
|
||||
'sftp.context.edit': '编辑',
|
||||
'sftp.context.preview': '预览',
|
||||
'sftp.opener.title': '打开方式',
|
||||
'sftp.opener.desc': '选择一个应用程序来打开此文件',
|
||||
'sftp.opener.builtInEditor': '内置编辑器',
|
||||
'sftp.opener.editDescription': '编辑文本文件',
|
||||
'sftp.opener.builtInImageViewer': '内置图片预览',
|
||||
'sftp.opener.previewDescription': '预览图片',
|
||||
'sftp.opener.systemApp': '选择应用程序...',
|
||||
'sftp.opener.systemAppDescription': '从本地选择一个应用程序',
|
||||
'sftp.opener.onlySystemApp': '此文件只能用外部应用程序打开',
|
||||
'sftp.opener.noAppsAvailable': '无可用应用程序',
|
||||
'sftp.opener.noExtension': '无扩展名文件',
|
||||
'sftp.opener.setDefault': '始终使用此方式打开 {ext} 文件',
|
||||
'sftp.opener.confirmTitle': '设为默认?',
|
||||
'sftp.opener.confirmDescription': '是否始终使用 {app} 打开 {ext} 文件?',
|
||||
'sftp.opener.yesRemember': '是,记住此选择',
|
||||
'sftp.opener.justOnce': '仅此一次',
|
||||
'sftp.opener.confirm.title': '设置默认应用程序',
|
||||
'sftp.opener.confirm.desc': '是否始终使用此应用程序打开 .{ext} 文件?',
|
||||
'sftp.editor.title': '文本编辑器',
|
||||
'sftp.editor.save': '保存到远程',
|
||||
'sftp.editor.saving': '保存中...',
|
||||
'sftp.editor.saved': '保存成功',
|
||||
'sftp.editor.saveFailed': '保存文件失败',
|
||||
'sftp.editor.unsavedChanges': '您有未保存的更改。确定要关闭吗?',
|
||||
'sftp.editor.syntaxHighlight': '语法高亮',
|
||||
'sftp.preview.title': '图片预览',
|
||||
'sftp.preview.zoomIn': '放大',
|
||||
'sftp.preview.zoomOut': '缩小',
|
||||
'sftp.preview.resetZoom': '重置缩放',
|
||||
'sftp.preview.fitToWindow': '适应窗口',
|
||||
|
||||
// Settings > SFTP File Associations
|
||||
'settings.tab.sftpFileAssociations': 'SFTP',
|
||||
'settings.sftp.transferConcurrency': '传输并发数',
|
||||
'settings.sftp.transferConcurrency.desc': '上传或下载文件夹时并行传输的文件数量。较高的值可能提高速度,但可能导致某些服务器过载。',
|
||||
'settings.sftp.defaultOpener': '默认文件打开方式',
|
||||
'settings.sftp.defaultOpener.desc': '选择没有特定文件关联时的默认打开方式',
|
||||
'settings.sftp.defaultOpener.ask': '每次询问',
|
||||
'settings.sftp.defaultOpener.askDesc': '每次打开文件时弹出选择对话框',
|
||||
'settings.sftp.defaultOpener.builtInDesc': '默认使用内置编辑器打开文本文件',
|
||||
'settings.sftp.defaultOpener.systemApp': '选择应用程序...',
|
||||
'settings.sftp.defaultOpener.systemAppDesc': '默认使用指定的外部应用程序打开文件',
|
||||
'settings.sftpFileAssociations.title': 'SFTP 文件关联',
|
||||
'settings.sftpFileAssociations.desc': '配置按扩展名打开文件的默认应用程序',
|
||||
'settings.sftpFileAssociations.extension': '扩展名',
|
||||
'settings.sftpFileAssociations.application': '应用程序',
|
||||
'settings.sftpFileAssociations.noAssociations': '未配置文件关联',
|
||||
'settings.sftpFileAssociations.remove': '移除',
|
||||
'settings.sftpFileAssociations.removeConfirm': '确定移除 .{ext} 的关联吗?',
|
||||
|
||||
// Settings > SFTP Behavior
|
||||
'settings.sftp.doubleClickBehavior': '双击行为',
|
||||
'settings.sftp.doubleClickBehavior.desc': '选择在 SFTP 视图中双击文件时的操作',
|
||||
'settings.sftp.doubleClickBehavior.open': '打开文件',
|
||||
'settings.sftp.doubleClickBehavior.transfer': '传输到另一侧',
|
||||
'settings.sftp.doubleClickBehavior.openDesc': '使用默认应用程序打开文件',
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': '将文件传输到另一窗格的活动主机',
|
||||
|
||||
// Settings > SFTP Auto Sync
|
||||
'settings.sftp.autoSync': '自动同步到远程',
|
||||
'settings.sftp.autoSync.desc': '使用外部应用程序打开文件时,自动将文件更改同步回远程服务器',
|
||||
'settings.sftp.autoSync.enable': '启用自动同步',
|
||||
'settings.sftp.autoSync.enableDesc': '在外部应用程序中保存文件时,更改将自动上传到远程服务器',
|
||||
|
||||
// Settings > SFTP 自动打开侧栏
|
||||
'settings.sftp.autoOpenSidebar': '连接时自动打开侧栏',
|
||||
'settings.sftp.autoOpenSidebar.desc': '连接到主机时自动打开 SFTP 文件浏览器侧栏',
|
||||
'settings.sftp.autoOpenSidebar.enable': '启用自动打开侧栏',
|
||||
'settings.sftp.autoOpenSidebar.enableDesc': '当终端会话连接到远程主机时,SFTP 侧栏将自动打开',
|
||||
|
||||
'settings.sftp.defaultViewMode': '默认视图模式',
|
||||
'settings.sftp.defaultViewMode.desc': '选择打开新 SFTP 标签页时的默认视图模式。每个主机的偏好设置会覆盖此全局设置。',
|
||||
'settings.sftp.defaultViewMode.list': '列表视图',
|
||||
'settings.sftp.defaultViewMode.listDesc': '以平面列表显示当前目录的文件',
|
||||
'settings.sftp.defaultViewMode.tree': '树形视图',
|
||||
'settings.sftp.defaultViewMode.treeDesc': '以层级树形结构显示文件',
|
||||
|
||||
'sftp.autoSync.success': '文件已同步到远程:{fileName}',
|
||||
'sftp.autoSync.error': '同步文件失败:{error}',
|
||||
|
||||
// SFTP Folder Upload Progress
|
||||
'sftp.upload.progress': '正在上传 {current}/{total} 个文件...',
|
||||
'sftp.upload.uploading': '正在上传...',
|
||||
'sftp.upload.compressing': '正在压缩...',
|
||||
'sftp.upload.extracting': '正在解压...',
|
||||
'sftp.upload.scanning': '正在扫描文件...',
|
||||
'sftp.upload.completed': '已完成',
|
||||
'sftp.upload.compressed': '压缩传输',
|
||||
'sftp.upload.currentFile': '当前: {fileName}',
|
||||
'sftp.upload.cancelled': '上传已取消',
|
||||
'sftp.upload.cancel': '取消',
|
||||
'sftp.upload.completedToPath': '已上传至 {path}',
|
||||
|
||||
// SFTP Download
|
||||
'sftp.download.completed': '已下载',
|
||||
'sftp.download.cancelled': '下载已取消',
|
||||
|
||||
// SFTP Reconnecting
|
||||
'sftp.reconnecting.title': '正在重连...',
|
||||
'sftp.reconnecting.desc': '连接已断开,正在尝试重新连接',
|
||||
'sftp.reconnected': '连接已恢复',
|
||||
'sftp.error.reconnectFailed': '重连失败,请重试。',
|
||||
'sftp.error.connectionLostManual': '连接已断开,请手动重新连接。',
|
||||
'sftp.error.connectionLostReconnecting': '连接已断开,正在重连...',
|
||||
'sftp.error.sessionLost': 'SFTP 会话已断开,请重新连接。',
|
||||
|
||||
// Settings > SFTP Show Hidden Files
|
||||
'settings.sftp.showHiddenFiles': '显示隐藏文件',
|
||||
'settings.sftp.showHiddenFiles.desc': '在 SFTP 文件浏览器中显示隐藏文件(Unix/macOS 点文件和 Windows 隐藏属性文件)。',
|
||||
'settings.sftp.showHiddenFiles.enable': '显示隐藏文件',
|
||||
'settings.sftp.showHiddenFiles.enableDesc': '浏览本地和远程文件系统时显示隐藏文件',
|
||||
|
||||
// Settings > SFTP Compressed Upload
|
||||
'settings.sftp.compressedUpload': '文件夹压缩传输',
|
||||
'settings.sftp.compressedUpload.desc': '上传前压缩文件夹,可大幅减少传输时间。',
|
||||
'settings.sftp.compressedUpload.enable': '启用文件夹压缩',
|
||||
'settings.sftp.compressedUpload.enableDesc': '自动使用 tar 压缩文件夹后再传输。需要服务器支持 tar 命令,不支持时自动回退到普通传输。',
|
||||
|
||||
// Settings > Terminal
|
||||
'settings.terminal.section.theme': '终端主题',
|
||||
'settings.terminal.themeModal.title': '选择主题',
|
||||
'settings.terminal.themeModal.darkThemes': '深色主题',
|
||||
'settings.terminal.themeModal.lightThemes': '浅色主题',
|
||||
'settings.terminal.theme.selectButton': '选择主题',
|
||||
'settings.terminal.theme.followApp': '跟随应用主题',
|
||||
'settings.terminal.theme.followApp.desc': '终端背景色自动匹配当前应用主题,保持视觉一致性。',
|
||||
'settings.terminal.theme.darkTheme': '深色模式终端主题',
|
||||
'settings.terminal.theme.lightTheme': '浅色模式终端主题',
|
||||
'settings.terminal.theme.auto': '自动(跟随界面主题)',
|
||||
'settings.terminal.theme.autoDesc': '跟随当前界面主题预设',
|
||||
'settings.terminal.section.font': '字体',
|
||||
'settings.terminal.section.cursor': '光标',
|
||||
'settings.terminal.section.keyboard': '键盘',
|
||||
'settings.terminal.section.accessibility': '无障碍',
|
||||
'settings.terminal.section.behavior': '行为',
|
||||
'settings.terminal.section.scrollback': '回滚',
|
||||
'settings.terminal.section.keywordHighlight': '关键字高亮',
|
||||
'settings.terminal.font.family': '字体',
|
||||
'settings.terminal.font.family.desc': '终端字体',
|
||||
'settings.terminal.font.cjk': '中文 / CJK 字体',
|
||||
'settings.terminal.font.cjk.desc': '用于渲染中 / 日 / 韩字符的字体;"Auto" 会按主字体智能搭配',
|
||||
'settings.terminal.font.cjk.option.auto': 'Auto · 按主字体智能搭配',
|
||||
'settings.terminal.font.cjk.option.sarasaSC': 'Sarasa Mono SC (更纱黑体 简)',
|
||||
'settings.terminal.font.cjk.option.sarasaTC': 'Sarasa Mono TC (更纱黑体 繁)',
|
||||
'settings.terminal.font.cjk.option.mapleCN': 'Maple Mono CN',
|
||||
'settings.terminal.font.cjk.option.sourceHan': 'Source Han Mono SC (思源等宽)',
|
||||
'settings.terminal.font.cjk.option.notoCJK': 'Noto Sans Mono CJK SC',
|
||||
'settings.terminal.font.cjk.option.lxgwWenkai': 'LXGW WenKai Mono (霞鹜文楷等宽)',
|
||||
'settings.terminal.font.cjk.option.simSun': 'SimSun (宋体)',
|
||||
'settings.terminal.font.cjk.option.legacy': '{font} · 不推荐(非等宽字体)',
|
||||
'settings.terminal.font.size': '字体大小',
|
||||
'settings.terminal.font.size.desc': '终端文字大小',
|
||||
'settings.terminal.font.weight': '字重',
|
||||
'settings.terminal.font.weight.desc': '常规文本字重 (100-900)',
|
||||
'settings.terminal.font.weightBold': '粗体字重',
|
||||
'settings.terminal.font.weightBold.desc': '粗体文本字重 (100-900)',
|
||||
'settings.terminal.font.linePadding': '行间距',
|
||||
'settings.terminal.font.linePadding.desc': '行之间的额外间距 (0-10)',
|
||||
'settings.terminal.font.emulationType': '终端仿真类型',
|
||||
'settings.terminal.cursor.style': '光标样式',
|
||||
'settings.terminal.cursor.style.block': '块',
|
||||
'settings.terminal.cursor.style.bar': '竖线',
|
||||
'settings.terminal.cursor.style.underline': '下划线',
|
||||
'settings.terminal.cursor.blink': '光标闪烁',
|
||||
'settings.terminal.keyboard.altAsMeta': '将 Option 作为 Meta 键',
|
||||
'settings.terminal.keyboard.altAsMeta.desc': '使用 Option (Alt) 作为 Meta 键,而不是用于输入特殊字符',
|
||||
'settings.terminal.keyboard.optionArrowWordJump': 'Option+←/→ 按单词跳转',
|
||||
'settings.terminal.keyboard.optionArrowWordJump.desc': '按 Option+左/右 时发送 Meta-b / Meta-f,让 Shell 按单词移动光标(而非默认的 ^[[1;3D / ^[[1;3C)',
|
||||
'settings.terminal.accessibility.minimumContrastRatio': '最小对比度',
|
||||
'settings.terminal.accessibility.minimumContrastRatio.desc': '调整颜色以满足对比度要求 (1 = 禁用, 21 = 最大)',
|
||||
'settings.terminal.behavior.rightClick': '右键行为',
|
||||
'settings.terminal.behavior.rightClick.desc': '在终端中右键时执行的操作',
|
||||
'settings.terminal.behavior.rightClick.menu': '显示菜单',
|
||||
'settings.terminal.behavior.rightClick.paste': '粘贴',
|
||||
'settings.terminal.behavior.rightClick.selectWord': '选择单词',
|
||||
'settings.terminal.behavior.copyOnSelect': '选择即复制',
|
||||
'settings.terminal.behavior.copyOnSelect.desc': '自动复制选中的文本。在 tmux/vim 鼠标模式下,macOS 按住 Option,Windows/Linux 按住 Shift 拖选即可选中文本',
|
||||
'settings.terminal.behavior.middleClickPaste': '中键粘贴',
|
||||
'settings.terminal.behavior.middleClickPaste.desc': '中键点击时粘贴剪贴板内容',
|
||||
'settings.terminal.behavior.bracketedPaste': '括号粘贴模式',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'粘贴文本时使用转义序列包裹,以便终端区分粘贴和键入。如果出现 ^[[200~ 字样请关闭此选项。',
|
||||
'settings.terminal.behavior.clearWipesScrollback': '`clear` 同时清空回滚历史',
|
||||
'settings.terminal.behavior.clearWipesScrollback.desc':
|
||||
'`clear` 命令同时清空回滚历史(POSIX 默认行为)。关闭则保留历史。',
|
||||
'settings.terminal.behavior.preserveSelectionOnInput': '输入时保留选区',
|
||||
'settings.terminal.behavior.preserveSelectionOnInput.desc':
|
||||
'键盘输入时不清除鼠标选中的文本,方便选中路径后输入 `sz ` 之类命令再粘贴。',
|
||||
'settings.terminal.behavior.forcePromptNewLine': '提示符另起一行',
|
||||
'settings.terminal.behavior.forcePromptNewLine.desc':
|
||||
'当命令输出的最后一行未以换行符结束时,将识别到的 shell 提示符移动到下一行显示。',
|
||||
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 剪贴板',
|
||||
'settings.terminal.behavior.osc52Clipboard.desc':
|
||||
'允许远程程序(tmux、vim 等)通过 OSC-52 转义序列访问本地剪贴板。',
|
||||
'settings.terminal.behavior.osc52Clipboard.off': '关闭',
|
||||
'settings.terminal.behavior.osc52Clipboard.writeOnly': '仅写入',
|
||||
'settings.terminal.behavior.osc52Clipboard.readWrite': '读写',
|
||||
'settings.terminal.behavior.osc52Clipboard.prompt': '写入 + 读取时询问',
|
||||
'terminal.osc52.readPrompt.title': '剪贴板读取请求',
|
||||
'terminal.osc52.readPrompt.desc': '远程程序正在请求读取您的剪贴板,是否允许?',
|
||||
'terminal.osc52.readPrompt.allow': '允许',
|
||||
'terminal.osc52.readPrompt.deny': '拒绝',
|
||||
'settings.terminal.behavior.scrollOnInput': '输入时自动滚动',
|
||||
'settings.terminal.behavior.scrollOnInput.desc': '输入时将终端滚动到底部',
|
||||
'settings.terminal.behavior.scrollOnOutput': '输出时自动滚动',
|
||||
'settings.terminal.behavior.scrollOnOutput.desc': '有新输出时将终端滚动到底部',
|
||||
'settings.terminal.behavior.scrollOnKeyPress': '按键时自动滚动',
|
||||
'settings.terminal.behavior.scrollOnKeyPress.desc': '按键(例如 Enter)时将终端滚动到底部',
|
||||
'settings.terminal.behavior.scrollOnPaste': '粘贴时自动滚动',
|
||||
'settings.terminal.behavior.scrollOnPaste.desc': '粘贴文本时将终端滚动到底部',
|
||||
'settings.terminal.behavior.smoothScrolling': '平滑滚动',
|
||||
'settings.terminal.behavior.smoothScrolling.desc': '滚动终端视口时使用平滑动画',
|
||||
'settings.terminal.behavior.linkModifier': '链接修饰键',
|
||||
'settings.terminal.behavior.linkModifier.desc': '按住此键再点击终端中的链接',
|
||||
'settings.terminal.behavior.linkModifier.none': '无(直接点击)',
|
||||
'settings.terminal.behavior.linkModifier.ctrl': 'Ctrl',
|
||||
'settings.terminal.behavior.linkModifier.alt': 'Alt / Option',
|
||||
'settings.terminal.behavior.linkModifier.meta': 'Cmd / Win',
|
||||
'settings.terminal.scrollback.desc': '限制终端行数。设为 0 表示不限制。',
|
||||
'settings.terminal.scrollback.rows': '行数 *',
|
||||
'settings.terminal.section.startupCommand': '启动命令',
|
||||
'settings.terminal.startupCommandDelay.label': '启动命令延迟(毫秒)',
|
||||
'settings.terminal.startupCommandDelay.desc': '连接建立后等待多久再发送启动命令;启动命令为多行时,行与行之间也使用该间隔。慢连接可调大。',
|
||||
'settings.terminal.keywordHighlight.title': '关键字高亮',
|
||||
'settings.terminal.keywordHighlight.resetColors': '重置为默认颜色',
|
||||
'settings.terminal.keywordHighlight.resetDefaults': '把内置规则恢复为默认',
|
||||
'settings.terminal.keywordHighlight.resetBuiltIn': '恢复内置标签与正则',
|
||||
'settings.terminal.keywordHighlight.addCustom': '添加自定义规则',
|
||||
'settings.terminal.keywordHighlight.editCustom': '编辑规则',
|
||||
'settings.terminal.keywordHighlight.editBuiltIn': '编辑内置规则',
|
||||
'settings.terminal.keywordHighlight.labelField': '标签与颜色',
|
||||
'settings.terminal.keywordHighlight.labelPlaceholder': '标签(如 Down)',
|
||||
'settings.terminal.keywordHighlight.patternField': '正则表达式',
|
||||
'settings.terminal.keywordHighlight.patternPlaceholder': '每行一个正则(如 \\bdown\\b)',
|
||||
'settings.terminal.keywordHighlight.patternHint': '每行一个正则。匹配忽略大小写,全局匹配。',
|
||||
'settings.terminal.keywordHighlight.invalidPattern': '无效的正则表达式',
|
||||
'settings.terminal.keywordHighlight.preview': '预览',
|
||||
'settings.terminal.section.localShell': '本地 Shell',
|
||||
'settings.terminal.localShell.shell': 'Shell 可执行文件',
|
||||
'settings.terminal.localShell.shell.desc': 'Shell 可执行文件的路径(例如 /bin/zsh、pwsh.exe)。留空使用系统默认。',
|
||||
'settings.terminal.localShell.shell.placeholder': '系统默认',
|
||||
'settings.terminal.localShell.shell.detected': '检测到',
|
||||
'settings.terminal.localShell.shell.notFound': '未找到 Shell 可执行文件',
|
||||
'settings.terminal.localShell.shell.isDirectory': '路径是目录,不是可执行文件',
|
||||
'settings.terminal.localShell.shell.default': '系统默认',
|
||||
'settings.terminal.localShell.shell.custom': '自定义...',
|
||||
'settings.terminal.localShell.shell.customPath': 'Shell 可执行文件路径',
|
||||
'settings.terminal.localShell.shell.commonPaths': '常用路径',
|
||||
'settings.terminal.localShell.shell.pathValid': '路径有效',
|
||||
'settings.terminal.localShell.startDir': '起始目录',
|
||||
'settings.terminal.localShell.startDir.desc': '打开本地终端时的起始目录。留空使用用户主目录。',
|
||||
'settings.terminal.localShell.startDir.placeholder': '用户主目录',
|
||||
'settings.terminal.localShell.startDir.notFound': '目录不存在',
|
||||
'settings.terminal.localShell.startDir.isFile': '路径是文件,不是目录',
|
||||
'settings.terminal.section.connection': '连接',
|
||||
'settings.terminal.connection.keepaliveInterval': '会话保持间隔',
|
||||
'settings.terminal.connection.keepaliveInterval.desc': '向服务器发送 SSH 保活数据包的频率(秒)。设为 0 表示全局禁用——单个主机可在自己的设置里覆盖此值。',
|
||||
'settings.terminal.connection.keepaliveCountMax': '最大无响应保活次数',
|
||||
'settings.terminal.connection.keepaliveCountMax.desc': '判定连接死亡前允许的无响应保活次数。值越大对短暂网络抖动和响应慢的 SSH 服务越宽容。',
|
||||
'settings.terminal.connection.x11Display': 'X11 显示地址',
|
||||
'settings.terminal.connection.x11Display.desc': '可选的本机 X11 显示地址。留空则使用系统默认值。',
|
||||
'settings.terminal.connection.x11Display.placeholder': '自动(:0 或 DISPLAY)',
|
||||
'settings.terminal.section.serverStats': '服务器状态(Linux)',
|
||||
'settings.terminal.serverStats.show': '显示服务器状态',
|
||||
'settings.terminal.serverStats.show.desc': '在终端状态栏显示 CPU、内存和磁盘使用情况(仅限 Linux 服务器)。',
|
||||
'settings.terminal.serverStats.refreshInterval': '刷新间隔',
|
||||
'settings.terminal.serverStats.refreshInterval.desc': '服务器状态刷新的频率。',
|
||||
'settings.terminal.serverStats.seconds': '秒',
|
||||
|
||||
// Settings > Terminal > Rendering
|
||||
'settings.terminal.section.rendering': '渲染',
|
||||
'settings.terminal.rendering.renderer': '渲染器',
|
||||
'settings.terminal.rendering.renderer.desc': '选择终端渲染技术。自动模式会在低内存设备上使用 DOM 渲染。更改将在新终端会话中生效。',
|
||||
'settings.terminal.rendering.auto': '自动',
|
||||
|
||||
// Settings > Terminal > Autocomplete
|
||||
'settings.terminal.section.autocomplete': '自动补全',
|
||||
'settings.terminal.autocomplete.enabled': '启用自动补全',
|
||||
'settings.terminal.autocomplete.enabled.desc': '输入时根据历史命令和命令规范显示补全建议。',
|
||||
'settings.terminal.autocomplete.ghostText': '行内建议',
|
||||
'settings.terminal.autocomplete.ghostText.desc': '在光标后显示灰色的建议文本(类似 fish shell)。',
|
||||
'settings.terminal.autocomplete.popupMenu': '弹出菜单',
|
||||
'settings.terminal.autocomplete.popupMenu.desc': '显示包含多个建议的浮动列表。',
|
||||
|
||||
// Settings > Shortcuts
|
||||
'settings.shortcuts.section.scheme': '快捷键方案',
|
||||
'settings.shortcuts.scheme.label': '键盘快捷键',
|
||||
'settings.shortcuts.scheme.desc': '选择快捷键使用的键盘布局',
|
||||
'settings.shortcuts.scheme.disabled': '禁用',
|
||||
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
|
||||
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
|
||||
'settings.shortcuts.section.custom': '自定义快捷键',
|
||||
'settings.shortcuts.resetAll': '全部重置',
|
||||
'settings.shortcuts.recording': '请按键...',
|
||||
'settings.shortcuts.none': '无',
|
||||
'settings.shortcuts.setDisabled': '设为禁用',
|
||||
'settings.shortcuts.category.tabs': '标签页',
|
||||
'settings.shortcuts.category.terminal': '终端',
|
||||
'settings.shortcuts.category.navigation': '导航',
|
||||
'settings.shortcuts.category.app': '应用',
|
||||
'settings.shortcuts.category.sftp': 'SFTP',
|
||||
'settings.shortcuts.binding.switch-tab-1-9': '切换到标签页 [1...9]',
|
||||
'settings.shortcuts.binding.next-tab': '下一个标签页',
|
||||
'settings.shortcuts.binding.prev-tab': '上一个标签页',
|
||||
'settings.shortcuts.binding.close-tab': '关闭标签页',
|
||||
'settings.shortcuts.binding.new-tab': '新建本地标签页',
|
||||
'settings.shortcuts.binding.copy': '从终端复制',
|
||||
'settings.shortcuts.binding.paste': '粘贴到终端',
|
||||
'settings.shortcuts.binding.select-all': '全选终端内容',
|
||||
'settings.shortcuts.binding.clear-buffer': '清空终端缓冲区',
|
||||
'settings.shortcuts.binding.search-terminal': '打开终端搜索',
|
||||
'settings.shortcuts.binding.move-focus': '在分屏间移动焦点',
|
||||
'settings.shortcuts.binding.split-horizontal': '水平分屏',
|
||||
'settings.shortcuts.binding.split-vertical': '垂直分屏',
|
||||
'settings.shortcuts.binding.open-hosts': '打开主机列表',
|
||||
'settings.shortcuts.binding.open-local': '打开本地终端',
|
||||
'settings.shortcuts.binding.open-sftp': '打开 SFTP',
|
||||
'settings.shortcuts.binding.port-forwarding': '打开端口转发',
|
||||
'settings.shortcuts.binding.command-palette': '打开命令面板',
|
||||
'settings.shortcuts.binding.quick-switch': '快速切换',
|
||||
'settings.shortcuts.binding.new-workspace': '新建工作区',
|
||||
'settings.shortcuts.binding.snippets': '打开代码片段',
|
||||
'settings.shortcuts.binding.broadcast': '切换广播模式',
|
||||
'settings.shortcuts.binding.toggle-side-panel': '切换侧边栏',
|
||||
'settings.shortcuts.binding.sftp-copy': '复制文件',
|
||||
'settings.shortcuts.binding.sftp-cut': '剪切文件',
|
||||
'settings.shortcuts.binding.sftp-paste': '粘贴文件',
|
||||
'settings.shortcuts.binding.sftp-select-all': '全选文件',
|
||||
'settings.shortcuts.binding.sftp-rename': '重命名文件',
|
||||
'settings.shortcuts.binding.sftp-delete': '删除文件',
|
||||
'settings.shortcuts.binding.sftp-refresh': '刷新',
|
||||
'settings.shortcuts.binding.sftp-new-folder': '新建文件夹',
|
||||
|
||||
// Host Details (sub-panels)
|
||||
'hostDetails.proxyPanel.title': '通过 HTTP/SOCKS5 代理',
|
||||
'hostDetails.proxyPanel.hostPlaceholder': '代理主机',
|
||||
'hostDetails.proxyPanel.credentials': '凭据',
|
||||
'hostDetails.proxyPanel.usernamePlaceholder': '用户名',
|
||||
'hostDetails.proxyPanel.passwordPlaceholder': '密码',
|
||||
'hostDetails.proxyPanel.identities': '身份',
|
||||
'hostDetails.proxyPanel.remove': '移除代理',
|
||||
'hostDetails.proxyPanel.savedProxy': '已保存代理',
|
||||
'hostDetails.proxyPanel.selectSaved': '选择已保存代理',
|
||||
'hostDetails.proxyPanel.customProxy': '自定义代理',
|
||||
'hostDetails.proxyPanel.missing': '缺失',
|
||||
'hostDetails.proxyPanel.missingSaved': '保存的代理不存在',
|
||||
'hostDetails.proxyPanel.error.required': '代理主机和端口不能为空。',
|
||||
'hostDetails.envVars.title': '环境变量',
|
||||
'hostDetails.envVars.desc': '为 {host} 设置环境变量。',
|
||||
'hostDetails.envVars.note': '部分 SSH 服务器默认只允许以 LC_ 和 LANG_ 为前缀的变量。',
|
||||
'hostDetails.envVars.variable': '变量',
|
||||
'hostDetails.envVars.value': '值',
|
||||
'hostDetails.envVars.newVariable': '新变量',
|
||||
'hostDetails.envVars.variableName': '变量名',
|
||||
'hostDetails.chain.title': '编辑链路',
|
||||
'hostDetails.chain.desc': '添加另一台主机将创建到 {host} 的连接。',
|
||||
'hostDetails.chain.addHost': '添加主机',
|
||||
'hostDetails.chain.target': '目标',
|
||||
'hostDetails.chain.availableHosts': '可用主机',
|
||||
'hostDetails.chain.clear': '清空',
|
||||
'hostDetails.group.title': '新建分组',
|
||||
'hostDetails.group.general': '常规',
|
||||
'hostDetails.group.namePlaceholder': '分组名称',
|
||||
'hostDetails.group.parentPlaceholder': '父分组',
|
||||
'hostDetails.group.cloudSync': '云同步',
|
||||
'hostDetails.group.addProtocol': '添加协议',
|
||||
|
||||
// Keychain
|
||||
'keychain.filter.key': '密钥',
|
||||
'keychain.filter.certificate': '证书',
|
||||
'keychain.action.generateKey': '生成密钥',
|
||||
'keychain.action.importKey': '导入密钥',
|
||||
'keychain.action.newIdentity': '新建身份',
|
||||
'keychain.action.importCertificate': '导入证书',
|
||||
'keychain.view.grid': '网格',
|
||||
'keychain.view.list': '列表',
|
||||
'keychain.section.keys': '密钥',
|
||||
'keychain.section.identities': '身份',
|
||||
'keychain.count.items': '{count} 项',
|
||||
'keychain.empty.title': '设置密钥',
|
||||
'keychain.empty.desc': '导入或生成 SSH 密钥用于安全认证。',
|
||||
'keychain.panel.generateKey': '生成密钥',
|
||||
'keychain.panel.newKey': '新建密钥',
|
||||
'keychain.panel.keyDetails': '密钥详情',
|
||||
'keychain.panel.editKey': '编辑密钥',
|
||||
'keychain.panel.editIdentity': '编辑身份',
|
||||
'keychain.panel.newIdentity': '新建身份',
|
||||
'keychain.panel.keyExport': '密钥导出',
|
||||
'keychain.validation.labelRequired': '请填写密钥的 Label',
|
||||
'keychain.validation.labelAndPrivateKeyRequired': 'Label 和私钥为必填项',
|
||||
'keychain.validation.labelAndUsernameRequired': 'Label 和用户名为必填项',
|
||||
'keychain.error.generationUnavailable': '无法生成密钥:请确保应用运行在 Electron 环境',
|
||||
'keychain.error.generateKeyPairFailed': '生成密钥对失败',
|
||||
'keychain.error.generateKeyFailed': '生成密钥失败',
|
||||
'keychain.error.keyGenerationTitle': '密钥生成',
|
||||
'keychain.export.exportTo': '导出到 *',
|
||||
'keychain.export.selectHost': '选择主机',
|
||||
'keychain.export.location': '位置 ~ $1 *',
|
||||
'keychain.export.filename': '文件名 ~ $2 *',
|
||||
'keychain.export.note': '密钥导出目前仅支持 {unix} 系统。请在 {advanced} 部分自定义导出脚本。',
|
||||
'keychain.export.script': '脚本 *',
|
||||
'keychain.export.scriptPlaceholder': '导出脚本...',
|
||||
'keychain.export.missingCredentials': '主机未保存密码或密钥。请先为该主机添加密码凭据。',
|
||||
'keychain.export.successTitle': '导出成功',
|
||||
'keychain.export.successMessage': '已导出公钥并绑定到 {host}',
|
||||
'keychain.export.failedTitle': '导出失败',
|
||||
'keychain.export.failedMessage': '导出密钥失败:{error}',
|
||||
'keychain.export.failedPrefix': '导出失败:{error}',
|
||||
'keychain.export.exitCode': '命令退出码 {code}',
|
||||
'keychain.export.exporting': '导出中...',
|
||||
'keychain.export.exportAndAttach': '导出并绑定',
|
||||
'keychain.export.title': '密钥导出',
|
||||
'keychain.export.exportToRequired': '导出到 *',
|
||||
'keychain.export.selectHostPlaceholder': '选择主机...',
|
||||
'keychain.export.locationLabel': '位置 ~ $1 *',
|
||||
'keychain.export.filenameLabel': '文件名 ~ $2 *',
|
||||
'keychain.export.advanced': '高级',
|
||||
'keychain.export.note.supportsOnly': '密钥导出目前仅支持',
|
||||
'keychain.export.note.systems': '系统。',
|
||||
'keychain.export.note.use': '请使用',
|
||||
'keychain.export.note.customize': '部分自定义导出脚本。',
|
||||
'keychain.export.scriptRequired': '脚本 *',
|
||||
'keychain.export.exportToHost': '导出到主机',
|
||||
'keychain.export.failedGeneric': '导出失败:{message}',
|
||||
'keychain.field.label': 'Label',
|
||||
'keychain.field.labelRequired': 'Label *',
|
||||
'keychain.field.labelPlaceholder': '密钥 Label',
|
||||
'keychain.field.privateKeyRequired': '私钥 *',
|
||||
'keychain.field.publicKey': '公钥',
|
||||
'keychain.field.certificatePlaceholder': '证书内容(可选)',
|
||||
'keychain.generate.keyType': '密钥类型',
|
||||
'keychain.generate.keySize': '密钥长度',
|
||||
'keychain.generate.labelPlaceholder': '密钥 Label',
|
||||
'keychain.generate.passphrasePlaceholder': 'Passphrase(可选)',
|
||||
'keychain.generate.savePassphrase': '保存 Passphrase',
|
||||
'keychain.generate.generate': '生成',
|
||||
'keychain.generate.generateSave': '生成并保存',
|
||||
'keychain.import.dropHint': '将密钥文件拖到这里',
|
||||
'keychain.import.importFromFile': '从文件导入',
|
||||
'keychain.import.saveKey': '保存密钥',
|
||||
'keychain.import.importedKeyLabel': '已导入密钥',
|
||||
'keychain.identity.usernameRequired': '用户名 *',
|
||||
'keychain.identity.method.passwordOnly': '密码',
|
||||
'keychain.identity.summary.password': '认证密码',
|
||||
'keychain.identity.summary.key': '认证密钥',
|
||||
'keychain.identity.summary.certificate': '认证证书',
|
||||
'keychain.identity.summary.passwordAndKey': '认证密码与密钥',
|
||||
'keychain.identity.summary.passwordAndCertificate': '认证密码与证书',
|
||||
'keychain.identity.summary.none': '无凭据',
|
||||
'keychain.identity.selectCredential': '选择{kind}',
|
||||
'keychain.identity.save': '保存',
|
||||
'keychain.identity.update': '更新',
|
||||
'keychain.keyDialog.newTitle': '新建密钥',
|
||||
'keychain.keyDialog.newDesc': '添加新的 SSH 密钥',
|
||||
'keychain.keyDialog.editTitle': '编辑密钥',
|
||||
'keychain.keyDialog.editDesc': '更新此 SSH 密钥',
|
||||
'keychain.keyDialog.updateKey': '更新密钥',
|
||||
|
||||
// Tabs
|
||||
'tabs.closeSessionAria': '关闭会话',
|
||||
'tabs.closeLogViewAria': '关闭日志视图',
|
||||
'tabs.logPrefix': '日志:',
|
||||
'tabs.logLocal': '本地',
|
||||
'tabs.copyTab': '复制标签页',
|
||||
'tabs.closeOthers': '关闭其他标签',
|
||||
'tabs.closeToRight': '关闭右侧标签',
|
||||
'tabs.closeAll': '关闭所有标签',
|
||||
'keychain.edit.labelRequired': 'Label *',
|
||||
'keychain.edit.keyLabelPlaceholder': '密钥 Label',
|
||||
'keychain.edit.privateKeyRequired': '私钥 *',
|
||||
'keychain.edit.publicKey': '公钥',
|
||||
'keychain.edit.certificate': '证书',
|
||||
'keychain.edit.certificatePlaceholder': '证书内容(可选)',
|
||||
'keychain.edit.filePath': '文件路径',
|
||||
'keychain.edit.keyExport': '密钥导出',
|
||||
'keychain.edit.exportToHost': '导出到主机',
|
||||
|
||||
// Snippets
|
||||
'snippets.searchPlaceholder': '搜索代码片段...',
|
||||
'snippets.action.newSnippet': '新建代码片段',
|
||||
'snippets.action.newPackage': '新建代码包',
|
||||
'snippets.panel.newTitle': '新建代码片段',
|
||||
'snippets.panel.editTitle': '编辑代码片段',
|
||||
'snippets.field.description': '描述',
|
||||
'snippets.field.descriptionPlaceholder': '例如:check network load',
|
||||
'snippets.field.package': '添加代码包',
|
||||
'snippets.field.packagePlaceholder': '选择或创建代码包',
|
||||
'snippets.field.createPackage': '创建代码包',
|
||||
'snippets.field.scriptRequired': '脚本 *',
|
||||
'snippets.scriptEditor.expand': '弹窗编辑',
|
||||
'snippets.scriptEditor.resize': '调整编辑器高度',
|
||||
'snippets.scriptEditor.modalTitle': '编辑脚本',
|
||||
'snippets.targets.title': '目标主机',
|
||||
'snippets.targets.add': '添加目标主机',
|
||||
'snippets.history.title': 'Shell 历史',
|
||||
'snippets.history.subtitle': '{count} 条命令',
|
||||
'snippets.history.emptyTitle': '暂无 Shell 历史',
|
||||
'snippets.history.emptyDesc': '你执行过的命令会显示在这里',
|
||||
'snippets.history.loadMore': '加载更多',
|
||||
'snippets.history.separator': '•',
|
||||
'snippets.history.labelPlaceholder': '为此代码片段设置一个 Label',
|
||||
'snippets.history.saveAsSnippet': '保存为代码片段',
|
||||
'snippets.history.time.justNow': '刚刚',
|
||||
'snippets.history.time.minutesAgo': '{count} 分钟前',
|
||||
'snippets.history.time.hoursAgo': '{count} 小时前',
|
||||
'snippets.history.time.daysAgo': '{count} 天前',
|
||||
'snippets.breadcrumb.allPackages': '全部代码包',
|
||||
'snippets.breadcrumb.separator': '›',
|
||||
'snippets.empty.title': '创建代码片段',
|
||||
'snippets.empty.desc': '将常用命令保存为代码片段,一键复用。',
|
||||
'snippets.search.noResults.title': '无匹配结果',
|
||||
'snippets.search.noResults.desc': '没有代码片段或代码包与"{query}"匹配。换一个关键字,或清除搜索进行浏览。',
|
||||
'snippets.section.packages': '代码包',
|
||||
'snippets.section.snippets': '代码片段',
|
||||
'snippets.package.count': '{count} 个代码片段',
|
||||
'snippets.commandFallback': '命令',
|
||||
'snippets.view.grid': '网格',
|
||||
'snippets.view.list': '列表',
|
||||
'snippets.packageDialog.title': '新建代码包',
|
||||
'snippets.packageDialog.parent': '父级:{parent}',
|
||||
'snippets.packageDialog.root': '根目录',
|
||||
'snippets.packageDialog.placeholder': '例如:ops/maintenance',
|
||||
'snippets.packageDialog.hint': '使用 "/" 创建嵌套代码包。',
|
||||
|
||||
// Snippets Rename Dialog
|
||||
'snippets.renameDialog.title': '重命名代码包',
|
||||
'snippets.renameDialog.currentPath': '当前路径:{path}',
|
||||
'snippets.renameDialog.placeholder': '输入新名称',
|
||||
'snippets.renameDialog.error.empty': '代码包名称不能为空',
|
||||
'snippets.renameDialog.error.duplicate': '已存在同名的代码包',
|
||||
'snippets.renameDialog.error.invalidChars': '代码包名称只能包含字母、数字、连字符和下划线',
|
||||
|
||||
'snippets.field.noAutoRun': '仅粘贴(不自动执行)',
|
||||
// Snippet Shortkey
|
||||
'snippets.field.shortkey': '快捷键',
|
||||
'snippets.shortkey.placeholder': '点击设置快捷键',
|
||||
'snippets.shortkey.recording': '请按下快捷键组合...',
|
||||
'snippets.shortkey.hint': '在终端中按下此快捷键可快速发送命令。',
|
||||
'snippets.shortkey.clear': '清除快捷键',
|
||||
'snippets.shortkey.error.systemConflict': '此快捷键与系统快捷键冲突',
|
||||
'snippets.shortkey.error.snippetConflict': '此快捷键已被代码片段使用:{name}',
|
||||
|
||||
'snippets.variables.dialogTitle': '填写变量',
|
||||
'snippets.variables.dialogDesc': '运行「{label}」前请填写以下变量。',
|
||||
'snippets.variables.hint': '变量值将原样插入脚本(不会进行 shell 转义)。',
|
||||
'snippets.variables.preview': '预览',
|
||||
'snippets.variables.placeholder': '请输入',
|
||||
'snippets.variables.placeholderDefault': '默认:{value}',
|
||||
'snippets.variables.required': '请填写此变量',
|
||||
'snippets.variables.run': '运行',
|
||||
'snippets.field.variablesHelp': '在脚本中使用 {{名称}} 或 {{名称:默认值}} 定义变量。',
|
||||
'snippets.field.variablesDetected': '变量',
|
||||
'snippets.field.variableDefault': '默认 {value}',
|
||||
|
||||
// Serial Port
|
||||
'serial.button': '串口',
|
||||
'serial.modal.title': '连接串口',
|
||||
'serial.modal.desc': '配置串口连接参数',
|
||||
'serial.field.port': '串口',
|
||||
'serial.field.selectPort': '选择串口...',
|
||||
'serial.field.baudRate': '波特率',
|
||||
'serial.field.dataBits': '数据位',
|
||||
'serial.field.stopBits': '停止位',
|
||||
'serial.field.stopBits15Warning': '1.5 停止位在 Windows 下可能不被所有设备支持',
|
||||
'serial.field.parity': '校验位',
|
||||
'serial.field.flowControl': '流控制',
|
||||
'serial.noPorts': '未检测到串口设备。请连接设备后刷新。',
|
||||
'serial.field.customPort': '自定义串口路径',
|
||||
'serial.field.customPortPlaceholder': '例如 /dev/ttys001 或 COM1',
|
||||
'serial.type.hardware': '硬件',
|
||||
'serial.type.pseudo': '虚拟终端',
|
||||
'serial.type.custom': '自定义',
|
||||
'serial.parity.none': '无',
|
||||
'serial.parity.even': '偶校验',
|
||||
'serial.parity.odd': '奇校验',
|
||||
'serial.parity.mark': 'Mark',
|
||||
'serial.parity.space': 'Space',
|
||||
'serial.flowControl.none': '无',
|
||||
'serial.flowControl.xon/xoff': 'XON/XOFF (软件)',
|
||||
'serial.flowControl.rts/cts': 'RTS/CTS (硬件)',
|
||||
'serial.field.localEcho': '强制本地回显',
|
||||
'serial.field.localEchoDesc': '本地回显输入字符(用于没有远程回显的设备)',
|
||||
'serial.field.lineMode': '行模式',
|
||||
'serial.field.lineModeDesc': '缓冲输入,按回车后发送(而不是逐字符发送)',
|
||||
'serial.field.charset': '字符编码',
|
||||
'serial.connectionError': '连接串口失败',
|
||||
'serial.field.baudRatePlaceholder': '选择或输入波特率...',
|
||||
'serial.field.baudRateEmpty': '输入自定义波特率',
|
||||
'serial.field.customBaudRate': '使用自定义波特率',
|
||||
'serial.field.saveConfig': '保存配置',
|
||||
'serial.field.saveConfigDesc': '将此串口配置保存到主机列表以便快速访问',
|
||||
'serial.field.configLabel': '配置名称',
|
||||
'serial.field.configLabelPlaceholder': '例如 Arduino Uno',
|
||||
'serial.connectAndSave': '连接并保存',
|
||||
'serial.edit.title': '串口设置',
|
||||
|
||||
// Keyboard Interactive Authentication (2FA/MFA)
|
||||
'keyboard.interactive.title': '需要验证',
|
||||
'keyboard.interactive.desc': '服务器需要额外的身份验证。',
|
||||
'keyboard.interactive.descWithHost': '服务器 {hostname} 需要额外的身份验证。',
|
||||
'keyboard.interactive.response': '响应',
|
||||
'keyboard.interactive.enterCode': '输入验证码',
|
||||
'keyboard.interactive.enterResponse': '输入响应',
|
||||
'keyboard.interactive.submit': '提交',
|
||||
'keyboard.interactive.verifying': '验证中...',
|
||||
'keyboard.interactive.savePassword': '保存密码',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'SSH 密钥密码',
|
||||
'passphrase.desc': '请输入 {keyName} 的密码',
|
||||
'passphrase.descWithHost': '请输入 {keyName} 的密码以连接到 {hostname}',
|
||||
'passphrase.label': '密码',
|
||||
'passphrase.keyPath': '密钥',
|
||||
'passphrase.unlock': '解锁',
|
||||
'passphrase.unlocking': '解锁中...',
|
||||
'passphrase.skip': '跳过',
|
||||
'passphrase.remember': '记住此密码',
|
||||
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': '自动换行',
|
||||
'sftp.editor.maximize': '最大化',
|
||||
'sftp.editor.unsavedTitle': '未保存的修改',
|
||||
'sftp.editor.unsavedMessage': '{fileName} 有未保存的修改,是否保存后关闭?',
|
||||
'sftp.editor.discardChanges': '不保存',
|
||||
'sftp.editor.saveAndClose': '保存并关闭',
|
||||
'sftp.editor.quitBlockedByDirty': '存在未保存的编辑器,请先处理后再退出',
|
||||
|
||||
};
|
||||
673
application/i18n/locales/zh-CN/vault.ts
Normal file
673
application/i18n/locales/zh-CN/vault.ts
Normal file
@@ -0,0 +1,673 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCNVaultMessages: Messages = {
|
||||
// Select Host panel
|
||||
'selectHost.title': '选择主机',
|
||||
'selectHost.noHostsFound': '未找到主机',
|
||||
'selectHost.newHost': '新建主机',
|
||||
'selectHost.continue': '继续',
|
||||
'selectHost.continueWithCount': '继续(已选 {count} 个)',
|
||||
|
||||
// Quick Connect
|
||||
'quickConnect.knownHost.title': '确认要连接吗?',
|
||||
'quickConnect.knownHost.authenticity': '无法验证 {hostname} 的真实性。',
|
||||
'quickConnect.knownHost.fingerprintLabel': '{keyType} fingerprint (SHA256):',
|
||||
'quickConnect.knownHost.addQuestion': '是否将它加入 Known Hosts?',
|
||||
'quickConnect.knownHost.addAndContinue': '加入并继续',
|
||||
'quickConnect.addKey': '添加 key',
|
||||
'quickConnect.warning.unparsedOptions': '部分 SSH 参数已被忽略: {options}',
|
||||
|
||||
// Protocol select dialog
|
||||
'protocolSelect.chooseProtocol': '选择协议',
|
||||
'protocolSelect.port': '端口:',
|
||||
// Host Details
|
||||
'hostDetails.title.details': '主机详情',
|
||||
'hostDetails.title.new': '新建主机',
|
||||
'hostDetails.saveAria': '保存',
|
||||
'hostDetails.section.address': '地址',
|
||||
'hostDetails.hostname.placeholder': 'IP 或 主机名',
|
||||
'hostDetails.section.general': '通用',
|
||||
'hostDetails.section.sftp': 'SFTP 设置',
|
||||
'hostDetails.sftp.sudo': 'Sudo 提权模式',
|
||||
'hostDetails.sftp.sudo.desc': '使用保存的密码自动获取 Root 权限',
|
||||
'hostDetails.sftp.sudo.passwordWarning': 'Sudo 模式需要密码。请在上方配置密码,或确保服务器允许免密 sudo。',
|
||||
'hostDetails.sftp.encoding': '文件名编码',
|
||||
'hostDetails.sftp.encoding.desc': '选择用于解码和发送 SFTP 文件名的编码。',
|
||||
'hostDetails.label.placeholder': '名称(例如:Production Server)',
|
||||
'hostDetails.notes.label': '备注',
|
||||
'hostDetails.notes.placeholder': '硬件配置、项目、客户、地域、角色...',
|
||||
'hostDetails.notes.help': '支持 Markdown。请勿在此存放密码或私钥。',
|
||||
'hostDetails.notes.tab.edit': '编辑',
|
||||
'hostDetails.notes.tab.preview': '预览',
|
||||
'hostDetails.notes.preview.empty': '暂无内容可预览。',
|
||||
'hostDetails.group.placeholder': '父级 Group',
|
||||
'hostDetails.section.credentials': '凭据',
|
||||
'hostDetails.section.portCredentials': '端口与凭据',
|
||||
'hostDetails.section.appearance': '外观',
|
||||
'hostDetails.distro.title': 'Linux 发行版',
|
||||
'hostDetails.distro.desc': '可在连接后自动探测,也可以手动覆盖图标所用的发行版。',
|
||||
'hostDetails.distro.mode': '来源',
|
||||
'hostDetails.distro.mode.auto': '自动探测',
|
||||
'hostDetails.distro.mode.manual': '手动覆盖',
|
||||
'hostDetails.distro.detectedLabel': '当前值',
|
||||
'hostDetails.distro.manualLabel': '手动指定',
|
||||
'hostDetails.distro.pending': '首次连接后自动探测',
|
||||
'hostDetails.distro.unknown': '未知',
|
||||
'hostDetails.distro.option.linux': '通用 Linux',
|
||||
'hostDetails.distro.option.ubuntu': 'Ubuntu',
|
||||
'hostDetails.distro.option.debian': 'Debian',
|
||||
'hostDetails.distro.option.centos': 'CentOS',
|
||||
'hostDetails.distro.option.rocky': 'Rocky Linux',
|
||||
'hostDetails.distro.option.fedora': 'Fedora',
|
||||
'hostDetails.distro.option.arch': 'Arch Linux',
|
||||
'hostDetails.distro.option.alpine': 'Alpine',
|
||||
'hostDetails.distro.option.amazon': 'Amazon Linux',
|
||||
'hostDetails.distro.option.opensuse': 'openSUSE / SLES',
|
||||
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
|
||||
'hostDetails.distro.option.almalinux': 'AlmaLinux',
|
||||
'hostDetails.distro.option.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.mikrotik': 'MikroTik',
|
||||
'hostDetails.distro.option.fortinet': '飞塔',
|
||||
'hostDetails.distro.option.paloalto': 'Palo Alto Networks',
|
||||
'hostDetails.distro.option.zyxel': '合勤',
|
||||
'hostDetails.distro.option.ruijie': '锐捷',
|
||||
'hostDetails.section.mosh': 'Mosh',
|
||||
'hostDetails.username.placeholder': '用户名',
|
||||
'hostDetails.password.placeholder': '密码',
|
||||
'hostDetails.password.show': '显示密码',
|
||||
'hostDetails.password.hide': '隐藏密码',
|
||||
'hostDetails.password.save': '保存密码',
|
||||
'hostDetails.identity.suggestions': '身份',
|
||||
'hostDetails.identity.missing': '身份不存在',
|
||||
'hostDetails.credential.keyCertificate': '密钥 / 证书 / 本地密钥',
|
||||
'hostDetails.credential.key': '密钥',
|
||||
'hostDetails.credential.certificate': '证书',
|
||||
'hostDetails.credential.localKeyFile': '本地密钥文件',
|
||||
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
|
||||
'hostDetails.credential.browseKeyFile': '浏览…',
|
||||
'hostDetails.credential.missing': '凭据不存在',
|
||||
'hostDetails.keys.search': '搜索密钥…',
|
||||
'hostDetails.keys.empty': '暂无密钥',
|
||||
'hostDetails.certs.search': '搜索证书…',
|
||||
'hostDetails.certs.empty': '暂无证书',
|
||||
'hostDetails.agentForwarding': '转发 SSH 密钥',
|
||||
'hostDetails.agentForwarding.desc': '允许远程服务器使用本地 SSH 密钥(例如用于 git 操作)',
|
||||
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent 不可用',
|
||||
'hostDetails.agentForwarding.agentNotRunningHint': '未检测到 SSH Agent。请启用 Windows OpenSSH Authentication Agent 服务,或使用兼容的 Agent(如 Bitwarden、1Password、gpg-agent)。',
|
||||
'hostDetails.section.agentForwarding': 'SSH 代理',
|
||||
'hostDetails.x11Forwarding': '转发 X11 图形应用',
|
||||
'hostDetails.x11Forwarding.desc': '本机运行 X 服务时,让远程图形程序显示在本地桌面。',
|
||||
'hostDetails.section.x11Forwarding': 'X11 转发',
|
||||
'hostDetails.section.deviceType': '设备类型',
|
||||
'hostDetails.deviceType': '网络设备模式',
|
||||
'hostDetails.deviceType.desc': '适用于通过 SSH 连接的网络设备(交换机、路由器、防火墙)。命令将原样发送,不进行 Shell 包装,兼容华为 VRP、Cisco IOS 等厂商 CLI。',
|
||||
'hostDetails.deviceType.warning': 'AI 代理命令将直接发送,无法获取退出码。仅建议在设备不运行标准 Shell 时启用。',
|
||||
'hostDetails.section.sshAlgorithms': 'SSH 算法',
|
||||
'hostDetails.section.terminalBehavior': '终端行为',
|
||||
'hostDetails.legacyAlgorithms': '允许旧版算法',
|
||||
'hostDetails.legacyAlgorithms.desc': '启用已弃用的 SSH 算法(diffie-hellman-group1、ssh-dss、3des-cbc 等)以连接老旧网络设备。',
|
||||
'hostDetails.legacyAlgorithms.warning': '这些算法存在已知安全漏洞,仅建议在老旧设备不支持现代加密时启用。',
|
||||
'hostDetails.skipEcdsaHostKey': '跳过 ECDSA 主机密钥',
|
||||
'hostDetails.skipEcdsaHostKey.desc': '某些老款华为 / 思科交换机的 ECDSA 主机密钥签名不规范,会导致连接报 "signature verification failed"。开启后客户端不再 advertise ecdsa-sha2-*,强制使用 RSA / Ed25519。',
|
||||
'hostDetails.algorithms.advanced': '高级算法配置',
|
||||
'hostDetails.algorithms.advanced.desc': '针对单个 host 自定义各分类的算法清单。不勾选 = 使用默认;勾选子集后将完全替换默认列表。配置错误可能导致无法连接。',
|
||||
'hostDetails.algorithms.inheritedNotice': '当前组已设置以下分类的算法 override:{categories}。本面板的"恢复默认"只会回到组的设置,而不是 NetCatty 默认列表。若要忽略组的限制,请到组的算法设置里取消。',
|
||||
'hostDetails.algorithms.customized': '已自定义',
|
||||
'hostDetails.algorithms.reset': '恢复默认',
|
||||
'hostDetails.algorithms.category.kex': '密钥交换 (KEX)',
|
||||
'hostDetails.algorithms.category.cipher': '加密算法 (Cipher)',
|
||||
'hostDetails.algorithms.category.hmac': '完整性算法 (HMAC)',
|
||||
'hostDetails.algorithms.category.serverHostKey': '主机密钥 (Host Key)',
|
||||
'hostDetails.algorithms.category.compress': '压缩 (Compression)',
|
||||
'hostDetails.section.keepalive': '会话保活',
|
||||
'hostDetails.keepalive.override': '为此主机单独配置',
|
||||
'hostDetails.keepalive.desc': '为该主机使用专属的保活策略,而不是跟随全局设置。适用于不响应 keepalive@openssh.com 请求的老旧路由器 / 交换机——将间隔设为 0 可对该主机彻底关闭保活。',
|
||||
'hostDetails.keepalive.interval': '间隔(秒)',
|
||||
'hostDetails.keepalive.countMax': '最大无响应保活次数',
|
||||
'hostDetails.keepalive.disabledHint': '间隔为 0 时该主机不发送保活包,仅依赖 TCP 层超时检测断连。',
|
||||
'hostDetails.backspaceBehavior': 'Backspace 行为',
|
||||
'hostDetails.backspaceBehavior.default': '默认',
|
||||
'hostDetails.jumpHosts': '通过主机代理',
|
||||
'hostDetails.jumpHosts.hops': '{count} 跳',
|
||||
'hostDetails.jumpHosts.direct': '直连',
|
||||
'hostDetails.jumpHosts.configure': '配置代理主机',
|
||||
'hostDetails.proxy': '通过 HTTP/SOCKS5 代理',
|
||||
'hostDetails.proxy.none': '无',
|
||||
'hostDetails.proxy.edit': '编辑代理',
|
||||
'hostDetails.proxy.configure': '配置代理',
|
||||
'hostDetails.envVars': '环境变量',
|
||||
'hostDetails.envVars.add': '添加环境变量',
|
||||
'hostDetails.startupCommand': '启动命令',
|
||||
'hostDetails.startupCommand.placeholder': '连接后执行的命令(例如:cd /app && ls)',
|
||||
'hostDetails.startupCommand.help': 'SSH 连接建立后将自动执行该命令。',
|
||||
'hostDetails.otherProtocols': '其他协议',
|
||||
'hostDetails.telnetOn': 'Telnet on',
|
||||
'hostDetails.port': '端口',
|
||||
'hostDetails.telnet.credentials': '凭据',
|
||||
'hostDetails.telnet.username': 'Telnet 用户名',
|
||||
'hostDetails.telnet.password': 'Telnet 密码',
|
||||
'hostDetails.charset.placeholder': '字符集(例如 UTF-8)',
|
||||
'hostDetails.telnet.add': '添加 Telnet 协议',
|
||||
'hostDetails.telnet.setDefault': '默认用 Telnet 连接',
|
||||
'hostDetails.tags': '标签',
|
||||
'hostDetails.group': '分组',
|
||||
'hostDetails.selectGroup': '选择分组',
|
||||
'hostDetails.addTag': '添加标签...',
|
||||
'hostDetails.createTag': '创建标签',
|
||||
'hostDetails.createGroup': '创建分组',
|
||||
|
||||
// Host form (legacy modal)
|
||||
'hostForm.title.edit': '编辑主机',
|
||||
'hostForm.title.new': '新建主机',
|
||||
'hostForm.desc.edit': '更新该主机的连接信息',
|
||||
'hostForm.desc.new': '创建一个新的 SSH 主机条目',
|
||||
'hostForm.field.label': '名称',
|
||||
'hostForm.placeholder.label': 'My Production Server',
|
||||
'hostForm.field.hostname': 'Hostname / IP',
|
||||
'hostForm.placeholder.hostname': '192.168.1.1',
|
||||
'hostForm.field.port': '端口',
|
||||
'hostForm.field.username': '用户名',
|
||||
'hostForm.field.osType': '操作系统类型',
|
||||
'hostForm.placeholder.selectOs': '选择操作系统',
|
||||
'hostForm.field.group': '分组',
|
||||
'hostForm.placeholder.group': '例如:AWS、DigitalOcean',
|
||||
'hostForm.field.tags': '标签',
|
||||
'hostForm.placeholder.addTag': '添加标签…',
|
||||
'hostForm.auth.method': '认证方式',
|
||||
'hostForm.auth.password': '密码',
|
||||
'hostForm.auth.sshKey': 'SSH密钥',
|
||||
'hostForm.auth.selectKey': '选择 SSH密钥',
|
||||
'hostForm.auth.noKeys': '暂无密钥',
|
||||
'hostForm.auth.noKeysHint': '钥匙串中未找到 SSH密钥,请先创建一个。',
|
||||
'hostForm.saveHost': '保存主机',
|
||||
|
||||
// Connection logs
|
||||
'logs.table.date': '日期',
|
||||
'logs.table.user': '用户',
|
||||
'logs.table.host': '主机',
|
||||
'logs.table.saved': '收藏',
|
||||
'logs.empty.title': '暂无连接日志',
|
||||
'logs.empty.desc': '当你连接主机或打开本地终端后,这里会显示连接历史。',
|
||||
'logs.loadMore': '加载更多 ({count} 条)',
|
||||
'logs.ongoing': '进行中',
|
||||
'logs.localTerminal': '本地终端',
|
||||
'logs.action.save': '收藏',
|
||||
'logs.action.unsave': '取消收藏',
|
||||
'logs.action.delete': '删除',
|
||||
|
||||
// Log view
|
||||
'logView.customizeAppearance': '自定义外观',
|
||||
'logView.appearance': '外观',
|
||||
'logView.readOnly': '只读',
|
||||
'logView.export': '导出',
|
||||
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': '打开 SFTP',
|
||||
'terminal.toolbar.availableAfterConnect': '连接后可用',
|
||||
'terminal.toolbar.sftp': 'SFTP',
|
||||
'terminal.toolbar.more': '更多操作',
|
||||
'terminal.toolbar.scripts': '脚本',
|
||||
'terminal.toolbar.library': '库',
|
||||
'terminal.toolbar.noSnippets': '暂无代码片段',
|
||||
'terminal.toolbar.terminalSettings': '终端设置',
|
||||
'terminal.toolbar.searchTerminal': '搜索终端',
|
||||
'terminal.toolbar.search': '搜索',
|
||||
'terminal.toolbar.broadcast': '广播',
|
||||
'terminal.toolbar.broadcastEnable': '启用广播模式',
|
||||
'terminal.toolbar.broadcastDisable': '关闭广播模式',
|
||||
'terminal.toolbar.composeBar': '撰写栏',
|
||||
'terminal.composeBar.placeholder': '在此输入命令,按回车发送...',
|
||||
'terminal.composeBar.send': '发送',
|
||||
'terminal.composeBar.close': '关闭撰写栏',
|
||||
'terminal.composeBar.broadcasting': '正在广播到所有会话',
|
||||
'terminal.toolbar.focus': '聚焦',
|
||||
'terminal.toolbar.focusMode': '聚焦模式',
|
||||
'terminal.toolbar.encoding': '终端编码',
|
||||
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
||||
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
||||
'terminal.toolbar.closeSession': '关闭会话',
|
||||
'terminal.toolbar.hostHighlight.title': '主机关键字高亮',
|
||||
'terminal.toolbar.hostHighlight.noRules': '此主机未定义自定义高亮规则',
|
||||
'terminal.toolbar.hostHighlight.addRule': '添加新规则',
|
||||
'terminal.toolbar.hostHighlight.labelPlaceholder': '标签(例如:错误)',
|
||||
'terminal.toolbar.hostHighlight.patternPlaceholder': '正则表达式(例如:\\bfailed\\b)',
|
||||
'terminal.toolbar.hostHighlight.invalidPattern': '无效的正则表达式',
|
||||
'terminal.toolbar.hostHighlight.clearAll': '清除全部',
|
||||
'terminal.toolbar.hostHighlight.changeColor': '更改高亮颜色',
|
||||
'terminal.toolbar.hostHighlight.selectColor': '选择新规则的颜色',
|
||||
'terminal.statusbar.copyHostname.label': '复制主机地址',
|
||||
'terminal.statusbar.copyHostname.tooltip': '复制主机地址({hostname})',
|
||||
'terminal.statusbar.copyHostname.toast': '已复制主机地址:{hostname}',
|
||||
'terminal.statusbar.copyHostname.error': '复制主机地址失败',
|
||||
'terminal.serverStats.cpu': 'CPU 使用率',
|
||||
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
|
||||
'terminal.serverStats.memory': '内存使用',
|
||||
'terminal.serverStats.memoryDetails': '内存详情',
|
||||
'terminal.serverStats.memUsed': '已用',
|
||||
'terminal.serverStats.memBuffers': '缓冲区',
|
||||
'terminal.serverStats.memCached': '缓存',
|
||||
'terminal.serverStats.memFree': '空闲',
|
||||
'terminal.serverStats.swap': '交换空间',
|
||||
'terminal.serverStats.swapUsed': '已用交换',
|
||||
'terminal.serverStats.swapFree': '空闲交换',
|
||||
'terminal.serverStats.swapTotal': '总计',
|
||||
'terminal.serverStats.topProcesses': '内存占用前十进程',
|
||||
'terminal.serverStats.disk': '磁盘使用(根分区)',
|
||||
'terminal.serverStats.diskDetails': '已挂载磁盘',
|
||||
'terminal.serverStats.network': '网络速度',
|
||||
'terminal.serverStats.networkDetails': '网络接口',
|
||||
'terminal.serverStats.noData': '暂无数据',
|
||||
'terminal.dragDrop.localTitle': '拖放以插入路径',
|
||||
'terminal.dragDrop.localMessage': '文件路径将被插入到终端',
|
||||
'terminal.dragDrop.remoteTitle': '拖放以上传文件',
|
||||
'terminal.dragDrop.remoteMessage': '文件将通过 SFTP 上传',
|
||||
'terminal.dragDrop.notConnected': '无法拖放文件 - 终端未连接',
|
||||
'terminal.dragDrop.errorTitle': '拖放错误',
|
||||
'terminal.dragDrop.errorMessage': '处理拖放文件失败',
|
||||
'terminal.search.placeholder': '搜索…',
|
||||
'terminal.search.noResults': '无结果',
|
||||
'terminal.search.prevMatch': '上一个匹配 (Shift+Enter)',
|
||||
'terminal.search.nextMatch': '下一个匹配 (Enter)',
|
||||
'terminal.menu.copy': '复制',
|
||||
'terminal.menu.paste': '粘贴',
|
||||
'terminal.menu.pasteSelection': '粘贴选中文本',
|
||||
'terminal.menu.selectAll': '全选',
|
||||
'terminal.menu.reconnect': '重新连接',
|
||||
'terminal.menu.splitHorizontal': '水平分屏',
|
||||
'terminal.menu.splitVertical': '垂直分屏',
|
||||
'terminal.menu.clearBuffer': '清空缓冲区',
|
||||
'terminal.menu.closeTerminal': '关闭终端',
|
||||
'terminal.auth.password': '密码',
|
||||
'terminal.auth.sshKey': 'SSH Key',
|
||||
'terminal.auth.username': '用户名',
|
||||
'terminal.auth.username.placeholder': 'root',
|
||||
'terminal.auth.passwordLabel': '密码',
|
||||
'terminal.auth.password.placeholder': '输入密码',
|
||||
'terminal.auth.passphrase': '密码短语',
|
||||
'terminal.auth.passphrase.placeholder': '可选:所选私钥的密码短语',
|
||||
'terminal.auth.certificate': '证书',
|
||||
'terminal.auth.selectKey': '选择密钥',
|
||||
'terminal.auth.noKeysHint': '暂无密钥,请先在钥匙串中添加。',
|
||||
'terminal.auth.continueSave': '继续并保存',
|
||||
'terminal.auth.credentialsUnavailable': '当前设备无法解密已保存凭据,请重新输入并再次保存。',
|
||||
'terminal.auth.jumpCredentialsUnavailable': '某个跳板机的已保存凭据无法在当前设备解密,请到主机设置中重新填写。',
|
||||
'terminal.auth.proxyCredentialsUnavailable': '代理凭据无法在当前设备解密,请到主机设置中重新填写代理密码。',
|
||||
'terminal.auth.keyUnavailableFallbackPassword': '已保存 SSH 密钥在当前设备不可用,改用密码认证。',
|
||||
'terminal.connectionErrorTitle': '连接错误',
|
||||
'terminal.progress.timeoutIn': '将在 {seconds}s 后超时',
|
||||
'terminal.progress.disconnected': '已断开',
|
||||
'terminal.progress.cancelling': '正在取消...',
|
||||
'terminal.progress.startOver': '重新开始',
|
||||
'terminal.connection.dismissDisconnectedDialog': '关闭断连提示',
|
||||
'terminal.connection.chainOf': 'Chain {current} / {total}',
|
||||
'terminal.connection.showLogs': '显示日志',
|
||||
'terminal.connection.hideLogs': '隐藏日志',
|
||||
'terminal.connection.protocol.ssh': 'SSH',
|
||||
'terminal.connection.protocol.telnet': 'Telnet',
|
||||
'terminal.connection.protocol.mosh': 'Mosh',
|
||||
'terminal.connection.protocol.serial': '串口',
|
||||
'terminal.connection.protocol.local': '本地终端',
|
||||
'terminal.hostKey.unknownTitle': '确认主机指纹',
|
||||
'terminal.hostKey.changedTitle': '主机指纹已变化',
|
||||
'terminal.hostKey.unknownDescription': '尚未确认 {host} 的真实性。',
|
||||
'terminal.hostKey.changedDescription': '{host} 的已保存指纹与当前服务器不一致。',
|
||||
'terminal.hostKey.fingerprintLabel': '{keyType} 指纹为 SHA256:',
|
||||
'terminal.hostKey.savedFingerprintLabel': '已保存的指纹',
|
||||
'terminal.hostKey.unknownHint': '如果这个指纹属于你预期连接的服务器,可以记住它。',
|
||||
'terminal.hostKey.changedHint': '只有在你确认这台主机确实变更过时才继续。',
|
||||
'terminal.hostKey.addAndContinue': '记住并继续',
|
||||
'terminal.hostKey.updateAndContinue': '更新并继续',
|
||||
'terminal.themeModal.title': 'Terminal 外观',
|
||||
'terminal.themeModal.tab.theme': '主题',
|
||||
'terminal.themeModal.tab.font': '字体',
|
||||
'terminal.themeModal.tab.custom': '自定义',
|
||||
'terminal.themeModal.globalTheme': '全局主题',
|
||||
'terminal.themeModal.globalFont': '全局字体',
|
||||
'terminal.themeModal.fontSize': '字体大小',
|
||||
'terminal.themeModal.fontWeight': '字体粗细',
|
||||
'terminal.themeModal.livePreview': '实时预览',
|
||||
'terminal.themeModal.themeType': '{type} 主题',
|
||||
'terminal.hiddenTheme.title': '当前隐藏主题',
|
||||
'terminal.hiddenTheme.desc': '这个主题已从手动选择列表中隐藏;当你选择其他可见主题后,它会被替换。',
|
||||
'topTabs.toggleTheme.systemExitTitle': '当前正在跟随系统主题',
|
||||
'topTabs.toggleTheme.systemExitMessage': '请到设置里选择固定的浅色或深色主题。',
|
||||
'topTabs.toggleTheme.openSettings': '打开设置',
|
||||
|
||||
// Custom Themes
|
||||
'terminal.customTheme.section': '自定义主题',
|
||||
'terminal.customTheme.yourThemes': '我的主题',
|
||||
'terminal.customTheme.new': '新建主题',
|
||||
'terminal.customTheme.newDesc': '克隆当前主题并自定义',
|
||||
'terminal.customTheme.newTitle': '新建自定义主题',
|
||||
'terminal.customTheme.editTitle': '编辑主题',
|
||||
'terminal.customTheme.import': '导入 .itermcolors',
|
||||
'terminal.customTheme.importDesc': '从 iTerm2 配色方案文件导入',
|
||||
'terminal.customTheme.importError': '无法解析所选文件,请确保它是有效的 .itermcolors XML 文件。',
|
||||
'terminal.customTheme.delete': '删除主题',
|
||||
'terminal.customTheme.confirmDelete': '确认删除',
|
||||
'terminal.customTheme.name': '名称',
|
||||
'terminal.customTheme.namePlaceholder': '我的自定义主题',
|
||||
'terminal.customTheme.type': '类型',
|
||||
'terminal.customTheme.group.general': '通用',
|
||||
'terminal.customTheme.group.normal': '标准色',
|
||||
'terminal.customTheme.group.bright': '高亮色',
|
||||
'terminal.customTheme.color.background': '背景',
|
||||
'terminal.customTheme.color.foreground': '前景',
|
||||
'terminal.customTheme.color.cursor': '光标',
|
||||
'terminal.customTheme.color.selection': '选区',
|
||||
'terminal.customTheme.color.black': '黑色',
|
||||
'terminal.customTheme.color.red': '红色',
|
||||
'terminal.customTheme.color.green': '绿色',
|
||||
'terminal.customTheme.color.yellow': '黄色',
|
||||
'terminal.customTheme.color.blue': '蓝色',
|
||||
'terminal.customTheme.color.magenta': '品红',
|
||||
'terminal.customTheme.color.cyan': '青色',
|
||||
'terminal.customTheme.color.white': '白色',
|
||||
'terminal.customTheme.color.brightBlack': '亮黑',
|
||||
'terminal.customTheme.color.brightRed': '亮红',
|
||||
'terminal.customTheme.color.brightGreen': '亮绿',
|
||||
'terminal.customTheme.color.brightYellow': '亮黄',
|
||||
'terminal.customTheme.color.brightBlue': '亮蓝',
|
||||
'terminal.customTheme.color.brightMagenta': '亮品红',
|
||||
'terminal.customTheme.color.brightCyan': '亮青色',
|
||||
'terminal.customTheme.color.brightWhite': '亮白',
|
||||
|
||||
'cloudSync.gate.title': '端到端加密同步',
|
||||
'cloudSync.gate.desc':
|
||||
'数据会在本地加密后再同步,云端不会看到明文。设置主密钥以启用安全同步。',
|
||||
'cloudSync.gate.masterKey': '主密钥',
|
||||
'cloudSync.gate.confirmMasterKey': '确认主密钥',
|
||||
'cloudSync.gate.placeholder': '输入一个强密码',
|
||||
'cloudSync.gate.confirmPlaceholder': '再次输入密码',
|
||||
'cloudSync.gate.mismatch': '两次输入的密码不一致',
|
||||
'cloudSync.gate.warning':
|
||||
'我已了解:如果忘记主密钥,数据无法恢复,且没有密码重置功能。',
|
||||
'cloudSync.gate.enableVault': '启用加密 Vault',
|
||||
'cloudSync.gate.enabledToast': '已启用加密 Vault',
|
||||
'cloudSync.gate.setupFailed': '设置主密钥失败',
|
||||
'cloudSync.passwordStrength.tooShort': '太短',
|
||||
'cloudSync.passwordStrength.weak': '弱',
|
||||
'cloudSync.passwordStrength.moderate': '一般',
|
||||
'cloudSync.passwordStrength.strong': '强',
|
||||
'cloudSync.passwordStrength.veryStrong': '非常强',
|
||||
'cloudSync.provider.notConnected': '未连接',
|
||||
'cloudSync.provider.sync': '同步',
|
||||
'cloudSync.provider.connect': '连接',
|
||||
'cloudSync.provider.connecting': '连接中...',
|
||||
'cloudSync.provider.webdav': 'WebDAV',
|
||||
'cloudSync.provider.webdav.desc': '连接到自建 WebDAV 端点',
|
||||
'cloudSync.provider.s3': 'S3 兼容存储',
|
||||
'cloudSync.provider.s3.desc': '连接到 S3 兼容对象存储',
|
||||
'cloudSync.provider.comingSoon': '即将支持',
|
||||
'cloudSync.webdav.title': 'WebDAV 设置',
|
||||
'cloudSync.webdav.desc': '配置 WebDAV 端点用于加密同步。',
|
||||
'cloudSync.webdav.endpoint': '端点地址',
|
||||
'cloudSync.webdav.authType': '认证方式',
|
||||
'cloudSync.webdav.auth.basic': 'Basic',
|
||||
'cloudSync.webdav.auth.digest': 'Digest',
|
||||
'cloudSync.webdav.auth.token': 'Token',
|
||||
'cloudSync.webdav.username': '用户名',
|
||||
'cloudSync.webdav.password': '密码',
|
||||
'cloudSync.webdav.token': 'Token',
|
||||
'cloudSync.webdav.showSecret': '显示密钥',
|
||||
'cloudSync.webdav.allowInsecure': '允许不安全的连接(忽略证书错误)',
|
||||
'cloudSync.webdav.validation.endpoint': '请输入有效的 WebDAV 端点。',
|
||||
'cloudSync.webdav.validation.credentials': '请输入用户名和密码。',
|
||||
'cloudSync.webdav.validation.token': '请输入 Token。',
|
||||
'cloudSync.s3.title': 'S3 设置',
|
||||
'cloudSync.s3.desc': '连接到 S3 兼容对象存储以进行加密同步。',
|
||||
'cloudSync.s3.endpoint': '端点地址',
|
||||
'cloudSync.s3.region': 'Region',
|
||||
'cloudSync.s3.bucket': 'Bucket',
|
||||
'cloudSync.s3.accessKeyId': 'Access Key ID',
|
||||
'cloudSync.s3.secretAccessKey': 'Secret Access Key',
|
||||
'cloudSync.s3.sessionToken': 'Session Token(可选)',
|
||||
'cloudSync.s3.prefix': 'Key 前缀(可选)',
|
||||
'cloudSync.s3.forcePathStyle': '强制使用 path-style URL(适用于 MinIO/R2 等)',
|
||||
'cloudSync.s3.showSecret': '显示密钥',
|
||||
'cloudSync.s3.validation.required': '端点、Region、Bucket、Access Key 与 Secret 必填。',
|
||||
'cloudSync.smb.title': 'SMB 设置',
|
||||
'cloudSync.smb.desc': '连接到 SMB/CIFS 文件共享以进行加密同步。',
|
||||
'cloudSync.smb.share': '共享路径',
|
||||
'cloudSync.smb.username': '用户名',
|
||||
'cloudSync.smb.password': '密码',
|
||||
'cloudSync.smb.domain': '域(可选)',
|
||||
'cloudSync.smb.domainPlaceholder': '例如:WORKGROUP',
|
||||
'cloudSync.smb.port': '端口(可选)',
|
||||
'cloudSync.smb.showSecret': '显示密码',
|
||||
'cloudSync.smb.validation.share': '共享路径必填。',
|
||||
'cloudSync.smb.validation.port': '端口必须是 1 到 65535 之间的数字。',
|
||||
'cloudSync.connect.smb.success': 'SMB 已连接',
|
||||
'cloudSync.connect.smb.failedTitle': 'SMB 连接失败',
|
||||
'cloudSync.provider.smb': 'SMB 共享',
|
||||
'cloudSync.connect.webdav.success': 'WebDAV 已连接',
|
||||
'cloudSync.connect.webdav.failedTitle': 'WebDAV 连接失败',
|
||||
'cloudSync.connect.s3.success': 'S3 已连接',
|
||||
'cloudSync.connect.s3.failedTitle': 'S3 连接失败',
|
||||
'cloudSync.lastSync.never': '从未',
|
||||
'cloudSync.lastSync.justNow': '刚刚',
|
||||
'cloudSync.lastSync.minutesAgo': '{minutes} 分钟前',
|
||||
'cloudSync.changeKey': '更改 Key',
|
||||
'cloudSync.providers.title': '云服务',
|
||||
'cloudSync.syncAll': '同步所有已连接的服务',
|
||||
'cloudSync.autoSync.title': '自动同步',
|
||||
'cloudSync.autoSync.desc': '发生变更时自动同步',
|
||||
'cloudSync.strategy.title': '同步策略',
|
||||
'cloudSync.strategy.desc': '当本地和云端都发生变化时,选择如何处理。',
|
||||
'cloudSync.strategy.smartMerge': '智能合并(推荐)',
|
||||
'cloudSync.strategy.smartMergeDesc': '尽量保留两边的变化;如果无法安全判断,会再让你手动选择。',
|
||||
'cloudSync.strategy.preferCloud': '云端优先',
|
||||
'cloudSync.strategy.preferCloudDesc': '两边都有变化时,下载云端版本,并替换本地变化。',
|
||||
'cloudSync.strategy.preferLocal': '本地优先',
|
||||
'cloudSync.strategy.preferLocalDesc': '两边都有变化时,上传本地版本,并替换云端变化。',
|
||||
'cloudSync.status.title': '同步状态',
|
||||
'cloudSync.status.localVersion': '本地版本',
|
||||
'cloudSync.status.remoteVersion': '远端版本',
|
||||
'cloudSync.history.title': '同步历史',
|
||||
'cloudSync.history.upload': '上传',
|
||||
'cloudSync.history.download': '下载',
|
||||
'cloudSync.history.resolved': '已解决',
|
||||
'cloudSync.history.error': '错误',
|
||||
'cloudSync.localBackups.title': '本地备份历史',
|
||||
'cloudSync.localBackups.desc': 'Netcatty 会在版本变化前,以及恢复主机库前,自动留下一份本地恢复点。',
|
||||
'cloudSync.localBackups.retentionTitle': '备份保留数量',
|
||||
'cloudSync.localBackups.retentionDesc': '设置 Netcatty 最多保留多少份本地备份。',
|
||||
'cloudSync.localBackups.maxCount': '最多保留',
|
||||
'cloudSync.localBackups.maxSaved': '已保存保留数量:{count}',
|
||||
'cloudSync.localBackups.maxInvalid': '请输入 1 到 100 之间的数字。',
|
||||
'cloudSync.localBackups.empty': '还没有本地备份。',
|
||||
'cloudSync.localBackups.reason.appVersionChange': '版本变化前',
|
||||
'cloudSync.localBackups.reason.beforeRestore': '恢复前',
|
||||
'cloudSync.localBackups.versionChange': '{from} -> {to}',
|
||||
'cloudSync.localBackups.counts': '{hosts} 台主机,{keys} 个密钥,{snippets} 个代码片段',
|
||||
'cloudSync.localBackups.restore': '恢复',
|
||||
'cloudSync.localBackups.restoreSuccess': '已恢复本地备份。',
|
||||
'cloudSync.localBackups.restoreFailedTitle': '恢复失败',
|
||||
'cloudSync.localBackups.restoreMissing': '找不到这份备份。',
|
||||
'cloudSync.localBackups.protectiveBackupFailed': '无法创建保护性备份,已中止恢复以避免覆盖当前数据。请先解决底层问题(例如钥匙串访问)后重试。详情:{message}',
|
||||
'cloudSync.localBackups.restoreConfirmTitle': '确认恢复此备份?',
|
||||
'cloudSync.localBackups.restoreConfirmDesc': '当前的主机、密钥、代码片段与设置将被替换为此备份中的内容。系统会先自动创建一个保护性快照,便于撤销。',
|
||||
'cloudSync.localBackups.restoreConfirmButton': '恢复',
|
||||
'cloudSync.localBackups.restoreConfirmCancel': '取消',
|
||||
'cloudSync.localBackups.unavailableTitle': '无法使用本地备份',
|
||||
'cloudSync.localBackups.unavailableDesc': '当前平台未提供受支持的安全密钥库,Netcatty 无法安全地写入本地备份。请在支持系统钥匙串的环境中运行,或改用云同步保留恢复点。',
|
||||
'cloudSync.localBackups.lockedTitle': '需要主密钥',
|
||||
'cloudSync.localBackups.lockedDesc': '请先配置或解锁主密钥再恢复备份,以确保恢复后的凭据仍保持加密。',
|
||||
'cloudSync.revisionHistory.viewButton': '历史版本',
|
||||
'cloudSync.revisionHistory.title': '主机库版本历史',
|
||||
'cloudSync.revisionHistory.description': '浏览并恢复 Gist 修订历史中的旧版主机库数据。',
|
||||
'cloudSync.revisionHistory.empty': '未找到修订记录。',
|
||||
'cloudSync.revisionHistory.current': '当前版本',
|
||||
'cloudSync.revisionHistory.revision': '修订',
|
||||
'cloudSync.revisionHistory.revisionPreview': '修订内容',
|
||||
'cloudSync.revisionHistory.device': '设备',
|
||||
'cloudSync.revisionHistory.hosts': '主机',
|
||||
'cloudSync.revisionHistory.keys': '密钥',
|
||||
'cloudSync.revisionHistory.snippets': '代码片段',
|
||||
'cloudSync.revisionHistory.identities': '身份',
|
||||
'cloudSync.revisionHistory.restoreButton': '恢复此版本',
|
||||
'cloudSync.revisionHistory.restored': '已从选中的修订恢复主机库数据。',
|
||||
'cloudSync.revisionHistory.revisionNotFound': '修订未找到或不包含主机库数据。',
|
||||
'cloudSync.revisionHistory.decryptFailed': '无法解密此修订。可能是使用了不同的主密钥加密的。',
|
||||
'cloudSync.changeKey.title': '更改主密钥',
|
||||
'cloudSync.changeKey.current': '当前主密钥',
|
||||
'cloudSync.changeKey.new': '新的主密钥',
|
||||
'cloudSync.changeKey.confirmNew': '确认新的主密钥',
|
||||
'cloudSync.changeKey.currentPlaceholder': '输入当前主密钥',
|
||||
'cloudSync.changeKey.newPlaceholder': '输入新的主密钥',
|
||||
'cloudSync.changeKey.confirmPlaceholder': '再次输入新的主密钥',
|
||||
'cloudSync.changeKey.fillAll': '请填写所有字段',
|
||||
'cloudSync.changeKey.minLength': '新的主密钥至少 8 个字符',
|
||||
'cloudSync.changeKey.notMatch': '两次输入的主密钥不一致',
|
||||
'cloudSync.changeKey.incorrectCurrent': '当前主密钥不正确',
|
||||
'cloudSync.changeKey.failed': '更改主密钥失败',
|
||||
'cloudSync.changeKey.desc': '这将重新加密 Vault,请务必记住新的主密钥。',
|
||||
'cloudSync.changeKey.showKeys': '显示主密钥',
|
||||
'cloudSync.changeKey.updatedToast': '主密钥已更新',
|
||||
'cloudSync.changeKey.updateButton': '更新主密钥',
|
||||
'cloudSync.unlock.title': '输入主密钥',
|
||||
'cloudSync.unlock.masterKey': '主密钥',
|
||||
'cloudSync.unlock.desc': '仅需输入一次主密钥以启用加密同步,之后会通过系统 Keychain 安全存储。',
|
||||
'cloudSync.unlock.placeholder': '输入你的主密钥',
|
||||
'cloudSync.unlock.empty': '请输入主密钥',
|
||||
'cloudSync.unlock.incorrect': '主密钥不正确',
|
||||
'cloudSync.unlock.failed': '解锁 Vault 失败',
|
||||
'cloudSync.unlock.showKey': '显示主密钥',
|
||||
'cloudSync.unlock.notNow': '暂不',
|
||||
'cloudSync.unlock.readyToast': 'Vault 已就绪',
|
||||
'cloudSync.unlock.unlockButton': '解锁',
|
||||
'cloudSync.header.vaultReady': 'Vault 已就绪',
|
||||
'cloudSync.header.preparingVault': '正在准备 Vault...',
|
||||
'cloudSync.header.providersConnected': '已连接 {count} 个 provider',
|
||||
'cloudSync.githubFlow.title': '连接到 GitHub',
|
||||
'cloudSync.githubFlow.desc': '复制下面的 code,并在 GitHub 页面输入以授权 Netcatty。',
|
||||
'cloudSync.githubFlow.copyCode': '复制 code',
|
||||
'cloudSync.githubFlow.copied': '已复制',
|
||||
'cloudSync.githubFlow.openGitHub': '打开 GitHub',
|
||||
'cloudSync.githubFlow.waiting': '等待授权...',
|
||||
'cloudSync.conflict.title': '检测到版本冲突',
|
||||
'cloudSync.conflict.desc': '选择保留哪个版本',
|
||||
'cloudSync.conflict.local': '本地',
|
||||
'cloudSync.conflict.cloud': '云端',
|
||||
'cloudSync.conflict.detailsTitle': '发生变化的数据',
|
||||
'cloudSync.conflict.detailsCounts': '本地 {local} · 云端 {cloud} · 冲突 {conflicts}',
|
||||
'cloudSync.conflict.entity.hosts': '主机',
|
||||
'cloudSync.conflict.entity.keys': '密钥',
|
||||
'cloudSync.conflict.entity.identities': '身份',
|
||||
'cloudSync.conflict.entity.proxyProfiles': '代理配置',
|
||||
'cloudSync.conflict.entity.snippets': '片段',
|
||||
'cloudSync.conflict.entity.customGroups': '分组',
|
||||
'cloudSync.conflict.entity.snippetPackages': '片段包',
|
||||
'cloudSync.conflict.entity.portForwardingRules': '端口转发',
|
||||
'cloudSync.conflict.entity.groupConfigs': '分组设置',
|
||||
'cloudSync.conflict.entity.settings': '设置',
|
||||
'cloudSync.conflict.keepLocal': '覆盖云端(保留本地)',
|
||||
'cloudSync.conflict.useCloud': '下载云端(覆盖本地)',
|
||||
'cloudSync.connect.browserContinue': '请在浏览器中完成授权',
|
||||
'cloudSync.connect.browserCancelled': '已取消上一个浏览器授权流程',
|
||||
'cloudSync.connect.github.success': 'GitHub 已连接',
|
||||
'cloudSync.connect.github.failedTitle': 'GitHub 连接失败',
|
||||
'cloudSync.connect.github.timeout': '连接 GitHub 超时,请检查网络或代理设置。',
|
||||
'cloudSync.connect.github.networkError': '无法访问 GitHub,请检查网络或代理设置。',
|
||||
'cloudSync.connect.google.failedTitle': 'Google 连接失败',
|
||||
'cloudSync.connect.onedrive.failedTitle': 'OneDrive 连接失败',
|
||||
'cloudSync.sync.success': '已同步到 {provider}',
|
||||
'cloudSync.sync.failed': '同步失败',
|
||||
'cloudSync.sync.failedTitle': '同步失败',
|
||||
'cloudSync.sync.errorTitle': '同步错误',
|
||||
'cloudSync.resolve.downloaded': '已下载云端数据',
|
||||
'cloudSync.resolve.uploaded': '已上传本地数据',
|
||||
'cloudSync.resolve.failedTitle': '冲突处理失败',
|
||||
'cloudSync.clearLocal.title': '清空本地数据',
|
||||
'cloudSync.clearLocal.desc': '重置本地版本和同步历史。下次同步将从云端下载。',
|
||||
'cloudSync.clearLocal.button': '清空',
|
||||
'cloudSync.clearLocal.dialog.title': '清空本地 Vault 数据?',
|
||||
'cloudSync.clearLocal.dialog.desc': '这将重置本地版本为 0 并清除同步历史。下次同步时会从云端下载数据,替换本地数据。',
|
||||
'cloudSync.clearLocal.dialog.cancel': '取消',
|
||||
'cloudSync.clearLocal.dialog.confirm': '确认清空',
|
||||
'cloudSync.clearLocal.toast.title': '本地数据已清空',
|
||||
'cloudSync.clearLocal.toast.desc': '本地版本已重置为 0。同步以从云端下载数据。',
|
||||
|
||||
// Common (additional)
|
||||
'common.searchPlaceholder': '搜索...',
|
||||
'common.import': '导入',
|
||||
'common.generate': '生成',
|
||||
'common.delete': '删除',
|
||||
'common.edit': '编辑',
|
||||
'common.clear': '清除',
|
||||
'common.optional': '可选',
|
||||
'common.selectPlaceholder': '请选择...',
|
||||
'common.error': '错误',
|
||||
'common.validation': '验证',
|
||||
'common.saveChanges': '保存修改',
|
||||
'common.advanced': '高级',
|
||||
'common.selectAHostPlaceholder': '选择主机...',
|
||||
|
||||
// Actions
|
||||
'action.duplicate': '复制',
|
||||
'action.open': '打开',
|
||||
'action.copy': '复制',
|
||||
'action.run': '运行',
|
||||
'action.start': '启动',
|
||||
'action.stop': '停止',
|
||||
|
||||
// Port Forwarding (form)
|
||||
'pf.form.labelPlaceholder': '规则标签',
|
||||
'pf.form.intermediateHost': '中转主机 *',
|
||||
'pf.form.createRule': '创建规则',
|
||||
'pf.form.openWizard': '打开向导',
|
||||
'pf.form.openWizardTitle': '打开端口转发向导',
|
||||
'pf.action.newForwarding': '新建转发',
|
||||
'pf.view.grid': '网格',
|
||||
'pf.view.list': '列表',
|
||||
'pf.rule.summary.dynamic': 'SOCKS 监听于 {bindAddress}:{localPort}',
|
||||
'pf.rule.summary.default': '{bindAddress}:{localPort} -> {remoteHost}:{remotePort}',
|
||||
'pf.tooltip.relayHost': '中转主机',
|
||||
'pf.tooltip.hostLabel': '主机',
|
||||
'pf.tooltip.hostAddress': '地址',
|
||||
'pf.tooltip.noHost': '未配置中转主机',
|
||||
'pf.tooltip.localDesc': '本地端口转发:通过 SSH 隧道访问远程服务',
|
||||
'pf.tooltip.remoteDesc': '远程端口转发:将本地服务暴露给远程主机',
|
||||
'pf.tooltip.dynamicDesc': '动态 SOCKS 代理:通过 SSH 隧道转发流量',
|
||||
'pf.deleteActive.title': '删除正在运行的端口转发?',
|
||||
'pf.deleteActive.desc': '端口转发规则 "{label}" 当前正在运行。删除前将先关闭转发连接。',
|
||||
'pf.deleteActive.confirm': '关闭并删除',
|
||||
'pf.form.autoStart': '自动启动',
|
||||
'pf.form.autoStartDesc': '应用启动时自动开启此规则',
|
||||
|
||||
// SFTP (pane + conflict)
|
||||
'sftp.pane.local': '本地',
|
||||
'sftp.pane.remote': '远端',
|
||||
'sftp.pane.selectHost': '选择主机',
|
||||
'sftp.pane.selectHostToStart': '先选择一个主机',
|
||||
'sftp.pane.chooseFilesystem': '选择要浏览的本地或远端文件系统',
|
||||
'sftp.tabs.addTab': '新建标签页',
|
||||
'sftp.tabs.closeTab': '关闭标签页',
|
||||
'sftp.tabs.newTab': '新标签页',
|
||||
'sftp.conflict.title': '文件冲突',
|
||||
'sftp.conflict.desc': '目标位置已存在同名文件',
|
||||
'sftp.conflict.alreadyExistsSuffix': '已存在',
|
||||
'sftp.conflict.existingFile': '已有文件',
|
||||
'sftp.conflict.newFile': '新文件',
|
||||
'sftp.conflict.size': '大小:',
|
||||
'sftp.conflict.modified': '修改时间:',
|
||||
'sftp.conflict.applyToAll': '将此操作应用到剩余的 {count} 个冲突',
|
||||
'sftp.conflict.action.stop': '停止',
|
||||
'sftp.conflict.action.skip': '跳过',
|
||||
'sftp.conflict.action.keepBoth': '保留两者',
|
||||
'sftp.conflict.action.duplicate': '创建副本',
|
||||
'sftp.conflict.action.merge': '合并',
|
||||
'sftp.conflict.action.replace': '替换',
|
||||
|
||||
// SFTP Upload Phases
|
||||
'sftp.upload.phase.compressing': '正在压缩',
|
||||
'sftp.upload.phase.uploading': '正在上传',
|
||||
'sftp.upload.phase.extracting': '正在解压',
|
||||
'sftp.upload.phase.compressed': '压缩传输',
|
||||
|
||||
};
|
||||
@@ -1,11 +1,13 @@
|
||||
import en, { type Messages } from './locales/en';
|
||||
import zhCN from './locales/zh-CN';
|
||||
import ru from './locales/ru';
|
||||
|
||||
// Keep keys stable; add new locales by adding another import and map entry.
|
||||
export { type Messages };
|
||||
|
||||
export const MESSAGES_BY_LOCALE: Record<string, Messages> = {
|
||||
en,
|
||||
ru,
|
||||
'zh-CN': zhCN,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback,useSyncExternalStore } from 'react';
|
||||
import { useCallback, useSyncExternalStore } from 'react';
|
||||
|
||||
// Simple store for active tab that allows fine-grained subscriptions
|
||||
type Listener = () => void;
|
||||
@@ -92,7 +92,11 @@ export const useIsEditorTabActive = (tabId: string): boolean => {
|
||||
// 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 activeTabId = useActiveTabId();
|
||||
const isTerminalTab = activeTabId !== 'vault' && activeTabId !== 'sftp' && !isEditorTabId(activeTabId);
|
||||
return isTerminalTab || !!draggingSessionId;
|
||||
const getSnapshot = useCallback(() => {
|
||||
const activeTabId = activeTabStore.getActiveTabId();
|
||||
const isTerminalTab = activeTabId !== 'vault' && activeTabId !== 'sftp' && !isEditorTabId(activeTabId);
|
||||
return isTerminalTab || !!draggingSessionId;
|
||||
}, [draggingSessionId]);
|
||||
|
||||
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot);
|
||||
};
|
||||
|
||||
39
application/state/aiProviderCleanup.ts
Normal file
39
application/state/aiProviderCleanup.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export function removeProviderReferences(
|
||||
removedProviderId: string,
|
||||
agentProviderMap: Record<string, string>,
|
||||
agentModelMap: Record<string, string>,
|
||||
): {
|
||||
agentProviderMap: Record<string, string>;
|
||||
agentModelMap: Record<string, string>;
|
||||
providerMapChanged: boolean;
|
||||
modelMapChanged: boolean;
|
||||
} {
|
||||
let providerMapChanged = false;
|
||||
let modelMapChanged = false;
|
||||
const orphanedAgents = new Set<string>();
|
||||
const nextAgentProviderMap: Record<string, string> = {};
|
||||
|
||||
for (const [agentId, providerId] of Object.entries(agentProviderMap)) {
|
||||
if (providerId === removedProviderId) {
|
||||
providerMapChanged = true;
|
||||
orphanedAgents.add(agentId);
|
||||
} else {
|
||||
nextAgentProviderMap[agentId] = providerId;
|
||||
}
|
||||
}
|
||||
|
||||
const nextAgentModelMap: Record<string, string> = { ...agentModelMap };
|
||||
for (const agentId of orphanedAgents) {
|
||||
if (agentId in nextAgentModelMap) {
|
||||
delete nextAgentModelMap[agentId];
|
||||
modelMapChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
agentProviderMap: providerMapChanged ? nextAgentProviderMap : agentProviderMap,
|
||||
agentModelMap: modelMapChanged ? nextAgentModelMap : agentModelMap,
|
||||
providerMapChanged,
|
||||
modelMapChanged,
|
||||
};
|
||||
}
|
||||
20
application/state/aiStateEvents.ts
Normal file
20
application/state/aiStateEvents.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Same-window AI-state-changed event plumbing.
|
||||
*
|
||||
* `localStorage` writes only emit `storage` events in *other* windows; the
|
||||
* window doing the write never gets notified. That's a problem for code
|
||||
* that mutates AI storage outside of `useAIState`'s setters (e.g. sync
|
||||
* apply): without a manual nudge, mounted components keep showing stale
|
||||
* AI state until reload.
|
||||
*
|
||||
* Both the dispatcher and `useAIState`'s listener live here so non-React
|
||||
* call sites (sync, IPC handlers, etc.) can fire the event without
|
||||
* pulling in the hook.
|
||||
*/
|
||||
|
||||
export const AI_STATE_CHANGED_EVENT = 'netcatty:ai-state-changed';
|
||||
|
||||
export function emitAIStateChanged(key: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.dispatchEvent(new CustomEvent<{ key: string }>(AI_STATE_CHANGED_EVENT, { detail: { key } }));
|
||||
}
|
||||
226
application/state/aiStateSnapshots.ts
Normal file
226
application/state/aiStateSnapshots.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
||||
STORAGE_KEY_AI_SESSIONS,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type {
|
||||
AIDraft,
|
||||
AIPanelView,
|
||||
AISession,
|
||||
AIPermissionMode,
|
||||
AIToolIntegrationMode,
|
||||
} from '../../infrastructure/ai/types';
|
||||
import {
|
||||
bumpDraftMutationVersionState,
|
||||
bumpDraftUploadGenerationState,
|
||||
getDraftUploadGenerationState,
|
||||
} from './aiDraftState';
|
||||
import {
|
||||
pruneInactiveScopedSessions,
|
||||
pruneInactiveScopedTransientState,
|
||||
} from './aiScopeCleanup';
|
||||
import { emitAIStateChanged } from './aiStateEvents';
|
||||
|
||||
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
|
||||
export interface AIBridge {
|
||||
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
|
||||
aiMcpSetPermissionMode?: (mode: AIPermissionMode) => Promise<unknown> | unknown;
|
||||
aiMcpSetToolIntegrationMode?: (mode: AIToolIntegrationMode) => Promise<unknown> | unknown;
|
||||
aiMcpSetCommandBlocklist?: (blocklist: string[]) => Promise<unknown> | unknown;
|
||||
aiMcpSetCommandTimeout?: (timeout: number) => Promise<unknown> | unknown;
|
||||
aiMcpSetMaxIterations?: (maxIterations: number) => Promise<unknown> | unknown;
|
||||
}
|
||||
|
||||
export function getAIBridge() {
|
||||
return (window as unknown as { netcatty?: AIBridge }).netcatty;
|
||||
}
|
||||
|
||||
|
||||
export const AI_STATE_CHANGED_DRAFTS_BY_SCOPE = 'netcatty:ai-drafts-by-scope';
|
||||
export const AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE = 'netcatty:ai-panel-view-by-scope';
|
||||
|
||||
export type DraftsByScope = Partial<Record<string, AIDraft>>;
|
||||
export type PanelViewByScope = Partial<Record<string, AIPanelView>>;
|
||||
|
||||
export function cleanupAcpSessions(sessionIds: string[]) {
|
||||
const bridge = getAIBridge();
|
||||
if (!bridge?.aiAcpCleanup || sessionIds.length === 0) return;
|
||||
for (const sessionId of sessionIds) {
|
||||
void bridge.aiAcpCleanup(sessionId).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function isScopeKeyActive(scopeKey: string, activeTargetIds: Set<string>) {
|
||||
const separatorIndex = scopeKey.indexOf(':');
|
||||
if (separatorIndex === -1) return true;
|
||||
|
||||
const targetId = scopeKey.slice(separatorIndex + 1);
|
||||
if (!targetId) return true;
|
||||
|
||||
return activeTargetIds.has(targetId);
|
||||
}
|
||||
|
||||
export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
const currentSessions = latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? [];
|
||||
|
||||
// Sessions shown by a still-live scope must be protected from cleanup
|
||||
// even when their own `scope.targetId` points at a closed terminal —
|
||||
// history can be resumed into a different terminal and we must not
|
||||
// delete it outright while it's actively being used.
|
||||
const preCleanupActiveSessionMap = latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {};
|
||||
const activeSessionIds = new Set<string>();
|
||||
for (const [scopeKey, sessionId] of Object.entries(preCleanupActiveSessionMap)) {
|
||||
if (!sessionId) continue;
|
||||
if (!isScopeKeyActive(scopeKey, activeTargetIds)) continue;
|
||||
activeSessionIds.add(sessionId);
|
||||
}
|
||||
|
||||
const nextSessionCleanup = pruneInactiveScopedSessions(
|
||||
currentSessions,
|
||||
activeTargetIds,
|
||||
activeSessionIds,
|
||||
);
|
||||
|
||||
if (nextSessionCleanup.orphanedSessionIds.length > 0) {
|
||||
cleanupAcpSessions(nextSessionCleanup.orphanedSessionIds);
|
||||
}
|
||||
|
||||
if (nextSessionCleanup.sessions !== currentSessions) {
|
||||
setLatestAISessionsSnapshot(nextSessionCleanup.sessions);
|
||||
localStorageAdapter.write(
|
||||
STORAGE_KEY_AI_SESSIONS,
|
||||
pruneSessionsForStorage(nextSessionCleanup.sessions),
|
||||
);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
||||
}
|
||||
|
||||
const activeSessionIdMap = preCleanupActiveSessionMap;
|
||||
let activeSessionMapChanged = false;
|
||||
const nextActiveSessionIdMap = { ...activeSessionIdMap };
|
||||
|
||||
for (const scopeKey of Object.keys(activeSessionIdMap)) {
|
||||
if (isScopeKeyActive(scopeKey, activeTargetIds)) continue;
|
||||
delete nextActiveSessionIdMap[scopeKey];
|
||||
activeSessionMapChanged = true;
|
||||
}
|
||||
|
||||
if (activeSessionMapChanged) {
|
||||
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
}
|
||||
|
||||
const currentActiveSessionIdMap = activeSessionMapChanged
|
||||
? nextActiveSessionIdMap
|
||||
: activeSessionIdMap;
|
||||
const currentDraftsByScope = latestAIDraftsByScopeSnapshot ?? {};
|
||||
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
|
||||
const prunedScopedTransientState = pruneInactiveScopedTransientState(
|
||||
currentActiveSessionIdMap,
|
||||
currentDraftsByScope,
|
||||
currentPanelViewByScope,
|
||||
activeTargetIds,
|
||||
);
|
||||
|
||||
if (prunedScopedTransientState.activeSessionIdMap !== currentActiveSessionIdMap) {
|
||||
setLatestAIActiveSessionMapSnapshot(prunedScopedTransientState.activeSessionIdMap);
|
||||
localStorageAdapter.write(
|
||||
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
||||
prunedScopedTransientState.activeSessionIdMap,
|
||||
);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
}
|
||||
|
||||
if (prunedScopedTransientState.draftsByScope !== currentDraftsByScope) {
|
||||
for (const scopeKey of Object.keys(currentDraftsByScope)) {
|
||||
if (scopeKey in prunedScopedTransientState.draftsByScope) continue;
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
bumpDraftUploadGeneration(scopeKey);
|
||||
}
|
||||
setLatestAIDraftsByScopeSnapshot(prunedScopedTransientState.draftsByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
|
||||
}
|
||||
|
||||
if (prunedScopedTransientState.panelViewByScope !== currentPanelViewByScope) {
|
||||
for (const scopeKey of Object.keys(currentPanelViewByScope)) {
|
||||
if (scopeKey in prunedScopedTransientState.panelViewByScope) continue;
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
}
|
||||
setLatestAIPanelViewByScopeSnapshot(prunedScopedTransientState.panelViewByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Maximum number of sessions to keep in localStorage. */
|
||||
const MAX_STORED_SESSIONS = 50;
|
||||
/** Maximum number of messages per session when persisting to localStorage. */
|
||||
const MAX_SESSION_MESSAGES = 200;
|
||||
|
||||
/**
|
||||
* Prune sessions before writing to localStorage to prevent hitting the
|
||||
* ~5-10 MB storage quota. Only affects what is persisted — the in-memory
|
||||
* state retains all messages until the session is reloaded.
|
||||
*
|
||||
* - Keeps only the MAX_STORED_SESSIONS most-recently-updated sessions.
|
||||
* - Trims each session's messages to the last MAX_SESSION_MESSAGES.
|
||||
*/
|
||||
export function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
|
||||
// Sort by updatedAt descending so we keep the newest
|
||||
const sorted = [...sessions].sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
const limited = sorted.slice(0, MAX_STORED_SESSIONS);
|
||||
return limited.map(s => {
|
||||
if (s.messages.length > MAX_SESSION_MESSAGES) {
|
||||
return { ...s, messages: s.messages.slice(-MAX_SESSION_MESSAGES) };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
}
|
||||
|
||||
export let latestAISessionsSnapshot: AISession[] | null = null;
|
||||
export let latestAIActiveSessionMapSnapshot: Record<string, string | null> | null = null;
|
||||
export let latestAIDraftsByScopeSnapshot: DraftsByScope | null = null;
|
||||
export let latestAIPanelViewByScopeSnapshot: PanelViewByScope | null = null;
|
||||
let latestAIDraftMutationVersionByScopeSnapshot: Record<string, number> = {};
|
||||
let latestAIDraftUploadGenerationByScopeSnapshot: Record<string, number> = {};
|
||||
|
||||
export function setLatestAISessionsSnapshot(sessions: AISession[]) {
|
||||
latestAISessionsSnapshot = sessions;
|
||||
}
|
||||
|
||||
export function setLatestAIActiveSessionMapSnapshot(activeSessionIdMap: Record<string, string | null>) {
|
||||
latestAIActiveSessionMapSnapshot = activeSessionIdMap;
|
||||
}
|
||||
|
||||
export function setLatestAIDraftsByScopeSnapshot(draftsByScope: DraftsByScope) {
|
||||
latestAIDraftsByScopeSnapshot = draftsByScope;
|
||||
}
|
||||
|
||||
export function setLatestAIPanelViewByScopeSnapshot(panelViewByScope: PanelViewByScope) {
|
||||
latestAIPanelViewByScopeSnapshot = panelViewByScope;
|
||||
}
|
||||
|
||||
export function bumpDraftMutationVersion(scopeKey: string) {
|
||||
latestAIDraftMutationVersionByScopeSnapshot = bumpDraftMutationVersionState(
|
||||
latestAIDraftMutationVersionByScopeSnapshot,
|
||||
scopeKey,
|
||||
);
|
||||
}
|
||||
|
||||
export function getDraftUploadGeneration(scopeKey: string) {
|
||||
return getDraftUploadGenerationState(
|
||||
latestAIDraftUploadGenerationByScopeSnapshot,
|
||||
scopeKey,
|
||||
);
|
||||
}
|
||||
|
||||
export function bumpDraftUploadGeneration(scopeKey: string) {
|
||||
latestAIDraftUploadGenerationByScopeSnapshot = bumpDraftUploadGenerationState(
|
||||
latestAIDraftUploadGenerationByScopeSnapshot,
|
||||
scopeKey,
|
||||
);
|
||||
}
|
||||
111
application/state/autoSyncRemoteSchedule.test.ts
Normal file
111
application/state/autoSyncRemoteSchedule.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
getRuntimeRemoteCheckIntervalMs,
|
||||
shouldRunRuntimeRemoteCheck,
|
||||
} from './autoSyncRemoteSchedule';
|
||||
|
||||
test("runtime remote checks wait for the startup check to finish", () => {
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
hasAnyConnectedProvider: true,
|
||||
autoSyncEnabled: true,
|
||||
isUnlocked: true,
|
||||
startupRemoteCheckDone: false,
|
||||
isSyncing: false,
|
||||
isSyncRunning: false,
|
||||
remoteCheckInFlight: false,
|
||||
now: 10_000,
|
||||
lastRemoteCheckAt: null,
|
||||
minIntervalMs: 30_000,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("runtime remote checks run immediately after startup gate opens", () => {
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
hasAnyConnectedProvider: true,
|
||||
autoSyncEnabled: true,
|
||||
isUnlocked: true,
|
||||
startupRemoteCheckDone: true,
|
||||
isSyncing: false,
|
||||
isSyncRunning: false,
|
||||
remoteCheckInFlight: false,
|
||||
now: 10_000,
|
||||
lastRemoteCheckAt: null,
|
||||
minIntervalMs: 30_000,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("runtime remote checks respect the minimum interval", () => {
|
||||
const common = {
|
||||
hasAnyConnectedProvider: true,
|
||||
autoSyncEnabled: true,
|
||||
isUnlocked: true,
|
||||
startupRemoteCheckDone: true,
|
||||
isSyncing: false,
|
||||
isSyncRunning: false,
|
||||
remoteCheckInFlight: false,
|
||||
minIntervalMs: 30_000,
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
...common,
|
||||
now: 35_000,
|
||||
lastRemoteCheckAt: 10_000,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
...common,
|
||||
now: 40_000,
|
||||
lastRemoteCheckAt: 10_000,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("forced runtime remote checks bypass only the interval gate", () => {
|
||||
const common = {
|
||||
hasAnyConnectedProvider: true,
|
||||
autoSyncEnabled: true,
|
||||
isUnlocked: true,
|
||||
startupRemoteCheckDone: true,
|
||||
isSyncing: false,
|
||||
isSyncRunning: false,
|
||||
remoteCheckInFlight: false,
|
||||
minIntervalMs: 30_000,
|
||||
force: true,
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
...common,
|
||||
now: 35_000,
|
||||
lastRemoteCheckAt: 10_000,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
...common,
|
||||
isSyncing: true,
|
||||
now: 35_000,
|
||||
lastRemoteCheckAt: 10_000,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("configured auto-sync intervals map to bounded remote recheck intervals", () => {
|
||||
assert.equal(getRuntimeRemoteCheckIntervalMs(1), 30_000);
|
||||
assert.equal(getRuntimeRemoteCheckIntervalMs(10), 300_000);
|
||||
assert.equal(getRuntimeRemoteCheckIntervalMs(120), 300_000);
|
||||
});
|
||||
35
application/state/autoSyncRemoteSchedule.ts
Normal file
35
application/state/autoSyncRemoteSchedule.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
const MIN_RUNTIME_REMOTE_CHECK_MS = 30_000;
|
||||
const MAX_RUNTIME_REMOTE_CHECK_MS = 5 * 60_000;
|
||||
|
||||
export function getRuntimeRemoteCheckIntervalMs(autoSyncIntervalMinutes: number): number {
|
||||
const configuredMs = Math.max(1, Number(autoSyncIntervalMinutes) || 1) * 60_000;
|
||||
return Math.max(
|
||||
MIN_RUNTIME_REMOTE_CHECK_MS,
|
||||
Math.min(MAX_RUNTIME_REMOTE_CHECK_MS, Math.floor(configuredMs / 2)),
|
||||
);
|
||||
}
|
||||
|
||||
export interface RuntimeRemoteCheckInput {
|
||||
hasAnyConnectedProvider: boolean;
|
||||
autoSyncEnabled: boolean;
|
||||
isUnlocked: boolean;
|
||||
startupRemoteCheckDone: boolean;
|
||||
isSyncing: boolean;
|
||||
isSyncRunning: boolean;
|
||||
remoteCheckInFlight: boolean;
|
||||
force?: boolean;
|
||||
now: number;
|
||||
lastRemoteCheckAt: number | null;
|
||||
minIntervalMs: number;
|
||||
}
|
||||
|
||||
export function shouldRunRuntimeRemoteCheck(input: RuntimeRemoteCheckInput): boolean {
|
||||
if (!input.hasAnyConnectedProvider) return false;
|
||||
if (!input.autoSyncEnabled) return false;
|
||||
if (!input.isUnlocked) return false;
|
||||
if (!input.startupRemoteCheckDone) return false;
|
||||
if (input.isSyncing || input.isSyncRunning || input.remoteCheckInFlight) return false;
|
||||
if (input.force === true) return true;
|
||||
if (input.lastRemoteCheckAt == null) return true;
|
||||
return input.now - input.lastRemoteCheckAt >= input.minIntervalMs;
|
||||
}
|
||||
@@ -244,16 +244,3 @@ export const useEditorTab = (id: EditorTabId): EditorTab | undefined => {
|
||||
const getSnapshot = useCallback(() => editorTabStore.getTab(id), [id]);
|
||||
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot);
|
||||
};
|
||||
|
||||
export const useEditorDirty = (id: EditorTabId): boolean => {
|
||||
const getSnapshot = useCallback(() => editorTabStore.isDirty(id), [id]);
|
||||
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot);
|
||||
};
|
||||
|
||||
export const useAnyEditorDirty = (): boolean => {
|
||||
const getSnapshot = useCallback(
|
||||
() => editorTabStore.getTabs().some((t) => t.content !== t.baselineContent),
|
||||
[],
|
||||
);
|
||||
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot);
|
||||
};
|
||||
|
||||
24
application/state/logViewState.ts
Normal file
24
application/state/logViewState.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { ConnectionLog } from "../../domain/models";
|
||||
|
||||
export interface LogView {
|
||||
id: string;
|
||||
connectionLogId: string;
|
||||
log: ConnectionLog;
|
||||
}
|
||||
|
||||
export const getLogViewTabId = (log: Pick<ConnectionLog, "id">): string => `log-${log.id}`;
|
||||
|
||||
export const addLogView = (views: LogView[], log: ConnectionLog): LogView[] => {
|
||||
if (views.some((view) => view.connectionLogId === log.id)) return views;
|
||||
return [
|
||||
...views,
|
||||
{
|
||||
id: getLogViewTabId(log),
|
||||
connectionLogId: log.id,
|
||||
log,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const removeLogView = (views: LogView[], logViewId: string): LogView[] =>
|
||||
views.filter((view) => view.id !== logViewId);
|
||||
@@ -3,33 +3,27 @@ import assert from "node:assert/strict";
|
||||
|
||||
import { resolveCloseIntent } from "./resolveCloseIntent.ts";
|
||||
|
||||
const baseWorkspace = {
|
||||
id: "w1",
|
||||
focusedSessionId: "s1",
|
||||
};
|
||||
|
||||
const baseWorkspace = { id: "w1", focusedSessionId: "s1" };
|
||||
const baseSession = { id: "s1" };
|
||||
|
||||
test("non-workspace tab → closeSingleTab with session id", () => {
|
||||
const result = resolveCloseIntent({
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "s1",
|
||||
workspace: null,
|
||||
sessionForTab: baseSession,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: true,
|
||||
});
|
||||
assert.deepEqual(result, { kind: "closeSingleTab", sessionId: "s1" });
|
||||
assert.deepEqual(r, { kind: "closeSingleTab", sessionId: "s1" });
|
||||
});
|
||||
|
||||
test("non-workspace session tab + sidebar open → closeSidePanel (sidebar beats session close)", () => {
|
||||
test("non-workspace session tab → closeSingleTab even when focus is outside the terminal", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "s1",
|
||||
workspace: null,
|
||||
sessionForTab: { id: "s1" },
|
||||
activeSidePanelTab: "ai",
|
||||
focusIsInsideTerminal: true, // focus IS in terminal, but sidebar wins
|
||||
focusIsInsideTerminal: false,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeSidePanel" });
|
||||
assert.deepEqual(r, { kind: "closeSingleTab", sessionId: "s1" });
|
||||
});
|
||||
|
||||
test("vault/sftp tab → noop", () => {
|
||||
@@ -37,74 +31,37 @@ test("vault/sftp tab → noop", () => {
|
||||
activeTabId: "vault",
|
||||
workspace: null,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: false,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "noop" });
|
||||
});
|
||||
|
||||
test("workspace + focus in terminal + sidebar open → closeSidePanel wins (sidebar beats focus)", () => {
|
||||
test("workspace + focus in terminal → closeTerminal (side panel no longer intercepts)", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: baseWorkspace,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: "ai",
|
||||
focusIsInsideTerminal: true,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeSidePanel" });
|
||||
});
|
||||
|
||||
test("workspace + focus NOT in terminal + sidebar open → closeSidePanel", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: baseWorkspace,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: "sftp",
|
||||
focusIsInsideTerminal: false,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeSidePanel" });
|
||||
});
|
||||
|
||||
test("workspace + sidebar closed + focus in terminal → closeTerminal", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: baseWorkspace,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: true,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeTerminal", sessionId: "s1" });
|
||||
});
|
||||
|
||||
test("workspace + sidebar closed + focus NOT in terminal → closeWorkspace", () => {
|
||||
test("workspace + focus NOT in terminal → closeWorkspace", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: baseWorkspace,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: false,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeWorkspace", workspaceId: "w1" });
|
||||
});
|
||||
|
||||
test("workspace with no focused session + sidebar closed → closeWorkspace", () => {
|
||||
test("workspace with no focused session → closeWorkspace", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: { id: "w1", focusedSessionId: undefined },
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: true, // even if flag true, no focused id → cannot closeTerminal
|
||||
focusIsInsideTerminal: true,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeWorkspace", workspaceId: "w1" });
|
||||
});
|
||||
|
||||
test("workspace with no focused session + sidebar open → closeSidePanel", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: { id: "w1", focusedSessionId: undefined },
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: "ai",
|
||||
focusIsInsideTerminal: false,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeSidePanel" });
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export type CloseIntent =
|
||||
| { kind: 'closeTerminal'; sessionId: string }
|
||||
| { kind: 'closeSidePanel' }
|
||||
| { kind: 'closeWorkspace'; workspaceId: string }
|
||||
| { kind: 'closeSingleTab'; sessionId: string }
|
||||
| { kind: 'noop' };
|
||||
@@ -9,22 +8,14 @@ export interface ResolveCloseInput {
|
||||
activeTabId: string | null;
|
||||
workspace: { id: string; focusedSessionId?: string } | null;
|
||||
sessionForTab: { id: string } | null;
|
||||
activeSidePanelTab: string | null;
|
||||
focusIsInsideTerminal: boolean;
|
||||
}
|
||||
|
||||
export function resolveCloseIntent(input: ResolveCloseInput): CloseIntent {
|
||||
const { activeTabId, workspace, sessionForTab, activeSidePanelTab, focusIsInsideTerminal } = input;
|
||||
const { activeTabId, workspace, sessionForTab, focusIsInsideTerminal } = input;
|
||||
|
||||
if (!activeTabId) return { kind: 'noop' };
|
||||
|
||||
// Sidebar always wins — applies to any tab type (workspace, single-session, etc.).
|
||||
// Modals take priority over this but are intercepted upstream in App.tsx before the
|
||||
// hotkey reaches resolveCloseIntent.
|
||||
if (activeSidePanelTab !== null) {
|
||||
return { kind: 'closeSidePanel' };
|
||||
}
|
||||
|
||||
if (sessionForTab && !workspace) {
|
||||
return { kind: 'closeSingleTab', sessionId: sessionForTab.id };
|
||||
}
|
||||
|
||||
19
application/state/resolveSidePanelToggleIntent.test.ts
Normal file
19
application/state/resolveSidePanelToggleIntent.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { resolveSidePanelToggleIntent } from "./resolveSidePanelToggleIntent.ts";
|
||||
|
||||
test("open: closed with a remembered tab → open that tab", () => {
|
||||
const r = resolveSidePanelToggleIntent({ isOpen: false, lastTab: "sftp", fallbackTab: "scripts" });
|
||||
assert.deepEqual(r, { kind: "open", tab: "sftp" });
|
||||
});
|
||||
|
||||
test("open: closed with no memory → open the fallback tab", () => {
|
||||
const r = resolveSidePanelToggleIntent({ isOpen: false, lastTab: null, fallbackTab: "scripts" });
|
||||
assert.deepEqual(r, { kind: "open", tab: "scripts" });
|
||||
});
|
||||
|
||||
test("close: already open → close", () => {
|
||||
const r = resolveSidePanelToggleIntent({ isOpen: true, lastTab: "theme", fallbackTab: "sftp" });
|
||||
assert.deepEqual(r, { kind: "close" });
|
||||
});
|
||||
18
application/state/resolveSidePanelToggleIntent.ts
Normal file
18
application/state/resolveSidePanelToggleIntent.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export type SidePanelToggleIntent<T extends string> =
|
||||
| { kind: 'close' }
|
||||
| { kind: 'open'; tab: T };
|
||||
|
||||
/**
|
||||
* Decide what the "toggle side panel" shortcut should do.
|
||||
* - If a panel is open → close it.
|
||||
* - If closed → reopen the last-shown sub-panel for the tab, falling back to
|
||||
* `fallbackTab` when the tab has no remembered panel.
|
||||
*/
|
||||
export function resolveSidePanelToggleIntent<T extends string>(input: {
|
||||
isOpen: boolean;
|
||||
lastTab: T | null;
|
||||
fallbackTab: T;
|
||||
}): SidePanelToggleIntent<T> {
|
||||
if (input.isOpen) return { kind: 'close' };
|
||||
return { kind: 'open', tab: input.lastTab ?? input.fallbackTab };
|
||||
}
|
||||
32
application/state/resolveTerminalSessionExitIntent.test.ts
Normal file
32
application/state/resolveTerminalSessionExitIntent.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { resolveTerminalSessionExitIntent } from "./resolveTerminalSessionExitIntent.ts";
|
||||
|
||||
test("normal backend exited events close the session tab", () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 0 }),
|
||||
{ kind: "closeSession" },
|
||||
);
|
||||
});
|
||||
|
||||
test("backend timeout events keep the tab and mark it disconnected", () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "timeout", error: "idle timeout" }),
|
||||
{ kind: "markDisconnected" },
|
||||
);
|
||||
});
|
||||
|
||||
test("backend error events keep the tab and mark it disconnected", () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "error", error: "connection reset" }),
|
||||
{ kind: "markDisconnected" },
|
||||
);
|
||||
});
|
||||
|
||||
test("backend closed events keep the tab and mark it disconnected", () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "closed", exitCode: 0 }),
|
||||
{ kind: "markDisconnected" },
|
||||
);
|
||||
});
|
||||
22
application/state/resolveTerminalSessionExitIntent.ts
Normal file
22
application/state/resolveTerminalSessionExitIntent.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export type TerminalSessionExitEvent = {
|
||||
exitCode?: number;
|
||||
signal?: number;
|
||||
error?: string;
|
||||
reason?: "exited" | "error" | "timeout" | "closed";
|
||||
};
|
||||
|
||||
export type TerminalSessionExitIntent =
|
||||
| { kind: "closeSession" }
|
||||
| { kind: "markDisconnected" };
|
||||
|
||||
export function resolveTerminalSessionExitIntent(
|
||||
evt: TerminalSessionExitEvent,
|
||||
): TerminalSessionExitIntent {
|
||||
if (evt.reason === "exited") {
|
||||
return { kind: "closeSession" };
|
||||
}
|
||||
|
||||
// Timeouts, transport errors, and channel closes should keep the tab visible
|
||||
// so the user can inspect output and reconnect.
|
||||
return { kind: "markDisconnected" };
|
||||
}
|
||||
89
application/state/sessionFactories.ts
Normal file
89
application/state/sessionFactories.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { Host, SerialConfig, TerminalSession } from "../../domain/models";
|
||||
|
||||
export interface LocalTerminalOptions {
|
||||
shellType?: TerminalSession["shellType"];
|
||||
shell?: string;
|
||||
shellArgs?: string[];
|
||||
shellName?: string;
|
||||
shellIcon?: string;
|
||||
}
|
||||
|
||||
export const createLocalTerminalSession = (
|
||||
sessionId: string,
|
||||
options?: LocalTerminalOptions,
|
||||
): TerminalSession => ({
|
||||
id: sessionId,
|
||||
hostId: `local-${sessionId}`,
|
||||
hostLabel: options?.shellName || "Local Terminal",
|
||||
hostname: "localhost",
|
||||
username: "local",
|
||||
status: "connecting",
|
||||
protocol: "local",
|
||||
shellType: options?.shellType,
|
||||
localShell: options?.shell,
|
||||
localShellArgs: options?.shellArgs,
|
||||
localShellName: options?.shellName,
|
||||
localShellIcon: options?.shellIcon,
|
||||
});
|
||||
|
||||
export const createSerialTerminalSession = (
|
||||
sessionId: string,
|
||||
config: SerialConfig,
|
||||
options?: { charset?: string },
|
||||
): TerminalSession => {
|
||||
const portName = config.path.split("/").pop() || config.path;
|
||||
return {
|
||||
id: sessionId,
|
||||
hostId: `serial-${sessionId}`,
|
||||
hostLabel: `Serial: ${portName}`,
|
||||
hostname: config.path,
|
||||
username: "",
|
||||
status: "connecting",
|
||||
protocol: "serial",
|
||||
serialConfig: config,
|
||||
charset: options?.charset,
|
||||
};
|
||||
};
|
||||
|
||||
export const createHostTerminalSession = (
|
||||
sessionId: string,
|
||||
host: Host,
|
||||
): TerminalSession => {
|
||||
if (host.protocol === "serial") {
|
||||
const serialConfig: SerialConfig = host.serialConfig || {
|
||||
path: host.hostname,
|
||||
baudRate: host.port || 115200,
|
||||
dataBits: 8,
|
||||
stopBits: 1,
|
||||
parity: "none",
|
||||
flowControl: "none",
|
||||
localEcho: false,
|
||||
lineMode: false,
|
||||
};
|
||||
const portName = serialConfig.path.split("/").pop() || serialConfig.path;
|
||||
return {
|
||||
id: sessionId,
|
||||
hostId: host.id,
|
||||
hostLabel: host.label || `Serial: ${portName}`,
|
||||
hostname: serialConfig.path,
|
||||
username: "",
|
||||
status: "connecting",
|
||||
protocol: "serial",
|
||||
serialConfig,
|
||||
charset: host.charset,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: sessionId,
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
username: host.username,
|
||||
status: "connecting",
|
||||
protocol: host.protocol,
|
||||
port: host.port,
|
||||
moshEnabled: host.moshEnabled,
|
||||
charset: host.charset,
|
||||
};
|
||||
};
|
||||
235
application/state/settingsIpcSync.ts
Normal file
235
application/state/settingsIpcSync.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { useEffect, type Dispatch, type SetStateAction } from 'react';
|
||||
import type { CustomKeyBindings, HotkeyScheme, SessionLogFormat, TerminalSettings, UILanguage } from '../../domain/models';
|
||||
import { parseCustomKeyBindingsStorageRecord } from '../../domain/customKeyBindings';
|
||||
import { resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import {
|
||||
STORAGE_KEY_ACCENT_MODE,
|
||||
STORAGE_KEY_AUTO_UPDATE_ENABLED,
|
||||
STORAGE_KEY_COLOR,
|
||||
STORAGE_KEY_CUSTOM_CSS,
|
||||
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
|
||||
STORAGE_KEY_EDITOR_WORD_WRAP,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_HOTKEY_RECORDING,
|
||||
STORAGE_KEY_HOTKEY_SCHEME,
|
||||
STORAGE_KEY_SESSION_LOGS_DIR,
|
||||
STORAGE_KEY_SESSION_LOGS_ENABLED,
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
|
||||
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
|
||||
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
STORAGE_KEY_TERM_THEME,
|
||||
STORAGE_KEY_TERM_THEME_DARK,
|
||||
STORAGE_KEY_TERM_THEME_LIGHT,
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_UI_FONT_FAMILY,
|
||||
STORAGE_KEY_UI_LANGUAGE,
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { isValidUiFontId, migrateIncomingTerminalFontId } from './settingsStateDefaults';
|
||||
|
||||
interface UseSettingsIpcSyncParams {
|
||||
syncAppearanceFromStorage: () => void;
|
||||
syncCustomCssFromStorage: () => void;
|
||||
setUiLanguage: Dispatch<SetStateAction<UILanguage>>;
|
||||
setUiFontFamilyId: Dispatch<SetStateAction<string>>;
|
||||
setTerminalThemeId: Dispatch<SetStateAction<string>>;
|
||||
setTerminalThemeDarkId: Dispatch<SetStateAction<string>>;
|
||||
setTerminalThemeLightId: Dispatch<SetStateAction<string>>;
|
||||
setFollowAppTerminalThemeState: Dispatch<SetStateAction<boolean>>;
|
||||
setTerminalFontFamilyId: Dispatch<SetStateAction<string>>;
|
||||
setTerminalFontSize: Dispatch<SetStateAction<number>>;
|
||||
mergeIncomingTerminalSettings: (incoming: Partial<TerminalSettings>) => void;
|
||||
setEditorWordWrapState: Dispatch<SetStateAction<boolean>>;
|
||||
setSessionLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSessionLogsDir: Dispatch<SetStateAction<string>>;
|
||||
setSessionLogsFormat: Dispatch<SetStateAction<SessionLogFormat>>;
|
||||
setHotkeyScheme: Dispatch<SetStateAction<HotkeyScheme>>;
|
||||
applyIncomingCustomKeyBindings: (incoming: { bindings: CustomKeyBindings; version: number; origin: string }) => void;
|
||||
setIsHotkeyRecordingState: Dispatch<SetStateAction<boolean>>;
|
||||
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpAutoOpenSidebar: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
|
||||
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
|
||||
setSftpTransferConcurrencyState: Dispatch<SetStateAction<number>>;
|
||||
}
|
||||
|
||||
export function useSettingsIpcSync({
|
||||
syncAppearanceFromStorage,
|
||||
syncCustomCssFromStorage,
|
||||
setUiLanguage,
|
||||
setUiFontFamilyId,
|
||||
setTerminalThemeId,
|
||||
setTerminalThemeDarkId,
|
||||
setTerminalThemeLightId,
|
||||
setFollowAppTerminalThemeState,
|
||||
setTerminalFontFamilyId,
|
||||
setTerminalFontSize,
|
||||
mergeIncomingTerminalSettings,
|
||||
setEditorWordWrapState,
|
||||
setSessionLogsEnabled,
|
||||
setSessionLogsDir,
|
||||
setSessionLogsFormat,
|
||||
setHotkeyScheme,
|
||||
applyIncomingCustomKeyBindings,
|
||||
setIsHotkeyRecordingState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setAutoUpdateEnabled,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpDefaultViewMode,
|
||||
setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState,
|
||||
}: UseSettingsIpcSyncParams) {
|
||||
// Listen for settings changes from other windows via IPC
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onSettingsChanged) return;
|
||||
const unsubscribe = bridge.onSettingsChanged((payload) => {
|
||||
const { key, value } = payload;
|
||||
if (
|
||||
key === STORAGE_KEY_THEME ||
|
||||
key === STORAGE_KEY_UI_THEME_LIGHT ||
|
||||
key === STORAGE_KEY_UI_THEME_DARK ||
|
||||
key === STORAGE_KEY_ACCENT_MODE ||
|
||||
key === STORAGE_KEY_COLOR
|
||||
) {
|
||||
syncAppearanceFromStorage();
|
||||
return;
|
||||
}
|
||||
if (key === STORAGE_KEY_UI_LANGUAGE && typeof value === 'string') {
|
||||
const next = resolveSupportedLocale(value);
|
||||
setUiLanguage((prev) => (prev === next ? prev : next));
|
||||
document.documentElement.lang = next;
|
||||
}
|
||||
if (key === STORAGE_KEY_CUSTOM_CSS && typeof value === 'string') {
|
||||
syncCustomCssFromStorage();
|
||||
}
|
||||
if (key === STORAGE_KEY_UI_FONT_FAMILY && typeof value === 'string') {
|
||||
if (isValidUiFontId(value)) {
|
||||
setUiFontFamilyId(value);
|
||||
}
|
||||
}
|
||||
if (key === STORAGE_KEY_TERM_THEME && typeof value === 'string') {
|
||||
setTerminalThemeId(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_TERM_THEME_DARK && typeof value === 'string') {
|
||||
setTerminalThemeDarkId(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_TERM_THEME_LIGHT && typeof value === 'string') {
|
||||
setTerminalThemeLightId(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_TERM_FOLLOW_APP_THEME) {
|
||||
const next = value === true || value === 'true';
|
||||
setFollowAppTerminalThemeState((prev) => (prev === next ? prev : next));
|
||||
}
|
||||
if (key === STORAGE_KEY_TERM_FONT_FAMILY && typeof value === 'string') {
|
||||
const migrated = migrateIncomingTerminalFontId(value);
|
||||
if (migrated) setTerminalFontFamilyId(migrated);
|
||||
}
|
||||
if (key === STORAGE_KEY_TERM_FONT_SIZE && typeof value === 'number') {
|
||||
setTerminalFontSize(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_TERM_SETTINGS) {
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Partial<TerminalSettings>;
|
||||
mergeIncomingTerminalSettings(parsed);
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
} else if (value && typeof value === 'object') {
|
||||
mergeIncomingTerminalSettings(value as Partial<TerminalSettings>);
|
||||
}
|
||||
}
|
||||
if (key === STORAGE_KEY_EDITOR_WORD_WRAP && typeof value === 'boolean') {
|
||||
setEditorWordWrapState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SESSION_LOGS_ENABLED && typeof value === 'boolean') {
|
||||
setSessionLogsEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SESSION_LOGS_DIR && typeof value === 'string') {
|
||||
setSessionLogsDir((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (
|
||||
key === STORAGE_KEY_SESSION_LOGS_FORMAT &&
|
||||
(value === 'txt' || value === 'raw' || value === 'html')
|
||||
) {
|
||||
setSessionLogsFormat((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_HOTKEY_SCHEME && (value === 'disabled' || value === 'mac' || value === 'pc')) {
|
||||
setHotkeyScheme(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_CUSTOM_KEY_BINDINGS) {
|
||||
const parsed = parseCustomKeyBindingsStorageRecord(value);
|
||||
if (parsed) {
|
||||
applyIncomingCustomKeyBindings(parsed);
|
||||
}
|
||||
}
|
||||
if (key === STORAGE_KEY_HOTKEY_RECORDING && typeof value === 'boolean') {
|
||||
setIsHotkeyRecordingState(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && typeof value === 'boolean') {
|
||||
setGlobalHotkeyEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_AUTO_UPDATE_ENABLED && typeof value === 'boolean') {
|
||||
setAutoUpdateEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && typeof value === 'boolean') {
|
||||
setSftpAutoOpenSidebar((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE && typeof value === 'string') {
|
||||
if (value === 'list' || value === 'tree') {
|
||||
setSftpDefaultViewMode((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
}
|
||||
if (key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && (value === 'dim' || value === 'border')) {
|
||||
setWorkspaceFocusStyleState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY && typeof value === 'number') {
|
||||
setSftpTransferConcurrencyState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
try {
|
||||
unsubscribe?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
}, [
|
||||
applyIncomingCustomKeyBindings,
|
||||
mergeIncomingTerminalSettings,
|
||||
setAutoUpdateEnabled,
|
||||
setEditorWordWrapState,
|
||||
setFollowAppTerminalThemeState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setHotkeyScheme,
|
||||
setIsHotkeyRecordingState,
|
||||
setSessionLogsDir,
|
||||
setSessionLogsEnabled,
|
||||
setSessionLogsFormat,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpDefaultViewMode,
|
||||
setSftpTransferConcurrencyState,
|
||||
setTerminalFontFamilyId,
|
||||
setTerminalFontSize,
|
||||
setTerminalThemeDarkId,
|
||||
setTerminalThemeId,
|
||||
setTerminalThemeLightId,
|
||||
setUiFontFamilyId,
|
||||
setUiLanguage,
|
||||
setWorkspaceFocusStyleState,
|
||||
syncAppearanceFromStorage,
|
||||
syncCustomCssFromStorage,
|
||||
]);
|
||||
|
||||
|
||||
}
|
||||
158
application/state/settingsStateDefaults.ts
Normal file
158
application/state/settingsStateDefaults.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import type { HotkeyScheme, SessionLogFormat, TerminalSettings } from '../../domain/models';
|
||||
import { STORAGE_KEY_TERM_FONT_FAMILY } from '../../infrastructure/config/storageKeys';
|
||||
import { isDeprecatedPrimaryFontId } from '../../infrastructure/config/fonts';
|
||||
import { DARK_UI_THEMES, LIGHT_UI_THEMES, type UiThemeTokens } from '../../infrastructure/config/uiThemes';
|
||||
import { UI_FONTS } from '../../infrastructure/config/uiFonts';
|
||||
import { uiFontStore } from './uiFontStore';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export const DEFAULT_THEME: 'light' | 'dark' | 'system' = 'dark';
|
||||
|
||||
/** Resolve the current OS color scheme preference. */
|
||||
export const getSystemPreference = (): 'light' | 'dark' =>
|
||||
typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
export const DEFAULT_LIGHT_UI_THEME = 'snow';
|
||||
export const DEFAULT_DARK_UI_THEME = 'midnight';
|
||||
export const DEFAULT_ACCENT_MODE: 'theme' | 'custom' = 'theme';
|
||||
export const DEFAULT_CUSTOM_ACCENT = '221.2 83.2% 53.3%';
|
||||
export const DEFAULT_TERMINAL_THEME = 'netcatty-dark';
|
||||
export const DEFAULT_FONT_FAMILY = 'menlo';
|
||||
|
||||
/**
|
||||
* Migrate any terminal font id arriving from storage / IPC / sync to a
|
||||
* safe value. If `raw` is a deprecated proportional id (pingfang-sc,
|
||||
* microsoft-yahei, comic-sans-ms), persist the rewrite back to
|
||||
* localStorage so subsequent ingest paths and cloud-sync uploads stop
|
||||
* carrying it. Used by every place that reads STORAGE_KEY_TERM_FONT_FAMILY
|
||||
* — initial useState init, rehydrateAllFromStorage, IPC notifySettings
|
||||
* change listener, and cross-window storage event listener — so a
|
||||
* single point of truth keeps deprecated ids from re-entering state.
|
||||
*
|
||||
* Returns null when there's nothing to apply (raw is empty); callers
|
||||
* fall back to DEFAULT_FONT_FAMILY in that case.
|
||||
*/
|
||||
export function migrateIncomingTerminalFontId(raw: string | null | undefined): string | null {
|
||||
if (!raw) return null;
|
||||
if (isDeprecatedPrimaryFontId(raw)) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, DEFAULT_FONT_FAMILY);
|
||||
return DEFAULT_FONT_FAMILY;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
// Auto-detect default hotkey scheme based on platform
|
||||
export const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
|
||||
typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform)
|
||||
? 'mac'
|
||||
: 'pc';
|
||||
export const DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR: 'open' | 'transfer' = 'open';
|
||||
export const DEFAULT_SFTP_AUTO_SYNC = false;
|
||||
export const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
|
||||
export const DEFAULT_SFTP_USE_COMPRESSED_UPLOAD = true;
|
||||
export const DEFAULT_SFTP_AUTO_OPEN_SIDEBAR = false;
|
||||
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;
|
||||
|
||||
// Editor defaults
|
||||
export const DEFAULT_EDITOR_WORD_WRAP = false;
|
||||
|
||||
// Session Logs defaults
|
||||
export const DEFAULT_SESSION_LOGS_ENABLED = false;
|
||||
export const DEFAULT_SESSION_LOGS_FORMAT: SessionLogFormat = 'txt';
|
||||
|
||||
export const readStoredString = (key: string): string | null => {
|
||||
const raw = localStorageAdapter.readString(key);
|
||||
if (!raw) return null;
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
return typeof parsed === 'string' ? parsed : trimmed;
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
};
|
||||
|
||||
export const isValidTheme = (value: unknown): value is 'light' | 'dark' | 'system' => value === 'light' || value === 'dark' || value === 'system';
|
||||
|
||||
export const isValidHslToken = (value: string): boolean => {
|
||||
// Expect: "<h> <s>% <l>%", e.g. "221.2 83.2% 53.3%"
|
||||
return /^\s*\d+(\.\d+)?\s+\d+(\.\d+)?%\s+\d+(\.\d+)?%\s*$/.test(value);
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
export const isValidUiFontId = (value: string): boolean => {
|
||||
// Local fonts are always considered valid
|
||||
if (value.startsWith('local-')) return true;
|
||||
// Check bundled fonts first, then check dynamically loaded fonts
|
||||
return UI_FONTS.some((font) => font.id === value) ||
|
||||
uiFontStore.getAvailableFonts().some((font) => font.id === value);
|
||||
};
|
||||
|
||||
export const serializeTerminalSettings = (settings: TerminalSettings): string =>
|
||||
JSON.stringify(settings);
|
||||
|
||||
export const areTerminalSettingsEqual = (a: TerminalSettings, b: TerminalSettings): boolean =>
|
||||
serializeTerminalSettings(a) === serializeTerminalSettings(b);
|
||||
|
||||
export const createCustomKeyBindingsSyncOrigin = (): string => {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
};
|
||||
|
||||
export const applyThemeTokens = (
|
||||
themeSource: 'light' | 'dark' | 'system',
|
||||
resolvedTheme: 'light' | 'dark',
|
||||
tokens: UiThemeTokens,
|
||||
accentMode: 'theme' | 'custom',
|
||||
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.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);
|
||||
netcattyBridge.get()?.setBackgroundColor?.(tokens.background);
|
||||
};
|
||||
|
||||
412
application/state/settingsStorageSync.ts
Normal file
412
application/state/settingsStorageSync.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
import { useEffect, useRef, type Dispatch, type SetStateAction } from 'react';
|
||||
import type { CustomKeyBindings, HotkeyScheme, SessionLogFormat, TerminalSettings, UILanguage } from '../../domain/models';
|
||||
import { parseCustomKeyBindingsStorageRecord } from '../../domain/customKeyBindings';
|
||||
import { resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import {
|
||||
STORAGE_KEY_ACCENT_MODE,
|
||||
STORAGE_KEY_AUTO_UPDATE_ENABLED,
|
||||
STORAGE_KEY_COLOR,
|
||||
STORAGE_KEY_CUSTOM_CSS,
|
||||
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
|
||||
STORAGE_KEY_EDITOR_WORD_WRAP,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_HOTKEY_SCHEME,
|
||||
STORAGE_KEY_SESSION_LOGS_DIR,
|
||||
STORAGE_KEY_SESSION_LOGS_ENABLED,
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_AUTO_SYNC,
|
||||
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
|
||||
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
STORAGE_KEY_TERM_THEME,
|
||||
STORAGE_KEY_TERM_THEME_DARK,
|
||||
STORAGE_KEY_TERM_THEME_LIGHT,
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_UI_FONT_FAMILY,
|
||||
STORAGE_KEY_UI_LANGUAGE,
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import {
|
||||
isValidHslToken,
|
||||
isValidTheme,
|
||||
isValidUiFontId,
|
||||
isValidUiThemeId,
|
||||
migrateIncomingTerminalFontId,
|
||||
} from './settingsStateDefaults';
|
||||
|
||||
interface UseSettingsStorageSyncParams {
|
||||
theme: 'dark' | 'light' | 'system';
|
||||
lightUiThemeId: string;
|
||||
darkUiThemeId: string;
|
||||
accentMode: 'theme' | 'custom';
|
||||
customAccent: string;
|
||||
customCSS: string;
|
||||
uiFontFamilyId: string;
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
uiLanguage: UILanguage;
|
||||
terminalThemeId: string;
|
||||
followAppTerminalTheme: boolean;
|
||||
terminalFontFamilyId: string;
|
||||
terminalFontSize: number;
|
||||
sftpDoubleClickBehavior: 'open' | 'transfer';
|
||||
sftpAutoSync: boolean;
|
||||
sftpShowHiddenFiles: boolean;
|
||||
sftpUseCompressedUpload: boolean;
|
||||
sftpAutoOpenSidebar: boolean;
|
||||
sftpDefaultViewMode: 'list' | 'tree';
|
||||
showRecentHosts: boolean;
|
||||
showOnlyUngroupedHostsInRoot: boolean;
|
||||
showSftpTab: boolean;
|
||||
editorWordWrap: boolean;
|
||||
sessionLogsEnabled: boolean;
|
||||
sessionLogsDir: string;
|
||||
sessionLogsFormat: SessionLogFormat;
|
||||
globalHotkeyEnabled: boolean;
|
||||
autoUpdateEnabled: boolean;
|
||||
setTheme: Dispatch<SetStateAction<'dark' | 'light' | 'system'>>;
|
||||
setLightUiThemeId: Dispatch<SetStateAction<string>>;
|
||||
setDarkUiThemeId: Dispatch<SetStateAction<string>>;
|
||||
setAccentMode: Dispatch<SetStateAction<'theme' | 'custom'>>;
|
||||
setCustomAccent: Dispatch<SetStateAction<string>>;
|
||||
setCustomCSS: Dispatch<SetStateAction<string>>;
|
||||
setUiFontFamilyId: Dispatch<SetStateAction<string>>;
|
||||
setHotkeyScheme: Dispatch<SetStateAction<HotkeyScheme>>;
|
||||
setUiLanguage: Dispatch<SetStateAction<UILanguage>>;
|
||||
setTerminalThemeId: Dispatch<SetStateAction<string>>;
|
||||
setTerminalThemeDarkId: Dispatch<SetStateAction<string>>;
|
||||
setTerminalThemeLightId: Dispatch<SetStateAction<string>>;
|
||||
setFollowAppTerminalThemeState: Dispatch<SetStateAction<boolean>>;
|
||||
setTerminalFontFamilyId: Dispatch<SetStateAction<string>>;
|
||||
setTerminalFontSize: Dispatch<SetStateAction<number>>;
|
||||
setSftpDoubleClickBehavior: Dispatch<SetStateAction<'open' | 'transfer'>>;
|
||||
setSftpAutoSync: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpShowHiddenFiles: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpUseCompressedUpload: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpAutoOpenSidebar: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
|
||||
setShowRecentHostsState: Dispatch<SetStateAction<boolean>>;
|
||||
setShowOnlyUngroupedHostsInRootState: Dispatch<SetStateAction<boolean>>;
|
||||
setShowSftpTabState: Dispatch<SetStateAction<boolean>>;
|
||||
setEditorWordWrapState: Dispatch<SetStateAction<boolean>>;
|
||||
setSessionLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSessionLogsDir: Dispatch<SetStateAction<string>>;
|
||||
setSessionLogsFormat: Dispatch<SetStateAction<SessionLogFormat>>;
|
||||
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
|
||||
setSftpTransferConcurrencyState: Dispatch<SetStateAction<number>>;
|
||||
applyIncomingCustomKeyBindings: (incoming: { bindings: CustomKeyBindings; version: number; origin: string }) => void;
|
||||
mergeIncomingTerminalSettings: (incoming: Partial<TerminalSettings>) => void;
|
||||
}
|
||||
|
||||
export function useSettingsStorageSync({
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
|
||||
globalHotkeyEnabled, autoUpdateEnabled,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
|
||||
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
|
||||
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat,
|
||||
setGlobalHotkeyEnabled, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
}: UseSettingsStorageSyncParams) {
|
||||
// Fix 4: Keep a ref snapshot of current settings so the storage event handler
|
||||
// can compare without capturing 25+ state variables in its closure / dep array.
|
||||
// This avoids constant listener detach/reattach on every state change.
|
||||
const settingsSnapshotRef = useRef({
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
|
||||
globalHotkeyEnabled, autoUpdateEnabled,
|
||||
});
|
||||
settingsSnapshotRef.current = {
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
|
||||
globalHotkeyEnabled, autoUpdateEnabled,
|
||||
};
|
||||
|
||||
// Listen for storage changes from other windows (cross-window sync)
|
||||
useEffect(() => {
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
const s = settingsSnapshotRef.current;
|
||||
if (e.key === STORAGE_KEY_THEME && e.newValue) {
|
||||
if (isValidTheme(e.newValue) && e.newValue !== s.theme) {
|
||||
setTheme(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_UI_THEME_LIGHT && e.newValue) {
|
||||
if (isValidUiThemeId('light', e.newValue) && e.newValue !== s.lightUiThemeId) {
|
||||
setLightUiThemeId(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_UI_THEME_DARK && e.newValue) {
|
||||
if (isValidUiThemeId('dark', e.newValue) && e.newValue !== s.darkUiThemeId) {
|
||||
setDarkUiThemeId(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_ACCENT_MODE && e.newValue) {
|
||||
if ((e.newValue === 'theme' || e.newValue === 'custom') && e.newValue !== s.accentMode) {
|
||||
setAccentMode(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_COLOR && e.newValue) {
|
||||
if (isValidHslToken(e.newValue) && e.newValue !== s.customAccent) {
|
||||
setCustomAccent(e.newValue.trim());
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_CUSTOM_CSS && e.newValue !== null) {
|
||||
if (e.newValue !== s.customCSS) {
|
||||
setCustomCSS(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_UI_FONT_FAMILY && e.newValue) {
|
||||
if (isValidUiFontId(e.newValue) && e.newValue !== s.uiFontFamilyId) {
|
||||
setUiFontFamilyId(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_HOTKEY_SCHEME && e.newValue) {
|
||||
const newScheme = e.newValue as HotkeyScheme;
|
||||
if (newScheme !== s.hotkeyScheme) {
|
||||
setHotkeyScheme(newScheme);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_UI_LANGUAGE && e.newValue) {
|
||||
const next = resolveSupportedLocale(e.newValue);
|
||||
if (next !== s.uiLanguage) {
|
||||
setUiLanguage(next as UILanguage);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_CUSTOM_KEY_BINDINGS && e.newValue) {
|
||||
const parsed = parseCustomKeyBindingsStorageRecord(e.newValue);
|
||||
if (parsed) {
|
||||
applyIncomingCustomKeyBindings(parsed);
|
||||
}
|
||||
}
|
||||
// Sync terminal settings from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_SETTINGS && e.newValue) {
|
||||
try {
|
||||
const newSettings = JSON.parse(e.newValue) as TerminalSettings;
|
||||
mergeIncomingTerminalSettings(newSettings);
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
// Sync terminal theme from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_THEME && e.newValue) {
|
||||
if (e.newValue !== s.terminalThemeId) {
|
||||
setTerminalThemeId(e.newValue);
|
||||
}
|
||||
}
|
||||
// Sync per-mode follow terminal themes from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_THEME_DARK && e.newValue) {
|
||||
const next = e.newValue;
|
||||
setTerminalThemeDarkId((prev) => (prev === next ? prev : next));
|
||||
}
|
||||
if (e.key === STORAGE_KEY_TERM_THEME_LIGHT && e.newValue) {
|
||||
const next = e.newValue;
|
||||
setTerminalThemeLightId((prev) => (prev === next ? prev : next));
|
||||
}
|
||||
// Sync follow-app-theme toggle from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_FOLLOW_APP_THEME && e.newValue) {
|
||||
const next = e.newValue === 'true';
|
||||
if (next !== s.followAppTerminalTheme) {
|
||||
setFollowAppTerminalThemeState(next);
|
||||
}
|
||||
}
|
||||
// Sync terminal font family from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_FONT_FAMILY && e.newValue) {
|
||||
const migrated = migrateIncomingTerminalFontId(e.newValue);
|
||||
if (migrated && migrated !== s.terminalFontFamilyId) {
|
||||
setTerminalFontFamilyId(migrated);
|
||||
}
|
||||
}
|
||||
// Sync terminal font size from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_FONT_SIZE && e.newValue) {
|
||||
const newSize = parseInt(e.newValue, 10);
|
||||
if (!isNaN(newSize) && newSize !== s.terminalFontSize) {
|
||||
setTerminalFontSize(newSize);
|
||||
}
|
||||
}
|
||||
// Sync SFTP double-click behavior from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR && e.newValue) {
|
||||
if ((e.newValue === 'open' || e.newValue === 'transfer') && e.newValue !== s.sftpDoubleClickBehavior) {
|
||||
setSftpDoubleClickBehavior(e.newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP auto-sync setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_AUTO_SYNC && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.sftpAutoSync) {
|
||||
setSftpAutoSync(newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP show hidden files setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.sftpShowHiddenFiles) {
|
||||
setSftpShowHiddenFiles(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_EDITOR_WORD_WRAP && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.editorWordWrap) {
|
||||
setEditorWordWrapState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SESSION_LOGS_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.sessionLogsEnabled) {
|
||||
setSessionLogsEnabled(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SESSION_LOGS_DIR && e.newValue !== null) {
|
||||
if (e.newValue !== s.sessionLogsDir) {
|
||||
setSessionLogsDir(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SESSION_LOGS_FORMAT && e.newValue) {
|
||||
if (
|
||||
(e.newValue === 'txt' || e.newValue === 'raw' || e.newValue === 'html') &&
|
||||
e.newValue !== s.sessionLogsFormat
|
||||
) {
|
||||
setSessionLogsFormat(e.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';
|
||||
if (newValue !== s.sftpUseCompressedUpload) {
|
||||
setSftpUseCompressedUpload(newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP auto-open sidebar setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.sftpAutoOpenSidebar) {
|
||||
setSftpAutoOpenSidebar(newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP default view mode from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE && e.newValue) {
|
||||
if ((e.newValue === 'list' || e.newValue === 'tree') && e.newValue !== s.sftpDefaultViewMode) {
|
||||
setSftpDefaultViewMode(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SHOW_RECENT_HOSTS && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.showRecentHosts) {
|
||||
setShowRecentHostsState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.showOnlyUngroupedHostsInRoot) {
|
||||
setShowOnlyUngroupedHostsInRootState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SHOW_SFTP_TAB && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.showSftpTab) {
|
||||
setShowSftpTabState(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';
|
||||
if (newValue !== s.globalHotkeyEnabled) {
|
||||
setGlobalHotkeyEnabled(newValue);
|
||||
}
|
||||
}
|
||||
// Sync auto-update enabled setting from other windows
|
||||
if (e.key === STORAGE_KEY_AUTO_UPDATE_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.autoUpdateEnabled) {
|
||||
setAutoUpdateEnabled(newValue);
|
||||
}
|
||||
}
|
||||
// Sync workspace focus style from other windows
|
||||
if (e.key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && e.newValue !== null) {
|
||||
if (e.newValue === 'dim' || e.newValue === 'border') {
|
||||
setWorkspaceFocusStyleState(e.newValue);
|
||||
}
|
||||
}
|
||||
// Sync transfer concurrency from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY && e.newValue !== null) {
|
||||
const num = Number(e.newValue);
|
||||
if (num >= 1 && num <= 16) {
|
||||
setSftpTransferConcurrencyState(num);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, [
|
||||
applyIncomingCustomKeyBindings,
|
||||
mergeIncomingTerminalSettings,
|
||||
setAccentMode,
|
||||
setAutoUpdateEnabled,
|
||||
setCustomAccent,
|
||||
setCustomCSS,
|
||||
setDarkUiThemeId,
|
||||
setEditorWordWrapState,
|
||||
setFollowAppTerminalThemeState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setHotkeyScheme,
|
||||
setLightUiThemeId,
|
||||
setSessionLogsDir,
|
||||
setSessionLogsEnabled,
|
||||
setSessionLogsFormat,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpAutoSync,
|
||||
setSftpDefaultViewMode,
|
||||
setSftpDoubleClickBehavior,
|
||||
setSftpShowHiddenFiles,
|
||||
setSftpTransferConcurrencyState,
|
||||
setSftpUseCompressedUpload,
|
||||
setShowOnlyUngroupedHostsInRootState,
|
||||
setShowRecentHostsState,
|
||||
setShowSftpTabState,
|
||||
setTerminalFontFamilyId,
|
||||
setTerminalFontSize,
|
||||
setTerminalThemeDarkId,
|
||||
setTerminalThemeId,
|
||||
setTerminalThemeLightId,
|
||||
setTheme,
|
||||
setUiFontFamilyId,
|
||||
setUiLanguage,
|
||||
setWorkspaceFocusStyleState,
|
||||
]);
|
||||
|
||||
|
||||
}
|
||||
49
application/state/settingsTerminalTheme.ts
Normal file
49
application/state/settingsTerminalTheme.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { TerminalTheme } from '../../domain/models';
|
||||
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
|
||||
import { applyCustomAccentToTerminalTheme, resolveFollowedTerminalThemeId } from '../../domain/terminalAppearance';
|
||||
|
||||
interface ResolveCurrentTerminalThemeParams {
|
||||
terminalThemeId: string;
|
||||
terminalThemeDarkId: string;
|
||||
terminalThemeLightId: string;
|
||||
customThemes: TerminalTheme[];
|
||||
followAppTerminalTheme: boolean;
|
||||
resolvedTheme: 'light' | 'dark';
|
||||
lightUiThemeId: string;
|
||||
darkUiThemeId: string;
|
||||
accentMode: 'theme' | 'custom';
|
||||
customAccent: string;
|
||||
}
|
||||
|
||||
export function resolveCurrentTerminalTheme({
|
||||
terminalThemeId,
|
||||
terminalThemeDarkId,
|
||||
terminalThemeLightId,
|
||||
customThemes,
|
||||
followAppTerminalTheme,
|
||||
resolvedTheme,
|
||||
lightUiThemeId,
|
||||
darkUiThemeId,
|
||||
accentMode,
|
||||
customAccent,
|
||||
}: ResolveCurrentTerminalThemeParams): TerminalTheme {
|
||||
if (followAppTerminalTheme) {
|
||||
const followedId = resolveFollowedTerminalThemeId({
|
||||
resolvedTheme,
|
||||
terminalThemeDarkId,
|
||||
terminalThemeLightId,
|
||||
lightUiThemeId,
|
||||
darkUiThemeId,
|
||||
fallbackThemeId: terminalThemeId,
|
||||
});
|
||||
const followed = TERMINAL_THEMES.find(t => t.id === followedId)
|
||||
|| customThemes.find(t => t.id === followedId);
|
||||
if (followed) {
|
||||
return applyCustomAccentToTerminalTheme(followed, accentMode, customAccent);
|
||||
}
|
||||
}
|
||||
const baseTheme = TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|
||||
|| customThemes.find(t => t.id === terminalThemeId)
|
||||
|| TERMINAL_THEMES[0];
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
}
|
||||
23
application/state/sftp/bookmarkHelpers.ts
Normal file
23
application/state/sftp/bookmarkHelpers.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { SftpBookmark } from "../../../domain/models";
|
||||
|
||||
const ROOT_PATH_RE = /^[A-Za-z]:[\\/]?$/;
|
||||
|
||||
export function getSftpBookmarkLabel(path: string): string {
|
||||
const trimmed = path.trim();
|
||||
if (trimmed === "/" || ROOT_PATH_RE.test(trimmed)) return trimmed;
|
||||
return trimmed.split(/[\\/]/).filter(Boolean).pop() || trimmed;
|
||||
}
|
||||
|
||||
export function createSftpBookmark(
|
||||
path: string,
|
||||
options: { global?: boolean; idPrefix?: string } = {},
|
||||
): SftpBookmark {
|
||||
const global = options.global === true;
|
||||
const idPrefix = options.idPrefix ?? (global ? "gbm" : "bm");
|
||||
return {
|
||||
id: `${idPrefix}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
path,
|
||||
label: getSftpBookmarkLabel(path),
|
||||
...(global ? { global: true } : {}),
|
||||
};
|
||||
}
|
||||
45
application/state/sftp/globalSftpBookmarks.ts
Normal file
45
application/state/sftp/globalSftpBookmarks.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { SftpBookmark } from "../../../domain/models";
|
||||
import { STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS } from "../../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
let snapshot: SftpBookmark[] =
|
||||
localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) ?? [];
|
||||
|
||||
export function subscribeGlobalSftpBookmarks(listener: Listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function getGlobalSftpBookmarksSnapshot() {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function rehydrateGlobalSftpBookmarks() {
|
||||
snapshot = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) ?? [];
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
export function setGlobalSftpBookmarks(
|
||||
next: SftpBookmark[] | ((prev: SftpBookmark[]) => SftpBookmark[]),
|
||||
) {
|
||||
snapshot = typeof next === "function" ? next(snapshot) : next;
|
||||
localStorageAdapter.write(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS, snapshot);
|
||||
for (const listener of listeners) listener();
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("sftp-bookmarks-changed"));
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("storage", (event) => {
|
||||
if (event.key === STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) {
|
||||
rehydrateGlobalSftpBookmarks();
|
||||
}
|
||||
});
|
||||
}
|
||||
105
application/state/sftp/transferConflictOps.ts
Normal file
105
application/state/sftp/transferConflictOps.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useCallback } from "react";
|
||||
import type { SftpFilenameEncoding, TransferTask } from "../../../domain/models";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import type { SftpPane } from "./types";
|
||||
import { getParentPath, joinPath } from "./utils";
|
||||
|
||||
export function useSftpTransferConflictOps() {
|
||||
const splitNameForDuplicate = useCallback((fileName: string, isDirectory: boolean) => {
|
||||
if (isDirectory) return { baseName: fileName, ext: "" };
|
||||
const lastDot = fileName.lastIndexOf(".");
|
||||
if (lastDot <= 0) return { baseName: fileName, ext: "" };
|
||||
return {
|
||||
baseName: fileName.slice(0, lastDot),
|
||||
ext: fileName.slice(lastDot),
|
||||
};
|
||||
}, []);
|
||||
|
||||
const statTargetPath = useCallback(
|
||||
async (
|
||||
targetPane: SftpPane,
|
||||
targetSftpId: string | null,
|
||||
targetPath: string,
|
||||
targetEncoding: SftpFilenameEncoding,
|
||||
): Promise<{ type?: "file" | "directory" | "symlink"; size: number; mtime: number } | null> => {
|
||||
if (!targetPane.connection) return null;
|
||||
|
||||
if (targetPane.connection.isLocal) {
|
||||
const stat = await netcattyBridge.get()?.statLocal?.(targetPath);
|
||||
if (!stat) return null;
|
||||
return {
|
||||
type: stat.type as "file" | "directory" | "symlink" | undefined,
|
||||
size: stat.size,
|
||||
mtime: stat.lastModified || Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
if (!targetSftpId) return null;
|
||||
const stat = await netcattyBridge.get()?.statSftp?.(
|
||||
targetSftpId,
|
||||
targetPath,
|
||||
targetEncoding,
|
||||
);
|
||||
if (!stat) return null;
|
||||
return {
|
||||
type: stat.type as "file" | "directory" | "symlink" | undefined,
|
||||
size: stat.size,
|
||||
mtime: stat.lastModified || Date.now(),
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const getDuplicateTarget = useCallback(
|
||||
async (
|
||||
task: TransferTask,
|
||||
targetPane: SftpPane,
|
||||
targetSftpId: string | null,
|
||||
targetEncoding: SftpFilenameEncoding,
|
||||
) => {
|
||||
const parentPath = getParentPath(task.targetPath);
|
||||
const { baseName, ext } = splitNameForDuplicate(task.fileName, task.isDirectory);
|
||||
|
||||
for (let index = 1; index < 1000; index++) {
|
||||
const suffix = index === 1 ? " (copy)" : ` (copy ${index})`;
|
||||
const fileName = `${baseName}${suffix}${ext}`;
|
||||
const targetPath = joinPath(parentPath, fileName);
|
||||
try {
|
||||
const existing = await statTargetPath(targetPane, targetSftpId, targetPath, targetEncoding);
|
||||
if (!existing) return { fileName, targetPath };
|
||||
} catch {
|
||||
return { fileName, targetPath };
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackName = `${baseName} (copy ${Date.now()})${ext}`;
|
||||
return { fileName: fallbackName, targetPath: joinPath(parentPath, fallbackName) };
|
||||
},
|
||||
[splitNameForDuplicate, statTargetPath],
|
||||
);
|
||||
|
||||
const deleteTargetPath = useCallback(
|
||||
async (
|
||||
task: TransferTask,
|
||||
targetPane: SftpPane,
|
||||
targetSftpId: string | null,
|
||||
targetEncoding: SftpFilenameEncoding,
|
||||
) => {
|
||||
if (!targetPane.connection) return;
|
||||
if (targetPane.connection.isLocal) {
|
||||
const deleteLocalFile = netcattyBridge.get()?.deleteLocalFile;
|
||||
if (!deleteLocalFile) throw new Error("Local delete unavailable");
|
||||
await deleteLocalFile(task.targetPath);
|
||||
return;
|
||||
}
|
||||
if (!targetSftpId) throw new Error("Target SFTP session not found");
|
||||
const deleteSftp = netcattyBridge.get()?.deleteSftp;
|
||||
if (!deleteSftp) throw new Error("SFTP delete unavailable");
|
||||
await deleteSftp(targetSftpId, task.targetPath, targetEncoding);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
|
||||
return { statTargetPath, getDuplicateTarget, deleteTargetPath };
|
||||
}
|
||||
455
application/state/sftp/transferDirectoryOps.ts
Normal file
455
application/state/sftp/transferDirectoryOps.ts
Normal file
@@ -0,0 +1,455 @@
|
||||
import { useCallback, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
|
||||
import type { SftpFileEntry, SftpFilenameEncoding, TransferStatus, TransferTask } from "../../../domain/models";
|
||||
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from "../../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { joinPath } from "./utils";
|
||||
|
||||
interface UseSftpDirectoryTransferOpsParams {
|
||||
cancelledTasksRef: MutableRefObject<Set<string>>;
|
||||
activeChildIdsRef: MutableRefObject<Map<string, Set<string>>>;
|
||||
setTransfers: Dispatch<SetStateAction<TransferTask[]>>;
|
||||
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
|
||||
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
|
||||
}
|
||||
|
||||
export function useSftpDirectoryTransferOps({
|
||||
cancelledTasksRef,
|
||||
activeChildIdsRef,
|
||||
setTransfers,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
}: UseSftpDirectoryTransferOpsParams) {
|
||||
const getEntrySize = useCallback((entry: SftpFileEntry): number => {
|
||||
if (typeof entry.size === "string") {
|
||||
const parsed = parseInt(entry.size, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
return typeof entry.size === "number" && entry.size > 0 ? entry.size : 0;
|
||||
}, []);
|
||||
|
||||
const MAX_SYMLINK_DEPTH = 32;
|
||||
|
||||
const estimateDirectoryBytes = useCallback(
|
||||
async (
|
||||
sourcePath: string,
|
||||
sourceSftpId: string | null,
|
||||
sourceIsLocal: boolean,
|
||||
sourceEncoding: SftpFilenameEncoding,
|
||||
rootTaskId: string,
|
||||
symlinkDepth = 0,
|
||||
followSymlinks = false,
|
||||
): Promise<number> => {
|
||||
const estT0 = performance.now();
|
||||
if (cancelledTasksRef.current.has(rootTaskId)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
const files = sourceIsLocal
|
||||
? await listLocalFiles(sourcePath)
|
||||
: sourceSftpId
|
||||
? await listRemoteFiles(sourceSftpId, sourcePath, sourceEncoding)
|
||||
: null;
|
||||
|
||||
if (!files) {
|
||||
throw new Error("No source connection");
|
||||
}
|
||||
|
||||
let totalBytes = 0;
|
||||
const subdirs: { entry: SftpFileEntry; nextDepth: number }[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (file.name === ".." || file.name === ".") continue;
|
||||
|
||||
if (file.type === "directory") {
|
||||
subdirs.push({ entry: file, nextDepth: symlinkDepth });
|
||||
} else if (followSymlinks && file.type === "symlink" && file.linkTarget === "directory") {
|
||||
if (symlinkDepth < MAX_SYMLINK_DEPTH) {
|
||||
subdirs.push({ entry: file, nextDepth: symlinkDepth + 1 });
|
||||
}
|
||||
// Skip at max depth — consistent with transferDirectory
|
||||
} else {
|
||||
totalBytes += getEntrySize(file);
|
||||
}
|
||||
}
|
||||
|
||||
if (subdirs.length > 0) {
|
||||
if (cancelledTasksRef.current.has(rootTaskId)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
const subResults = await Promise.all(
|
||||
subdirs.map(({ entry: subdir, nextDepth }) =>
|
||||
estimateDirectoryBytes(
|
||||
joinPath(sourcePath, subdir.name),
|
||||
sourceSftpId,
|
||||
sourceIsLocal,
|
||||
sourceEncoding,
|
||||
rootTaskId,
|
||||
nextDepth,
|
||||
followSymlinks,
|
||||
),
|
||||
),
|
||||
);
|
||||
totalBytes += subResults.reduce((sum, size) => sum + size, 0);
|
||||
}
|
||||
|
||||
logger.debug(`[SFTP:perf] estimateDirectoryBytes ${sourcePath} = ${totalBytes} — ${(performance.now() - estT0).toFixed(0)}ms`);
|
||||
return totalBytes;
|
||||
},
|
||||
[cancelledTasksRef, getEntrySize, listLocalFiles, listRemoteFiles],
|
||||
);
|
||||
|
||||
const transferFile = async (
|
||||
task: TransferTask,
|
||||
sourceSftpId: string | null,
|
||||
targetSftpId: string | null,
|
||||
sourceIsLocal: boolean,
|
||||
targetIsLocal: boolean,
|
||||
sourceEncoding: SftpFilenameEncoding,
|
||||
targetEncoding: SftpFilenameEncoding,
|
||||
rootTaskId: string, // The original top-level task ID for cancellation checking
|
||||
sameHost?: boolean,
|
||||
onStreamProgress?: (transferred: number, total: number, speed: number) => void,
|
||||
): Promise<void> => {
|
||||
// Check if task or root task was cancelled before starting
|
||||
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
transferId: task.id,
|
||||
sourcePath: task.sourcePath,
|
||||
targetPath: task.targetPath,
|
||||
sourceType: sourceIsLocal ? ("local" as const) : ("sftp" as const),
|
||||
targetType: targetIsLocal ? ("local" as const) : ("sftp" as const),
|
||||
sourceSftpId: sourceSftpId || undefined,
|
||||
targetSftpId: targetSftpId || undefined,
|
||||
totalBytes: task.totalBytes || undefined,
|
||||
sourceEncoding: sourceIsLocal ? undefined : sourceEncoding,
|
||||
targetEncoding: targetIsLocal ? undefined : targetEncoding,
|
||||
sameHost: sameHost || undefined,
|
||||
};
|
||||
|
||||
let lastProgressUpdate = 0;
|
||||
const onProgress = (
|
||||
transferred: number,
|
||||
total: number,
|
||||
speed: number,
|
||||
) => {
|
||||
// Bubble up streaming progress to parent (for directory transfers)
|
||||
onStreamProgress?.(transferred, total, speed);
|
||||
|
||||
// Throttle state updates to at most once per 100ms
|
||||
const now = Date.now();
|
||||
if (now - lastProgressUpdate < 100 && transferred < total) return;
|
||||
lastProgressUpdate = now;
|
||||
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => {
|
||||
if (t.id !== task.id) return t;
|
||||
if (t.status === "cancelled") return t;
|
||||
const normalizedTotal = total > 0 ? total : t.totalBytes;
|
||||
// Clamp to [previous, total] — the backend normalizes progress
|
||||
// but we guard against any non-monotonic edge cases.
|
||||
const normalizedTransferred = Math.max(
|
||||
t.transferredBytes,
|
||||
Math.min(transferred, normalizedTotal > 0 ? normalizedTotal : transferred),
|
||||
);
|
||||
return {
|
||||
...t,
|
||||
transferredBytes: normalizedTransferred,
|
||||
totalBytes: normalizedTotal,
|
||||
speed: Number.isFinite(speed) && speed > 0 ? speed : 0,
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onComplete = () => {
|
||||
resolve();
|
||||
};
|
||||
|
||||
const onError = (error: string) => {
|
||||
reject(new Error(error));
|
||||
};
|
||||
|
||||
netcattyBridge.require().startStreamTransfer!(
|
||||
options,
|
||||
onProgress,
|
||||
onComplete,
|
||||
onError,
|
||||
).catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
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,
|
||||
sourceSftpId: string | null,
|
||||
sourceIsLocal: boolean,
|
||||
sourceEncoding: SftpFilenameEncoding,
|
||||
rootTaskId: string,
|
||||
symlinkDepth = 0,
|
||||
followSymlinks = false,
|
||||
): Promise<number> => {
|
||||
if (cancelledTasksRef.current.has(rootTaskId)) return 0;
|
||||
|
||||
const files = sourceIsLocal
|
||||
? await listLocalFiles(sourcePath)
|
||||
: sourceSftpId
|
||||
? await listRemoteFiles(sourceSftpId, sourcePath, sourceEncoding)
|
||||
: null;
|
||||
if (!files) return 0;
|
||||
|
||||
let count = 0;
|
||||
const subdirPromises: Promise<number>[] = [];
|
||||
for (const file of files) {
|
||||
if (file.name === ".." || file.name === ".") continue;
|
||||
if (file.type === "directory") {
|
||||
subdirPromises.push(
|
||||
countDirectoryFiles(joinPath(sourcePath, file.name), sourceSftpId, sourceIsLocal, sourceEncoding, rootTaskId, symlinkDepth, followSymlinks),
|
||||
);
|
||||
} else if (followSymlinks && file.type === "symlink" && file.linkTarget === "directory") {
|
||||
// Only recurse if within depth limit; skip entirely at max depth
|
||||
// (consistent with transferDirectory which also skips these)
|
||||
if (symlinkDepth < MAX_SYMLINK_DEPTH) {
|
||||
subdirPromises.push(
|
||||
countDirectoryFiles(joinPath(sourcePath, file.name), sourceSftpId, sourceIsLocal, sourceEncoding, rootTaskId, symlinkDepth + 1, followSymlinks),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
if (subdirPromises.length > 0) {
|
||||
const subCounts = await Promise.all(subdirPromises);
|
||||
count += subCounts.reduce((a, b) => a + b, 0);
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
/** Returns number of failed child file transfers */
|
||||
const transferDirectory = async (
|
||||
task: TransferTask,
|
||||
sourceSftpId: string | null,
|
||||
targetSftpId: string | null,
|
||||
sourceIsLocal: boolean,
|
||||
targetIsLocal: boolean,
|
||||
sourceEncoding: SftpFilenameEncoding,
|
||||
targetEncoding: SftpFilenameEncoding,
|
||||
rootTaskId: string, // The original top-level task ID for cancellation checking
|
||||
sameHost?: boolean,
|
||||
symlinkDepth = 0,
|
||||
followSymlinks = false, // Only true for downloadToLocal — uploads/copies treat symlinks as files
|
||||
) => {
|
||||
// Check if task or root task was cancelled before starting
|
||||
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
let totalErrors = 0;
|
||||
|
||||
if (targetIsLocal) {
|
||||
try {
|
||||
await netcattyBridge.get()?.mkdirLocal?.(task.targetPath);
|
||||
} catch (mkdirErr: unknown) {
|
||||
const isEEXIST = mkdirErr instanceof Error && mkdirErr.message.includes("EEXIST");
|
||||
if (!isEEXIST) throw mkdirErr;
|
||||
// EEXIST: verify the existing path is actually a directory, not a file
|
||||
const stat = await netcattyBridge.get()?.statLocal?.(task.targetPath);
|
||||
if (stat && stat.type !== 'directory') {
|
||||
throw new Error(`Target path exists as a file: ${task.targetPath}`);
|
||||
}
|
||||
}
|
||||
} else if (targetSftpId) {
|
||||
await netcattyBridge.get()?.mkdirSftp(targetSftpId, task.targetPath, targetEncoding);
|
||||
}
|
||||
|
||||
let files: SftpFileEntry[];
|
||||
if (sourceIsLocal) {
|
||||
files = await listLocalFiles(task.sourcePath);
|
||||
} else if (sourceSftpId) {
|
||||
files = await listRemoteFiles(sourceSftpId, task.sourcePath, sourceEncoding);
|
||||
} else {
|
||||
throw new Error("No source connection");
|
||||
}
|
||||
|
||||
// Filter both "." and ".." — some SFTP servers include "." in readdir
|
||||
const filtered = files.filter((f) => f.name !== ".." && f.name !== ".");
|
||||
// Separate directories from files.
|
||||
// Symlink directories are only followed when followSymlinks is true
|
||||
// (downloadToLocal). Uploads/copies treat symlinks as regular entries
|
||||
// to preserve existing behavior and avoid expanding symlinked trees.
|
||||
const dirs: SftpFileEntry[] = [];
|
||||
const regularFiles: SftpFileEntry[] = [];
|
||||
for (const f of filtered) {
|
||||
if (f.type === "directory") {
|
||||
dirs.push(f);
|
||||
} else if (followSymlinks && f.type === "symlink" && f.linkTarget === "directory") {
|
||||
if (symlinkDepth < MAX_SYMLINK_DEPTH) {
|
||||
dirs.push(f);
|
||||
} else {
|
||||
// Count as an error so the parent task is marked failed
|
||||
totalErrors++;
|
||||
logger.warn(`[SFTP] Skipping symlink directory at max depth: ${joinPath(task.sourcePath, f.name)}`);
|
||||
}
|
||||
} else {
|
||||
regularFiles.push(f);
|
||||
}
|
||||
}
|
||||
|
||||
// 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().
|
||||
for (const dir of dirs) {
|
||||
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
const childTask: TransferTask = {
|
||||
...task,
|
||||
id: crypto.randomUUID(),
|
||||
fileName: dir.name,
|
||||
originalFileName: dir.name,
|
||||
sourcePath: joinPath(task.sourcePath, dir.name),
|
||||
targetPath: joinPath(task.targetPath, dir.name),
|
||||
isDirectory: true,
|
||||
progressMode: "files",
|
||||
parentTaskId: task.id,
|
||||
};
|
||||
|
||||
const isSymlink = dir.type === "symlink";
|
||||
const subdirErrors = await transferDirectory(
|
||||
childTask,
|
||||
sourceSftpId,
|
||||
targetSftpId,
|
||||
sourceIsLocal,
|
||||
targetIsLocal,
|
||||
sourceEncoding,
|
||||
targetEncoding,
|
||||
rootTaskId,
|
||||
sameHost,
|
||||
isSymlink ? symlinkDepth + 1 : symlinkDepth,
|
||||
followSymlinks,
|
||||
);
|
||||
totalErrors += subdirErrors;
|
||||
}
|
||||
|
||||
// Transfer files in parallel with concurrency limit
|
||||
if (regularFiles.length > 0) {
|
||||
let fileIndex = 0;
|
||||
const errors: Error[] = [];
|
||||
|
||||
const worker = async () => {
|
||||
while (fileIndex < regularFiles.length) {
|
||||
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);
|
||||
|
||||
// Track child ID outside React state for immediate cancellation visibility
|
||||
if (!activeChildIdsRef.current.has(rootTaskId)) {
|
||||
activeChildIdsRef.current.set(rootTaskId, new Set());
|
||||
}
|
||||
activeChildIdsRef.current.get(rootTaskId)!.add(fileId);
|
||||
|
||||
const childTask: TransferTask = {
|
||||
...task,
|
||||
id: fileId,
|
||||
fileName: file.name,
|
||||
originalFileName: file.name,
|
||||
sourcePath: joinPath(task.sourcePath, file.name),
|
||||
targetPath: joinPath(task.targetPath, file.name),
|
||||
isDirectory: false,
|
||||
progressMode: "bytes",
|
||||
parentTaskId: rootTaskId,
|
||||
totalBytes: fileSize,
|
||||
// Inherit retryable from parent — downloadToLocal sets retryable: false
|
||||
// because "local" targetConnectionId can't be resolved by retryTransfer
|
||||
retryable: task.retryable,
|
||||
};
|
||||
|
||||
// Register child in transfers array so UI can render it
|
||||
setTransfers((prev) => [...prev, {
|
||||
...childTask,
|
||||
status: "transferring" as TransferStatus,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
}]);
|
||||
|
||||
try {
|
||||
await transferFile(
|
||||
childTask,
|
||||
sourceSftpId,
|
||||
targetSftpId,
|
||||
sourceIsLocal,
|
||||
targetIsLocal,
|
||||
sourceEncoding,
|
||||
targetEncoding,
|
||||
rootTaskId,
|
||||
sameHost,
|
||||
);
|
||||
|
||||
activeChildIdsRef.current.get(rootTaskId)?.delete(fileId);
|
||||
// Mark child as completed & update parent file count
|
||||
setTransfers((prev) => {
|
||||
const updated = prev.map((t) => {
|
||||
if (t.id === fileId) {
|
||||
return { ...t, status: "completed" as TransferStatus, endTime: Date.now(), transferredBytes: t.totalBytes };
|
||||
}
|
||||
if (t.id === rootTaskId) {
|
||||
return { ...t, transferredBytes: t.transferredBytes + 1 };
|
||||
}
|
||||
return t;
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
} catch (err) {
|
||||
activeChildIdsRef.current.get(rootTaskId)?.delete(fileId);
|
||||
// Mark child as failed
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === fileId
|
||||
? { ...t, status: "failed" as TransferStatus, error: err instanceof Error ? err.message : String(err) }
|
||||
: t,
|
||||
),
|
||||
);
|
||||
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) {
|
||||
logger.debug?.("[SFTP] Some files in directory transfer failed", errors);
|
||||
}
|
||||
}
|
||||
|
||||
return totalErrors;
|
||||
};
|
||||
|
||||
|
||||
return { estimateDirectoryBytes, transferFile, countDirectoryFiles, transferDirectory };
|
||||
}
|
||||
115
application/state/sftp/transferTaskOps.ts
Normal file
115
application/state/sftp/transferTaskOps.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useCallback, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
|
||||
import type { FileConflict, TransferStatus, TransferTask } from "../../../domain/models";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import type { TransferResult } from "./useSftpTransfers.types";
|
||||
|
||||
interface UseSftpTransferTaskOpsParams {
|
||||
cancelledTasksRef: MutableRefObject<Set<string>>;
|
||||
activeChildIdsRef: MutableRefObject<Map<string, Set<string>>>;
|
||||
transfersRef: MutableRefObject<TransferTask[]>;
|
||||
completionHandlersRef: MutableRefObject<Map<string, (result: TransferResult) => void | Promise<void>>>;
|
||||
setConflicts: Dispatch<SetStateAction<FileConflict[]>>;
|
||||
setTransfers: Dispatch<SetStateAction<TransferTask[]>>;
|
||||
}
|
||||
|
||||
export function useSftpTransferTaskOps({
|
||||
cancelledTasksRef,
|
||||
activeChildIdsRef,
|
||||
transfersRef,
|
||||
completionHandlersRef,
|
||||
setConflicts,
|
||||
setTransfers,
|
||||
}: UseSftpTransferTaskOpsParams) {
|
||||
const completeCancelledTask = useCallback(
|
||||
async (task: TransferTask) => {
|
||||
const completionHandler = completionHandlersRef.current.get(task.id);
|
||||
if (completionHandler) {
|
||||
try {
|
||||
await completionHandler({
|
||||
id: task.id,
|
||||
fileName: task.fileName,
|
||||
originalFileName: task.originalFileName ?? task.fileName,
|
||||
status: "cancelled",
|
||||
});
|
||||
} finally {
|
||||
completionHandlersRef.current.delete(task.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
[completionHandlersRef],
|
||||
);
|
||||
|
||||
const cancelBackendTransfers = useCallback(async (transferIds: string[]) => {
|
||||
const idsToCancel = new Set<string>();
|
||||
const currentTransfers = transfersRef.current;
|
||||
for (const transferId of transferIds) {
|
||||
idsToCancel.add(transferId);
|
||||
const trackedChildren = activeChildIdsRef.current.get(transferId);
|
||||
if (trackedChildren) {
|
||||
for (const childId of trackedChildren) {
|
||||
idsToCancel.add(childId);
|
||||
cancelledTasksRef.current.add(childId);
|
||||
}
|
||||
}
|
||||
for (const transfer of currentTransfers) {
|
||||
if (
|
||||
transfer.parentTaskId === transferId &&
|
||||
(transfer.status === "transferring" || transfer.status === "pending")
|
||||
) {
|
||||
idsToCancel.add(transfer.id);
|
||||
cancelledTasksRef.current.add(transfer.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cancelTransferAtBackend = netcattyBridge.get()?.cancelTransfer;
|
||||
if (!cancelTransferAtBackend) return;
|
||||
|
||||
await Promise.all(
|
||||
Array.from(idsToCancel).map((id) =>
|
||||
cancelTransferAtBackend(id).catch((err) => {
|
||||
logger.warn("Failed to cancel transfer at backend:", err);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}, [activeChildIdsRef, cancelledTasksRef, transfersRef]);
|
||||
|
||||
const markBatchStopped = useCallback(
|
||||
async (task: TransferTask) => {
|
||||
const batchId = task.batchId;
|
||||
const affected = transfersRef.current.filter((candidate) =>
|
||||
candidate.id === task.id ||
|
||||
(!!batchId && candidate.batchId === batchId && (candidate.status === "pending" || candidate.status === "transferring")),
|
||||
);
|
||||
|
||||
affected.forEach((candidate) => cancelledTasksRef.current.add(candidate.id));
|
||||
const affectedIds = new Set(affected.map((candidate) => candidate.id));
|
||||
setConflicts((prev) => prev.filter((conflict) => conflict.transferId !== task.id && (!batchId || conflict.batchId !== batchId)));
|
||||
setTransfers((prev) => {
|
||||
for (const candidate of prev) {
|
||||
if (candidate.parentTaskId && affectedIds.has(candidate.parentTaskId)) {
|
||||
cancelledTasksRef.current.add(candidate.id);
|
||||
}
|
||||
}
|
||||
|
||||
return prev
|
||||
.filter((candidate) => !(candidate.parentTaskId && affectedIds.has(candidate.parentTaskId)))
|
||||
.map((candidate) =>
|
||||
affectedIds.has(candidate.id)
|
||||
? { ...candidate, status: "cancelled" as TransferStatus, endTime: Date.now() }
|
||||
: candidate,
|
||||
);
|
||||
});
|
||||
await cancelBackendTransfers(affected.map((candidate) => candidate.id));
|
||||
|
||||
for (const candidate of affected) {
|
||||
await completeCancelledTask(candidate);
|
||||
}
|
||||
},
|
||||
[cancelBackendTransfers, cancelledTasksRef, completeCancelledTask, setConflicts, setTransfers, transfersRef],
|
||||
);
|
||||
|
||||
|
||||
return { completeCancelledTask, cancelBackendTransfers, markBatchStopped };
|
||||
}
|
||||
99
application/state/sftp/uploadTaskCallbacks.ts
Normal file
99
application/state/sftp/uploadTaskCallbacks.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { TransferTask, TransferStatus } from "../../../domain/models";
|
||||
import type { UploadCallbacks, UploadTaskInfo } from "../../../lib/uploadService";
|
||||
import { joinPath } from "./utils";
|
||||
|
||||
interface UploadTaskCallbacksParams {
|
||||
connectionId: string;
|
||||
targetPath: string;
|
||||
targetHostId?: string;
|
||||
targetConnectionKey?: string;
|
||||
addExternalUpload?: (task: TransferTask) => void;
|
||||
updateExternalUpload?: (taskId: string, updates: Partial<TransferTask>) => void;
|
||||
dismissExternalUpload?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export const createUploadTaskCallbacks = ({
|
||||
connectionId,
|
||||
targetPath,
|
||||
targetHostId,
|
||||
targetConnectionKey,
|
||||
addExternalUpload,
|
||||
updateExternalUpload,
|
||||
dismissExternalUpload,
|
||||
}: UploadTaskCallbacksParams): UploadCallbacks => ({
|
||||
onScanningStart: (taskId: string) => {
|
||||
if (!addExternalUpload) return;
|
||||
addExternalUpload({
|
||||
id: taskId,
|
||||
fileName: "Scanning files...",
|
||||
sourcePath: "local",
|
||||
targetPath,
|
||||
sourceConnectionId: "external",
|
||||
targetConnectionId: connectionId,
|
||||
targetHostId,
|
||||
targetConnectionKey,
|
||||
direction: "upload",
|
||||
status: "pending" as TransferStatus,
|
||||
totalBytes: 0,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
isDirectory: true,
|
||||
progressMode: "bytes",
|
||||
});
|
||||
},
|
||||
onScanningEnd: (taskId: string) => {
|
||||
dismissExternalUpload?.(taskId);
|
||||
},
|
||||
onTaskCreated: (task: UploadTaskInfo) => {
|
||||
if (!addExternalUpload) return;
|
||||
addExternalUpload({
|
||||
id: task.id,
|
||||
fileName: task.displayName,
|
||||
sourcePath: "local",
|
||||
targetPath: joinPath(targetPath, task.fileName),
|
||||
sourceConnectionId: "external",
|
||||
targetConnectionId: connectionId,
|
||||
targetHostId,
|
||||
targetConnectionKey,
|
||||
direction: "upload",
|
||||
status: "transferring" as TransferStatus,
|
||||
totalBytes: task.totalBytes,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
isDirectory: task.isDirectory,
|
||||
progressMode: task.progressMode ?? "bytes",
|
||||
parentTaskId: task.parentTaskId,
|
||||
});
|
||||
},
|
||||
onTaskProgress: (taskId: string, progress) => {
|
||||
updateExternalUpload?.(taskId, {
|
||||
transferredBytes: progress.transferred,
|
||||
speed: progress.speed,
|
||||
});
|
||||
},
|
||||
onTaskCompleted: (taskId: string, totalBytes: number) => {
|
||||
updateExternalUpload?.(taskId, {
|
||||
status: "completed" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
transferredBytes: totalBytes,
|
||||
speed: 0,
|
||||
});
|
||||
},
|
||||
onTaskFailed: (taskId: string, error: string) => {
|
||||
updateExternalUpload?.(taskId, {
|
||||
status: "failed" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
error,
|
||||
speed: 0,
|
||||
});
|
||||
},
|
||||
onTaskCancelled: (taskId: string) => {
|
||||
updateExternalUpload?.(taskId, {
|
||||
status: "cancelled" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useCallback, useRef, useMemo, useState } from "react";
|
||||
import { FileConflict, FileConflictAction, TransferTask, TransferStatus, SftpFilenameEncoding } from "../../../domain/models";
|
||||
import { useCallback, useRef, useMemo, useState } from "react";
|
||||
import { FileConflict, FileConflictAction, TransferStatus, SftpFilenameEncoding } from "../../../domain/models";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { SftpPane } from "./types";
|
||||
import { joinPath } from "./utils";
|
||||
import { createUploadTaskCallbacks } from "./uploadTaskCallbacks";
|
||||
import {
|
||||
UploadController,
|
||||
uploadFromDataTransfer,
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
UploadBridge,
|
||||
UploadCallbacks,
|
||||
UploadResult,
|
||||
UploadTaskInfo,
|
||||
startUploadScanningTask,
|
||||
} from "../../../lib/uploadService";
|
||||
import type { DropEntry } from "../../../lib/sftpFileUtils";
|
||||
@@ -20,64 +19,7 @@ import type { DropEntry } from "../../../lib/sftpFileUtils";
|
||||
// Re-export UploadResult for external usage
|
||||
export type { UploadResult };
|
||||
|
||||
interface UseSftpExternalOperationsParams {
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
getPaneByConnectionId: (connectionId: string) => SftpPane | null;
|
||||
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
|
||||
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
|
||||
connectionCacheKeyMapRef: React.MutableRefObject<Map<string, string>>;
|
||||
clearDirCacheEntry?: (connectionId: string, path: string) => void;
|
||||
useCompressedUpload?: boolean;
|
||||
addExternalUpload?: (task: TransferTask) => void;
|
||||
updateExternalUpload?: (taskId: string, updates: Partial<TransferTask>) => void;
|
||||
isTransferCancelled?: (taskId: string) => boolean;
|
||||
dismissExternalUpload?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
interface SftpExternalOperationsResult {
|
||||
readTextFile: (side: "left" | "right", filePath: string) => Promise<string>;
|
||||
readBinaryFile: (side: "left" | "right", filePath: string) => Promise<ArrayBuffer>;
|
||||
writeTextFile: (side: "left" | "right", filePath: string, content: string) => Promise<void>;
|
||||
writeTextFileByConnection: (
|
||||
connectionId: string,
|
||||
expectedHostId: string,
|
||||
filePath: string,
|
||||
content: string,
|
||||
filenameEncoding?: SftpFilenameEncoding,
|
||||
) => Promise<void>;
|
||||
downloadToTempAndOpen: (
|
||||
side: "left" | "right",
|
||||
remotePath: string,
|
||||
fileName: string,
|
||||
appPath: string,
|
||||
options?: { enableWatch?: boolean }
|
||||
) => Promise<{ localTempPath: string; watchId?: string }>;
|
||||
activeFileWatchCountRef: React.MutableRefObject<number>;
|
||||
uploadExternalFiles: (
|
||||
side: "left" | "right",
|
||||
dataTransfer: DataTransfer,
|
||||
targetPath?: string
|
||||
) => Promise<UploadResult[]>;
|
||||
uploadExternalFileList: (
|
||||
side: "left" | "right",
|
||||
fileList: FileList | File[],
|
||||
targetPath?: string
|
||||
) => Promise<UploadResult[]>;
|
||||
uploadExternalFolderPath: (
|
||||
side: "left" | "right",
|
||||
folderPath: string,
|
||||
targetPath?: string
|
||||
) => Promise<UploadResult[]>;
|
||||
uploadExternalEntries: (
|
||||
side: "left" | "right",
|
||||
entries: DropEntry[],
|
||||
options?: { targetPath?: string }
|
||||
) => Promise<UploadResult[]>;
|
||||
cancelExternalUpload: () => Promise<void>;
|
||||
selectApplication: () => Promise<{ path: string; name: string } | null>;
|
||||
uploadConflicts: FileConflict[];
|
||||
resolveUploadConflict: (conflictId: string, action: FileConflictAction, applyToAll?: boolean) => void;
|
||||
}
|
||||
import type { UseSftpExternalOperationsParams, SftpExternalOperationsResult } from "./useSftpExternalOperations.types";
|
||||
|
||||
export const useSftpExternalOperations = (
|
||||
params: UseSftpExternalOperationsParams
|
||||
@@ -421,99 +363,15 @@ export const useSftpExternalOperations = (
|
||||
targetPath: string,
|
||||
targetHostId?: string,
|
||||
targetConnectionKey?: string,
|
||||
): UploadCallbacks => {
|
||||
return {
|
||||
onScanningStart: (taskId: string) => {
|
||||
if (addExternalUpload) {
|
||||
const scanningTask: TransferTask = {
|
||||
id: taskId,
|
||||
fileName: "Scanning files...",
|
||||
sourcePath: "local",
|
||||
targetPath,
|
||||
sourceConnectionId: "external",
|
||||
targetConnectionId: connectionId,
|
||||
targetHostId,
|
||||
targetConnectionKey,
|
||||
direction: "upload",
|
||||
status: "pending" as TransferStatus,
|
||||
totalBytes: 0,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
isDirectory: true,
|
||||
progressMode: "bytes",
|
||||
};
|
||||
addExternalUpload(scanningTask);
|
||||
}
|
||||
},
|
||||
onScanningEnd: (taskId: string) => {
|
||||
if (dismissExternalUpload) {
|
||||
dismissExternalUpload(taskId);
|
||||
}
|
||||
},
|
||||
onTaskCreated: (task: UploadTaskInfo) => {
|
||||
if (addExternalUpload) {
|
||||
const transferTask: TransferTask = {
|
||||
id: task.id,
|
||||
fileName: task.displayName,
|
||||
sourcePath: "local",
|
||||
targetPath: joinPath(targetPath, task.fileName),
|
||||
sourceConnectionId: "external",
|
||||
targetConnectionId: connectionId,
|
||||
targetHostId,
|
||||
targetConnectionKey,
|
||||
direction: "upload",
|
||||
status: "transferring" as TransferStatus,
|
||||
totalBytes: task.totalBytes,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
isDirectory: task.isDirectory,
|
||||
progressMode: task.progressMode ?? "bytes",
|
||||
parentTaskId: task.parentTaskId,
|
||||
};
|
||||
addExternalUpload(transferTask);
|
||||
}
|
||||
},
|
||||
onTaskProgress: (taskId: string, progress) => {
|
||||
if (updateExternalUpload) {
|
||||
updateExternalUpload(taskId, {
|
||||
transferredBytes: progress.transferred,
|
||||
speed: progress.speed,
|
||||
});
|
||||
}
|
||||
},
|
||||
onTaskCompleted: (taskId: string, totalBytes: number) => {
|
||||
if (updateExternalUpload) {
|
||||
updateExternalUpload(taskId, {
|
||||
status: "completed" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
transferredBytes: totalBytes,
|
||||
speed: 0,
|
||||
});
|
||||
}
|
||||
},
|
||||
onTaskFailed: (taskId: string, error: string) => {
|
||||
if (updateExternalUpload) {
|
||||
updateExternalUpload(taskId, {
|
||||
status: "failed" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
error,
|
||||
speed: 0,
|
||||
});
|
||||
}
|
||||
},
|
||||
onTaskCancelled: (taskId: string) => {
|
||||
if (updateExternalUpload) {
|
||||
updateExternalUpload(taskId, {
|
||||
status: "cancelled" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}, [addExternalUpload, updateExternalUpload, dismissExternalUpload]);
|
||||
): UploadCallbacks => createUploadTaskCallbacks({
|
||||
connectionId,
|
||||
targetPath,
|
||||
targetHostId,
|
||||
targetConnectionKey,
|
||||
addExternalUpload,
|
||||
updateExternalUpload,
|
||||
dismissExternalUpload,
|
||||
}), [addExternalUpload, updateExternalUpload, dismissExternalUpload]);
|
||||
|
||||
const resolveUploadConflict = useCallback((conflictId: string, action: FileConflictAction, applyToAll = false) => {
|
||||
const conflict = uploadConflicts.find((item) => item.transferId === conflictId);
|
||||
|
||||
65
application/state/sftp/useSftpExternalOperations.types.ts
Normal file
65
application/state/sftp/useSftpExternalOperations.types.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type React from "react";
|
||||
import type { FileConflict, FileConflictAction, TransferTask, SftpFilenameEncoding } from "../../../domain/models";
|
||||
import type { UploadResult } from "../../../lib/uploadService";
|
||||
import type { DropEntry } from "../../../lib/sftpFileUtils";
|
||||
import type { SftpPane } from "./types";
|
||||
|
||||
export interface UseSftpExternalOperationsParams {
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
getPaneByConnectionId: (connectionId: string) => SftpPane | null;
|
||||
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
|
||||
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
|
||||
connectionCacheKeyMapRef: React.MutableRefObject<Map<string, string>>;
|
||||
clearDirCacheEntry?: (connectionId: string, path: string) => void;
|
||||
useCompressedUpload?: boolean;
|
||||
addExternalUpload?: (task: TransferTask) => void;
|
||||
updateExternalUpload?: (taskId: string, updates: Partial<TransferTask>) => void;
|
||||
isTransferCancelled?: (taskId: string) => boolean;
|
||||
dismissExternalUpload?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export interface SftpExternalOperationsResult {
|
||||
readTextFile: (side: "left" | "right", filePath: string) => Promise<string>;
|
||||
readBinaryFile: (side: "left" | "right", filePath: string) => Promise<ArrayBuffer>;
|
||||
writeTextFile: (side: "left" | "right", filePath: string, content: string) => Promise<void>;
|
||||
writeTextFileByConnection: (
|
||||
connectionId: string,
|
||||
expectedHostId: string,
|
||||
filePath: string,
|
||||
content: string,
|
||||
filenameEncoding?: SftpFilenameEncoding,
|
||||
) => Promise<void>;
|
||||
downloadToTempAndOpen: (
|
||||
side: "left" | "right",
|
||||
remotePath: string,
|
||||
fileName: string,
|
||||
appPath: string,
|
||||
options?: { enableWatch?: boolean }
|
||||
) => Promise<{ localTempPath: string; watchId?: string }>;
|
||||
activeFileWatchCountRef: React.MutableRefObject<number>;
|
||||
uploadExternalFiles: (
|
||||
side: "left" | "right",
|
||||
dataTransfer: DataTransfer,
|
||||
targetPath?: string
|
||||
) => Promise<UploadResult[]>;
|
||||
uploadExternalFileList: (
|
||||
side: "left" | "right",
|
||||
fileList: FileList | File[],
|
||||
targetPath?: string
|
||||
) => Promise<UploadResult[]>;
|
||||
uploadExternalFolderPath: (
|
||||
side: "left" | "right",
|
||||
folderPath: string,
|
||||
targetPath?: string
|
||||
) => Promise<UploadResult[]>;
|
||||
uploadExternalEntries: (
|
||||
side: "left" | "right",
|
||||
entries: DropEntry[],
|
||||
options?: { targetPath?: string }
|
||||
) => Promise<UploadResult[]>;
|
||||
cancelExternalUpload: () => Promise<void>;
|
||||
selectApplication: () => Promise<{ path: string; name: string } | null>;
|
||||
uploadConflicts: FileConflict[];
|
||||
resolveUploadConflict: (conflictId: string, action: FileConflictAction, applyToAll?: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -113,6 +113,9 @@ export const buildSftpHostCredentials = ({
|
||||
identityFilePaths: jumpKeyAuth.identityFilePaths,
|
||||
keepaliveInterval: hopKeepalive.interval,
|
||||
keepaliveCountMax: hopKeepalive.countMax,
|
||||
legacyAlgorithms: jumpHost.legacyAlgorithms,
|
||||
skipEcdsaHostKey: jumpHost.skipEcdsaHostKey,
|
||||
algorithmOverrides: jumpHost.algorithms,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -159,6 +162,13 @@ export const buildSftpHostCredentials = ({
|
||||
identityFilePaths: keyAuth.identityFilePaths,
|
||||
keepaliveInterval: targetKeepalive.interval,
|
||||
keepaliveCountMax: targetKeepalive.countMax,
|
||||
// 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,
|
||||
// even though the terminal session works.
|
||||
legacyAlgorithms: host.legacyAlgorithms,
|
||||
skipEcdsaHostKey: host.skipEcdsaHostKey,
|
||||
algorithmOverrides: host.algorithms,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
FileConflict,
|
||||
FileConflictAction,
|
||||
SftpFileEntry,
|
||||
SftpFilenameEncoding,
|
||||
TransferDirection,
|
||||
TransferStatus,
|
||||
@@ -11,66 +10,11 @@ import {
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { SftpPane } from "./types";
|
||||
import { useSftpDirectoryTransferOps } from "./transferDirectoryOps";
|
||||
import { useSftpTransferConflictOps } from "./transferConflictOps";
|
||||
import { useSftpTransferTaskOps } from "./transferTaskOps";
|
||||
import type { TransferResult, UseSftpTransfersParams, UseSftpTransfersResult } from "./useSftpTransfers.types";
|
||||
import { getParentPath, joinPath } from "./utils";
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from "../../../infrastructure/config/storageKeys";
|
||||
|
||||
interface UseSftpTransfersParams {
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
getPaneByConnectionId: (connectionId: string) => SftpPane | null;
|
||||
getTabByConnectionId: (connectionId: string) => { side: "left" | "right"; tabId: string; pane: SftpPane } | null;
|
||||
updateTab: (side: "left" | "right", tabId: string, updater: (pane: SftpPane) => SftpPane) => void;
|
||||
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
|
||||
clearCacheForConnection: (connectionId: string) => void;
|
||||
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
|
||||
connectionCacheKeyMapRef: React.MutableRefObject<Map<string, string>>;
|
||||
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
|
||||
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
|
||||
handleSessionError: (side: "left" | "right", error: Error) => void;
|
||||
}
|
||||
|
||||
interface UseSftpTransfersResult {
|
||||
transfers: TransferTask[];
|
||||
conflicts: FileConflict[];
|
||||
activeTransfersCount: number;
|
||||
startTransfer: (
|
||||
sourceFiles: { name: string; isDirectory: boolean }[],
|
||||
sourceSide: "left" | "right",
|
||||
targetSide: "left" | "right",
|
||||
options?: {
|
||||
sourcePane?: SftpPane;
|
||||
sourcePath?: string;
|
||||
sourceConnectionId?: string;
|
||||
targetPath?: string;
|
||||
onTransferComplete?: (result: TransferResult) => void | Promise<void>;
|
||||
},
|
||||
) => Promise<TransferResult[]>;
|
||||
downloadToLocal: (params: {
|
||||
fileName: string;
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
sftpId: string;
|
||||
connectionId: string;
|
||||
sourceEncoding?: SftpFilenameEncoding;
|
||||
isDirectory: boolean;
|
||||
totalBytes?: number;
|
||||
}) => Promise<TransferStatus>;
|
||||
addExternalUpload: (task: TransferTask) => void;
|
||||
updateExternalUpload: (taskId: string, updates: Partial<TransferTask>) => void;
|
||||
cancelTransfer: (transferId: string) => Promise<void>;
|
||||
isTransferCancelled: (transferId: string) => boolean;
|
||||
retryTransfer: (transferId: string) => Promise<void>;
|
||||
clearCompletedTransfers: () => void;
|
||||
dismissTransfer: (transferId: string) => void;
|
||||
resolveConflict: (conflictId: string, action: FileConflictAction, applyToAll?: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
interface TransferResult {
|
||||
id: string;
|
||||
fileName: string;
|
||||
originalFileName?: string;
|
||||
status: TransferStatus;
|
||||
}
|
||||
|
||||
export const useSftpTransfers = ({
|
||||
getActivePane,
|
||||
@@ -130,618 +74,24 @@ export const useSftpTransfers = ({
|
||||
[],
|
||||
);
|
||||
|
||||
const splitNameForDuplicate = useCallback((fileName: string, isDirectory: boolean) => {
|
||||
if (isDirectory) return { baseName: fileName, ext: "" };
|
||||
const lastDot = fileName.lastIndexOf(".");
|
||||
if (lastDot <= 0) return { baseName: fileName, ext: "" };
|
||||
return {
|
||||
baseName: fileName.slice(0, lastDot),
|
||||
ext: fileName.slice(lastDot),
|
||||
};
|
||||
}, []);
|
||||
const { completeCancelledTask, cancelBackendTransfers, markBatchStopped } = useSftpTransferTaskOps({
|
||||
cancelledTasksRef,
|
||||
activeChildIdsRef,
|
||||
transfersRef,
|
||||
completionHandlersRef,
|
||||
setConflicts,
|
||||
setTransfers,
|
||||
});
|
||||
|
||||
const statTargetPath = useCallback(
|
||||
async (
|
||||
targetPane: SftpPane,
|
||||
targetSftpId: string | null,
|
||||
targetPath: string,
|
||||
targetEncoding: SftpFilenameEncoding,
|
||||
): Promise<{ type?: "file" | "directory" | "symlink"; size: number; mtime: number } | null> => {
|
||||
if (!targetPane.connection) return null;
|
||||
const { statTargetPath, getDuplicateTarget, deleteTargetPath } = useSftpTransferConflictOps();
|
||||
|
||||
if (targetPane.connection.isLocal) {
|
||||
const stat = await netcattyBridge.get()?.statLocal?.(targetPath);
|
||||
if (!stat) return null;
|
||||
return {
|
||||
type: stat.type as "file" | "directory" | "symlink" | undefined,
|
||||
size: stat.size,
|
||||
mtime: stat.lastModified || Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
if (!targetSftpId) return null;
|
||||
const stat = await netcattyBridge.get()?.statSftp?.(
|
||||
targetSftpId,
|
||||
targetPath,
|
||||
targetEncoding,
|
||||
);
|
||||
if (!stat) return null;
|
||||
return {
|
||||
type: stat.type as "file" | "directory" | "symlink" | undefined,
|
||||
size: stat.size,
|
||||
mtime: stat.lastModified || Date.now(),
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const getDuplicateTarget = useCallback(
|
||||
async (
|
||||
task: TransferTask,
|
||||
targetPane: SftpPane,
|
||||
targetSftpId: string | null,
|
||||
targetEncoding: SftpFilenameEncoding,
|
||||
) => {
|
||||
const parentPath = getParentPath(task.targetPath);
|
||||
const { baseName, ext } = splitNameForDuplicate(task.fileName, task.isDirectory);
|
||||
|
||||
for (let index = 1; index < 1000; index++) {
|
||||
const suffix = index === 1 ? " (copy)" : ` (copy ${index})`;
|
||||
const fileName = `${baseName}${suffix}${ext}`;
|
||||
const targetPath = joinPath(parentPath, fileName);
|
||||
try {
|
||||
const existing = await statTargetPath(targetPane, targetSftpId, targetPath, targetEncoding);
|
||||
if (!existing) return { fileName, targetPath };
|
||||
} catch {
|
||||
return { fileName, targetPath };
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackName = `${baseName} (copy ${Date.now()})${ext}`;
|
||||
return { fileName: fallbackName, targetPath: joinPath(parentPath, fallbackName) };
|
||||
},
|
||||
[splitNameForDuplicate, statTargetPath],
|
||||
);
|
||||
|
||||
const completeCancelledTask = useCallback(
|
||||
async (task: TransferTask) => {
|
||||
const completionHandler = completionHandlersRef.current.get(task.id);
|
||||
if (completionHandler) {
|
||||
try {
|
||||
await completionHandler({
|
||||
id: task.id,
|
||||
fileName: task.fileName,
|
||||
originalFileName: task.originalFileName ?? task.fileName,
|
||||
status: "cancelled",
|
||||
});
|
||||
} finally {
|
||||
completionHandlersRef.current.delete(task.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const cancelBackendTransfers = useCallback(async (transferIds: string[]) => {
|
||||
const idsToCancel = new Set<string>();
|
||||
const currentTransfers = transfersRef.current;
|
||||
for (const transferId of transferIds) {
|
||||
idsToCancel.add(transferId);
|
||||
const trackedChildren = activeChildIdsRef.current.get(transferId);
|
||||
if (trackedChildren) {
|
||||
for (const childId of trackedChildren) {
|
||||
idsToCancel.add(childId);
|
||||
cancelledTasksRef.current.add(childId);
|
||||
}
|
||||
}
|
||||
for (const transfer of currentTransfers) {
|
||||
if (
|
||||
transfer.parentTaskId === transferId &&
|
||||
(transfer.status === "transferring" || transfer.status === "pending")
|
||||
) {
|
||||
idsToCancel.add(transfer.id);
|
||||
cancelledTasksRef.current.add(transfer.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cancelTransferAtBackend = netcattyBridge.get()?.cancelTransfer;
|
||||
if (!cancelTransferAtBackend) return;
|
||||
|
||||
await Promise.all(
|
||||
Array.from(idsToCancel).map((id) =>
|
||||
cancelTransferAtBackend(id).catch((err) => {
|
||||
logger.warn("Failed to cancel transfer at backend:", err);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const markBatchStopped = useCallback(
|
||||
async (task: TransferTask) => {
|
||||
const batchId = task.batchId;
|
||||
const affected = transfersRef.current.filter((candidate) =>
|
||||
candidate.id === task.id ||
|
||||
(!!batchId && candidate.batchId === batchId && (candidate.status === "pending" || candidate.status === "transferring")),
|
||||
);
|
||||
|
||||
affected.forEach((candidate) => cancelledTasksRef.current.add(candidate.id));
|
||||
const affectedIds = new Set(affected.map((candidate) => candidate.id));
|
||||
setConflicts((prev) => prev.filter((conflict) => conflict.transferId !== task.id && (!batchId || conflict.batchId !== batchId)));
|
||||
setTransfers((prev) => {
|
||||
for (const candidate of prev) {
|
||||
if (candidate.parentTaskId && affectedIds.has(candidate.parentTaskId)) {
|
||||
cancelledTasksRef.current.add(candidate.id);
|
||||
}
|
||||
}
|
||||
|
||||
return prev
|
||||
.filter((candidate) => !(candidate.parentTaskId && affectedIds.has(candidate.parentTaskId)))
|
||||
.map((candidate) =>
|
||||
affectedIds.has(candidate.id)
|
||||
? { ...candidate, status: "cancelled" as TransferStatus, endTime: Date.now() }
|
||||
: candidate,
|
||||
);
|
||||
});
|
||||
await cancelBackendTransfers(affected.map((candidate) => candidate.id));
|
||||
|
||||
for (const candidate of affected) {
|
||||
await completeCancelledTask(candidate);
|
||||
}
|
||||
},
|
||||
[cancelBackendTransfers, completeCancelledTask],
|
||||
);
|
||||
|
||||
const deleteTargetPath = useCallback(
|
||||
async (
|
||||
task: TransferTask,
|
||||
targetPane: SftpPane,
|
||||
targetSftpId: string | null,
|
||||
targetEncoding: SftpFilenameEncoding,
|
||||
) => {
|
||||
if (!targetPane.connection) return;
|
||||
if (targetPane.connection.isLocal) {
|
||||
const deleteLocalFile = netcattyBridge.get()?.deleteLocalFile;
|
||||
if (!deleteLocalFile) throw new Error("Local delete unavailable");
|
||||
await deleteLocalFile(task.targetPath);
|
||||
return;
|
||||
}
|
||||
if (!targetSftpId) throw new Error("Target SFTP session not found");
|
||||
const deleteSftp = netcattyBridge.get()?.deleteSftp;
|
||||
if (!deleteSftp) throw new Error("SFTP delete unavailable");
|
||||
await deleteSftp(targetSftpId, task.targetPath, targetEncoding);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const getEntrySize = useCallback((entry: SftpFileEntry): number => {
|
||||
if (typeof entry.size === "string") {
|
||||
const parsed = parseInt(entry.size, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
return typeof entry.size === "number" && entry.size > 0 ? entry.size : 0;
|
||||
}, []);
|
||||
|
||||
const MAX_SYMLINK_DEPTH = 32;
|
||||
|
||||
const estimateDirectoryBytes = useCallback(
|
||||
async (
|
||||
sourcePath: string,
|
||||
sourceSftpId: string | null,
|
||||
sourceIsLocal: boolean,
|
||||
sourceEncoding: SftpFilenameEncoding,
|
||||
rootTaskId: string,
|
||||
symlinkDepth = 0,
|
||||
followSymlinks = false,
|
||||
): Promise<number> => {
|
||||
const estT0 = performance.now();
|
||||
if (cancelledTasksRef.current.has(rootTaskId)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
const files = sourceIsLocal
|
||||
? await listLocalFiles(sourcePath)
|
||||
: sourceSftpId
|
||||
? await listRemoteFiles(sourceSftpId, sourcePath, sourceEncoding)
|
||||
: null;
|
||||
|
||||
if (!files) {
|
||||
throw new Error("No source connection");
|
||||
}
|
||||
|
||||
let totalBytes = 0;
|
||||
const subdirs: { entry: SftpFileEntry; nextDepth: number }[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (file.name === ".." || file.name === ".") continue;
|
||||
|
||||
if (file.type === "directory") {
|
||||
subdirs.push({ entry: file, nextDepth: symlinkDepth });
|
||||
} else if (followSymlinks && file.type === "symlink" && file.linkTarget === "directory") {
|
||||
if (symlinkDepth < MAX_SYMLINK_DEPTH) {
|
||||
subdirs.push({ entry: file, nextDepth: symlinkDepth + 1 });
|
||||
}
|
||||
// Skip at max depth — consistent with transferDirectory
|
||||
} else {
|
||||
totalBytes += getEntrySize(file);
|
||||
}
|
||||
}
|
||||
|
||||
if (subdirs.length > 0) {
|
||||
if (cancelledTasksRef.current.has(rootTaskId)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
const subResults = await Promise.all(
|
||||
subdirs.map(({ entry: subdir, nextDepth }) =>
|
||||
estimateDirectoryBytes(
|
||||
joinPath(sourcePath, subdir.name),
|
||||
sourceSftpId,
|
||||
sourceIsLocal,
|
||||
sourceEncoding,
|
||||
rootTaskId,
|
||||
nextDepth,
|
||||
followSymlinks,
|
||||
),
|
||||
),
|
||||
);
|
||||
totalBytes += subResults.reduce((sum, size) => sum + size, 0);
|
||||
}
|
||||
|
||||
logger.debug(`[SFTP:perf] estimateDirectoryBytes ${sourcePath} = ${totalBytes} — ${(performance.now() - estT0).toFixed(0)}ms`);
|
||||
return totalBytes;
|
||||
},
|
||||
[getEntrySize, listLocalFiles, listRemoteFiles],
|
||||
);
|
||||
|
||||
const transferFile = async (
|
||||
task: TransferTask,
|
||||
sourceSftpId: string | null,
|
||||
targetSftpId: string | null,
|
||||
sourceIsLocal: boolean,
|
||||
targetIsLocal: boolean,
|
||||
sourceEncoding: SftpFilenameEncoding,
|
||||
targetEncoding: SftpFilenameEncoding,
|
||||
rootTaskId: string, // The original top-level task ID for cancellation checking
|
||||
sameHost?: boolean,
|
||||
onStreamProgress?: (transferred: number, total: number, speed: number) => void,
|
||||
): Promise<void> => {
|
||||
// Check if task or root task was cancelled before starting
|
||||
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
transferId: task.id,
|
||||
sourcePath: task.sourcePath,
|
||||
targetPath: task.targetPath,
|
||||
sourceType: sourceIsLocal ? ("local" as const) : ("sftp" as const),
|
||||
targetType: targetIsLocal ? ("local" as const) : ("sftp" as const),
|
||||
sourceSftpId: sourceSftpId || undefined,
|
||||
targetSftpId: targetSftpId || undefined,
|
||||
totalBytes: task.totalBytes || undefined,
|
||||
sourceEncoding: sourceIsLocal ? undefined : sourceEncoding,
|
||||
targetEncoding: targetIsLocal ? undefined : targetEncoding,
|
||||
sameHost: sameHost || undefined,
|
||||
};
|
||||
|
||||
let lastProgressUpdate = 0;
|
||||
const onProgress = (
|
||||
transferred: number,
|
||||
total: number,
|
||||
speed: number,
|
||||
) => {
|
||||
// Bubble up streaming progress to parent (for directory transfers)
|
||||
onStreamProgress?.(transferred, total, speed);
|
||||
|
||||
// Throttle state updates to at most once per 100ms
|
||||
const now = Date.now();
|
||||
if (now - lastProgressUpdate < 100 && transferred < total) return;
|
||||
lastProgressUpdate = now;
|
||||
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => {
|
||||
if (t.id !== task.id) return t;
|
||||
if (t.status === "cancelled") return t;
|
||||
const normalizedTotal = total > 0 ? total : t.totalBytes;
|
||||
// Clamp to [previous, total] — the backend normalizes progress
|
||||
// but we guard against any non-monotonic edge cases.
|
||||
const normalizedTransferred = Math.max(
|
||||
t.transferredBytes,
|
||||
Math.min(transferred, normalizedTotal > 0 ? normalizedTotal : transferred),
|
||||
);
|
||||
return {
|
||||
...t,
|
||||
transferredBytes: normalizedTransferred,
|
||||
totalBytes: normalizedTotal,
|
||||
speed: Number.isFinite(speed) && speed > 0 ? speed : 0,
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onComplete = () => {
|
||||
resolve();
|
||||
};
|
||||
|
||||
const onError = (error: string) => {
|
||||
reject(new Error(error));
|
||||
};
|
||||
|
||||
netcattyBridge.require().startStreamTransfer!(
|
||||
options,
|
||||
onProgress,
|
||||
onComplete,
|
||||
onError,
|
||||
).catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
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,
|
||||
sourceSftpId: string | null,
|
||||
sourceIsLocal: boolean,
|
||||
sourceEncoding: SftpFilenameEncoding,
|
||||
rootTaskId: string,
|
||||
symlinkDepth = 0,
|
||||
followSymlinks = false,
|
||||
): Promise<number> => {
|
||||
if (cancelledTasksRef.current.has(rootTaskId)) return 0;
|
||||
|
||||
const files = sourceIsLocal
|
||||
? await listLocalFiles(sourcePath)
|
||||
: sourceSftpId
|
||||
? await listRemoteFiles(sourceSftpId, sourcePath, sourceEncoding)
|
||||
: null;
|
||||
if (!files) return 0;
|
||||
|
||||
let count = 0;
|
||||
const subdirPromises: Promise<number>[] = [];
|
||||
for (const file of files) {
|
||||
if (file.name === ".." || file.name === ".") continue;
|
||||
if (file.type === "directory") {
|
||||
subdirPromises.push(
|
||||
countDirectoryFiles(joinPath(sourcePath, file.name), sourceSftpId, sourceIsLocal, sourceEncoding, rootTaskId, symlinkDepth, followSymlinks),
|
||||
);
|
||||
} else if (followSymlinks && file.type === "symlink" && file.linkTarget === "directory") {
|
||||
// Only recurse if within depth limit; skip entirely at max depth
|
||||
// (consistent with transferDirectory which also skips these)
|
||||
if (symlinkDepth < MAX_SYMLINK_DEPTH) {
|
||||
subdirPromises.push(
|
||||
countDirectoryFiles(joinPath(sourcePath, file.name), sourceSftpId, sourceIsLocal, sourceEncoding, rootTaskId, symlinkDepth + 1, followSymlinks),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
if (subdirPromises.length > 0) {
|
||||
const subCounts = await Promise.all(subdirPromises);
|
||||
count += subCounts.reduce((a, b) => a + b, 0);
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
/** Returns number of failed child file transfers */
|
||||
const transferDirectory = async (
|
||||
task: TransferTask,
|
||||
sourceSftpId: string | null,
|
||||
targetSftpId: string | null,
|
||||
sourceIsLocal: boolean,
|
||||
targetIsLocal: boolean,
|
||||
sourceEncoding: SftpFilenameEncoding,
|
||||
targetEncoding: SftpFilenameEncoding,
|
||||
rootTaskId: string, // The original top-level task ID for cancellation checking
|
||||
sameHost?: boolean,
|
||||
symlinkDepth = 0,
|
||||
followSymlinks = false, // Only true for downloadToLocal — uploads/copies treat symlinks as files
|
||||
) => {
|
||||
// Check if task or root task was cancelled before starting
|
||||
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
let totalErrors = 0;
|
||||
|
||||
if (targetIsLocal) {
|
||||
try {
|
||||
await netcattyBridge.get()?.mkdirLocal?.(task.targetPath);
|
||||
} catch (mkdirErr: unknown) {
|
||||
const isEEXIST = mkdirErr instanceof Error && mkdirErr.message.includes("EEXIST");
|
||||
if (!isEEXIST) throw mkdirErr;
|
||||
// EEXIST: verify the existing path is actually a directory, not a file
|
||||
const stat = await netcattyBridge.get()?.statLocal?.(task.targetPath);
|
||||
if (stat && stat.type !== 'directory') {
|
||||
throw new Error(`Target path exists as a file: ${task.targetPath}`);
|
||||
}
|
||||
}
|
||||
} else if (targetSftpId) {
|
||||
await netcattyBridge.get()?.mkdirSftp(targetSftpId, task.targetPath, targetEncoding);
|
||||
}
|
||||
|
||||
let files: SftpFileEntry[];
|
||||
if (sourceIsLocal) {
|
||||
files = await listLocalFiles(task.sourcePath);
|
||||
} else if (sourceSftpId) {
|
||||
files = await listRemoteFiles(sourceSftpId, task.sourcePath, sourceEncoding);
|
||||
} else {
|
||||
throw new Error("No source connection");
|
||||
}
|
||||
|
||||
// Filter both "." and ".." — some SFTP servers include "." in readdir
|
||||
const filtered = files.filter((f) => f.name !== ".." && f.name !== ".");
|
||||
// Separate directories from files.
|
||||
// Symlink directories are only followed when followSymlinks is true
|
||||
// (downloadToLocal). Uploads/copies treat symlinks as regular entries
|
||||
// to preserve existing behavior and avoid expanding symlinked trees.
|
||||
const dirs: SftpFileEntry[] = [];
|
||||
const regularFiles: SftpFileEntry[] = [];
|
||||
for (const f of filtered) {
|
||||
if (f.type === "directory") {
|
||||
dirs.push(f);
|
||||
} else if (followSymlinks && f.type === "symlink" && f.linkTarget === "directory") {
|
||||
if (symlinkDepth < MAX_SYMLINK_DEPTH) {
|
||||
dirs.push(f);
|
||||
} else {
|
||||
// Count as an error so the parent task is marked failed
|
||||
totalErrors++;
|
||||
logger.warn(`[SFTP] Skipping symlink directory at max depth: ${joinPath(task.sourcePath, f.name)}`);
|
||||
}
|
||||
} else {
|
||||
regularFiles.push(f);
|
||||
}
|
||||
}
|
||||
|
||||
// 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().
|
||||
for (const dir of dirs) {
|
||||
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
const childTask: TransferTask = {
|
||||
...task,
|
||||
id: crypto.randomUUID(),
|
||||
fileName: dir.name,
|
||||
originalFileName: dir.name,
|
||||
sourcePath: joinPath(task.sourcePath, dir.name),
|
||||
targetPath: joinPath(task.targetPath, dir.name),
|
||||
isDirectory: true,
|
||||
progressMode: "files",
|
||||
parentTaskId: task.id,
|
||||
};
|
||||
|
||||
const isSymlink = dir.type === "symlink";
|
||||
const subdirErrors = await transferDirectory(
|
||||
childTask,
|
||||
sourceSftpId,
|
||||
targetSftpId,
|
||||
sourceIsLocal,
|
||||
targetIsLocal,
|
||||
sourceEncoding,
|
||||
targetEncoding,
|
||||
rootTaskId,
|
||||
sameHost,
|
||||
isSymlink ? symlinkDepth + 1 : symlinkDepth,
|
||||
followSymlinks,
|
||||
);
|
||||
totalErrors += subdirErrors;
|
||||
}
|
||||
|
||||
// Transfer files in parallel with concurrency limit
|
||||
if (regularFiles.length > 0) {
|
||||
let fileIndex = 0;
|
||||
const errors: Error[] = [];
|
||||
|
||||
const worker = async () => {
|
||||
while (fileIndex < regularFiles.length) {
|
||||
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);
|
||||
|
||||
// Track child ID outside React state for immediate cancellation visibility
|
||||
if (!activeChildIdsRef.current.has(rootTaskId)) {
|
||||
activeChildIdsRef.current.set(rootTaskId, new Set());
|
||||
}
|
||||
activeChildIdsRef.current.get(rootTaskId)!.add(fileId);
|
||||
|
||||
const childTask: TransferTask = {
|
||||
...task,
|
||||
id: fileId,
|
||||
fileName: file.name,
|
||||
originalFileName: file.name,
|
||||
sourcePath: joinPath(task.sourcePath, file.name),
|
||||
targetPath: joinPath(task.targetPath, file.name),
|
||||
isDirectory: false,
|
||||
progressMode: "bytes",
|
||||
parentTaskId: rootTaskId,
|
||||
totalBytes: fileSize,
|
||||
// Inherit retryable from parent — downloadToLocal sets retryable: false
|
||||
// because "local" targetConnectionId can't be resolved by retryTransfer
|
||||
retryable: task.retryable,
|
||||
};
|
||||
|
||||
// Register child in transfers array so UI can render it
|
||||
setTransfers((prev) => [...prev, {
|
||||
...childTask,
|
||||
status: "transferring" as TransferStatus,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
}]);
|
||||
|
||||
try {
|
||||
await transferFile(
|
||||
childTask,
|
||||
sourceSftpId,
|
||||
targetSftpId,
|
||||
sourceIsLocal,
|
||||
targetIsLocal,
|
||||
sourceEncoding,
|
||||
targetEncoding,
|
||||
rootTaskId,
|
||||
sameHost,
|
||||
);
|
||||
|
||||
activeChildIdsRef.current.get(rootTaskId)?.delete(fileId);
|
||||
// Mark child as completed & update parent file count
|
||||
setTransfers((prev) => {
|
||||
const updated = prev.map((t) => {
|
||||
if (t.id === fileId) {
|
||||
return { ...t, status: "completed" as TransferStatus, endTime: Date.now(), transferredBytes: t.totalBytes };
|
||||
}
|
||||
if (t.id === rootTaskId) {
|
||||
return { ...t, transferredBytes: t.transferredBytes + 1 };
|
||||
}
|
||||
return t;
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
} catch (err) {
|
||||
activeChildIdsRef.current.get(rootTaskId)?.delete(fileId);
|
||||
// Mark child as failed
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === fileId
|
||||
? { ...t, status: "failed" as TransferStatus, error: err instanceof Error ? err.message : String(err) }
|
||||
: t,
|
||||
),
|
||||
);
|
||||
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) {
|
||||
logger.debug?.("[SFTP] Some files in directory transfer failed", errors);
|
||||
}
|
||||
}
|
||||
|
||||
return totalErrors;
|
||||
};
|
||||
const { estimateDirectoryBytes, transferFile, countDirectoryFiles, transferDirectory } = useSftpDirectoryTransferOps({
|
||||
cancelledTasksRef,
|
||||
activeChildIdsRef,
|
||||
setTransfers,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
});
|
||||
|
||||
const processTransfer = async (
|
||||
task: TransferTask,
|
||||
|
||||
60
application/state/sftp/useSftpTransfers.types.ts
Normal file
60
application/state/sftp/useSftpTransfers.types.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { MutableRefObject } from "react";
|
||||
import type { FileConflict, FileConflictAction, SftpFileEntry, SftpFilenameEncoding, TransferStatus, TransferTask } from "../../../domain/models";
|
||||
import type { SftpPane } from "./types";
|
||||
|
||||
export interface UseSftpTransfersParams {
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
getPaneByConnectionId: (connectionId: string) => SftpPane | null;
|
||||
getTabByConnectionId: (connectionId: string) => { side: "left" | "right"; tabId: string; pane: SftpPane } | null;
|
||||
updateTab: (side: "left" | "right", tabId: string, updater: (pane: SftpPane) => SftpPane) => void;
|
||||
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
|
||||
clearCacheForConnection: (connectionId: string) => void;
|
||||
sftpSessionsRef: MutableRefObject<Map<string, string>>;
|
||||
connectionCacheKeyMapRef: MutableRefObject<Map<string, string>>;
|
||||
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
|
||||
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
|
||||
handleSessionError: (side: "left" | "right", error: Error) => void;
|
||||
}
|
||||
|
||||
export interface UseSftpTransfersResult {
|
||||
transfers: TransferTask[];
|
||||
conflicts: FileConflict[];
|
||||
activeTransfersCount: number;
|
||||
startTransfer: (
|
||||
sourceFiles: { name: string; isDirectory: boolean }[],
|
||||
sourceSide: "left" | "right",
|
||||
targetSide: "left" | "right",
|
||||
options?: {
|
||||
sourcePane?: SftpPane;
|
||||
sourcePath?: string;
|
||||
sourceConnectionId?: string;
|
||||
targetPath?: string;
|
||||
onTransferComplete?: (result: TransferResult) => void | Promise<void>;
|
||||
},
|
||||
) => Promise<TransferResult[]>;
|
||||
downloadToLocal: (params: {
|
||||
fileName: string;
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
sftpId: string;
|
||||
connectionId: string;
|
||||
sourceEncoding?: SftpFilenameEncoding;
|
||||
isDirectory: boolean;
|
||||
totalBytes?: number;
|
||||
}) => Promise<TransferStatus>;
|
||||
addExternalUpload: (task: TransferTask) => void;
|
||||
updateExternalUpload: (taskId: string, updates: Partial<TransferTask>) => void;
|
||||
cancelTransfer: (transferId: string) => Promise<void>;
|
||||
isTransferCancelled: (transferId: string) => boolean;
|
||||
retryTransfer: (transferId: string) => Promise<void>;
|
||||
clearCompletedTransfers: () => void;
|
||||
dismissTransfer: (transferId: string) => void;
|
||||
resolveConflict: (conflictId: string, action: FileConflictAction, applyToAll?: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface TransferResult {
|
||||
id: string;
|
||||
fileName: string;
|
||||
originalFileName?: string;
|
||||
status: TransferStatus;
|
||||
}
|
||||
11
application/state/sftp/utils.test.ts
Normal file
11
application/state/sftp/utils.test.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { isConcreteTransferTargetPath } from "./utils";
|
||||
|
||||
test("concrete transfer target paths exclude temporary placeholders", () => {
|
||||
assert.equal(isConcreteTransferTargetPath({ targetPath: "/Users/alice/Downloads/report.pdf" }), true);
|
||||
assert.equal(isConcreteTransferTargetPath({ targetPath: "C:\\Users\\alice\\Downloads\\report.pdf" }), true);
|
||||
assert.equal(isConcreteTransferTargetPath({ targetPath: "(temp)" }), false);
|
||||
assert.equal(isConcreteTransferTargetPath({ targetPath: " " }), false);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SftpFileEntry } from "../../../domain/models";
|
||||
import { SftpFileEntry, TransferTask } from "../../../domain/models";
|
||||
|
||||
export const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return "--";
|
||||
@@ -76,6 +76,11 @@ export const getParentPath = (path: string): string => {
|
||||
return result;
|
||||
};
|
||||
|
||||
export const isConcreteTransferTargetPath = (task: Pick<TransferTask, "targetPath">): boolean => {
|
||||
const targetPath = task.targetPath.trim();
|
||||
return targetPath.length > 0 && targetPath !== "(temp)";
|
||||
};
|
||||
|
||||
export const getFileName = (path: string): string => {
|
||||
const parts = path.split(/[\\/]/).filter(Boolean);
|
||||
return parts[parts.length - 1] || "";
|
||||
|
||||
4
application/state/snippetVariableValues.ts
Normal file
4
application/state/snippetVariableValues.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
readSnippetVariableValuesForSnippet,
|
||||
saveSnippetVariableValues,
|
||||
} from '../../infrastructure/persistence/snippetVariableValuesStorage';
|
||||
123
application/state/systemSettingsEffects.ts
Normal file
123
application/state/systemSettingsEffects.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { useEffect, type MutableRefObject } from 'react';
|
||||
import {
|
||||
STORAGE_KEY_AUTO_UPDATE_ENABLED,
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
interface UseSystemSettingsEffectsParams {
|
||||
toggleWindowHotkey: string;
|
||||
globalHotkeyEnabled: boolean;
|
||||
closeToTray: boolean;
|
||||
autoUpdateEnabled: boolean;
|
||||
persistMountedRef: MutableRefObject<boolean>;
|
||||
setHotkeyRegistrationError: (error: string | null) => void;
|
||||
setAutoUpdateEnabled: (enabled: boolean | ((prev: boolean) => boolean)) => void;
|
||||
notifySettingsChanged: (key: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
export function useSystemSettingsEffects({
|
||||
toggleWindowHotkey,
|
||||
globalHotkeyEnabled,
|
||||
closeToTray,
|
||||
autoUpdateEnabled,
|
||||
persistMountedRef,
|
||||
setHotkeyRegistrationError,
|
||||
setAutoUpdateEnabled,
|
||||
notifySettingsChanged,
|
||||
}: UseSystemSettingsEffectsParams) {
|
||||
// Persist and sync toggle window hotkey setting
|
||||
useEffect(() => {
|
||||
// Register/unregister the global hotkey in main process (needed on mount)
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.registerGlobalHotkey) {
|
||||
if (toggleWindowHotkey && globalHotkeyEnabled) {
|
||||
setHotkeyRegistrationError(null);
|
||||
bridge
|
||||
.registerGlobalHotkey(toggleWindowHotkey)
|
||||
.then((result) => {
|
||||
if (result?.success === false) {
|
||||
console.warn('[GlobalHotkey] Hotkey registration failed:', result.error);
|
||||
setHotkeyRegistrationError(result.error || 'Failed to register hotkey');
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('[GlobalHotkey] Failed to register hotkey:', err);
|
||||
setHotkeyRegistrationError(err?.message || 'Failed to register hotkey');
|
||||
});
|
||||
} else {
|
||||
setHotkeyRegistrationError(null);
|
||||
bridge.unregisterGlobalHotkey?.().catch((err) => {
|
||||
console.warn('[GlobalHotkey] Failed to unregister hotkey:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
|
||||
// Skip IPC on initial mount
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
|
||||
}, [
|
||||
toggleWindowHotkey,
|
||||
globalHotkeyEnabled,
|
||||
notifySettingsChanged,
|
||||
persistMountedRef,
|
||||
setHotkeyRegistrationError,
|
||||
]);
|
||||
|
||||
// Persist global hotkey enabled setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled);
|
||||
}, [globalHotkeyEnabled, notifySettingsChanged, persistMountedRef]);
|
||||
|
||||
// Persist and sync close to tray setting
|
||||
useEffect(() => {
|
||||
// Update main process tray behavior (needed on mount)
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.setCloseToTray) {
|
||||
bridge.setCloseToTray(closeToTray).catch((err) => {
|
||||
console.warn('[SystemTray] Failed to set close-to-tray:', err);
|
||||
});
|
||||
}
|
||||
localStorageAdapter.writeString(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray ? 'true' : 'false');
|
||||
// Skip IPC on initial mount
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
|
||||
}, [closeToTray, 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(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
void bridge?.getAutoUpdate?.().then((result) => {
|
||||
if (result && typeof result.enabled === 'boolean') {
|
||||
setAutoUpdateEnabled((prev) => {
|
||||
if (prev === result.enabled) return prev;
|
||||
// Sync localStorage with the main-process truth
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AUTO_UPDATE_ENABLED, result.enabled ? 'true' : 'false');
|
||||
return result.enabled;
|
||||
});
|
||||
}
|
||||
}).catch(() => { /* bridge unavailable */ });
|
||||
}, [setAutoUpdateEnabled]);
|
||||
|
||||
// Persist auto-update enabled setting.
|
||||
// Initial mount still writes localStorage, but skips cross-window/main-process IPC.
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled);
|
||||
// Notify main process on user-initiated changes
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.setAutoUpdate?.(autoUpdateEnabled).catch((err: unknown) => {
|
||||
console.warn('[AutoUpdate] Failed to set auto-update:', err);
|
||||
});
|
||||
}, [autoUpdateEnabled, notifySettingsChanged, persistMountedRef]);
|
||||
|
||||
|
||||
}
|
||||
@@ -15,11 +15,11 @@ import {
|
||||
STORAGE_KEY_AI_SESSIONS,
|
||||
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type {
|
||||
AIDraft,
|
||||
AIPanelView,
|
||||
AISession,
|
||||
AIPermissionMode,
|
||||
AIToolIntegrationMode,
|
||||
@@ -33,228 +33,36 @@ import type {
|
||||
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
|
||||
import {
|
||||
activateDraftView,
|
||||
bumpDraftMutationVersionState,
|
||||
bumpDraftUploadGenerationState,
|
||||
clearScopeDraftState,
|
||||
ensureDraftForScopeState,
|
||||
getDraftUploadGenerationState,
|
||||
setSessionView,
|
||||
updateDraftForScope,
|
||||
} from './aiDraftState';
|
||||
import {
|
||||
pruneInactiveScopedSessions,
|
||||
pruneInactiveScopedTransientState,
|
||||
} from './aiScopeCleanup';
|
||||
import { convertFilesToUploads } from './useFileUpload';
|
||||
import { removeProviderReferences } from './aiProviderCleanup';
|
||||
|
||||
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
|
||||
interface AIBridge {
|
||||
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
|
||||
aiMcpSetPermissionMode?: (mode: AIPermissionMode) => Promise<unknown> | unknown;
|
||||
aiMcpSetToolIntegrationMode?: (mode: AIToolIntegrationMode) => Promise<unknown> | unknown;
|
||||
aiMcpSetCommandBlocklist?: (blocklist: string[]) => Promise<unknown> | unknown;
|
||||
aiMcpSetCommandTimeout?: (timeout: number) => Promise<unknown> | unknown;
|
||||
aiMcpSetMaxIterations?: (maxIterations: number) => Promise<unknown> | unknown;
|
||||
}
|
||||
|
||||
function getAIBridge() {
|
||||
return (window as unknown as { netcatty?: AIBridge }).netcatty;
|
||||
}
|
||||
|
||||
const AI_STATE_CHANGED_EVENT = 'netcatty:ai-state-changed';
|
||||
const AI_STATE_CHANGED_DRAFTS_BY_SCOPE = 'netcatty:ai-drafts-by-scope';
|
||||
const AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE = 'netcatty:ai-panel-view-by-scope';
|
||||
|
||||
type DraftsByScope = Partial<Record<string, AIDraft>>;
|
||||
type PanelViewByScope = Partial<Record<string, AIPanelView>>;
|
||||
|
||||
function emitAIStateChanged(key: string) {
|
||||
window.dispatchEvent(new CustomEvent<{ key: string }>(AI_STATE_CHANGED_EVENT, { detail: { key } }));
|
||||
}
|
||||
|
||||
function cleanupAcpSessions(sessionIds: string[]) {
|
||||
const bridge = getAIBridge();
|
||||
if (!bridge?.aiAcpCleanup || sessionIds.length === 0) return;
|
||||
for (const sessionId of sessionIds) {
|
||||
void bridge.aiAcpCleanup(sessionId).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function isScopeKeyActive(scopeKey: string, activeTargetIds: Set<string>) {
|
||||
const separatorIndex = scopeKey.indexOf(':');
|
||||
if (separatorIndex === -1) return true;
|
||||
|
||||
const targetId = scopeKey.slice(separatorIndex + 1);
|
||||
if (!targetId) return true;
|
||||
|
||||
return activeTargetIds.has(targetId);
|
||||
}
|
||||
|
||||
export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
const currentSessions = latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? [];
|
||||
|
||||
// Sessions shown by a still-live scope must be protected from cleanup
|
||||
// even when their own `scope.targetId` points at a closed terminal —
|
||||
// history can be resumed into a different terminal and we must not
|
||||
// delete it outright while it's actively being used.
|
||||
const preCleanupActiveSessionMap = latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {};
|
||||
const activeSessionIds = new Set<string>();
|
||||
for (const [scopeKey, sessionId] of Object.entries(preCleanupActiveSessionMap)) {
|
||||
if (!sessionId) continue;
|
||||
if (!isScopeKeyActive(scopeKey, activeTargetIds)) continue;
|
||||
activeSessionIds.add(sessionId);
|
||||
}
|
||||
|
||||
const nextSessionCleanup = pruneInactiveScopedSessions(
|
||||
currentSessions,
|
||||
activeTargetIds,
|
||||
activeSessionIds,
|
||||
);
|
||||
|
||||
if (nextSessionCleanup.orphanedSessionIds.length > 0) {
|
||||
cleanupAcpSessions(nextSessionCleanup.orphanedSessionIds);
|
||||
}
|
||||
|
||||
if (nextSessionCleanup.sessions !== currentSessions) {
|
||||
setLatestAISessionsSnapshot(nextSessionCleanup.sessions);
|
||||
localStorageAdapter.write(
|
||||
STORAGE_KEY_AI_SESSIONS,
|
||||
pruneSessionsForStorage(nextSessionCleanup.sessions),
|
||||
);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
||||
}
|
||||
|
||||
const activeSessionIdMap = preCleanupActiveSessionMap;
|
||||
let activeSessionMapChanged = false;
|
||||
const nextActiveSessionIdMap = { ...activeSessionIdMap };
|
||||
|
||||
for (const scopeKey of Object.keys(activeSessionIdMap)) {
|
||||
if (isScopeKeyActive(scopeKey, activeTargetIds)) continue;
|
||||
delete nextActiveSessionIdMap[scopeKey];
|
||||
activeSessionMapChanged = true;
|
||||
}
|
||||
|
||||
if (activeSessionMapChanged) {
|
||||
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
}
|
||||
|
||||
const currentActiveSessionIdMap = activeSessionMapChanged
|
||||
? nextActiveSessionIdMap
|
||||
: activeSessionIdMap;
|
||||
const currentDraftsByScope = latestAIDraftsByScopeSnapshot ?? {};
|
||||
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
|
||||
const prunedScopedTransientState = pruneInactiveScopedTransientState(
|
||||
currentActiveSessionIdMap,
|
||||
currentDraftsByScope,
|
||||
currentPanelViewByScope,
|
||||
activeTargetIds,
|
||||
);
|
||||
|
||||
if (prunedScopedTransientState.activeSessionIdMap !== currentActiveSessionIdMap) {
|
||||
setLatestAIActiveSessionMapSnapshot(prunedScopedTransientState.activeSessionIdMap);
|
||||
localStorageAdapter.write(
|
||||
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
||||
prunedScopedTransientState.activeSessionIdMap,
|
||||
);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
}
|
||||
|
||||
if (prunedScopedTransientState.draftsByScope !== currentDraftsByScope) {
|
||||
for (const scopeKey of Object.keys(currentDraftsByScope)) {
|
||||
if (scopeKey in prunedScopedTransientState.draftsByScope) continue;
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
bumpDraftUploadGeneration(scopeKey);
|
||||
}
|
||||
setLatestAIDraftsByScopeSnapshot(prunedScopedTransientState.draftsByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
|
||||
}
|
||||
|
||||
if (prunedScopedTransientState.panelViewByScope !== currentPanelViewByScope) {
|
||||
for (const scopeKey of Object.keys(currentPanelViewByScope)) {
|
||||
if (scopeKey in prunedScopedTransientState.panelViewByScope) continue;
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
}
|
||||
setLatestAIPanelViewByScopeSnapshot(prunedScopedTransientState.panelViewByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Maximum number of sessions to keep in localStorage. */
|
||||
const MAX_STORED_SESSIONS = 50;
|
||||
/** Maximum number of messages per session when persisting to localStorage. */
|
||||
const MAX_SESSION_MESSAGES = 200;
|
||||
|
||||
/**
|
||||
* Prune sessions before writing to localStorage to prevent hitting the
|
||||
* ~5-10 MB storage quota. Only affects what is persisted — the in-memory
|
||||
* state retains all messages until the session is reloaded.
|
||||
*
|
||||
* - Keeps only the MAX_STORED_SESSIONS most-recently-updated sessions.
|
||||
* - Trims each session's messages to the last MAX_SESSION_MESSAGES.
|
||||
*/
|
||||
function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
|
||||
// Sort by updatedAt descending so we keep the newest
|
||||
const sorted = [...sessions].sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
const limited = sorted.slice(0, MAX_STORED_SESSIONS);
|
||||
return limited.map(s => {
|
||||
if (s.messages.length > MAX_SESSION_MESSAGES) {
|
||||
return { ...s, messages: s.messages.slice(-MAX_SESSION_MESSAGES) };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
}
|
||||
|
||||
let latestAISessionsSnapshot: AISession[] | null = null;
|
||||
let latestAIActiveSessionMapSnapshot: Record<string, string | null> | null = null;
|
||||
let latestAIDraftsByScopeSnapshot: DraftsByScope | null = null;
|
||||
let latestAIPanelViewByScopeSnapshot: PanelViewByScope | null = null;
|
||||
let latestAIDraftMutationVersionByScopeSnapshot: Record<string, number> = {};
|
||||
let latestAIDraftUploadGenerationByScopeSnapshot: Record<string, number> = {};
|
||||
|
||||
function setLatestAISessionsSnapshot(sessions: AISession[]) {
|
||||
latestAISessionsSnapshot = sessions;
|
||||
}
|
||||
|
||||
function setLatestAIActiveSessionMapSnapshot(activeSessionIdMap: Record<string, string | null>) {
|
||||
latestAIActiveSessionMapSnapshot = activeSessionIdMap;
|
||||
}
|
||||
|
||||
function setLatestAIDraftsByScopeSnapshot(draftsByScope: DraftsByScope) {
|
||||
latestAIDraftsByScopeSnapshot = draftsByScope;
|
||||
}
|
||||
|
||||
function setLatestAIPanelViewByScopeSnapshot(panelViewByScope: PanelViewByScope) {
|
||||
latestAIPanelViewByScopeSnapshot = panelViewByScope;
|
||||
}
|
||||
|
||||
function bumpDraftMutationVersion(scopeKey: string) {
|
||||
latestAIDraftMutationVersionByScopeSnapshot = bumpDraftMutationVersionState(
|
||||
latestAIDraftMutationVersionByScopeSnapshot,
|
||||
scopeKey,
|
||||
);
|
||||
}
|
||||
|
||||
function getDraftUploadGeneration(scopeKey: string) {
|
||||
return getDraftUploadGenerationState(
|
||||
latestAIDraftUploadGenerationByScopeSnapshot,
|
||||
scopeKey,
|
||||
);
|
||||
}
|
||||
|
||||
function bumpDraftUploadGeneration(scopeKey: string) {
|
||||
latestAIDraftUploadGenerationByScopeSnapshot = bumpDraftUploadGenerationState(
|
||||
latestAIDraftUploadGenerationByScopeSnapshot,
|
||||
scopeKey,
|
||||
);
|
||||
}
|
||||
|
||||
import {
|
||||
AI_STATE_CHANGED_DRAFTS_BY_SCOPE,
|
||||
AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE,
|
||||
bumpDraftMutationVersion,
|
||||
bumpDraftUploadGeneration,
|
||||
cleanupAcpSessions,
|
||||
cleanupOrphanedAISessions,
|
||||
getAIBridge,
|
||||
getDraftUploadGeneration,
|
||||
latestAIActiveSessionMapSnapshot,
|
||||
latestAIDraftsByScopeSnapshot,
|
||||
latestAIPanelViewByScopeSnapshot,
|
||||
latestAISessionsSnapshot,
|
||||
pruneSessionsForStorage,
|
||||
setLatestAIActiveSessionMapSnapshot,
|
||||
setLatestAIDraftsByScopeSnapshot,
|
||||
setLatestAIPanelViewByScopeSnapshot,
|
||||
setLatestAISessionsSnapshot,
|
||||
type DraftsByScope,
|
||||
type PanelViewByScope,
|
||||
} from './aiStateSnapshots';
|
||||
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
||||
export function useAIState() {
|
||||
// ── Provider Config ──
|
||||
const [providers, setProvidersRaw] = useState<ProviderConfig[]>(() =>
|
||||
@@ -326,6 +134,24 @@ export function useAIState() {
|
||||
const [agentModelMap, setAgentModelMapRaw] = useState<Record<string, string>>(() =>
|
||||
localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {}
|
||||
);
|
||||
const agentModelMapRef = useRef(agentModelMap);
|
||||
useEffect(() => {
|
||||
agentModelMapRef.current = agentModelMap;
|
||||
}, [agentModelMap]);
|
||||
// Per-agent provider override: remembers which provider config each agent
|
||||
// should bind to. Falls back to the global `activeProviderId` when an agent
|
||||
// has no entry. Used so that e.g. Catty Agent can stay on DeepSeek while
|
||||
// a Claude/Codex run continues on its existing provider.
|
||||
const [agentProviderMap, setAgentProviderMapRaw] = useState<Record<string, string>>(() =>
|
||||
localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_PROVIDER_MAP) ?? {}
|
||||
);
|
||||
// Mirror for non-functional reads inside removeProvider — needed to know
|
||||
// which agents were bound to the deleted provider so we can also drop
|
||||
// their saved model ids (those ids belonged to the now-missing provider).
|
||||
const agentProviderMapRef = useRef(agentProviderMap);
|
||||
useEffect(() => {
|
||||
agentProviderMapRef.current = agentProviderMap;
|
||||
}, [agentProviderMap]);
|
||||
|
||||
// ── Web Search Config ──
|
||||
const [webSearchConfig, setWebSearchConfigRaw] = useState<WebSearchConfig | null>(() =>
|
||||
@@ -413,6 +239,21 @@ export function useAIState() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setAgentProvider = useCallback((agentId: string, providerId: string) => {
|
||||
setAgentProviderMapRaw(prev => {
|
||||
// Empty string clears the per-agent override and lets the agent fall
|
||||
// back to the global `activeProviderId`.
|
||||
const next = { ...prev };
|
||||
if (providerId) {
|
||||
next[agentId] = providerId;
|
||||
} else {
|
||||
delete next[agentId];
|
||||
}
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setWebSearchConfig = useCallback((config: WebSearchConfig | null) => {
|
||||
setWebSearchConfigRaw(config);
|
||||
if (config) {
|
||||
@@ -600,6 +441,9 @@ export function useAIState() {
|
||||
case STORAGE_KEY_AI_AGENT_MODEL_MAP:
|
||||
setAgentModelMapRaw(localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {});
|
||||
break;
|
||||
case STORAGE_KEY_AI_AGENT_PROVIDER_MAP:
|
||||
setAgentProviderMapRaw(localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_PROVIDER_MAP) ?? {});
|
||||
break;
|
||||
case STORAGE_KEY_AI_ACTIVE_SESSION_MAP: {
|
||||
const nextActiveSessionIdMap =
|
||||
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {};
|
||||
@@ -1071,7 +915,6 @@ export function useAIState() {
|
||||
|
||||
const removeProvider = useCallback((id: string) => {
|
||||
setProviders(prev => prev.filter(p => p.id !== id));
|
||||
// Use the raw setter to avoid stale closure over setActiveProviderId
|
||||
setActiveProviderIdRaw(prevId => {
|
||||
if (prevId === id) {
|
||||
const next = '';
|
||||
@@ -1080,13 +923,25 @@ export function useAIState() {
|
||||
}
|
||||
return prevId;
|
||||
});
|
||||
const cleanup = removeProviderReferences(
|
||||
id,
|
||||
agentProviderMapRef.current,
|
||||
agentModelMapRef.current,
|
||||
);
|
||||
if (cleanup.providerMapChanged) {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, cleanup.agentProviderMap);
|
||||
setAgentProviderMapRaw(cleanup.agentProviderMap);
|
||||
}
|
||||
if (cleanup.modelMapChanged) {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_MODEL_MAP, cleanup.agentModelMap);
|
||||
setAgentModelMapRaw(cleanup.agentModelMap);
|
||||
}
|
||||
}, [setProviders]);
|
||||
|
||||
// ── Computed ──
|
||||
const activeProvider = providers.find(p => p.id === activeProviderId) ?? null;
|
||||
|
||||
return {
|
||||
// Provider config
|
||||
providers,
|
||||
setProviders,
|
||||
addProvider,
|
||||
@@ -1097,38 +952,28 @@ export function useAIState() {
|
||||
activeModelId,
|
||||
setActiveModelId,
|
||||
activeProvider,
|
||||
|
||||
// Permission model
|
||||
globalPermissionMode,
|
||||
setGlobalPermissionMode,
|
||||
toolIntegrationMode,
|
||||
setToolIntegrationMode,
|
||||
hostPermissions,
|
||||
setHostPermissions,
|
||||
|
||||
// External agents
|
||||
externalAgents,
|
||||
setExternalAgents,
|
||||
defaultAgentId,
|
||||
setDefaultAgentId,
|
||||
|
||||
// Safety
|
||||
commandBlocklist,
|
||||
setCommandBlocklist,
|
||||
commandTimeout,
|
||||
setCommandTimeout,
|
||||
maxIterations,
|
||||
setMaxIterations,
|
||||
|
||||
// Per-agent model memory
|
||||
agentModelMap,
|
||||
setAgentModel,
|
||||
|
||||
// Web search
|
||||
agentProviderMap,
|
||||
setAgentProvider,
|
||||
webSearchConfig,
|
||||
setWebSearchConfig,
|
||||
|
||||
// Sessions (per-scope active session)
|
||||
sessions,
|
||||
activeSessionIdMap,
|
||||
draftsByScope,
|
||||
|
||||
@@ -52,14 +52,19 @@ export function useAgentDiscovery(
|
||||
);
|
||||
if (!match) return ea;
|
||||
|
||||
// Check if args or ACP config differ
|
||||
// Check if args, ACP config, or Claude's resolved system path differ
|
||||
const currentArgs = JSON.stringify(ea.args || []);
|
||||
const newArgs = JSON.stringify(match.args);
|
||||
const acpChanged = ea.acpCommand !== match.acpCommand
|
||||
|| JSON.stringify(ea.acpArgs || []) !== JSON.stringify(match.acpArgs || []);
|
||||
if (currentArgs !== newArgs || acpChanged) {
|
||||
const env = match.command === 'claude'
|
||||
? { ...(ea.env ?? {}), CLAUDE_CODE_EXECUTABLE: match.path }
|
||||
: ea.env;
|
||||
const envChanged = match.command === 'claude'
|
||||
&& ea.env?.CLAUDE_CODE_EXECUTABLE !== match.path;
|
||||
if (currentArgs !== newArgs || acpChanged || envChanged) {
|
||||
changed = true;
|
||||
return { ...ea, args: match.args, acpCommand: match.acpCommand, acpArgs: match.acpArgs };
|
||||
return { ...ea, args: match.args, acpCommand: match.acpCommand, acpArgs: match.acpArgs, ...(env ? { env } : {}) };
|
||||
}
|
||||
return ea;
|
||||
});
|
||||
@@ -86,6 +91,7 @@ export function useAgentDiscovery(
|
||||
enabled: true,
|
||||
acpCommand: agent.acpCommand,
|
||||
acpArgs: agent.acpArgs,
|
||||
...(agent.command === 'claude' ? { env: { CLAUDE_CODE_EXECUTABLE: agent.path } } : {}),
|
||||
};
|
||||
},
|
||||
[],
|
||||
|
||||
@@ -16,14 +16,17 @@ import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
} from '../../domain/credentials';
|
||||
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
|
||||
import { mergeSyncPayloads } from '../../domain/syncMerge';
|
||||
import { resolveCloudSyncConflictAction } from '../../domain/syncStrategy';
|
||||
import {
|
||||
SYNCABLE_SETTING_STORAGE_KEYS,
|
||||
collectSyncableSettings,
|
||||
getEffectivePortForwardingRulesForSync,
|
||||
hasMeaningfulCloudSyncData,
|
||||
shouldPromptCloudVaultRecovery,
|
||||
} from '../syncPayload';
|
||||
import { readInterruptedVaultApply } from '../localVaultBackups';
|
||||
import {
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import {
|
||||
@@ -31,6 +34,10 @@ import {
|
||||
localStorageAdapter,
|
||||
} from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { notify } from '../notification';
|
||||
import {
|
||||
getRuntimeRemoteCheckIntervalMs,
|
||||
shouldRunRuntimeRemoteCheck,
|
||||
} from './autoSyncRemoteSchedule';
|
||||
|
||||
interface AutoSyncConfig {
|
||||
// Data to sync
|
||||
@@ -95,6 +102,11 @@ interface SyncNowOptions {
|
||||
trigger?: SyncTrigger;
|
||||
}
|
||||
|
||||
interface RemoteVersionCheckOptions {
|
||||
force?: boolean;
|
||||
notifyOnFailure?: boolean;
|
||||
}
|
||||
|
||||
export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const { t } = useI18n();
|
||||
const sync = useCloudSync();
|
||||
@@ -156,21 +168,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}, []);
|
||||
|
||||
const getSyncSnapshot = useCallback(() => {
|
||||
let effectivePFRules = config.portForwardingRules;
|
||||
if (!effectivePFRules || effectivePFRules.length === 0) {
|
||||
const stored = localStorageAdapter.read<SyncPayload['portForwardingRules']>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
);
|
||||
if (stored && Array.isArray(stored) && stored.length > 0) {
|
||||
effectivePFRules = stored.map((rule) => ({
|
||||
...rule,
|
||||
status: 'inactive' as const,
|
||||
error: undefined,
|
||||
lastUsedAt: undefined,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hosts: config.hosts,
|
||||
keys: config.keys,
|
||||
@@ -179,7 +176,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
snippets: config.snippets,
|
||||
customGroups: config.customGroups,
|
||||
snippetPackages: config.snippetPackages,
|
||||
portForwardingRules: effectivePFRules,
|
||||
portForwardingRules: getEffectivePortForwardingRulesForSync(config.portForwardingRules),
|
||||
groupConfigs: config.groupConfigs,
|
||||
};
|
||||
}, [
|
||||
@@ -324,15 +321,27 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
|
||||
// Apply merged payloads first (before checking for failures) so local
|
||||
// state gets updated even when some providers failed
|
||||
for (const result of results.values()) {
|
||||
const resultList = Array.from(results.values());
|
||||
const allProvidersSynced = resultList.length > 0
|
||||
&& resultList.every((result) => result.success);
|
||||
|
||||
for (const result of resultList) {
|
||||
if (result.mergedPayload) {
|
||||
await Promise.resolve(onApplyPayload(result.mergedPayload));
|
||||
skipNextSyncRef.current = true;
|
||||
if (result.remoteFile) {
|
||||
await sync.commitRemoteInspection(result.provider, result.remoteFile, result.mergedPayload, {
|
||||
recordDownload: true,
|
||||
});
|
||||
}
|
||||
skipNextSyncRef.current = allProvidersSynced;
|
||||
if (!allProvidersSynced) {
|
||||
console.warn('[AutoSync] Remote payload applied locally, but not every provider synced; leaving next auto-sync enabled for retry.');
|
||||
}
|
||||
break; // All providers share the same merged payload
|
||||
}
|
||||
}
|
||||
|
||||
for (const result of results.values()) {
|
||||
for (const result of resultList) {
|
||||
if (!result.success) {
|
||||
if (result.conflictDetected) {
|
||||
throw new Error(t('sync.autoSync.conflictDetected'));
|
||||
@@ -408,6 +417,10 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
useEffect(() => {
|
||||
buildPayloadRef.current = buildPayload;
|
||||
}, [buildPayload]);
|
||||
const getDataHashRef = useRef(getDataHash);
|
||||
useEffect(() => {
|
||||
getDataHashRef.current = getDataHash;
|
||||
}, [getDataHash]);
|
||||
|
||||
// Serialize `checkRemoteVersion` invocations. Overlapping runs would
|
||||
// race on `commitRemoteInspection` + `onApplyPayload`: two merges
|
||||
@@ -417,17 +430,20 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// windows but does NOT serialize same-window re-entry, so this
|
||||
// in-flight guard closes that gap at the top of the call.
|
||||
const checkRemoteInFlightRef = useRef(false);
|
||||
const lastRuntimeRemoteCheckAtRef = useRef<number | null>(null);
|
||||
|
||||
// Check remote version and pull if newer (on startup)
|
||||
const checkRemoteVersion = useCallback(async () => {
|
||||
const checkRemoteVersion = useCallback(async (options?: RemoteVersionCheckOptions) => {
|
||||
if (checkRemoteInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
const force = options?.force === true;
|
||||
const notifyOnFailure = options?.notifyOnFailure !== false;
|
||||
const state = manager.getState();
|
||||
const hasProvider = Object.values(state.providers).some((provider) => isProviderReadyForSync(provider));
|
||||
const unlocked = state.securityState === 'UNLOCKED';
|
||||
|
||||
if (!hasProvider || !unlocked || hasCheckedRemoteRef.current || startupReadyRef.current === false) {
|
||||
if (!hasProvider || !unlocked || (!force && hasCheckedRemoteRef.current) || startupReadyRef.current === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -451,6 +467,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// are consistent with the local vault. Only then should we latch
|
||||
// hasCheckedRemoteRef so that transient failures are retryable.
|
||||
let startupConsistent = false;
|
||||
let markCurrentDataSynced = true;
|
||||
try {
|
||||
// Load base BEFORE observing the remote payload (commitRemoteInspection overwrites the base).
|
||||
const base = await manager.loadSyncBase(connectedProvider);
|
||||
@@ -466,13 +483,11 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const remoteFile = inspection.remoteFile;
|
||||
const remotePayload = inspection.payload;
|
||||
const localPayload = buildPayloadRef.current();
|
||||
const localIsEmpty = !hasMeaningfulCloudSyncData(localPayload);
|
||||
const remoteHasData = hasMeaningfulCloudSyncData(remotePayload);
|
||||
|
||||
// If local vault is empty but cloud has data, this almost certainly
|
||||
// means the user's data was lost (update, storage corruption, etc.).
|
||||
// Pause and ask the user what to do instead of silently merging.
|
||||
if (localIsEmpty && remoteHasData) {
|
||||
if (shouldPromptCloudVaultRecovery(localPayload, remotePayload)) {
|
||||
const userAction = await new Promise<'restore' | 'keep-empty'>((resolve) => {
|
||||
emptyVaultResolveRef.current = resolve;
|
||||
setEmptyVaultConflict({
|
||||
@@ -493,7 +508,9 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// remote while local is still empty — the exact overwrite window
|
||||
// we're trying to close.
|
||||
await Promise.resolve(onApplyPayloadRef.current(remotePayload));
|
||||
await manager.commitRemoteInspection(connectedProvider, remoteFile, remotePayload);
|
||||
await manager.commitRemoteInspection(connectedProvider, remoteFile, remotePayload, {
|
||||
recordDownload: true,
|
||||
});
|
||||
skipNextSyncRef.current = true;
|
||||
startupConsistent = true;
|
||||
notify.success(t('sync.autoSync.restoredMessage'), t('sync.autoSync.restoredTitle'));
|
||||
@@ -509,7 +526,58 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const { mergeSyncPayloads } = await import('../../domain/syncMerge');
|
||||
const conflictAction = resolveCloudSyncConflictAction(state.syncStrategy, {
|
||||
hasConflict: inspection.remoteChanged,
|
||||
hasRemoteFile: Boolean(inspection.remoteFile),
|
||||
});
|
||||
|
||||
if (conflictAction === 'download-remote') {
|
||||
// Apply remote FIRST; only commit anchor/base after the UI-side
|
||||
// state has accepted the remote payload, matching the empty-vault
|
||||
// restore ordering above.
|
||||
await Promise.resolve(onApplyPayloadRef.current(remotePayload));
|
||||
await manager.commitRemoteInspection(connectedProvider, remoteFile, remotePayload, {
|
||||
recordDownload: true,
|
||||
});
|
||||
startupConsistent = true;
|
||||
markCurrentDataSynced = false;
|
||||
const roundTripResults = await manager.syncAllProviders(remotePayload, {
|
||||
conflictActionOverride: 'upload-local',
|
||||
});
|
||||
const roundTripResultList = Array.from(roundTripResults.values());
|
||||
const wasShrinkBlocked = roundTripResultList.some((result) => result.shrinkBlocked === true);
|
||||
const roundTripFullySynced = roundTripResultList.length > 0
|
||||
&& roundTripResultList.every((result) => result.success);
|
||||
skipNextSyncRef.current = roundTripFullySynced || wasShrinkBlocked;
|
||||
markCurrentDataSynced = roundTripFullySynced || wasShrinkBlocked;
|
||||
if (wasShrinkBlocked) {
|
||||
console.warn('[AutoSync] Cloud-wins round-trip was shrink-blocked; cloud data applied locally, leaving sync blocked for user review.');
|
||||
} else if (!roundTripFullySynced) {
|
||||
console.warn('[AutoSync] Cloud-wins round-trip did not update every provider; leaving next auto-sync enabled for retry.');
|
||||
}
|
||||
notify.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (conflictAction === 'upload-local') {
|
||||
const pushResults = await manager.syncAllProviders(localPayload);
|
||||
const results = Array.from(pushResults.values());
|
||||
const allProvidersSynced = results.length > 0
|
||||
&& results.every((result) => result.success);
|
||||
const wasShrinkBlocked = results.some((result) => result.shrinkBlocked === true);
|
||||
|
||||
if (allProvidersSynced) {
|
||||
startupConsistent = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (wasShrinkBlocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error('Startup local-wins sync failed for one or more providers');
|
||||
}
|
||||
|
||||
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
|
||||
|
||||
// Apply merged payload to local state BEFORE committing. If the apply
|
||||
@@ -521,6 +589,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// local-only state.
|
||||
await manager.commitRemoteInspection(connectedProvider, remoteFile, remotePayload);
|
||||
startupConsistent = true;
|
||||
markCurrentDataSynced = false;
|
||||
notify.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
|
||||
|
||||
// If the three-way merge introduced any local-only additions that the
|
||||
@@ -539,9 +608,10 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
if (mergeResult.payload) {
|
||||
try {
|
||||
const roundTripResults = await manager.syncAllProviders(mergeResult.payload);
|
||||
const wasShrinkBlocked = Array.from(roundTripResults.values()).some(
|
||||
(r) => r.shrinkBlocked === true,
|
||||
);
|
||||
const roundTripResultList = Array.from(roundTripResults.values());
|
||||
const wasShrinkBlocked = roundTripResultList.some((r) => r.shrinkBlocked === true);
|
||||
const roundTripFullySynced = roundTripResultList.length > 0
|
||||
&& roundTripResultList.every((result) => result.success);
|
||||
if (wasShrinkBlocked) {
|
||||
// The merged payload is already applied locally and is the source of truth
|
||||
// for THIS device. The blocking only prevents pushing it to cloud, which
|
||||
@@ -551,11 +621,15 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// in BLOCKED with no banner visible.
|
||||
console.warn('[AutoSync] Post-merge round-trip was shrink-blocked; merged data applied locally, reset syncState to IDLE for next attempt.');
|
||||
manager.clearShrinkBlockedState();
|
||||
} else if (!roundTripFullySynced) {
|
||||
console.warn('[AutoSync] Post-merge round-trip did not update every provider; leaving next auto-sync enabled for retry.');
|
||||
}
|
||||
// Suppress the debounced follow-up tick that otherwise fires
|
||||
// once React commits the applied state, since we've just
|
||||
// already pushed that exact payload upstream.
|
||||
skipNextSyncRef.current = true;
|
||||
// already pushed that exact payload upstream. If some provider
|
||||
// failed, allow the follow-up tick to retry the applied payload.
|
||||
skipNextSyncRef.current = roundTripFullySynced || wasShrinkBlocked;
|
||||
markCurrentDataSynced = roundTripFullySynced || wasShrinkBlocked;
|
||||
} catch (error) {
|
||||
// Non-fatal: the next user edit will drive another sync cycle.
|
||||
console.warn('[AutoSync] Post-merge round-trip push failed:', error);
|
||||
@@ -563,18 +637,28 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AutoSync] Failed to check remote version:', error);
|
||||
// Surface a degraded-sync hint to the user rather than silently
|
||||
// opening the auto-sync gate. Auto-sync will still retry on next
|
||||
// data change (see finally block), but without this toast the user
|
||||
// has no visible signal that startup reconciliation failed.
|
||||
notify.error(
|
||||
t('sync.autoSync.inspectFailedMessage'),
|
||||
t('sync.autoSync.inspectFailedTitle'),
|
||||
);
|
||||
if (notifyOnFailure) {
|
||||
// Surface a degraded-sync hint to the user rather than silently
|
||||
// opening the auto-sync gate. Auto-sync will still retry on next
|
||||
// data change (see finally block), but without this toast the user
|
||||
// has no visible signal that startup reconciliation failed.
|
||||
notify.error(
|
||||
t('sync.autoSync.inspectFailedMessage'),
|
||||
t('sync.autoSync.inspectFailedTitle'),
|
||||
);
|
||||
}
|
||||
// Leave hasCheckedRemoteRef=false so the next startup (or the next
|
||||
// provider/unlock transition) can retry.
|
||||
} finally {
|
||||
if (startupConsistent) {
|
||||
if (!isInitializedRef.current) {
|
||||
isInitializedRef.current = true;
|
||||
}
|
||||
if (markCurrentDataSynced) {
|
||||
lastSyncedDataRef.current = getDataHashRef.current();
|
||||
} else {
|
||||
lastSyncedDataRef.current = '';
|
||||
}
|
||||
hasCheckedRemoteRef.current = true;
|
||||
// Only open the auto-sync gate when the inspect actually
|
||||
// validated the remote state. Leaving the gate closed on
|
||||
@@ -741,12 +825,86 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
if (timerId) clearTimeout(timerId);
|
||||
};
|
||||
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, config.startupReady, checkRemoteVersion]);
|
||||
|
||||
const runRuntimeRemoteCheck = useCallback(async (options?: { force?: boolean }) => {
|
||||
const now = Date.now();
|
||||
const minIntervalMs = getRuntimeRemoteCheckIntervalMs(sync.autoSyncInterval);
|
||||
if (!shouldRunRuntimeRemoteCheck({
|
||||
hasAnyConnectedProvider: sync.hasAnyConnectedProvider,
|
||||
autoSyncEnabled: sync.autoSyncEnabled,
|
||||
isUnlocked: sync.isUnlocked,
|
||||
startupRemoteCheckDone: remoteCheckDoneRef.current,
|
||||
isSyncing: sync.isSyncing,
|
||||
isSyncRunning: isSyncRunningRef.current,
|
||||
remoteCheckInFlight: checkRemoteInFlightRef.current,
|
||||
force: options?.force === true,
|
||||
now,
|
||||
lastRemoteCheckAt: lastRuntimeRemoteCheckAtRef.current,
|
||||
minIntervalMs,
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastRuntimeRemoteCheckAtRef.current = now;
|
||||
await checkRemoteVersion({ force: true, notifyOnFailure: false });
|
||||
}, [
|
||||
checkRemoteVersion,
|
||||
sync.autoSyncEnabled,
|
||||
sync.autoSyncInterval,
|
||||
sync.hasAnyConnectedProvider,
|
||||
sync.isSyncing,
|
||||
sync.isUnlocked,
|
||||
]);
|
||||
|
||||
// Keep checking the cloud while the app is open. This closes the gap where
|
||||
// another device uploads changes after our startup inspection but before
|
||||
// this device edits anything locally.
|
||||
useEffect(() => {
|
||||
if (!sync.hasAnyConnectedProvider || !sync.autoSyncEnabled || !sync.isUnlocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalMs = getRuntimeRemoteCheckIntervalMs(sync.autoSyncInterval);
|
||||
const timerId = window.setInterval(() => {
|
||||
void runRuntimeRemoteCheck();
|
||||
}, intervalMs);
|
||||
|
||||
return () => window.clearInterval(timerId);
|
||||
}, [
|
||||
runRuntimeRemoteCheck,
|
||||
sync.autoSyncEnabled,
|
||||
sync.autoSyncInterval,
|
||||
sync.hasAnyConnectedProvider,
|
||||
sync.isUnlocked,
|
||||
]);
|
||||
|
||||
// Also re-check when the user returns to the app or the network comes back.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') return;
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void runRuntimeRemoteCheck({ force: true });
|
||||
}
|
||||
};
|
||||
const handleOnline = () => {
|
||||
void runRuntimeRemoteCheck({ force: true });
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
window.addEventListener('online', handleOnline);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
window.removeEventListener('online', handleOnline);
|
||||
};
|
||||
}, [runRuntimeRemoteCheck]);
|
||||
|
||||
// Reset check flags when provider disconnects
|
||||
useEffect(() => {
|
||||
if (!sync.hasAnyConnectedProvider) {
|
||||
hasCheckedRemoteRef.current = false;
|
||||
remoteCheckDoneRef.current = false;
|
||||
lastRuntimeRemoteCheckAtRef.current = null;
|
||||
}
|
||||
}, [sync.hasAnyConnectedProvider]);
|
||||
|
||||
|
||||
57
application/state/useCloudSync.masterKey.test.ts
Normal file
57
application/state/useCloudSync.masterKey.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { SYNC_STORAGE_KEYS } from "../../domain/sync.ts";
|
||||
import { EncryptionService } from "../../infrastructure/services/EncryptionService.ts";
|
||||
import { handleStorageEventImpl } from "../../infrastructure/services/cloudSync/stateAndSecurityMethods.ts";
|
||||
|
||||
test("master key replacement from another window locks the current window and clears the old password", async () => {
|
||||
const oldConfig = await EncryptionService.createMasterKeyConfig("old-master-password");
|
||||
const newConfig = await EncryptionService.createMasterKeyConfig("new-master-password");
|
||||
const fakeStorage = {};
|
||||
const originalWindow = globalThis.window;
|
||||
let notifyCount = 0;
|
||||
let stopAutoSyncCount = 0;
|
||||
let syncSecurityGenerationCount = 0;
|
||||
|
||||
(globalThis as typeof globalThis & { window?: unknown }).window = {
|
||||
localStorage: fakeStorage,
|
||||
};
|
||||
|
||||
const manager = {
|
||||
state: {
|
||||
masterKeyConfig: oldConfig,
|
||||
securityState: "UNLOCKED",
|
||||
unlockedKey: await EncryptionService.unlockMasterKey("old-master-password", oldConfig),
|
||||
},
|
||||
masterPassword: "old-master-password",
|
||||
safeJsonParse: (value: string | null) => (value ? JSON.parse(value) : null),
|
||||
stopAutoSync: () => {
|
||||
stopAutoSyncCount += 1;
|
||||
},
|
||||
bumpSyncSecurityGeneration: () => {
|
||||
syncSecurityGenerationCount += 1;
|
||||
},
|
||||
notifyStateChange: () => {
|
||||
notifyCount += 1;
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
handleStorageEventImpl.call(manager, {
|
||||
storageArea: fakeStorage,
|
||||
key: SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG,
|
||||
newValue: JSON.stringify(newConfig),
|
||||
} as StorageEvent);
|
||||
} finally {
|
||||
(globalThis as typeof globalThis & { window?: unknown }).window = originalWindow;
|
||||
}
|
||||
|
||||
assert.equal(manager.state.masterKeyConfig.verificationHash, newConfig.verificationHash);
|
||||
assert.equal(manager.state.securityState, "LOCKED");
|
||||
assert.equal(manager.state.unlockedKey, null);
|
||||
assert.equal(manager.masterPassword, null);
|
||||
assert.equal(stopAutoSyncCount, 1);
|
||||
assert.equal(syncSecurityGenerationCount, 1);
|
||||
assert.equal(notifyCount, 1);
|
||||
});
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
type ProviderConnection,
|
||||
type ConflictInfo,
|
||||
type ConflictResolution,
|
||||
type RemoteSyncPayload,
|
||||
type SyncedFile,
|
||||
type SyncPayload,
|
||||
type SyncResult,
|
||||
type SyncHistoryEntry,
|
||||
@@ -23,6 +25,8 @@ import {
|
||||
getSyncDotColor,
|
||||
isProviderReadyForSync,
|
||||
} from '../../domain/sync';
|
||||
import type { CloudSyncStrategy } from '../../domain/syncStrategy';
|
||||
import type { CloudSyncConflictAction } from '../../domain/syncStrategy';
|
||||
import {
|
||||
getCloudSyncManager,
|
||||
type SyncManagerState,
|
||||
@@ -48,6 +52,7 @@ export interface CloudSyncHook {
|
||||
deviceName: string;
|
||||
autoSyncEnabled: boolean;
|
||||
autoSyncInterval: number;
|
||||
syncStrategy: CloudSyncStrategy;
|
||||
localVersion: number;
|
||||
localUpdatedAt: number;
|
||||
remoteVersion: number;
|
||||
@@ -91,10 +96,11 @@ export interface CloudSyncHook {
|
||||
resetProviderStatus: (provider: CloudProvider) => void;
|
||||
|
||||
// Sync Actions
|
||||
syncNow: (payload: SyncPayload, opts?: { overrideShrink?: boolean }) => Promise<Map<CloudProvider, SyncResult>>;
|
||||
syncNow: (payload: SyncPayload, opts?: { overrideShrink?: boolean; conflictActionOverride?: CloudSyncConflictAction }) => Promise<Map<CloudProvider, SyncResult>>;
|
||||
syncToProvider: (provider: CloudProvider, payload: SyncPayload, opts?: { overrideShrink?: boolean }) => Promise<SyncResult>;
|
||||
downloadFromProvider: (provider: CloudProvider) => Promise<SyncPayload | null>;
|
||||
resolveConflict: (resolution: ConflictResolution) => Promise<SyncPayload | null>;
|
||||
downloadFromProvider: (provider: CloudProvider) => Promise<RemoteSyncPayload | null>;
|
||||
commitRemoteInspection: (provider: CloudProvider, remoteFile: SyncedFile, payload: SyncPayload, opts?: { recordDownload?: boolean }) => Promise<void>;
|
||||
resolveConflict: (resolution: ConflictResolution) => Promise<RemoteSyncPayload | null>;
|
||||
|
||||
// Gist Revision History
|
||||
getGistRevisionHistory: () => Promise<Array<{ version: string; date: Date }>>;
|
||||
@@ -113,6 +119,7 @@ export interface CloudSyncHook {
|
||||
// Settings
|
||||
setAutoSync: (enabled: boolean, intervalMinutes?: number) => void;
|
||||
setDeviceName: (name: string) => void;
|
||||
setSyncStrategy: (strategy: CloudSyncStrategy) => void;
|
||||
|
||||
// Local Data Reset
|
||||
resetLocalVersion: () => void;
|
||||
@@ -631,6 +638,10 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
const setDeviceName = useCallback((name: string) => {
|
||||
manager.setDeviceName(name);
|
||||
}, []);
|
||||
|
||||
const setSyncStrategy = useCallback((strategy: CloudSyncStrategy) => {
|
||||
manager.setSyncStrategy(strategy);
|
||||
}, []);
|
||||
|
||||
// ========== Utilities ==========
|
||||
|
||||
@@ -661,7 +672,7 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
throw new Error('Vault is locked');
|
||||
}, []);
|
||||
|
||||
const syncNowWithUnlock = useCallback(async (payload: SyncPayload, opts?: { overrideShrink?: boolean }) => {
|
||||
const syncNowWithUnlock = useCallback(async (payload: SyncPayload, opts?: { overrideShrink?: boolean; conflictActionOverride?: CloudSyncConflictAction }) => {
|
||||
await ensureUnlocked();
|
||||
return await manager.syncAllProviders(payload, opts);
|
||||
}, [ensureUnlocked]);
|
||||
@@ -676,6 +687,16 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
return await manager.downloadFromProvider(provider);
|
||||
}, [ensureUnlocked]);
|
||||
|
||||
const commitRemoteInspectionWithUnlock = useCallback(async (
|
||||
provider: CloudProvider,
|
||||
remoteFile: SyncedFile,
|
||||
payload: SyncPayload,
|
||||
opts: { recordDownload?: boolean } = {},
|
||||
) => {
|
||||
await ensureUnlocked();
|
||||
await manager.commitRemoteInspection(provider, remoteFile, payload, opts);
|
||||
}, [ensureUnlocked]);
|
||||
|
||||
const subscribeToEvents = useCallback(
|
||||
(callback: SyncEventCallback) => manager.subscribe(callback),
|
||||
[],
|
||||
@@ -703,6 +724,7 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
deviceName: state.deviceName,
|
||||
autoSyncEnabled: state.autoSyncEnabled,
|
||||
autoSyncInterval: state.autoSyncInterval,
|
||||
syncStrategy: state.syncStrategy,
|
||||
localVersion: state.localVersion,
|
||||
localUpdatedAt: state.localUpdatedAt,
|
||||
remoteVersion: state.remoteVersion,
|
||||
@@ -738,6 +760,7 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
syncNow: syncNowWithUnlock,
|
||||
syncToProvider: syncToProviderWithUnlock,
|
||||
downloadFromProvider: downloadFromProviderWithUnlock,
|
||||
commitRemoteInspection: commitRemoteInspectionWithUnlock,
|
||||
resolveConflict: resolveConflictWithUnlock,
|
||||
|
||||
// Gist Revision History (#679)
|
||||
@@ -747,6 +770,7 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
// Settings
|
||||
setAutoSync,
|
||||
setDeviceName,
|
||||
setSyncStrategy,
|
||||
|
||||
// Local Data Reset
|
||||
resetLocalVersion: () => manager.resetLocalVersion(),
|
||||
|
||||
@@ -1,41 +1,5 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { KeyBinding, matchesKeyBinding } from '../../domain/models';
|
||||
|
||||
interface HotkeyActions {
|
||||
// Tab management
|
||||
switchToTab: (tabIndex: number) => void;
|
||||
nextTab: () => void;
|
||||
prevTab: () => void;
|
||||
closeTab: () => void;
|
||||
newTab: () => void;
|
||||
|
||||
// Navigation
|
||||
openHosts: () => void;
|
||||
openSftp: () => void;
|
||||
quickSwitch: () => void;
|
||||
newWorkspace: () => void;
|
||||
commandPalette: () => void;
|
||||
portForwarding: () => void;
|
||||
snippets: () => void;
|
||||
|
||||
// Terminal actions (handled per-terminal)
|
||||
copy: () => void;
|
||||
paste: () => void;
|
||||
selectAll: () => void;
|
||||
clearBuffer: () => void;
|
||||
searchTerminal: () => void;
|
||||
|
||||
// Workspace/split actions
|
||||
splitHorizontal: () => void;
|
||||
splitVertical: () => void;
|
||||
moveFocus: (direction: 'up' | 'down' | 'left' | 'right') => void;
|
||||
|
||||
// App features
|
||||
broadcast: () => void;
|
||||
openLocal: () => void;
|
||||
openSettings: () => void;
|
||||
}
|
||||
|
||||
// Check if keyboard event matches our app-level shortcuts
|
||||
// Returns the matched binding action or null
|
||||
export const checkAppShortcut = (
|
||||
@@ -85,165 +49,8 @@ export const getTerminalPassthroughActions = (): Set<string> => {
|
||||
'selectAll',
|
||||
'clearBuffer',
|
||||
'searchTerminal',
|
||||
'increaseTerminalFontSize',
|
||||
'decreaseTerminalFontSize',
|
||||
'resetTerminalFontSize',
|
||||
]);
|
||||
};
|
||||
|
||||
interface UseGlobalHotkeysOptions {
|
||||
hotkeyScheme: 'disabled' | 'mac' | 'pc';
|
||||
keyBindings: KeyBinding[];
|
||||
actions: Partial<HotkeyActions>;
|
||||
orderedTabs: string[];
|
||||
sessions: { id: string }[];
|
||||
workspaces: { id: string }[];
|
||||
isSettingsOpen?: boolean;
|
||||
}
|
||||
|
||||
export const useGlobalHotkeys = ({
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
actions,
|
||||
orderedTabs,
|
||||
sessions,
|
||||
workspaces,
|
||||
isSettingsOpen = false,
|
||||
}: UseGlobalHotkeysOptions) => {
|
||||
const actionsRef = useRef(actions);
|
||||
actionsRef.current = actions;
|
||||
|
||||
const orderedTabsRef = useRef(orderedTabs);
|
||||
orderedTabsRef.current = orderedTabs;
|
||||
|
||||
const sessionsRef = useRef(sessions);
|
||||
sessionsRef.current = sessions;
|
||||
|
||||
const workspacesRef = useRef(workspaces);
|
||||
workspacesRef.current = workspaces;
|
||||
|
||||
const handleGlobalKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (hotkeyScheme === 'disabled') return;
|
||||
if (isSettingsOpen) return; // Don't handle hotkeys when settings is open
|
||||
|
||||
const isMac = hotkeyScheme === 'mac';
|
||||
const appLevelActions = getAppLevelActions();
|
||||
|
||||
// Check if this is an app-level shortcut
|
||||
const matched = checkAppShortcut(e, keyBindings, isMac);
|
||||
if (!matched) return;
|
||||
|
||||
const { action, binding: _binding } = matched;
|
||||
|
||||
// Only handle app-level actions here
|
||||
// Terminal-level actions are handled by the terminal itself
|
||||
if (!appLevelActions.has(action)) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const currentActions = actionsRef.current;
|
||||
switch (action) {
|
||||
case 'switchToTab': {
|
||||
const num = parseInt(e.key, 10);
|
||||
if (num >= 1 && num <= 9) {
|
||||
currentActions.switchToTab?.(num);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'nextTab':
|
||||
currentActions.nextTab?.();
|
||||
break;
|
||||
case 'prevTab':
|
||||
currentActions.prevTab?.();
|
||||
break;
|
||||
case 'closeTab':
|
||||
currentActions.closeTab?.();
|
||||
break;
|
||||
case 'newTab':
|
||||
currentActions.newTab?.();
|
||||
break;
|
||||
case 'openHosts':
|
||||
currentActions.openHosts?.();
|
||||
break;
|
||||
case 'openSftp':
|
||||
currentActions.openSftp?.();
|
||||
break;
|
||||
case 'openLocal':
|
||||
currentActions.openLocal?.();
|
||||
break;
|
||||
case 'quickSwitch':
|
||||
currentActions.quickSwitch?.();
|
||||
break;
|
||||
case 'newWorkspace':
|
||||
currentActions.newWorkspace?.();
|
||||
break;
|
||||
case 'commandPalette':
|
||||
currentActions.commandPalette?.();
|
||||
break;
|
||||
case 'portForwarding':
|
||||
currentActions.portForwarding?.();
|
||||
break;
|
||||
case 'snippets':
|
||||
currentActions.snippets?.();
|
||||
break;
|
||||
case 'splitHorizontal':
|
||||
currentActions.splitHorizontal?.();
|
||||
break;
|
||||
case 'splitVertical':
|
||||
currentActions.splitVertical?.();
|
||||
break;
|
||||
case 'moveFocus': {
|
||||
// Determine direction from arrow key
|
||||
const key = e.key;
|
||||
if (key === 'ArrowUp') currentActions.moveFocus?.('up');
|
||||
else if (key === 'ArrowDown') currentActions.moveFocus?.('down');
|
||||
else if (key === 'ArrowLeft') currentActions.moveFocus?.('left');
|
||||
else if (key === 'ArrowRight') currentActions.moveFocus?.('right');
|
||||
break;
|
||||
}
|
||||
case 'broadcast':
|
||||
currentActions.broadcast?.();
|
||||
break;
|
||||
case 'openSettings':
|
||||
currentActions.openSettings?.();
|
||||
break;
|
||||
}
|
||||
}, [hotkeyScheme, keyBindings, isSettingsOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
// Use capture phase to intercept before xterm
|
||||
window.addEventListener('keydown', handleGlobalKeyDown, true);
|
||||
return () => window.removeEventListener('keydown', handleGlobalKeyDown, true);
|
||||
}, [handleGlobalKeyDown]);
|
||||
};
|
||||
|
||||
// Helper to create key event handler for xterm's attachCustomKeyEventHandler
|
||||
// Returns false to let xterm handle the key, true to prevent xterm from handling
|
||||
export const createXtermKeyHandler = (
|
||||
keyBindings: KeyBinding[],
|
||||
isMac: boolean,
|
||||
onTerminalAction?: (action: string, e: KeyboardEvent) => void
|
||||
) => {
|
||||
const appLevelActions = getAppLevelActions();
|
||||
const terminalActions = getTerminalPassthroughActions();
|
||||
|
||||
return (e: KeyboardEvent): boolean => {
|
||||
const matched = checkAppShortcut(e, keyBindings, isMac);
|
||||
if (!matched) return true; // Let xterm handle it
|
||||
|
||||
const { action } = matched;
|
||||
|
||||
// App-level actions: prevent xterm from handling, let global handler take over
|
||||
if (appLevelActions.has(action)) {
|
||||
return false; // Don't let xterm handle, will bubble to global handler
|
||||
}
|
||||
|
||||
// Terminal-level actions: handle here and prevent default
|
||||
if (terminalActions.has(action)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onTerminalAction?.(action, e);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true; // Let xterm handle other keys
|
||||
};
|
||||
};
|
||||
|
||||
@@ -17,6 +17,13 @@ export const useKeychainBackend = () => {
|
||||
timeout?: number;
|
||||
enableKeyboardInteractive?: boolean;
|
||||
sessionId?: string;
|
||||
// Algorithm settings — let the keychain "export public key" flow honor
|
||||
// the same per-host SSH algorithm config the terminal uses, so a host
|
||||
// that needs the ECDSA skip / legacy mode / advanced overrides works
|
||||
// here too.
|
||||
legacyAlgorithms?: boolean;
|
||||
skipEcdsaHostKey?: boolean;
|
||||
algorithmOverrides?: import("../../domain/models").HostAlgorithmOverrides;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.execCommand) throw new Error("execCommand unavailable");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { MouseEvent,useCallback,useMemo,useRef,useState } from 'react';
|
||||
import { ConnectionLog,Host,SerialConfig,Snippet,TerminalSession,Workspace,WorkspaceViewMode } from '../../domain/models';
|
||||
import { addLogView, getLogViewTabId, removeLogView, type LogView } from './logViewState';
|
||||
import { createHostTerminalSession, createLocalTerminalSession, createSerialTerminalSession, type LocalTerminalOptions } from './sessionFactories';
|
||||
import {
|
||||
appendPaneToWorkspaceRoot,
|
||||
collectSessionIds,
|
||||
@@ -9,18 +11,13 @@ FocusDirection,
|
||||
getNextFocusSessionId,
|
||||
insertPaneIntoWorkspace,
|
||||
pruneWorkspaceNode,
|
||||
reorderWorkspaceFocusSessionOrder,
|
||||
SplitDirection,
|
||||
SplitHint,
|
||||
updateWorkspaceSplitSizes,
|
||||
} from '../../domain/workspace';
|
||||
import { activeTabStore } from './activeTabStore';
|
||||
|
||||
// LogView represents an open log replay tab
|
||||
export interface LogView {
|
||||
id: string; // Tab ID (log-${connectionLogId})
|
||||
connectionLogId: string;
|
||||
log: ConnectionLog;
|
||||
}
|
||||
|
||||
export const useSessionState = () => {
|
||||
const [sessions, setSessions] = useState<TerminalSession[]>([]);
|
||||
@@ -45,100 +42,22 @@ export const useSessionState = () => {
|
||||
// Log views: stores open log replay tabs
|
||||
const [logViews, setLogViews] = useState<LogView[]>([]);
|
||||
|
||||
const createLocalTerminal = useCallback((options?: {
|
||||
shellType?: TerminalSession['shellType'];
|
||||
shell?: string;
|
||||
shellArgs?: string[];
|
||||
shellName?: string;
|
||||
shellIcon?: string;
|
||||
}) => {
|
||||
const createLocalTerminal = useCallback((options?: LocalTerminalOptions) => {
|
||||
const sessionId = crypto.randomUUID();
|
||||
const localHostId = `local-${sessionId}`;
|
||||
const newSession: TerminalSession = {
|
||||
id: sessionId,
|
||||
hostId: localHostId,
|
||||
hostLabel: options?.shellName || 'Local Terminal',
|
||||
hostname: 'localhost',
|
||||
username: 'local',
|
||||
status: 'connecting',
|
||||
protocol: 'local',
|
||||
shellType: options?.shellType,
|
||||
localShell: options?.shell,
|
||||
localShellArgs: options?.shellArgs,
|
||||
localShellName: options?.shellName,
|
||||
localShellIcon: options?.shellIcon,
|
||||
};
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setSessions(prev => [...prev, createLocalTerminalSession(sessionId, options)]);
|
||||
setActiveTabId(sessionId);
|
||||
return sessionId;
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const createSerialSession = useCallback((config: SerialConfig, options?: { charset?: string }) => {
|
||||
const sessionId = crypto.randomUUID();
|
||||
const serialHostId = `serial-${sessionId}`;
|
||||
const portName = config.path.split('/').pop() || config.path;
|
||||
const newSession: TerminalSession = {
|
||||
id: sessionId,
|
||||
hostId: serialHostId,
|
||||
hostLabel: `Serial: ${portName}`,
|
||||
hostname: config.path,
|
||||
username: '',
|
||||
status: 'connecting',
|
||||
protocol: 'serial',
|
||||
serialConfig: config,
|
||||
charset: options?.charset,
|
||||
};
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setSessions(prev => [...prev, createSerialTerminalSession(sessionId, config, options)]);
|
||||
setActiveTabId(sessionId);
|
||||
return sessionId;
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const connectToHost = useCallback((host: Host) => {
|
||||
// Handle serial hosts specially - use createSerialSession for them
|
||||
if (host.protocol === 'serial') {
|
||||
// Use stored serialConfig or construct from host data
|
||||
const serialConfig: SerialConfig = host.serialConfig || {
|
||||
path: host.hostname,
|
||||
baudRate: host.port || 115200,
|
||||
dataBits: 8,
|
||||
stopBits: 1,
|
||||
parity: 'none',
|
||||
flowControl: 'none',
|
||||
localEcho: false,
|
||||
lineMode: false,
|
||||
};
|
||||
|
||||
const sessionId = crypto.randomUUID();
|
||||
const portName = serialConfig.path.split('/').pop() || serialConfig.path;
|
||||
const newSession: TerminalSession = {
|
||||
id: sessionId,
|
||||
hostId: host.id,
|
||||
hostLabel: host.label || `Serial: ${portName}`,
|
||||
hostname: serialConfig.path,
|
||||
username: '',
|
||||
status: 'connecting',
|
||||
protocol: 'serial',
|
||||
serialConfig: serialConfig,
|
||||
charset: host.charset,
|
||||
};
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(sessionId);
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
const newSession: TerminalSession = {
|
||||
id: crypto.randomUUID(),
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
username: host.username,
|
||||
status: 'connecting',
|
||||
// Store connection-time protocol settings from the host object
|
||||
protocol: host.protocol,
|
||||
port: host.port,
|
||||
moshEnabled: host.moshEnabled,
|
||||
charset: host.charset,
|
||||
};
|
||||
const newSession = createHostTerminalSession(crypto.randomUUID(), host);
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(newSession.id);
|
||||
return newSession.id;
|
||||
@@ -759,6 +678,27 @@ export const useSessionState = () => {
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const reorderWorkspaceSessions = useCallback((
|
||||
workspaceId: string,
|
||||
draggedSessionId: string,
|
||||
targetSessionId: string,
|
||||
position: 'before' | 'after' = 'before',
|
||||
) => {
|
||||
setWorkspaces(prev => prev.map(ws => {
|
||||
if (ws.id !== workspaceId) return ws;
|
||||
return {
|
||||
...ws,
|
||||
focusSessionOrder: reorderWorkspaceFocusSessionOrder(
|
||||
ws.root,
|
||||
ws.focusSessionOrder,
|
||||
draggedSessionId,
|
||||
targetSessionId,
|
||||
position,
|
||||
),
|
||||
};
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Move focus between panes in a workspace
|
||||
const moveFocusInWorkspace = useCallback((workspaceId: string, direction: FocusDirection): boolean => {
|
||||
const workspace = workspaces.find(w => w.id === workspaceId);
|
||||
@@ -791,8 +731,9 @@ export const useSessionState = () => {
|
||||
}, [workspaces]);
|
||||
|
||||
// Run a snippet on multiple target hosts - creates a focus mode workspace
|
||||
const runSnippet = useCallback((snippet: Snippet, targetHosts: Host[]) => {
|
||||
const runSnippet = useCallback((snippet: Snippet, targetHosts: Host[], commandOverride?: string) => {
|
||||
if (targetHosts.length === 0) return;
|
||||
const resolvedCommand = commandOverride ?? snippet.command;
|
||||
|
||||
// Create sessions for each target host
|
||||
const newSessions: TerminalSession[] = targetHosts.map(host => ({
|
||||
@@ -820,7 +761,7 @@ export const useSessionState = () => {
|
||||
...s,
|
||||
workspaceId: workspace.id,
|
||||
// Store the command to run after connection
|
||||
startupCommand: snippet.command,
|
||||
startupCommand: resolvedCommand,
|
||||
noAutoRun: snippet.noAutoRun,
|
||||
}));
|
||||
|
||||
@@ -831,36 +772,17 @@ export const useSessionState = () => {
|
||||
|
||||
const orphanSessions = useMemo(() => sessions.filter(s => !s.workspaceId), [sessions]);
|
||||
|
||||
// Open a log view tab
|
||||
const openLogView = useCallback((log: ConnectionLog) => {
|
||||
const tabId = `log-${log.id}`;
|
||||
// Check if already open
|
||||
setLogViews(prev => {
|
||||
if (prev.some(lv => lv.connectionLogId === log.id)) {
|
||||
// Already open, just switch to it
|
||||
setActiveTabId(tabId);
|
||||
return prev;
|
||||
}
|
||||
// Open new log view
|
||||
const newLogView: LogView = {
|
||||
id: tabId,
|
||||
connectionLogId: log.id,
|
||||
log,
|
||||
};
|
||||
setActiveTabId(tabId);
|
||||
return [...prev, newLogView];
|
||||
});
|
||||
const tabId = getLogViewTabId(log);
|
||||
setLogViews(prev => addLogView(prev, log));
|
||||
setActiveTabId(tabId);
|
||||
}, [setActiveTabId]);
|
||||
|
||||
// Close a log view tab
|
||||
const closeLogView = useCallback((logViewId: string) => {
|
||||
setLogViews(prev => {
|
||||
const updated = prev.filter(lv => lv.id !== logViewId);
|
||||
// If this was the active tab, switch to vault
|
||||
const currentActiveTabId = activeTabStore.getActiveTabId();
|
||||
if (currentActiveTabId === logViewId) {
|
||||
const fallback = updated.length > 0 ? updated[updated.length - 1].id : 'vault';
|
||||
setActiveTabId(fallback);
|
||||
const updated = removeLogView(prev, logViewId);
|
||||
if (activeTabStore.getActiveTabId() === logViewId) {
|
||||
setActiveTabId(updated.length > 0 ? updated[updated.length - 1].id : 'vault');
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
@@ -1049,6 +971,7 @@ export const useSessionState = () => {
|
||||
splitSession,
|
||||
toggleWorkspaceViewMode,
|
||||
setWorkspaceFocusedSession,
|
||||
reorderWorkspaceSessions,
|
||||
moveFocusInWorkspace,
|
||||
runSnippet,
|
||||
orphanSessions,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
|
||||
import { SyncConfig, TerminalTheme, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
|
||||
import { SyncConfig, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
|
||||
import {
|
||||
STORAGE_KEY_COLOR,
|
||||
STORAGE_KEY_SYNC,
|
||||
STORAGE_KEY_TERM_THEME,
|
||||
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
|
||||
STORAGE_KEY_TERM_THEME_DARK,
|
||||
STORAGE_KEY_TERM_THEME_LIGHT,
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
@@ -39,7 +41,6 @@ import {
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
|
||||
import {
|
||||
areCustomKeyBindingsEqual,
|
||||
nextCustomKeyBindingsSyncVersion,
|
||||
@@ -49,163 +50,51 @@ import {
|
||||
shouldApplyIncomingCustomKeyBindingsRecord,
|
||||
updateCustomKeyBinding as updateCustomKeyBindingRecord,
|
||||
} from '../../domain/customKeyBindings';
|
||||
import { applyCustomAccentToTerminalTheme, getTerminalThemeForUiTheme } from '../../domain/terminalAppearance';
|
||||
import { TERMINAL_THEME_AUTO } from '../../domain/terminalAppearance';
|
||||
import { customThemeStore, useCustomThemes } from '../state/customThemeStore';
|
||||
import { DEFAULT_FONT_SIZE, isDeprecatedPrimaryFontId } from '../../infrastructure/config/fonts';
|
||||
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
|
||||
import { UI_FONTS, DEFAULT_UI_FONT_ID } from '../../infrastructure/config/uiFonts';
|
||||
import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
|
||||
import { getUiThemeById } from '../../infrastructure/config/uiThemes';
|
||||
import { DEFAULT_UI_FONT_ID } from '../../infrastructure/config/uiFonts';
|
||||
import { uiFontStore, useUIFontsLoaded } from './uiFontStore';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
const DEFAULT_THEME: 'light' | 'dark' | 'system' = 'dark';
|
||||
|
||||
/** Resolve the current OS color scheme preference. */
|
||||
const getSystemPreference = (): 'light' | 'dark' =>
|
||||
typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
const DEFAULT_LIGHT_UI_THEME = 'snow';
|
||||
const DEFAULT_DARK_UI_THEME = 'midnight';
|
||||
const DEFAULT_ACCENT_MODE: 'theme' | 'custom' = 'theme';
|
||||
const DEFAULT_CUSTOM_ACCENT = '221.2 83.2% 53.3%';
|
||||
const DEFAULT_TERMINAL_THEME = 'netcatty-dark';
|
||||
const DEFAULT_FONT_FAMILY = 'menlo';
|
||||
|
||||
/**
|
||||
* Migrate any terminal font id arriving from storage / IPC / sync to a
|
||||
* safe value. If `raw` is a deprecated proportional id (pingfang-sc,
|
||||
* microsoft-yahei, comic-sans-ms), persist the rewrite back to
|
||||
* localStorage so subsequent ingest paths and cloud-sync uploads stop
|
||||
* carrying it. Used by every place that reads STORAGE_KEY_TERM_FONT_FAMILY
|
||||
* — initial useState init, rehydrateAllFromStorage, IPC notifySettings
|
||||
* change listener, and cross-window storage event listener — so a
|
||||
* single point of truth keeps deprecated ids from re-entering state.
|
||||
*
|
||||
* Returns null when there's nothing to apply (raw is empty); callers
|
||||
* fall back to DEFAULT_FONT_FAMILY in that case.
|
||||
*/
|
||||
function migrateIncomingTerminalFontId(raw: string | null | undefined): string | null {
|
||||
if (!raw) return null;
|
||||
if (isDeprecatedPrimaryFontId(raw)) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, DEFAULT_FONT_FAMILY);
|
||||
return DEFAULT_FONT_FAMILY;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
// Auto-detect default hotkey scheme based on platform
|
||||
const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
|
||||
typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform)
|
||||
? 'mac'
|
||||
: 'pc';
|
||||
const DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR: 'open' | 'transfer' = 'open';
|
||||
const DEFAULT_SFTP_AUTO_SYNC = false;
|
||||
const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
|
||||
const DEFAULT_SFTP_USE_COMPRESSED_UPLOAD = true;
|
||||
const DEFAULT_SFTP_AUTO_OPEN_SIDEBAR = false;
|
||||
const DEFAULT_SFTP_DEFAULT_VIEW_MODE: 'list' | 'tree' = 'list';
|
||||
const DEFAULT_SHOW_RECENT_HOSTS = true;
|
||||
const DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT = false;
|
||||
const DEFAULT_SHOW_SFTP_TAB = true;
|
||||
|
||||
// Editor defaults
|
||||
const DEFAULT_EDITOR_WORD_WRAP = false;
|
||||
|
||||
// Session Logs defaults
|
||||
const DEFAULT_SESSION_LOGS_ENABLED = false;
|
||||
const DEFAULT_SESSION_LOGS_FORMAT: SessionLogFormat = 'txt';
|
||||
|
||||
const readStoredString = (key: string): string | null => {
|
||||
const raw = localStorageAdapter.readString(key);
|
||||
if (!raw) return null;
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
return typeof parsed === 'string' ? parsed : trimmed;
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
};
|
||||
|
||||
const isValidTheme = (value: unknown): value is 'light' | 'dark' | 'system' => value === 'light' || value === 'dark' || value === 'system';
|
||||
|
||||
const isValidHslToken = (value: string): boolean => {
|
||||
// Expect: "<h> <s>% <l>%", e.g. "221.2 83.2% 53.3%"
|
||||
return /^\s*\d+(\.\d+)?\s+\d+(\.\d+)?%\s+\d+(\.\d+)?%\s*$/.test(value);
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const isValidUiFontId = (value: string): boolean => {
|
||||
// Local fonts are always considered valid
|
||||
if (value.startsWith('local-')) return true;
|
||||
// Check bundled fonts first, then check dynamically loaded fonts
|
||||
return UI_FONTS.some((font) => font.id === value) ||
|
||||
uiFontStore.getAvailableFonts().some((font) => font.id === value);
|
||||
};
|
||||
|
||||
const serializeTerminalSettings = (settings: TerminalSettings): string =>
|
||||
JSON.stringify(settings);
|
||||
|
||||
const areTerminalSettingsEqual = (a: TerminalSettings, b: TerminalSettings): boolean =>
|
||||
serializeTerminalSettings(a) === serializeTerminalSettings(b);
|
||||
|
||||
const createCustomKeyBindingsSyncOrigin = (): string => {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
};
|
||||
|
||||
const applyThemeTokens = (
|
||||
themeSource: 'light' | 'dark' | 'system',
|
||||
resolvedTheme: 'light' | 'dark',
|
||||
tokens: UiThemeTokens,
|
||||
accentMode: 'theme' | 'custom',
|
||||
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.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);
|
||||
netcattyBridge.get()?.setBackgroundColor?.(tokens.background);
|
||||
};
|
||||
import {
|
||||
DEFAULT_ACCENT_MODE,
|
||||
DEFAULT_CUSTOM_ACCENT,
|
||||
DEFAULT_DARK_UI_THEME,
|
||||
DEFAULT_EDITOR_WORD_WRAP,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_HOTKEY_SCHEME,
|
||||
DEFAULT_LIGHT_UI_THEME,
|
||||
DEFAULT_SESSION_LOGS_ENABLED,
|
||||
DEFAULT_SESSION_LOGS_FORMAT,
|
||||
DEFAULT_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
DEFAULT_SFTP_AUTO_SYNC,
|
||||
DEFAULT_SFTP_DEFAULT_VIEW_MODE,
|
||||
DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR,
|
||||
DEFAULT_SFTP_SHOW_HIDDEN_FILES,
|
||||
DEFAULT_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
DEFAULT_SHOW_RECENT_HOSTS,
|
||||
DEFAULT_SHOW_SFTP_TAB,
|
||||
DEFAULT_TERMINAL_THEME,
|
||||
DEFAULT_THEME,
|
||||
applyThemeTokens,
|
||||
areTerminalSettingsEqual,
|
||||
createCustomKeyBindingsSyncOrigin,
|
||||
getSystemPreference,
|
||||
isValidHslToken,
|
||||
isValidTheme,
|
||||
isValidUiFontId,
|
||||
isValidUiThemeId,
|
||||
migrateIncomingTerminalFontId,
|
||||
readStoredString,
|
||||
serializeTerminalSettings,
|
||||
} from './settingsStateDefaults';
|
||||
import { useSettingsStorageSync } from './settingsStorageSync';
|
||||
import { useSettingsIpcSync } from './settingsIpcSync';
|
||||
import { resolveCurrentTerminalTheme } from './settingsTerminalTheme';
|
||||
import { useSystemSettingsEffects } from './systemSettingsEffects';
|
||||
|
||||
export const useSettingsState = () => {
|
||||
const initialCustomKeyBindingsRecord =
|
||||
@@ -254,6 +143,12 @@ export const useSettingsState = () => {
|
||||
const isUpgrade = !!localStorageAdapter.readString(STORAGE_KEY_TERM_THEME);
|
||||
return !isUpgrade;
|
||||
});
|
||||
const [terminalThemeDarkId, setTerminalThemeDarkId] = useState<string>(
|
||||
() => localStorageAdapter.readString(STORAGE_KEY_TERM_THEME_DARK) || TERMINAL_THEME_AUTO,
|
||||
);
|
||||
const [terminalThemeLightId, setTerminalThemeLightId] = useState<string>(
|
||||
() => localStorageAdapter.readString(STORAGE_KEY_TERM_THEME_LIGHT) || TERMINAL_THEME_AUTO,
|
||||
);
|
||||
const [terminalFontFamilyId, setTerminalFontFamilyId] = useState<string>(() => {
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY);
|
||||
return migrateIncomingTerminalFontId(stored) ?? DEFAULT_FONT_FAMILY;
|
||||
@@ -536,6 +431,10 @@ export const useSettingsState = () => {
|
||||
// Terminal
|
||||
const storedTermTheme = readStoredString(STORAGE_KEY_TERM_THEME);
|
||||
if (storedTermTheme) setTerminalThemeId(storedTermTheme);
|
||||
const storedTermThemeDark = readStoredString(STORAGE_KEY_TERM_THEME_DARK);
|
||||
if (storedTermThemeDark) setTerminalThemeDarkId(storedTermThemeDark);
|
||||
const storedTermThemeLight = readStoredString(STORAGE_KEY_TERM_THEME_LIGHT);
|
||||
if (storedTermThemeLight) setTerminalThemeLightId(storedTermThemeLight);
|
||||
const storedTermFont = readStoredString(STORAGE_KEY_TERM_FONT_FAMILY);
|
||||
const migratedTermFont = migrateIncomingTerminalFontId(storedTermFont);
|
||||
if (migratedTermFont) setTerminalFontFamilyId(migratedTermFont);
|
||||
@@ -637,117 +536,32 @@ export const useSettingsState = () => {
|
||||
}
|
||||
}, [uiFontFamilyId, uiFontsLoaded, notifySettingsChanged]);
|
||||
|
||||
// Listen for settings changes from other windows via IPC
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onSettingsChanged) return;
|
||||
const unsubscribe = bridge.onSettingsChanged((payload) => {
|
||||
const { key, value } = payload;
|
||||
if (
|
||||
key === STORAGE_KEY_THEME ||
|
||||
key === STORAGE_KEY_UI_THEME_LIGHT ||
|
||||
key === STORAGE_KEY_UI_THEME_DARK ||
|
||||
key === STORAGE_KEY_ACCENT_MODE ||
|
||||
key === STORAGE_KEY_COLOR
|
||||
) {
|
||||
syncAppearanceFromStorage();
|
||||
return;
|
||||
}
|
||||
if (key === STORAGE_KEY_UI_LANGUAGE && typeof value === 'string') {
|
||||
const next = resolveSupportedLocale(value);
|
||||
setUiLanguage((prev) => (prev === next ? prev : next));
|
||||
document.documentElement.lang = next;
|
||||
}
|
||||
if (key === STORAGE_KEY_CUSTOM_CSS && typeof value === 'string') {
|
||||
syncCustomCssFromStorage();
|
||||
}
|
||||
if (key === STORAGE_KEY_UI_FONT_FAMILY && typeof value === 'string') {
|
||||
if (isValidUiFontId(value)) {
|
||||
setUiFontFamilyId(value);
|
||||
}
|
||||
}
|
||||
if (key === STORAGE_KEY_TERM_THEME && typeof value === 'string') {
|
||||
setTerminalThemeId(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_TERM_FOLLOW_APP_THEME) {
|
||||
const next = value === true || value === 'true';
|
||||
setFollowAppTerminalThemeState((prev) => (prev === next ? prev : next));
|
||||
}
|
||||
if (key === STORAGE_KEY_TERM_FONT_FAMILY && typeof value === 'string') {
|
||||
const migrated = migrateIncomingTerminalFontId(value);
|
||||
if (migrated) setTerminalFontFamilyId(migrated);
|
||||
}
|
||||
if (key === STORAGE_KEY_TERM_FONT_SIZE && typeof value === 'number') {
|
||||
setTerminalFontSize(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_TERM_SETTINGS) {
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Partial<TerminalSettings>;
|
||||
mergeIncomingTerminalSettings(parsed);
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
} else if (value && typeof value === 'object') {
|
||||
mergeIncomingTerminalSettings(value as Partial<TerminalSettings>);
|
||||
}
|
||||
}
|
||||
if (key === STORAGE_KEY_EDITOR_WORD_WRAP && typeof value === 'boolean') {
|
||||
setEditorWordWrapState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SESSION_LOGS_ENABLED && typeof value === 'boolean') {
|
||||
setSessionLogsEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SESSION_LOGS_DIR && typeof value === 'string') {
|
||||
setSessionLogsDir((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (
|
||||
key === STORAGE_KEY_SESSION_LOGS_FORMAT &&
|
||||
(value === 'txt' || value === 'raw' || value === 'html')
|
||||
) {
|
||||
setSessionLogsFormat((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_HOTKEY_SCHEME && (value === 'disabled' || value === 'mac' || value === 'pc')) {
|
||||
setHotkeyScheme(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_CUSTOM_KEY_BINDINGS) {
|
||||
const parsed = parseCustomKeyBindingsStorageRecord(value);
|
||||
if (parsed) {
|
||||
applyIncomingCustomKeyBindings(parsed);
|
||||
}
|
||||
}
|
||||
if (key === STORAGE_KEY_HOTKEY_RECORDING && typeof value === 'boolean') {
|
||||
setIsHotkeyRecordingState(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && typeof value === 'boolean') {
|
||||
setGlobalHotkeyEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_AUTO_UPDATE_ENABLED && typeof value === 'boolean') {
|
||||
setAutoUpdateEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && typeof value === 'boolean') {
|
||||
setSftpAutoOpenSidebar((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE && typeof value === 'string') {
|
||||
if (value === 'list' || value === 'tree') {
|
||||
setSftpDefaultViewMode((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
}
|
||||
if (key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && (value === 'dim' || value === 'border')) {
|
||||
setWorkspaceFocusStyleState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY && typeof value === 'number') {
|
||||
setSftpTransferConcurrencyState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
try {
|
||||
unsubscribe?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
}, [applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings, syncAppearanceFromStorage, syncCustomCssFromStorage]);
|
||||
useSettingsIpcSync({
|
||||
syncAppearanceFromStorage,
|
||||
syncCustomCssFromStorage,
|
||||
setUiLanguage,
|
||||
setUiFontFamilyId,
|
||||
setTerminalThemeId,
|
||||
setTerminalThemeDarkId,
|
||||
setTerminalThemeLightId,
|
||||
setFollowAppTerminalThemeState,
|
||||
setTerminalFontFamilyId,
|
||||
setTerminalFontSize,
|
||||
mergeIncomingTerminalSettings,
|
||||
setEditorWordWrapState,
|
||||
setSessionLogsEnabled,
|
||||
setSessionLogsDir,
|
||||
setSessionLogsFormat,
|
||||
setHotkeyScheme,
|
||||
applyIncomingCustomKeyBindings,
|
||||
setIsHotkeyRecordingState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setAutoUpdateEnabled,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpDefaultViewMode,
|
||||
setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -766,10 +580,7 @@ export const useSettingsState = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fix 4: Keep a ref snapshot of current settings so the storage event handler
|
||||
// can compare without capturing 25+ state variables in its closure / dep array.
|
||||
// This avoids constant listener detach/reattach on every state change.
|
||||
const settingsSnapshotRef = useRef({
|
||||
useSettingsStorageSync({
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
@@ -778,226 +589,17 @@ export const useSettingsState = () => {
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
|
||||
globalHotkeyEnabled, autoUpdateEnabled,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
|
||||
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
|
||||
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat,
|
||||
setGlobalHotkeyEnabled, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
});
|
||||
settingsSnapshotRef.current = {
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
|
||||
globalHotkeyEnabled, autoUpdateEnabled,
|
||||
};
|
||||
|
||||
// Listen for storage changes from other windows (cross-window sync)
|
||||
useEffect(() => {
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
const s = settingsSnapshotRef.current;
|
||||
if (e.key === STORAGE_KEY_THEME && e.newValue) {
|
||||
if (isValidTheme(e.newValue) && e.newValue !== s.theme) {
|
||||
setTheme(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_UI_THEME_LIGHT && e.newValue) {
|
||||
if (isValidUiThemeId('light', e.newValue) && e.newValue !== s.lightUiThemeId) {
|
||||
setLightUiThemeId(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_UI_THEME_DARK && e.newValue) {
|
||||
if (isValidUiThemeId('dark', e.newValue) && e.newValue !== s.darkUiThemeId) {
|
||||
setDarkUiThemeId(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_ACCENT_MODE && e.newValue) {
|
||||
if ((e.newValue === 'theme' || e.newValue === 'custom') && e.newValue !== s.accentMode) {
|
||||
setAccentMode(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_COLOR && e.newValue) {
|
||||
if (isValidHslToken(e.newValue) && e.newValue !== s.customAccent) {
|
||||
setCustomAccent(e.newValue.trim());
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_CUSTOM_CSS && e.newValue !== null) {
|
||||
if (e.newValue !== s.customCSS) {
|
||||
setCustomCSS(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_UI_FONT_FAMILY && e.newValue) {
|
||||
if (isValidUiFontId(e.newValue) && e.newValue !== s.uiFontFamilyId) {
|
||||
setUiFontFamilyId(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_HOTKEY_SCHEME && e.newValue) {
|
||||
const newScheme = e.newValue as HotkeyScheme;
|
||||
if (newScheme !== s.hotkeyScheme) {
|
||||
setHotkeyScheme(newScheme);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_UI_LANGUAGE && e.newValue) {
|
||||
const next = resolveSupportedLocale(e.newValue);
|
||||
if (next !== s.uiLanguage) {
|
||||
setUiLanguage(next as UILanguage);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_CUSTOM_KEY_BINDINGS && e.newValue) {
|
||||
const parsed = parseCustomKeyBindingsStorageRecord(e.newValue);
|
||||
if (parsed) {
|
||||
applyIncomingCustomKeyBindings(parsed);
|
||||
}
|
||||
}
|
||||
// Sync terminal settings from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_SETTINGS && e.newValue) {
|
||||
try {
|
||||
const newSettings = JSON.parse(e.newValue) as TerminalSettings;
|
||||
mergeIncomingTerminalSettings(newSettings);
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
// Sync terminal theme from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_THEME && e.newValue) {
|
||||
if (e.newValue !== s.terminalThemeId) {
|
||||
setTerminalThemeId(e.newValue);
|
||||
}
|
||||
}
|
||||
// Sync follow-app-theme toggle from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_FOLLOW_APP_THEME && e.newValue) {
|
||||
const next = e.newValue === 'true';
|
||||
if (next !== s.followAppTerminalTheme) {
|
||||
setFollowAppTerminalThemeState(next);
|
||||
}
|
||||
}
|
||||
// Sync terminal font family from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_FONT_FAMILY && e.newValue) {
|
||||
const migrated = migrateIncomingTerminalFontId(e.newValue);
|
||||
if (migrated && migrated !== s.terminalFontFamilyId) {
|
||||
setTerminalFontFamilyId(migrated);
|
||||
}
|
||||
}
|
||||
// Sync terminal font size from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_FONT_SIZE && e.newValue) {
|
||||
const newSize = parseInt(e.newValue, 10);
|
||||
if (!isNaN(newSize) && newSize !== s.terminalFontSize) {
|
||||
setTerminalFontSize(newSize);
|
||||
}
|
||||
}
|
||||
// Sync SFTP double-click behavior from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR && e.newValue) {
|
||||
if ((e.newValue === 'open' || e.newValue === 'transfer') && e.newValue !== s.sftpDoubleClickBehavior) {
|
||||
setSftpDoubleClickBehavior(e.newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP auto-sync setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_AUTO_SYNC && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.sftpAutoSync) {
|
||||
setSftpAutoSync(newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP show hidden files setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.sftpShowHiddenFiles) {
|
||||
setSftpShowHiddenFiles(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_EDITOR_WORD_WRAP && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.editorWordWrap) {
|
||||
setEditorWordWrapState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SESSION_LOGS_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.sessionLogsEnabled) {
|
||||
setSessionLogsEnabled(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SESSION_LOGS_DIR && e.newValue !== null) {
|
||||
if (e.newValue !== s.sessionLogsDir) {
|
||||
setSessionLogsDir(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SESSION_LOGS_FORMAT && e.newValue) {
|
||||
if (
|
||||
(e.newValue === 'txt' || e.newValue === 'raw' || e.newValue === 'html') &&
|
||||
e.newValue !== s.sessionLogsFormat
|
||||
) {
|
||||
setSessionLogsFormat(e.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';
|
||||
if (newValue !== s.sftpUseCompressedUpload) {
|
||||
setSftpUseCompressedUpload(newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP auto-open sidebar setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.sftpAutoOpenSidebar) {
|
||||
setSftpAutoOpenSidebar(newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP default view mode from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE && e.newValue) {
|
||||
if ((e.newValue === 'list' || e.newValue === 'tree') && e.newValue !== s.sftpDefaultViewMode) {
|
||||
setSftpDefaultViewMode(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SHOW_RECENT_HOSTS && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.showRecentHosts) {
|
||||
setShowRecentHostsState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.showOnlyUngroupedHostsInRoot) {
|
||||
setShowOnlyUngroupedHostsInRootState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SHOW_SFTP_TAB && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.showSftpTab) {
|
||||
setShowSftpTabState(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';
|
||||
if (newValue !== s.globalHotkeyEnabled) {
|
||||
setGlobalHotkeyEnabled(newValue);
|
||||
}
|
||||
}
|
||||
// Sync auto-update enabled setting from other windows
|
||||
if (e.key === STORAGE_KEY_AUTO_UPDATE_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.autoUpdateEnabled) {
|
||||
setAutoUpdateEnabled(newValue);
|
||||
}
|
||||
}
|
||||
// Sync workspace focus style from other windows
|
||||
if (e.key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && e.newValue !== null) {
|
||||
if (e.newValue === 'dim' || e.newValue === 'border') {
|
||||
setWorkspaceFocusStyleState(e.newValue);
|
||||
}
|
||||
}
|
||||
// Sync transfer concurrency from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY && e.newValue !== null) {
|
||||
const num = Number(e.newValue);
|
||||
if (num >= 1 && num <= 16) {
|
||||
setSftpTransferConcurrencyState(num);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, [applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings]); // Fix 4: stable deps only — state comparisons use settingsSnapshotRef
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
|
||||
@@ -1011,6 +613,18 @@ export const useSettingsState = () => {
|
||||
notifySettingsChanged(STORAGE_KEY_TERM_FOLLOW_APP_THEME, String(followAppTerminalTheme));
|
||||
}, [followAppTerminalTheme, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_DARK, terminalThemeDarkId);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TERM_THEME_DARK, terminalThemeDarkId);
|
||||
}, [terminalThemeDarkId, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_LIGHT, terminalThemeLightId);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TERM_THEME_LIGHT, terminalThemeLightId);
|
||||
}, [terminalThemeLightId, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, terminalFontFamilyId);
|
||||
if (!persistMountedRef.current) return;
|
||||
@@ -1165,89 +779,16 @@ export const useSettingsState = () => {
|
||||
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
|
||||
}, [sessionLogsFormat, notifySettingsChanged]);
|
||||
|
||||
// Persist and sync toggle window hotkey setting
|
||||
useEffect(() => {
|
||||
// Register/unregister the global hotkey in main process (needed on mount)
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.registerGlobalHotkey) {
|
||||
if (toggleWindowHotkey && globalHotkeyEnabled) {
|
||||
setHotkeyRegistrationError(null);
|
||||
bridge
|
||||
.registerGlobalHotkey(toggleWindowHotkey)
|
||||
.then((result) => {
|
||||
if (result?.success === false) {
|
||||
console.warn('[GlobalHotkey] Hotkey registration failed:', result.error);
|
||||
setHotkeyRegistrationError(result.error || 'Failed to register hotkey');
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('[GlobalHotkey] Failed to register hotkey:', err);
|
||||
setHotkeyRegistrationError(err?.message || 'Failed to register hotkey');
|
||||
});
|
||||
} else {
|
||||
setHotkeyRegistrationError(null);
|
||||
bridge.unregisterGlobalHotkey?.().catch((err) => {
|
||||
console.warn('[GlobalHotkey] Failed to unregister hotkey:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
|
||||
// Skip IPC on initial mount
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
|
||||
}, [toggleWindowHotkey, globalHotkeyEnabled, notifySettingsChanged]);
|
||||
|
||||
// Persist global hotkey enabled setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled);
|
||||
}, [globalHotkeyEnabled, notifySettingsChanged]);
|
||||
|
||||
// Persist and sync close to tray setting
|
||||
useEffect(() => {
|
||||
// Update main process tray behavior (needed on mount)
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.setCloseToTray) {
|
||||
bridge.setCloseToTray(closeToTray).catch((err) => {
|
||||
console.warn('[SystemTray] Failed to set close-to-tray:', err);
|
||||
});
|
||||
}
|
||||
localStorageAdapter.writeString(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray ? 'true' : 'false');
|
||||
// Skip IPC on initial mount
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
|
||||
}, [closeToTray, notifySettingsChanged]);
|
||||
|
||||
// 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(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
void bridge?.getAutoUpdate?.().then((result) => {
|
||||
if (result && typeof result.enabled === 'boolean') {
|
||||
setAutoUpdateEnabled((prev) => {
|
||||
if (prev === result.enabled) return prev;
|
||||
// Sync localStorage with the main-process truth
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AUTO_UPDATE_ENABLED, result.enabled ? 'true' : 'false');
|
||||
return result.enabled;
|
||||
});
|
||||
}
|
||||
}).catch(() => { /* bridge unavailable */ });
|
||||
}, []);
|
||||
|
||||
// Persist auto-update enabled setting.
|
||||
// Initial mount still writes localStorage, but skips cross-window/main-process IPC.
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled);
|
||||
// Notify main process on user-initiated changes
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.setAutoUpdate?.(autoUpdateEnabled).catch((err: unknown) => {
|
||||
console.warn('[AutoUpdate] Failed to set auto-update:', err);
|
||||
});
|
||||
}, [autoUpdateEnabled, notifySettingsChanged]);
|
||||
useSystemSettingsEffects({
|
||||
toggleWindowHotkey,
|
||||
globalHotkeyEnabled,
|
||||
closeToTray,
|
||||
autoUpdateEnabled,
|
||||
persistMountedRef,
|
||||
setHotkeyRegistrationError,
|
||||
setAutoUpdateEnabled,
|
||||
notifySettingsChanged,
|
||||
});
|
||||
|
||||
// Fix 1: Mark all persist effects as mounted.
|
||||
// This MUST be declared AFTER all persist useEffects so that React runs it last
|
||||
@@ -1292,26 +833,20 @@ export const useSettingsState = () => {
|
||||
// Subscribe to custom theme changes so editing in-place triggers re-render
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
const currentTerminalTheme = useMemo(() => {
|
||||
let baseTheme: TerminalTheme;
|
||||
// When "Follow Application Theme" is enabled, pick the terminal theme
|
||||
// whose background matches the active UI theme preset.
|
||||
if (followAppTerminalTheme) {
|
||||
const activeUiThemeId = resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId;
|
||||
const mapped = getTerminalThemeForUiTheme(activeUiThemeId);
|
||||
if (mapped) {
|
||||
const found = TERMINAL_THEMES.find(t => t.id === mapped);
|
||||
if (found) {
|
||||
baseTheme = found;
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
}
|
||||
}
|
||||
}
|
||||
baseTheme = TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|
||||
|| customThemes.find(t => t.id === terminalThemeId)
|
||||
|| TERMINAL_THEMES[0];
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
}, [terminalThemeId, customThemes, followAppTerminalTheme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
|
||||
const currentTerminalTheme = useMemo(() => resolveCurrentTerminalTheme({
|
||||
terminalThemeId,
|
||||
terminalThemeDarkId,
|
||||
terminalThemeLightId,
|
||||
customThemes,
|
||||
followAppTerminalTheme,
|
||||
resolvedTheme,
|
||||
lightUiThemeId,
|
||||
darkUiThemeId,
|
||||
accentMode,
|
||||
customAccent,
|
||||
}), [terminalThemeId, terminalThemeDarkId, terminalThemeLightId, customThemes,
|
||||
followAppTerminalTheme, resolvedTheme, lightUiThemeId, darkUiThemeId,
|
||||
accentMode, customAccent]);
|
||||
|
||||
const updateTerminalSetting = useCallback(<K extends keyof TerminalSettings>(
|
||||
key: K,
|
||||
@@ -1348,6 +883,10 @@ export const useSettingsState = () => {
|
||||
setTerminalThemeId,
|
||||
followAppTerminalTheme,
|
||||
setFollowAppTerminalTheme: setFollowAppTerminalThemeState,
|
||||
terminalThemeDarkId,
|
||||
setTerminalThemeDarkId,
|
||||
terminalThemeLightId,
|
||||
setTerminalThemeLightId,
|
||||
currentTerminalTheme,
|
||||
terminalFontFamilyId,
|
||||
setTerminalFontFamilyId,
|
||||
|
||||
@@ -150,6 +150,16 @@ export const useSftpBackend = () => {
|
||||
return bridge.getHomeDir();
|
||||
}, []);
|
||||
|
||||
const listDrives = useCallback(async () => {
|
||||
return await netcattyBridge.get()?.listDrives?.() ?? [];
|
||||
}, []);
|
||||
|
||||
const openPath = useCallback(async (path: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.openPath) throw new Error("openPath unavailable");
|
||||
return bridge.openPath(path);
|
||||
}, []);
|
||||
|
||||
const startStreamTransfer = useCallback(
|
||||
async (
|
||||
options: Parameters<NonNullable<NetcattyBridge["startStreamTransfer"]>>[0],
|
||||
@@ -268,6 +278,8 @@ export const useSftpBackend = () => {
|
||||
mkdirLocal,
|
||||
statLocal,
|
||||
getHomeDir,
|
||||
listDrives,
|
||||
openPath,
|
||||
|
||||
startStreamTransfer,
|
||||
cancelTransfer,
|
||||
|
||||
@@ -73,6 +73,11 @@ export const useTerminalBackend = () => {
|
||||
bridge?.resizeSession?.(sessionId, cols, rows);
|
||||
}, []);
|
||||
|
||||
const setSessionFlowPaused = useCallback((sessionId: string, paused: boolean) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.setSessionFlowPaused?.(sessionId, paused);
|
||||
}, []);
|
||||
|
||||
const closeSession = useCallback((sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.closeSession?.(sessionId);
|
||||
@@ -208,6 +213,7 @@ export const useTerminalBackend = () => {
|
||||
getServerStats,
|
||||
writeToSession,
|
||||
resizeSession,
|
||||
setSessionFlowPaused,
|
||||
closeSession,
|
||||
setSessionEncoding,
|
||||
onSessionData,
|
||||
@@ -240,6 +246,7 @@ export const useTerminalBackend = () => {
|
||||
getServerStats,
|
||||
writeToSession,
|
||||
resizeSession,
|
||||
setSessionFlowPaused,
|
||||
closeSession,
|
||||
setSessionEncoding,
|
||||
onSessionData,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { normalizeDistroId, sanitizeHost } from "../../domain/host";
|
||||
import { sanitizeGroupConfig } from "../../domain/groupConfig";
|
||||
import { normalizeKnownHosts } from "../../domain/knownHosts";
|
||||
import {
|
||||
ConnectionLog,
|
||||
GroupConfig,
|
||||
@@ -505,11 +506,22 @@ export const useVaultState = () => {
|
||||
if (savedGroups) setCustomGroups(savedGroups);
|
||||
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
|
||||
|
||||
// Load known hosts
|
||||
// Load known hosts. Records imported from `~/.ssh/known_hosts` and
|
||||
// records saved by older builds may be missing the `fingerprint` /
|
||||
// `keyType` fields the verifier compares against; backfill them now
|
||||
// so the next SSH connect can match without falling into the brittle
|
||||
// re-derivation path that caused the repeated "fingerprint changed"
|
||||
// warnings in #972.
|
||||
const savedKnownHosts = localStorageAdapter.read<KnownHost[]>(
|
||||
STORAGE_KEY_KNOWN_HOSTS,
|
||||
);
|
||||
if (savedKnownHosts) setKnownHosts(savedKnownHosts);
|
||||
if (savedKnownHosts) {
|
||||
const normalized = normalizeKnownHosts(savedKnownHosts);
|
||||
setKnownHosts(normalized);
|
||||
if (normalized !== savedKnownHosts) {
|
||||
localStorageAdapter.write(STORAGE_KEY_KNOWN_HOSTS, normalized);
|
||||
}
|
||||
}
|
||||
|
||||
// Load shell history
|
||||
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
|
||||
@@ -638,7 +650,7 @@ export const useVaultState = () => {
|
||||
|
||||
if (key === STORAGE_KEY_KNOWN_HOSTS) {
|
||||
const next = safeParse<KnownHost[]>(event.newValue) ?? [];
|
||||
setKnownHosts(next);
|
||||
setKnownHosts(normalizeKnownHosts(next));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,9 @@ const {
|
||||
applySyncPayload,
|
||||
buildLocalVaultPayload,
|
||||
buildSyncPayload,
|
||||
hasCloudSyncEntityData,
|
||||
hasMeaningfulCloudSyncData,
|
||||
shouldPromptCloudVaultRecovery,
|
||||
} = await import("./syncPayload.ts");
|
||||
const storageKeys = await import("../infrastructure/config/storageKeys.ts");
|
||||
|
||||
@@ -120,6 +122,7 @@ test("buildSyncPayload includes AI configuration settings", () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_COMMAND_TIMEOUT, "120");
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_MAX_ITERATIONS, "10");
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP, JSON.stringify({ codex: "gpt-test" }));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP, JSON.stringify({ catty: "openai-main" }));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify(webSearch));
|
||||
|
||||
const payload = buildSyncPayload(vault([]));
|
||||
@@ -135,6 +138,7 @@ test("buildSyncPayload includes AI configuration settings", () => {
|
||||
commandTimeout: 120,
|
||||
maxIterations: 10,
|
||||
agentModelMap: { codex: "gpt-test" },
|
||||
agentProviderMap: { catty: "openai-main" },
|
||||
webSearchConfig: webSearch,
|
||||
});
|
||||
});
|
||||
@@ -201,6 +205,7 @@ test("applySyncPayload restores AI configuration settings", async () => {
|
||||
commandTimeout: 30,
|
||||
maxIterations: 5,
|
||||
agentModelMap: { claude: "claude-test" },
|
||||
agentProviderMap: { catty: "anthropic-main" },
|
||||
webSearchConfig: webSearch,
|
||||
},
|
||||
},
|
||||
@@ -219,9 +224,104 @@ test("applySyncPayload restores AI configuration settings", async () => {
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_COMMAND_TIMEOUT), "30");
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_MAX_ITERATIONS), "5");
|
||||
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP)!), { claude: "claude-test" });
|
||||
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP)!), { catty: "anthropic-main" });
|
||||
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!), webSearch);
|
||||
});
|
||||
|
||||
test("applySyncPayload dispatches a same-window AI-state-changed event so the open chat panel rehydrates", async () => {
|
||||
// Without this nudge, the apply path writes to localStorage but
|
||||
// `useAIState` (listening for `storage` events) never sees the changes
|
||||
// in the calling window — mounted UI keeps showing pre-sync data.
|
||||
const dispatched: Array<{ type: string; detail: unknown }> = [];
|
||||
const fakeWindow = {
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent(event: Event) {
|
||||
dispatched.push({
|
||||
type: event.type,
|
||||
detail: (event as CustomEvent).detail,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, "window", { value: fakeWindow, configurable: true });
|
||||
try {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP, JSON.stringify({ catty: "deepseek-local" }));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP, JSON.stringify({ catty: "deepseek-v4-flash" }));
|
||||
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
settings: {
|
||||
ai: {
|
||||
providers: [{ id: "openai-main", providerId: "openai", name: "OpenAI", enabled: true }],
|
||||
},
|
||||
},
|
||||
syncedAt: 1,
|
||||
} as SyncPayload;
|
||||
|
||||
await applySyncPayload(payload, { importVaultData: () => {} });
|
||||
|
||||
const events = dispatched.filter((e) => e.type === "netcatty:ai-state-changed");
|
||||
const keys = events.map((e) => (e.detail as { key?: string })?.key);
|
||||
assert.ok(keys.includes(storageKeys.STORAGE_KEY_AI_PROVIDERS), "providers nudge");
|
||||
assert.ok(keys.includes(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP), "agentProviderMap nudge");
|
||||
assert.ok(keys.includes(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP), "agentModelMap nudge");
|
||||
} finally {
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
}
|
||||
});
|
||||
|
||||
test("applySyncPayload prunes per-agent bindings that reference providers absent from the synced set", async () => {
|
||||
// Local state has Catty bound to a provider the incoming sync no longer
|
||||
// ships — both the per-agent provider override and the saved model should
|
||||
// be cleared so we don't dispatch a ghost provider id (or its now-orphan
|
||||
// model name) to the wrong endpoint.
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP, JSON.stringify({
|
||||
catty: "deepseek-local",
|
||||
codex: "openai-main",
|
||||
}));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP, JSON.stringify({
|
||||
catty: "deepseek-v4-flash",
|
||||
codex: "gpt-test",
|
||||
}));
|
||||
|
||||
const syncedProviders = [
|
||||
{ id: "openai-main", providerId: "openai", name: "OpenAI", enabled: true },
|
||||
];
|
||||
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
settings: {
|
||||
ai: {
|
||||
providers: syncedProviders,
|
||||
// Intentionally omit agentProviderMap — exercises the reconcile path.
|
||||
},
|
||||
},
|
||||
syncedAt: 1,
|
||||
} as SyncPayload;
|
||||
|
||||
await applySyncPayload(payload, { importVaultData: () => {} });
|
||||
|
||||
assert.deepEqual(
|
||||
JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP)!),
|
||||
{ codex: "openai-main" },
|
||||
);
|
||||
// Catty's saved model belonged to the now-missing deepseek-local — drop it.
|
||||
// Codex's binding stays, so its saved model stays.
|
||||
assert.deepEqual(
|
||||
JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP)!),
|
||||
{ codex: "gpt-test" },
|
||||
);
|
||||
});
|
||||
|
||||
test("applySyncPayload preserves local externalAgents and ignores legacy payload field", async () => {
|
||||
const localAgents = [
|
||||
{ id: "codex", name: "Codex", command: "/usr/local/bin/codex", enabled: true },
|
||||
@@ -436,6 +536,38 @@ test("hasMeaningfulCloudSyncData ignores legacy cloud known hosts", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("hasCloudSyncEntityData ignores settings-only payloads for empty-vault recovery", () => {
|
||||
assert.equal(
|
||||
hasCloudSyncEntityData({
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
settings: { theme: "system", terminalTheme: "default" },
|
||||
syncedAt: 1,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldPromptCloudVaultRecovery ignores settings-only remote payloads", () => {
|
||||
const settingsOnlyPayload: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
settings: { theme: "system", terminalTheme: "default" },
|
||||
syncedAt: 1,
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
shouldPromptCloudVaultRecovery(settingsOnlyPayload, settingsOnlyPayload),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildLocalVaultPayload preserves known hosts for local backups", () => {
|
||||
const payload = buildLocalVaultPayload(vault([knownHost("kh-local")]));
|
||||
|
||||
|
||||
@@ -18,7 +18,12 @@ import type {
|
||||
Snippet,
|
||||
SSHKey,
|
||||
} from '../domain/models';
|
||||
import type { SyncPayload } from '../domain/sync';
|
||||
import {
|
||||
CLOUD_SYNC_PAYLOAD_ENTITY_KEYS,
|
||||
SYNC_PAYLOAD_ENTITY_KEYS,
|
||||
hasSyncPayloadEntityData,
|
||||
type SyncPayload,
|
||||
} from '../domain/sync';
|
||||
import {
|
||||
nextCustomKeyBindingsSyncVersion,
|
||||
parseCustomKeyBindingsStorageRecord,
|
||||
@@ -26,7 +31,8 @@ import {
|
||||
} from '../domain/customKeyBindings';
|
||||
import { isEncryptedCredentialPlaceholder } from '../domain/credentials';
|
||||
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
|
||||
import { rehydrateGlobalBookmarks } from '../components/sftp/hooks/useGlobalSftpBookmarks';
|
||||
import { emitAIStateChanged } from './state/aiStateEvents';
|
||||
import { rehydrateGlobalSftpBookmarks } from './state/sftp/globalSftpBookmarks';
|
||||
import {
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
@@ -38,6 +44,8 @@ import {
|
||||
STORAGE_KEY_CUSTOM_CSS,
|
||||
STORAGE_KEY_TERM_THEME,
|
||||
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
|
||||
STORAGE_KEY_TERM_THEME_DARK,
|
||||
STORAGE_KEY_TERM_THEME_LIGHT,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
@@ -66,7 +74,9 @@ import {
|
||||
STORAGE_KEY_AI_COMMAND_TIMEOUT,
|
||||
STORAGE_KEY_AI_MAX_ITERATIONS,
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -94,19 +104,7 @@ export interface SyncableVaultData {
|
||||
* protecting or syncing.
|
||||
*/
|
||||
export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
|
||||
const hasEntities =
|
||||
(payload.hosts?.length ?? 0) > 0 ||
|
||||
(payload.keys?.length ?? 0) > 0 ||
|
||||
(payload.snippets?.length ?? 0) > 0 ||
|
||||
(payload.identities?.length ?? 0) > 0 ||
|
||||
(payload.proxyProfiles?.length ?? 0) > 0 ||
|
||||
(payload.customGroups?.length ?? 0) > 0 ||
|
||||
(payload.snippetPackages?.length ?? 0) > 0 ||
|
||||
(payload.portForwardingRules?.length ?? 0) > 0 ||
|
||||
(payload.knownHosts?.length ?? 0) > 0 ||
|
||||
(payload.groupConfigs?.length ?? 0) > 0;
|
||||
|
||||
if (hasEntities) return true;
|
||||
if (hasSyncPayloadEntityData(payload, SYNC_PAYLOAD_ENTITY_KEYS)) return true;
|
||||
|
||||
return Boolean(
|
||||
payload.settings && Object.values(payload.settings).some((value) => value !== undefined),
|
||||
@@ -118,24 +116,55 @@ export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
|
||||
* Local-only trust records are intentionally ignored.
|
||||
*/
|
||||
export function hasMeaningfulCloudSyncData(payload: SyncPayload): boolean {
|
||||
const hasEntities =
|
||||
(payload.hosts?.length ?? 0) > 0 ||
|
||||
(payload.keys?.length ?? 0) > 0 ||
|
||||
(payload.snippets?.length ?? 0) > 0 ||
|
||||
(payload.identities?.length ?? 0) > 0 ||
|
||||
(payload.proxyProfiles?.length ?? 0) > 0 ||
|
||||
(payload.customGroups?.length ?? 0) > 0 ||
|
||||
(payload.snippetPackages?.length ?? 0) > 0 ||
|
||||
(payload.portForwardingRules?.length ?? 0) > 0 ||
|
||||
(payload.groupConfigs?.length ?? 0) > 0;
|
||||
|
||||
if (hasEntities) return true;
|
||||
if (hasSyncPayloadEntityData(payload, CLOUD_SYNC_PAYLOAD_ENTITY_KEYS)) return true;
|
||||
|
||||
return Boolean(
|
||||
payload.settings && Object.values(payload.settings).some((value) => value !== undefined),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true only when the payload contains synced vault entities.
|
||||
* Settings are intentionally ignored so default settings written on first
|
||||
* launch do not make a new device look non-empty during cloud restore checks.
|
||||
*/
|
||||
export function hasCloudSyncEntityData(payload: SyncPayload): boolean {
|
||||
return hasSyncPayloadEntityData(payload, CLOUD_SYNC_PAYLOAD_ENTITY_KEYS);
|
||||
}
|
||||
|
||||
export function shouldPromptCloudVaultRecovery(
|
||||
localPayload: SyncPayload,
|
||||
remotePayload: SyncPayload,
|
||||
): boolean {
|
||||
return !hasCloudSyncEntityData(localPayload) && hasCloudSyncEntityData(remotePayload);
|
||||
}
|
||||
|
||||
export function sanitizePortForwardingRulesForSync(
|
||||
rules: PortForwardingRule[] | undefined,
|
||||
): PortForwardingRule[] | undefined {
|
||||
if (!rules) return rules;
|
||||
return rules.map((rule) => ({
|
||||
...rule,
|
||||
status: 'inactive' as const,
|
||||
error: undefined,
|
||||
lastUsedAt: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getEffectivePortForwardingRulesForSync(
|
||||
rules: PortForwardingRule[] | undefined,
|
||||
): PortForwardingRule[] | undefined {
|
||||
let effectiveRules = rules;
|
||||
if (!effectiveRules || effectiveRules.length === 0) {
|
||||
const stored = localStorageAdapter.read<PortForwardingRule[]>(STORAGE_KEY_PORT_FORWARDING);
|
||||
if (Array.isArray(stored) && stored.length > 0) {
|
||||
effectiveRules = stored;
|
||||
}
|
||||
}
|
||||
|
||||
return sanitizePortForwardingRulesForSync(effectiveRules);
|
||||
}
|
||||
|
||||
/** Callbacks used by `applySyncPayload` to import data into local state. */
|
||||
interface SyncPayloadImporters {
|
||||
/** Import vault data. Cloud sync excludes local-only known hosts by default. */
|
||||
@@ -152,15 +181,16 @@ interface SyncPayloadImporters {
|
||||
|
||||
/** Terminal settings keys that are safe to sync (platform-agnostic). */
|
||||
const SYNCABLE_TERMINAL_KEYS = [
|
||||
'startupCommandDelayMs',
|
||||
'scrollback', 'drawBoldInBrightColors', 'terminalEmulationType',
|
||||
'fontLigatures', 'fontWeight', 'fontWeightBold', 'fallbackFont',
|
||||
'linePadding', 'cursorShape', 'cursorBlink', 'minimumContrastRatio',
|
||||
'altAsMeta', 'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
|
||||
'altAsMeta', 'optionArrowWordJump', 'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
|
||||
'smoothScrolling',
|
||||
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
|
||||
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
||||
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
|
||||
'preserveSelectionOnInput', 'osc52Clipboard', 'showServerStats',
|
||||
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'showServerStats',
|
||||
'serverStatsRefreshInterval', 'rendererType',
|
||||
'autocompleteEnabled', 'autocompleteGhostText', 'autocompletePopupMenu',
|
||||
'autocompleteDebounceMs', 'autocompleteMinChars', 'autocompleteMaxSuggestions',
|
||||
@@ -177,6 +207,8 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_CUSTOM_CSS,
|
||||
STORAGE_KEY_TERM_THEME,
|
||||
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
|
||||
STORAGE_KEY_TERM_THEME_DARK,
|
||||
STORAGE_KEY_TERM_THEME_LIGHT,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
@@ -205,6 +237,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_AI_COMMAND_TIMEOUT,
|
||||
STORAGE_KEY_AI_MAX_ITERATIONS,
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
] as const;
|
||||
|
||||
@@ -300,6 +333,10 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (followAppTermTheme === 'true' || followAppTermTheme === 'false') {
|
||||
settings.followAppTerminalTheme = followAppTermTheme === 'true';
|
||||
}
|
||||
const termThemeDark = localStorageAdapter.readString(STORAGE_KEY_TERM_THEME_DARK);
|
||||
if (termThemeDark) settings.terminalThemeDark = termThemeDark;
|
||||
const termThemeLight = localStorageAdapter.readString(STORAGE_KEY_TERM_THEME_LIGHT);
|
||||
if (termThemeLight) settings.terminalThemeLight = termThemeLight;
|
||||
const termFont = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY);
|
||||
if (termFont) settings.terminalFontFamily = termFont;
|
||||
const termSize = localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE);
|
||||
@@ -396,6 +433,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (maxIterations != null && Number.isFinite(maxIterations)) ai.maxIterations = maxIterations;
|
||||
const agentModelMap = readRecordSetting<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP);
|
||||
if (agentModelMap) ai.agentModelMap = agentModelMap;
|
||||
const agentProviderMap = readRecordSetting<Record<string, string>>(STORAGE_KEY_AI_AGENT_PROVIDER_MAP);
|
||||
if (agentProviderMap) ai.agentProviderMap = agentProviderMap;
|
||||
const webSearchConfig = readRecordSetting(STORAGE_KEY_AI_WEB_SEARCH);
|
||||
if (webSearchConfig) ai.webSearchConfig = stripDeviceBoundApiKey(webSearchConfig);
|
||||
if (Object.keys(ai).length > 0) settings.ai = ai;
|
||||
@@ -423,6 +462,8 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
if (settings.followAppTerminalTheme != null) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_FOLLOW_APP_THEME, String(settings.followAppTerminalTheme));
|
||||
}
|
||||
if (settings.terminalThemeDark != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_DARK, settings.terminalThemeDark);
|
||||
if (settings.terminalThemeLight != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_LIGHT, settings.terminalThemeLight);
|
||||
if (settings.terminalFontFamily != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, settings.terminalFontFamily);
|
||||
if (settings.terminalFontSize != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_SIZE, String(settings.terminalFontSize));
|
||||
|
||||
@@ -513,6 +554,7 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
if (ai.commandTimeout != null) localStorageAdapter.writeNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT, ai.commandTimeout);
|
||||
if (ai.maxIterations != null) localStorageAdapter.writeNumber(STORAGE_KEY_AI_MAX_ITERATIONS, ai.maxIterations);
|
||||
if (ai.agentModelMap != null) localStorageAdapter.write(STORAGE_KEY_AI_AGENT_MODEL_MAP, ai.agentModelMap);
|
||||
if (ai.agentProviderMap != null) localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, ai.agentProviderMap);
|
||||
if (ai.webSearchConfig !== undefined) {
|
||||
if (ai.webSearchConfig === null) {
|
||||
localStorageAdapter.remove(STORAGE_KEY_AI_WEB_SEARCH);
|
||||
@@ -523,6 +565,83 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
);
|
||||
}
|
||||
}
|
||||
// After all AI writes, reconcile per-agent bindings against the final
|
||||
// provider list. Sync payloads can land with a new `providers` set but
|
||||
// no `agentProviderMap`, or with a stale `agentProviderMap` that
|
||||
// points at ids the synced provider set doesn't include — either way
|
||||
// we'd leak overrides bound to ghost providers. Mirrors the same
|
||||
// cleanup `removeProvider` does for explicit user deletes.
|
||||
pruneOrphanPerAgentBindings();
|
||||
// Nudge same-window AI state listeners. localStorage writes only fire
|
||||
// `storage` events in *other* windows; without this nudge the open
|
||||
// chat panel keeps showing pre-sync providers/bindings until reload.
|
||||
notifyAIStateAfterSync(ai);
|
||||
}
|
||||
}
|
||||
|
||||
function notifyAIStateAfterSync(ai: NonNullable<SyncPayload['settings']>['ai']): void {
|
||||
if (!ai) return;
|
||||
// Every AI storage key that `applySyncableSettings` may have touched
|
||||
// gets a same-window nudge. `useAIState` listens for these and refreshes
|
||||
// the corresponding React state by re-reading localStorage.
|
||||
const touched: Array<string> = [];
|
||||
if (ai.providers != null) touched.push(STORAGE_KEY_AI_PROVIDERS);
|
||||
if (ai.activeProviderId != null) touched.push(STORAGE_KEY_AI_ACTIVE_PROVIDER);
|
||||
if (ai.activeModelId != null) touched.push(STORAGE_KEY_AI_ACTIVE_MODEL);
|
||||
if (ai.globalPermissionMode != null) touched.push(STORAGE_KEY_AI_PERMISSION_MODE);
|
||||
if (ai.toolIntegrationMode != null) touched.push(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE);
|
||||
if (ai.hostPermissions != null) touched.push(STORAGE_KEY_AI_HOST_PERMISSIONS);
|
||||
if (ai.defaultAgentId != null) touched.push(STORAGE_KEY_AI_DEFAULT_AGENT);
|
||||
if (ai.commandBlocklist != null) touched.push(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
|
||||
if (ai.commandTimeout != null) touched.push(STORAGE_KEY_AI_COMMAND_TIMEOUT);
|
||||
if (ai.maxIterations != null) touched.push(STORAGE_KEY_AI_MAX_ITERATIONS);
|
||||
if (ai.agentModelMap != null) touched.push(STORAGE_KEY_AI_AGENT_MODEL_MAP);
|
||||
// agentProviderMap is *always* potentially mutated because the reconcile
|
||||
// step may have pruned it even if the payload didn't ship one.
|
||||
touched.push(STORAGE_KEY_AI_AGENT_PROVIDER_MAP);
|
||||
// The reconcile may also have pruned saved models alongside provider
|
||||
// bindings, so always nudge the model map too.
|
||||
if (!touched.includes(STORAGE_KEY_AI_AGENT_MODEL_MAP)) {
|
||||
touched.push(STORAGE_KEY_AI_AGENT_MODEL_MAP);
|
||||
}
|
||||
if (ai.webSearchConfig !== undefined) touched.push(STORAGE_KEY_AI_WEB_SEARCH);
|
||||
for (const key of touched) {
|
||||
emitAIStateChanged(key);
|
||||
}
|
||||
}
|
||||
|
||||
function pruneOrphanPerAgentBindings(): void {
|
||||
const providers = localStorageAdapter.read<Array<{ id?: string }>>(STORAGE_KEY_AI_PROVIDERS) ?? [];
|
||||
const validIds = new Set(
|
||||
providers
|
||||
.map((p) => p?.id)
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0),
|
||||
);
|
||||
const providerMap = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_PROVIDER_MAP) ?? {};
|
||||
const modelMap = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {};
|
||||
let providerChanged = false;
|
||||
let modelChanged = false;
|
||||
const nextProviderMap: Record<string, string> = {};
|
||||
const nextModelMap: Record<string, string> = { ...modelMap };
|
||||
for (const agentId of Object.keys(providerMap)) {
|
||||
const providerId = providerMap[agentId];
|
||||
if (providerId && validIds.has(providerId)) {
|
||||
nextProviderMap[agentId] = providerId;
|
||||
} else {
|
||||
providerChanged = true;
|
||||
// Drop the saved model too — that id belonged to the now-missing
|
||||
// provider and isn't trustworthy against any other binding.
|
||||
if (agentId in nextModelMap) {
|
||||
delete nextModelMap[agentId];
|
||||
modelChanged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (providerChanged) {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, nextProviderMap);
|
||||
}
|
||||
if (modelChanged) {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_MODEL_MAP, nextModelMap);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -550,7 +669,7 @@ export function buildSyncPayload(
|
||||
customGroups: vault.customGroups,
|
||||
snippetPackages: vault.snippetPackages,
|
||||
groupConfigs: vault.groupConfigs,
|
||||
portForwardingRules,
|
||||
portForwardingRules: sanitizePortForwardingRulesForSync(portForwardingRules),
|
||||
settings: collectSyncableSettings(),
|
||||
syncedAt: Date.now(),
|
||||
};
|
||||
@@ -611,7 +730,7 @@ function applyPayload(
|
||||
if (payload.settings) {
|
||||
applySyncableSettings(payload.settings);
|
||||
// Rehydrate in-memory bookmark snapshot after localStorage was updated
|
||||
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalBookmarks();
|
||||
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalSftpBookmarks();
|
||||
importers.onSettingsApplied?.();
|
||||
}
|
||||
});
|
||||
|
||||
248
components/AIChatPanelContent.tsx
Normal file
248
components/AIChatPanelContent.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import React, { type Dispatch, type SetStateAction } from 'react';
|
||||
import { History, Plus } from 'lucide-react';
|
||||
import type { AIPermissionMode, AISession, ChatMessage, DiscoveredAgent, ExternalAgentConfig, AgentModelPreset, ProviderConfig, UploadedFile } from '../infrastructure/ai/types';
|
||||
import type { UserSkillOption } from './ai/userSkillsState';
|
||||
import { Button } from './ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
import AgentSelector from './ai/AgentSelector';
|
||||
import ChatInput from './ai/ChatInput';
|
||||
import ChatMessageList from './ai/ChatMessageList';
|
||||
import ConversationExport from './ai/ConversationExport';
|
||||
import { SessionHistoryDrawer, formatRelativeTime } from './AIChatSessionHistoryDrawer';
|
||||
|
||||
type Translate = (key: string) => string;
|
||||
type ExportFormat = 'md' | 'json' | 'txt';
|
||||
type TerminalSessionSummary = {
|
||||
sessionId: string;
|
||||
hostname: string;
|
||||
label: string;
|
||||
connected: boolean;
|
||||
};
|
||||
|
||||
interface AIChatPanelContentProps {
|
||||
t: Translate;
|
||||
currentAgentId: string;
|
||||
externalAgents: ExternalAgentConfig[];
|
||||
discoveredAgents: DiscoveredAgent[];
|
||||
isDiscovering: boolean;
|
||||
handleAgentChange: (agentId: string) => void;
|
||||
handleEnableDiscoveredAgent: (agent: DiscoveredAgent) => void;
|
||||
rediscover: () => void;
|
||||
handleOpenSettings: () => void;
|
||||
activeSession: AISession | null;
|
||||
handleExport: (format: ExportFormat) => void;
|
||||
showHistory: boolean;
|
||||
setShowHistory: Dispatch<SetStateAction<boolean>>;
|
||||
handleNewChat: () => void;
|
||||
historySessions: AISession[];
|
||||
activeSessionId: string | null;
|
||||
handleSelectSession: (sessionId: string) => void;
|
||||
handleDeleteSession: (event: React.MouseEvent, sessionId: string) => void;
|
||||
messages: ChatMessage[];
|
||||
isStreaming: boolean;
|
||||
inputValue: string;
|
||||
setInputValue: (value: string) => void;
|
||||
handleSend: () => void;
|
||||
handleStop: () => void;
|
||||
canSendCurrentAgent: boolean;
|
||||
providerDisplayName?: string;
|
||||
modelDisplayName?: string;
|
||||
agentModelPresets: AgentModelPreset[];
|
||||
selectedAgentModel: string;
|
||||
handleAgentModelSelect: (modelId: string) => void;
|
||||
cattyConfiguredProviders: ProviderConfig[];
|
||||
effectiveActiveProvider?: ProviderConfig;
|
||||
effectiveActiveModelId?: string;
|
||||
handleAgentProviderModelSelect: (providerId: string, modelId: string) => void;
|
||||
files: UploadedFile[];
|
||||
addFiles: (inputFiles: File[]) => Promise<void>;
|
||||
removeFile: (fileId: string) => void;
|
||||
terminalSessions: TerminalSessionSummary[];
|
||||
selectedUserSkills: UserSkillOption[];
|
||||
userSkillOptions: UserSkillOption[];
|
||||
addSelectedUserSkill: (slug: string) => void;
|
||||
removeSelectedUserSkill: (slug: string) => void;
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
setGlobalPermissionMode?: (mode: AIPermissionMode) => void;
|
||||
}
|
||||
|
||||
export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
t,
|
||||
currentAgentId,
|
||||
externalAgents,
|
||||
discoveredAgents,
|
||||
isDiscovering,
|
||||
handleAgentChange,
|
||||
handleEnableDiscoveredAgent,
|
||||
rediscover,
|
||||
handleOpenSettings,
|
||||
activeSession,
|
||||
handleExport,
|
||||
showHistory,
|
||||
setShowHistory,
|
||||
handleNewChat,
|
||||
historySessions,
|
||||
activeSessionId,
|
||||
handleSelectSession,
|
||||
handleDeleteSession,
|
||||
messages,
|
||||
isStreaming,
|
||||
inputValue,
|
||||
setInputValue,
|
||||
handleSend,
|
||||
handleStop,
|
||||
canSendCurrentAgent,
|
||||
providerDisplayName,
|
||||
modelDisplayName,
|
||||
agentModelPresets,
|
||||
selectedAgentModel,
|
||||
handleAgentModelSelect,
|
||||
cattyConfiguredProviders,
|
||||
effectiveActiveProvider,
|
||||
effectiveActiveModelId,
|
||||
handleAgentProviderModelSelect,
|
||||
files,
|
||||
addFiles,
|
||||
removeFile,
|
||||
terminalSessions,
|
||||
selectedUserSkills,
|
||||
userSkillOptions,
|
||||
addSelectedUserSkill,
|
||||
removeSelectedUserSkill,
|
||||
globalPermissionMode,
|
||||
setGlobalPermissionMode
|
||||
}) => (
|
||||
<div className="flex flex-col h-full bg-background" data-section="ai-chat-panel">
|
||||
{/* ── Header ── */}
|
||||
<div className="px-2.5 py-1.5 flex items-center justify-between border-b border-border/50 shrink-0">
|
||||
<AgentSelector
|
||||
currentAgentId={currentAgentId}
|
||||
externalAgents={externalAgents}
|
||||
discoveredAgents={discoveredAgents}
|
||||
isDiscovering={isDiscovering}
|
||||
onSelectAgent={handleAgentChange}
|
||||
onEnableDiscoveredAgent={handleEnableDiscoveredAgent}
|
||||
onRediscover={rediscover}
|
||||
onManageAgents={handleOpenSettings}
|
||||
/>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<ConversationExport
|
||||
session={activeSession}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
>
|
||||
<History size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.sessionHistory')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md text-primary/82 hover:bg-primary/[0.10] hover:text-primary"
|
||||
onClick={handleNewChat}
|
||||
>
|
||||
<Plus size={15} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.newChat')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Main content ── */}
|
||||
{showHistory ? (
|
||||
<SessionHistoryDrawer
|
||||
sessions={historySessions}
|
||||
activeSessionId={activeSessionId}
|
||||
onSelect={handleSelectSession}
|
||||
onDelete={handleDeleteSession}
|
||||
onClose={() => setShowHistory(false)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Chat messages */}
|
||||
<ChatMessageList
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
activeSessionId={activeSessionId}
|
||||
/>
|
||||
|
||||
{/* Recent sessions (Zed-style, shown when no messages) */}
|
||||
{messages.length === 0 && historySessions.length > 0 && (
|
||||
<div className="shrink-0 px-4 pb-1">
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<span className="text-[11px] text-muted-foreground/30 tracking-wide">{t('ai.chat.recent')}</span>
|
||||
<button
|
||||
onClick={() => setShowHistory(true)}
|
||||
className="text-[11px] text-muted-foreground/30 hover:text-muted-foreground/50 transition-colors cursor-pointer"
|
||||
>
|
||||
{t('ai.chat.viewAll')}
|
||||
</button>
|
||||
</div>
|
||||
{historySessions.slice(0, 3).map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
onClick={() => handleSelectSession(session.id)}
|
||||
className="w-full flex items-baseline justify-between py-1.5 text-left hover:text-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="text-[13px] text-foreground/60 truncate pr-4">
|
||||
{session.title || t('ai.chat.untitled')}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground/25 shrink-0">
|
||||
{formatRelativeTime(new Date(session.updatedAt), t)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input area */}
|
||||
<ChatInput
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onSend={handleSend}
|
||||
onStop={handleStop}
|
||||
isStreaming={isStreaming}
|
||||
disabled={!canSendCurrentAgent}
|
||||
providerName={providerDisplayName}
|
||||
modelName={modelDisplayName}
|
||||
agentName={currentAgentId === 'catty' ? 'Catty Agent' : externalAgents.find(a => a.id === currentAgentId)?.name}
|
||||
modelPresets={agentModelPresets}
|
||||
selectedModelId={selectedAgentModel}
|
||||
onModelSelect={handleAgentModelSelect}
|
||||
providerSwitcher={
|
||||
currentAgentId === 'catty' && cattyConfiguredProviders.length > 0
|
||||
? {
|
||||
providers: cattyConfiguredProviders,
|
||||
selectedProviderId: effectiveActiveProvider?.id,
|
||||
selectedModelId: effectiveActiveModelId || undefined,
|
||||
onSelect: handleAgentProviderModelSelect,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
files={files}
|
||||
onAddFiles={addFiles}
|
||||
onRemoveFile={removeFile}
|
||||
hosts={terminalSessions.map(s => ({ sessionId: s.sessionId, hostname: s.hostname, label: s.label, connected: s.connected }))}
|
||||
selectedUserSkills={selectedUserSkills}
|
||||
userSkills={userSkillOptions}
|
||||
onAddUserSkill={addSelectedUserSkill}
|
||||
onRemoveUserSkill={removeSelectedUserSkill}
|
||||
permissionMode={globalPermissionMode}
|
||||
onPermissionModeChange={setGlobalPermissionMode}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
112
components/AIChatSessionHistoryDrawer.tsx
Normal file
112
components/AIChatSessionHistoryDrawer.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import React from 'react';
|
||||
import { Trash2, X } from 'lucide-react';
|
||||
import type { AISession } from '../infrastructure/ai/types';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { cn } from '../lib/utils';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
import { SESSION_HISTORY_ROW_CLASSNAMES } from './ai/sessionHistoryLayout';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Session History Drawer
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
interface SessionHistoryDrawerProps {
|
||||
sessions: AISession[];
|
||||
activeSessionId: string | null;
|
||||
onSelect: (sessionId: string) => void;
|
||||
onDelete: (e: React.MouseEvent, sessionId: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
sessions,
|
||||
activeSessionId,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="px-4 py-2.5 flex items-center justify-between shrink-0 border-b border-border/30">
|
||||
<span className="text-[13px] font-medium text-foreground/80">{t('ai.chat.allSessions')}</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[12px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="px-3">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-[13px] text-muted-foreground/40">
|
||||
{t('ai.chat.noSessions')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
sessions.map((session) => {
|
||||
const isActive = session.id === activeSessionId;
|
||||
const time = new Date(session.updatedAt);
|
||||
const timeStr = formatRelativeTime(time, t);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(session.id)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onSelect(session.id); }}
|
||||
className={cn(
|
||||
SESSION_HISTORY_ROW_CLASSNAMES.row,
|
||||
isActive ? 'text-foreground' : 'text-foreground/70 hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<span className={SESSION_HISTORY_ROW_CLASSNAMES.title}>
|
||||
{session.title || t('ai.chat.untitled')}
|
||||
</span>
|
||||
<div className={SESSION_HISTORY_ROW_CLASSNAMES.meta}>
|
||||
<span className={SESSION_HISTORY_ROW_CLASSNAMES.time}>
|
||||
{timeStr}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => onDelete(e, session.id)}
|
||||
className={SESSION_HISTORY_ROW_CLASSNAMES.deleteButton}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('common.delete')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export function formatRelativeTime(date: Date, t: (key: string) => string): string {
|
||||
const now = Date.now();
|
||||
const diff = now - date.getTime();
|
||||
const minutes = Math.floor(diff / 60_000);
|
||||
const hours = Math.floor(diff / 3_600_000);
|
||||
const days = Math.floor(diff / 86_400_000);
|
||||
|
||||
if (minutes < 1) return t('ai.chat.justNow');
|
||||
if (minutes < 60) return t('ai.chat.minutesAgo').replace('{n}', String(minutes));
|
||||
if (hours < 24) return t('ai.chat.hoursAgo').replace('{n}', String(hours));
|
||||
if (days < 7) return t('ai.chat.daysAgo').replace('{n}', String(days));
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
}
|
||||
@@ -1,47 +1,19 @@
|
||||
/**
|
||||
* AIChatSidePanel - Main AI chat interface side panel
|
||||
*
|
||||
* Zed-style agent panel with agent selector, scoped chat sessions,
|
||||
* message list, input area, and session history drawer.
|
||||
*
|
||||
* Core logic is decomposed into focused hooks:
|
||||
* - useAIChatStreaming: stream processing, abort management, agent sub-flows
|
||||
* - useConversationExport: export formats & object URL lifecycle
|
||||
*/
|
||||
|
||||
import {
|
||||
History,
|
||||
Plus,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useWindowControls } from '../application/state/useWindowControls';
|
||||
import type {
|
||||
AIDraft,
|
||||
AIPanelView,
|
||||
AIPermissionMode,
|
||||
AIToolIntegrationMode,
|
||||
AgentModelPreset,
|
||||
AISession,
|
||||
AISessionScope,
|
||||
ChatMessage,
|
||||
DiscoveredAgent,
|
||||
ExternalAgentConfig,
|
||||
ProviderConfig,
|
||||
WebSearchConfig,
|
||||
} from '../infrastructure/ai/types';
|
||||
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
|
||||
import { getAgentModelPresets } from '../infrastructure/ai/types';
|
||||
import { matchesManagedAgentConfig } from '../infrastructure/ai/managedAgents';
|
||||
import { useAgentDiscovery } from '../application/state/useAgentDiscovery';
|
||||
import { Button } from './ui/button';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import AgentSelector from './ai/AgentSelector';
|
||||
import ChatInput from './ai/ChatInput';
|
||||
import ChatMessageList from './ai/ChatMessageList';
|
||||
import ConversationExport from './ai/ConversationExport';
|
||||
import {
|
||||
getReadyUserSkillOptions,
|
||||
getNextSelectedUserSkillSlugsMap,
|
||||
@@ -58,7 +30,6 @@ import {
|
||||
tryBeginDraftSend,
|
||||
} from './ai/draftSendGate';
|
||||
import { getSessionScopeMatchRank } from './ai/sessionScopeMatch';
|
||||
import { SESSION_HISTORY_ROW_CLASSNAMES } from './ai/sessionHistoryLayout';
|
||||
import { selectDraftForAgentSwitch } from '../application/state/aiDraftState';
|
||||
import type { CodexIntegrationStatus } from './settings/tabs/ai/types';
|
||||
import {
|
||||
@@ -70,131 +41,9 @@ import { buildAcpHistoryMessagesForBridge } from './ai/acpHistory';
|
||||
import { canSendWithAgent, findEnabledExternalAgent } from './ai/agentSendEligibility';
|
||||
import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGate';
|
||||
import { useConversationExport } from './ai/hooks/useConversationExport';
|
||||
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
|
||||
|
||||
function modelPresetMatchesId(preset: AgentModelPreset, modelId: string): boolean {
|
||||
if (preset.thinkingLevels?.length) {
|
||||
return preset.thinkingLevels.some((level) => `${preset.id}/${level}` === modelId);
|
||||
}
|
||||
return preset.id === modelId;
|
||||
}
|
||||
|
||||
function modelPresetsContainId(presets: AgentModelPreset[], modelId: string): boolean {
|
||||
return presets.some((preset) => modelPresetMatchesId(preset, modelId));
|
||||
}
|
||||
|
||||
function isCopilotAgentConfig(agent?: ExternalAgentConfig): boolean {
|
||||
if (!agent) return false;
|
||||
const tokens = [
|
||||
agent.id,
|
||||
agent.name,
|
||||
agent.icon,
|
||||
agent.command,
|
||||
agent.acpCommand,
|
||||
]
|
||||
.filter((value): value is string => typeof value === 'string' && value.length > 0)
|
||||
.map((value) => value.split('/').pop()?.toLowerCase() ?? value.toLowerCase());
|
||||
return tokens.some((token) => token.includes('copilot'));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Props
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
interface AIChatSidePanelProps {
|
||||
// Session state (per-scope)
|
||||
sessions: AISession[];
|
||||
activeSessionIdMap: Record<string, string | null>;
|
||||
draftsByScope: Partial<Record<string, AIDraft>>;
|
||||
panelViewByScope: Partial<Record<string, AIPanelView>>;
|
||||
setActiveSessionId: (scopeKey: string, id: string | null) => void;
|
||||
ensureDraftForScope: (scopeKey: string, agentId: string) => void;
|
||||
updateDraft: (
|
||||
scopeKey: string,
|
||||
fallbackAgentId: string,
|
||||
updater: (draft: AIDraft) => AIDraft,
|
||||
) => void;
|
||||
showDraftView: (scopeKey: string) => void;
|
||||
showSessionView: (scopeKey: string, sessionId: string) => void;
|
||||
clearDraftForScope: (scopeKey: string) => void;
|
||||
addDraftFiles: (scopeKey: string, fallbackAgentId: string, inputFiles: File[]) => Promise<void>;
|
||||
removeDraftFile: (scopeKey: string, fallbackAgentId: string, fileId: string) => void;
|
||||
createSession: (scope: AISessionScope, agentId?: string) => AISession;
|
||||
deleteSession: (sessionId: string, scopeKey?: string) => void;
|
||||
updateSessionTitle: (sessionId: string, title: string) => void;
|
||||
updateSessionExternalSessionId: (sessionId: string, externalSessionId: string | undefined) => void;
|
||||
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
|
||||
updateLastMessage: (
|
||||
sessionId: string,
|
||||
updater: (msg: ChatMessage) => ChatMessage,
|
||||
) => void;
|
||||
updateMessageById: (
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
updater: (msg: ChatMessage) => ChatMessage,
|
||||
) => void;
|
||||
// Provider config
|
||||
providers: ProviderConfig[];
|
||||
activeProviderId: string;
|
||||
activeModelId: string;
|
||||
|
||||
// Agent info
|
||||
defaultAgentId: string;
|
||||
toolIntegrationMode: AIToolIntegrationMode;
|
||||
externalAgents: ExternalAgentConfig[];
|
||||
setExternalAgents?: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void;
|
||||
agentModelMap: Record<string, string>;
|
||||
setAgentModel: (agentId: string, modelId: string) => void;
|
||||
|
||||
// Safety
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
setGlobalPermissionMode?: (mode: AIPermissionMode) => void;
|
||||
commandBlocklist?: string[];
|
||||
maxIterations?: number;
|
||||
|
||||
// Web search
|
||||
webSearchConfig?: WebSearchConfig | null;
|
||||
|
||||
// Context
|
||||
scopeType: 'terminal' | 'workspace';
|
||||
scopeTargetId?: string;
|
||||
scopeHostIds?: string[];
|
||||
scopeLabel?: string;
|
||||
|
||||
// Terminal session context (from parent)
|
||||
terminalSessions?: Array<{
|
||||
sessionId: string;
|
||||
hostId: string;
|
||||
hostname: string;
|
||||
label: string;
|
||||
os?: string;
|
||||
username?: string;
|
||||
protocol?: string;
|
||||
shellType?: string;
|
||||
deviceType?: string;
|
||||
connected: boolean;
|
||||
}>;
|
||||
resolveExecutorContext?: (scope: {
|
||||
type: 'terminal' | 'workspace';
|
||||
targetId?: string;
|
||||
label?: string;
|
||||
}) => ExecutorContext;
|
||||
|
||||
// Visibility
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function generateId(): string {
|
||||
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Component
|
||||
// -------------------------------------------------------------------
|
||||
import type { AIChatSidePanelProps } from './AIChatSidePanel.types';
|
||||
import { generateId, isCopilotAgentConfig, modelPresetsContainId } from './AIChatSidePanelHelpers';
|
||||
import { AIChatPanelContent } from './AIChatPanelContent';
|
||||
|
||||
const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
sessions,
|
||||
@@ -225,6 +74,8 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
setExternalAgents,
|
||||
agentModelMap,
|
||||
setAgentModel,
|
||||
agentProviderMap,
|
||||
setAgentProvider,
|
||||
globalPermissionMode,
|
||||
setGlobalPermissionMode,
|
||||
commandBlocklist,
|
||||
@@ -239,8 +90,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
isVisible = true,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
// ── Per-scope state ──
|
||||
// Derive scope key for per-scope isolation
|
||||
const scopeKey = `${scopeType}:${scopeTargetId ?? ''}`;
|
||||
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
@@ -252,7 +101,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
const resolveExecutorContextRef = useRef(resolveExecutorContext);
|
||||
resolveExecutorContextRef.current = resolveExecutorContext;
|
||||
|
||||
// ── Streaming hook ──
|
||||
const {
|
||||
streamingSessionIds,
|
||||
setStreamingForScope,
|
||||
@@ -348,7 +196,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
return undefined;
|
||||
}, [terminalSessions, scopeType, scopeTargetId]);
|
||||
|
||||
// Proactively sync terminal session metadata to main process whenever scope or sessions change
|
||||
useEffect(() => {
|
||||
const bridge = getNetcattyBridge();
|
||||
if (bridge?.aiMcpUpdateSessions) {
|
||||
@@ -375,11 +222,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
setActiveSessionId,
|
||||
]);
|
||||
|
||||
// When the resolved view is draft but activeSessionIdMap still points at a
|
||||
// previously-shown session, clear that stale entry. Otherwise
|
||||
// activeTerminalTargetIds keeps claiming ownership of the old session's
|
||||
// target and getSessionScopeMatchRank suppresses matching history from
|
||||
// other terminals until another action rewrites the map.
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
if (normalizedPanelView.mode !== 'draft') return;
|
||||
@@ -495,8 +337,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
};
|
||||
}, [isVisible, scopeKey, toolIntegrationMode, updateScopeDraft]);
|
||||
|
||||
// Sync provider configs to main process so it can decrypt API keys server-side.
|
||||
// Keys stay encrypted in transit; main process decrypts only when making HTTP requests.
|
||||
useEffect(() => {
|
||||
const bridge = getNetcattyBridge();
|
||||
if (bridge?.aiSyncProviders && providers.length > 0) {
|
||||
@@ -504,9 +344,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
}
|
||||
}, [providers]);
|
||||
|
||||
// Sync web search config to main process (allowlist + encrypted API key for server-side decryption).
|
||||
// Note: This is fire-and-forget; if the first search fires before sync completes, it will fail
|
||||
// with a clear error and succeed on retry. Making this blocking would require async tool creation.
|
||||
useEffect(() => {
|
||||
const bridge = getNetcattyBridge();
|
||||
if (bridge?.aiSyncWebSearch) {
|
||||
@@ -514,15 +351,11 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
}
|
||||
}, [webSearchConfig?.apiHost, webSearchConfig?.apiKey, webSearchConfig?.enabled]);
|
||||
|
||||
// Preserve active streams across tab switches. The panel is conditionally
|
||||
// mounted per tab, so unmounting here should not cancel in-flight work.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// no-op: stream lifecycle is managed by explicit stop/delete actions
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Agent discovery
|
||||
const {
|
||||
discoveredAgents,
|
||||
isDiscovering,
|
||||
@@ -552,19 +385,53 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
[selectedUserSkillSlugs, userSkillOptions],
|
||||
);
|
||||
|
||||
// ── Export hook ──
|
||||
const { handleExport } = useConversationExport(activeSession);
|
||||
|
||||
// Active provider info
|
||||
const activeProvider = useMemo(
|
||||
() => providers.find((p) => p.id === activeProviderId),
|
||||
[providers, activeProviderId],
|
||||
);
|
||||
|
||||
const providerDisplayName = activeProvider?.name ?? '';
|
||||
const modelDisplayName = activeModelId || activeProvider?.defaultModel || '';
|
||||
const cattyAgentProvider = useMemo(() => {
|
||||
const overrideId = agentProviderMap['catty'];
|
||||
if (overrideId) {
|
||||
const p = providers.find((cfg) => cfg.id === overrideId);
|
||||
if (p) return p;
|
||||
}
|
||||
return activeProvider;
|
||||
}, [agentProviderMap, providers, activeProvider]);
|
||||
|
||||
const cattyAgentModelId = useMemo(() => {
|
||||
const trim = (s: string | undefined | null): string => (s ?? '').trim();
|
||||
const overrideId = agentProviderMap['catty'];
|
||||
const overrideProvider = overrideId
|
||||
? providers.find((cfg) => cfg.id === overrideId)
|
||||
: undefined;
|
||||
if (overrideProvider) {
|
||||
return trim(agentModelMap['catty']) || trim(overrideProvider.defaultModel);
|
||||
}
|
||||
return trim(cattyAgentProvider?.defaultModel) || trim(activeModelId);
|
||||
}, [agentModelMap, agentProviderMap, providers, cattyAgentProvider, activeModelId]);
|
||||
|
||||
const effectiveActiveProvider = currentAgentId === 'catty' ? cattyAgentProvider : activeProvider;
|
||||
const effectiveActiveModelId = currentAgentId === 'catty' ? cattyAgentModelId : activeModelId;
|
||||
|
||||
const cattyConfiguredProviders = useMemo(
|
||||
() => (currentAgentId === 'catty' ? providers : []),
|
||||
[currentAgentId, providers],
|
||||
);
|
||||
|
||||
const handleAgentProviderModelSelect = useCallback(
|
||||
(providerId: string, modelId: string) => {
|
||||
setAgentProvider(currentAgentId, providerId);
|
||||
setAgentModel(currentAgentId, modelId);
|
||||
},
|
||||
[currentAgentId, setAgentProvider, setAgentModel],
|
||||
);
|
||||
|
||||
const providerDisplayName = effectiveActiveProvider?.name ?? '';
|
||||
const modelDisplayName = effectiveActiveModelId || effectiveActiveProvider?.defaultModel || '';
|
||||
|
||||
// Agent model presets for the current external agent
|
||||
const currentAgentConfig = useMemo(
|
||||
() => currentAgentId !== 'catty' ? externalAgents.find(a => a.id === currentAgentId) : undefined,
|
||||
[currentAgentId, externalAgents],
|
||||
@@ -582,10 +449,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
[currentAgentConfig],
|
||||
);
|
||||
|
||||
// For Codex, pick up the model declared in ~/.codex/config.toml (if any)
|
||||
// so the picker can show just that model instead of the hardcoded ChatGPT
|
||||
// preset list. Probing codex-acp for its full catalog returns the stock
|
||||
// OpenAI models regardless of the active provider, which is misleading.
|
||||
const [codexConfigModel, setCodexConfigModel] = useState<string | null>(null);
|
||||
const [codexCustomConfigResolved, setCodexCustomConfigResolved] = useState(false);
|
||||
useEffect(() => {
|
||||
@@ -603,9 +466,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
if (cancelled) return;
|
||||
const hasCustom = info?.state === 'connected_custom_config';
|
||||
setCodexConfigModel(info?.customConfig?.model ?? null);
|
||||
// Only flip "resolved" to true when the probe confirms this is a
|
||||
// custom-config session; otherwise keep it false so we fall back to
|
||||
// the static CODEX_MODEL_PRESETS.
|
||||
setCodexCustomConfigResolved(hasCustom);
|
||||
}).catch(() => {
|
||||
if (!cancelled) {
|
||||
@@ -621,9 +481,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentAgentConfig?.acpCommand) return;
|
||||
// ACP agents can expose their runtime model catalog during session setup.
|
||||
// Codex also exposes model/reasoning selectors through ACP config options,
|
||||
// which keeps the picker aligned with the user's installed CLI version.
|
||||
if (!isCopilotExternalAgent && !isClaudeManagedAgent && !isCodexManagedAgent) return;
|
||||
|
||||
const bridge = getNetcattyBridge();
|
||||
@@ -636,13 +493,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
undefined,
|
||||
undefined,
|
||||
`models_${currentAgentId}`,
|
||||
currentAgentConfig.env,
|
||||
).then((result) => {
|
||||
if (cancelled || !result?.ok || !Array.isArray(result.models)) return;
|
||||
// If the probe came back empty, drop any stale cached catalog for this
|
||||
// agent so `agentModelPresets` falls back to the hardcoded presets via
|
||||
// the `?? getAgentModelPresets(...)` branch. Without this, a previously
|
||||
// successful probe would keep surfacing models the backend no longer
|
||||
// advertises.
|
||||
if (result.models.length === 0) {
|
||||
setRuntimeAgentModelPresets((prev) => {
|
||||
if (!(currentAgentId in prev)) return prev;
|
||||
@@ -671,11 +524,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
};
|
||||
}, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, isClaudeManagedAgent, isCodexManagedAgent, setAgentModel]);
|
||||
|
||||
// When Codex is backed by a ~/.codex/config.toml custom provider, the
|
||||
// stock CODEX_MODEL_PRESETS catalog is invalid for that endpoint.
|
||||
// codexCustomConfigResolved (declared above alongside codexConfigModel)
|
||||
// stays false until the integration probe confirms this session is
|
||||
// custom-config, so we don't flash an empty picker while loading.
|
||||
const hasCodexCustomConfig = codexCustomConfigResolved && isCodexManagedAgent;
|
||||
|
||||
const agentModelPresets = useMemo(() => {
|
||||
@@ -684,25 +532,19 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
if (runtimePresets) {
|
||||
return runtimePresets;
|
||||
}
|
||||
// Config.toml with a pinned model → show just that model.
|
||||
if (codexConfigModel) {
|
||||
return [{ id: codexConfigModel, name: codexConfigModel }];
|
||||
}
|
||||
// Config.toml custom provider without a pinned model → codex-acp
|
||||
// uses its provider default. Don't surface the OpenAI presets; they
|
||||
// wouldn't work. Empty list disables the picker.
|
||||
return [];
|
||||
}
|
||||
return runtimePresets ?? getAgentModelPresets(currentAgentConfig?.command);
|
||||
}, [currentAgentConfig?.command, currentAgentId, runtimeAgentModelPresets, hasCodexCustomConfig, codexConfigModel]);
|
||||
|
||||
// Per-agent model: recall last selection or use first preset as default
|
||||
const selectedAgentModel = useMemo(() => {
|
||||
const stored = agentModelMap[currentAgentId];
|
||||
if (stored && modelPresetsContainId(agentModelPresets, stored)) {
|
||||
return stored;
|
||||
}
|
||||
// Default to first preset; for models with thinking levels, use the default level
|
||||
if (agentModelPresets.length > 0) {
|
||||
const first = agentModelPresets[0];
|
||||
if (first.thinkingLevels?.length) {
|
||||
@@ -723,9 +565,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
setAgentModel(currentAgentId, modelId);
|
||||
}, [currentAgentId, setAgentModel]);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Handlers
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
clearScopeDraft();
|
||||
@@ -744,9 +583,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
void openSettingsWindow();
|
||||
}, [openSettingsWindow]);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Shared helpers for handleSend sub-flows
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/** Ref to always access latest sessions (avoids stale closure in autoTitleSession). */
|
||||
const sessionsRef = useRef(sessions);
|
||||
@@ -807,9 +643,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
});
|
||||
}, [currentAgentId, enterScopeDraftMode, updateScopeDraft]);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Main send handler (thin orchestrator)
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const draft = currentDraftRef.current;
|
||||
@@ -817,9 +650,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
const currentSessionView = activeSessionRef.current;
|
||||
const trimmed = draft?.text.trim() ?? '';
|
||||
const sendScopeKey = scopeKey;
|
||||
// Double-submit protection currently relies on the draft being cleared
|
||||
// immediately after the first send path starts; `isStreaming` alone does
|
||||
// not protect the initial draft->session transition.
|
||||
if (!trimmed || isStreaming) return;
|
||||
const sendAgentId = currentSessionView?.agentId ?? draft?.agentId ?? currentAgentId;
|
||||
const agentConfig = sendAgentId !== 'catty' ? findEnabledExternalAgent(externalAgents, sendAgentId) : undefined;
|
||||
@@ -857,8 +687,10 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
const isExternalAgent = sendAgentId !== 'catty';
|
||||
|
||||
// No provider configured for built-in agent
|
||||
if (!isExternalAgent && !activeProvider) {
|
||||
const sendActiveProvider = isExternalAgent ? activeProvider : effectiveActiveProvider;
|
||||
const sendActiveModelId = isExternalAgent ? activeModelId : effectiveActiveModelId;
|
||||
|
||||
if (!isExternalAgent && !sendActiveProvider) {
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'user', content: trimmed, timestamp: Date.now() });
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProvider'), timestamp: Date.now() });
|
||||
if (currentPanelView.mode === 'session') {
|
||||
@@ -868,7 +700,16 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Add user message
|
||||
if (!isExternalAgent && !sendActiveModelId.trim()) {
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'user', content: trimmed, timestamp: Date.now() });
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProviderModel'), timestamp: Date.now() });
|
||||
if (currentPanelView.mode === 'session') {
|
||||
clearScopeDraft();
|
||||
showScopeSessionView(sessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(), role: 'user', content: trimmed,
|
||||
...(attachments.length > 0 ? { attachments } : {}),
|
||||
@@ -879,14 +720,13 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
setActiveSessionId(sessionId);
|
||||
setStreamingForScope(sessionId, true);
|
||||
|
||||
// Create assistant message placeholder with a tracked ID
|
||||
const assistantMsgId = generateId();
|
||||
addMessageToSession(sessionId, {
|
||||
id: assistantMsgId, role: 'assistant', content: '', timestamp: Date.now(),
|
||||
model: isExternalAgent
|
||||
? (selectedAgentModel || agentConfig?.name || 'external')
|
||||
: (activeModelId || activeProvider?.defaultModel || ''),
|
||||
providerId: isExternalAgent ? undefined : activeProvider?.providerId,
|
||||
: (sendActiveModelId || sendActiveProvider?.defaultModel || ''),
|
||||
providerId: isExternalAgent ? undefined : sendActiveProvider?.providerId,
|
||||
});
|
||||
|
||||
const abortController = new AbortController();
|
||||
@@ -926,8 +766,8 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
label: scopeLabel,
|
||||
} as const;
|
||||
await sendToCattyAgent(sessionId, sendScopeKey, trimmed, abortController, currentSession ?? undefined, assistantMsgId, {
|
||||
activeProvider,
|
||||
activeModelId,
|
||||
activeProvider: sendActiveProvider,
|
||||
activeModelId: sendActiveModelId,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeLabel,
|
||||
@@ -946,7 +786,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isStreaming, activeProvider, scopeKey, currentAgentId,
|
||||
isStreaming, activeProvider, effectiveActiveProvider, effectiveActiveModelId, scopeKey, currentAgentId,
|
||||
activeModelId, externalAgents,
|
||||
createSession, addMessageToSession, updateMessageById, updateLastMessage,
|
||||
setStreamingForScope,
|
||||
@@ -963,15 +803,12 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
controller?.abort();
|
||||
abortControllersRef.current.delete(activeSessionId);
|
||||
setStreamingForScope(activeSessionId, false);
|
||||
// Clear statusText on the last message so stale status indicators disappear
|
||||
updateLastMessage(activeSessionId, msg => ({
|
||||
...msg,
|
||||
statusText: '',
|
||||
executionStatus: msg.executionStatus === 'running' ? 'cancelled' : msg.executionStatus,
|
||||
}));
|
||||
// Clear pending approvals for this session (so tool execute functions don't hang)
|
||||
clearAllPendingApprovals(activeSessionId);
|
||||
// Cancel in-flight command executions (Catty Agent + ACP Agent)
|
||||
const bridge = getNetcattyBridge();
|
||||
bridge?.aiCattyCancelExec?.(activeSessionId);
|
||||
bridge?.aiAcpCancel?.('', activeSessionId);
|
||||
@@ -992,7 +829,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
(e: React.MouseEvent, sessionId: string) => {
|
||||
e.stopPropagation();
|
||||
deleteSession(sessionId, scopeKey);
|
||||
// Active session clearing is handled by deleteSession with scopeKey
|
||||
},
|
||||
[deleteSession, scopeKey],
|
||||
);
|
||||
@@ -1010,234 +846,59 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
setShowHistory(false);
|
||||
}, [ensureScopeDraft, showScopeDraftView, updateScopeDraft]);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Render
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background" data-section="ai-chat-panel">
|
||||
{/* ── Header ── */}
|
||||
<div className="px-2.5 py-1.5 flex items-center justify-between border-b border-border/50 shrink-0">
|
||||
<AgentSelector
|
||||
currentAgentId={currentAgentId}
|
||||
externalAgents={externalAgents}
|
||||
discoveredAgents={discoveredAgents}
|
||||
isDiscovering={isDiscovering}
|
||||
onSelectAgent={handleAgentChange}
|
||||
onEnableDiscoveredAgent={handleEnableDiscoveredAgent}
|
||||
onRediscover={rediscover}
|
||||
onManageAgents={handleOpenSettings}
|
||||
/>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<ConversationExport
|
||||
session={activeSession}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
title="Session history"
|
||||
>
|
||||
<History size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md text-primary/82 hover:bg-primary/[0.10] hover:text-primary"
|
||||
onClick={handleNewChat}
|
||||
title="New chat"
|
||||
>
|
||||
<Plus size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Main content ── */}
|
||||
{showHistory ? (
|
||||
<SessionHistoryDrawer
|
||||
sessions={historySessions}
|
||||
activeSessionId={activeSessionId}
|
||||
onSelect={handleSelectSession}
|
||||
onDelete={handleDeleteSession}
|
||||
onClose={() => setShowHistory(false)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Chat messages */}
|
||||
<ChatMessageList
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
activeSessionId={activeSessionId}
|
||||
/>
|
||||
|
||||
{/* Recent sessions (Zed-style, shown when no messages) */}
|
||||
{messages.length === 0 && historySessions.length > 0 && (
|
||||
<div className="shrink-0 px-4 pb-1">
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<span className="text-[11px] text-muted-foreground/30 tracking-wide">{t('ai.chat.recent')}</span>
|
||||
<button
|
||||
onClick={() => setShowHistory(true)}
|
||||
className="text-[11px] text-muted-foreground/30 hover:text-muted-foreground/50 transition-colors cursor-pointer"
|
||||
>
|
||||
{t('ai.chat.viewAll')}
|
||||
</button>
|
||||
</div>
|
||||
{historySessions.slice(0, 3).map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
onClick={() => handleSelectSession(session.id)}
|
||||
className="w-full flex items-baseline justify-between py-1.5 text-left hover:text-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="text-[13px] text-foreground/60 truncate pr-4">
|
||||
{session.title || t('ai.chat.untitled')}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground/25 shrink-0">
|
||||
{formatRelativeTime(new Date(session.updatedAt), t)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input area */}
|
||||
<ChatInput
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onSend={handleSend}
|
||||
onStop={handleStop}
|
||||
isStreaming={isStreaming}
|
||||
disabled={!canSendCurrentAgent}
|
||||
providerName={providerDisplayName}
|
||||
modelName={modelDisplayName}
|
||||
agentName={currentAgentId === 'catty' ? 'Catty Agent' : externalAgents.find(a => a.id === currentAgentId)?.name}
|
||||
modelPresets={agentModelPresets}
|
||||
selectedModelId={selectedAgentModel}
|
||||
onModelSelect={handleAgentModelSelect}
|
||||
files={files}
|
||||
onAddFiles={addFiles}
|
||||
onRemoveFile={removeFile}
|
||||
hosts={terminalSessions.map(s => ({ sessionId: s.sessionId, hostname: s.hostname, label: s.label, connected: s.connected }))}
|
||||
selectedUserSkills={selectedUserSkills}
|
||||
userSkills={userSkillOptions}
|
||||
onAddUserSkill={addSelectedUserSkill}
|
||||
onRemoveUserSkill={removeSelectedUserSkill}
|
||||
permissionMode={globalPermissionMode}
|
||||
onPermissionModeChange={setGlobalPermissionMode}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
<AIChatPanelContent
|
||||
t={t}
|
||||
currentAgentId={currentAgentId}
|
||||
externalAgents={externalAgents}
|
||||
discoveredAgents={discoveredAgents}
|
||||
isDiscovering={isDiscovering}
|
||||
handleAgentChange={handleAgentChange}
|
||||
handleEnableDiscoveredAgent={handleEnableDiscoveredAgent}
|
||||
rediscover={rediscover}
|
||||
handleOpenSettings={handleOpenSettings}
|
||||
activeSession={activeSession}
|
||||
handleExport={handleExport}
|
||||
showHistory={showHistory}
|
||||
setShowHistory={setShowHistory}
|
||||
handleNewChat={handleNewChat}
|
||||
historySessions={historySessions}
|
||||
activeSessionId={activeSessionId}
|
||||
handleSelectSession={handleSelectSession}
|
||||
handleDeleteSession={handleDeleteSession}
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
inputValue={inputValue}
|
||||
setInputValue={setInputValue}
|
||||
handleSend={handleSend}
|
||||
handleStop={handleStop}
|
||||
canSendCurrentAgent={canSendCurrentAgent}
|
||||
providerDisplayName={providerDisplayName}
|
||||
modelDisplayName={modelDisplayName}
|
||||
agentModelPresets={agentModelPresets}
|
||||
selectedAgentModel={selectedAgentModel}
|
||||
handleAgentModelSelect={handleAgentModelSelect}
|
||||
cattyConfiguredProviders={cattyConfiguredProviders}
|
||||
effectiveActiveProvider={effectiveActiveProvider}
|
||||
effectiveActiveModelId={effectiveActiveModelId}
|
||||
handleAgentProviderModelSelect={handleAgentProviderModelSelect}
|
||||
files={files}
|
||||
addFiles={addFiles}
|
||||
removeFile={removeFile}
|
||||
terminalSessions={terminalSessions}
|
||||
selectedUserSkills={selectedUserSkills}
|
||||
userSkillOptions={userSkillOptions}
|
||||
addSelectedUserSkill={addSelectedUserSkill}
|
||||
removeSelectedUserSkill={removeSelectedUserSkill}
|
||||
globalPermissionMode={globalPermissionMode}
|
||||
setGlobalPermissionMode={setGlobalPermissionMode}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Session History Drawer
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
interface SessionHistoryDrawerProps {
|
||||
sessions: AISession[];
|
||||
activeSessionId: string | null;
|
||||
onSelect: (sessionId: string) => void;
|
||||
onDelete: (e: React.MouseEvent, sessionId: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
sessions,
|
||||
activeSessionId,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="px-4 py-2.5 flex items-center justify-between shrink-0 border-b border-border/30">
|
||||
<span className="text-[13px] font-medium text-foreground/80">{t('ai.chat.allSessions')}</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[12px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="px-3">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-[13px] text-muted-foreground/40">
|
||||
{t('ai.chat.noSessions')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
sessions.map((session) => {
|
||||
const isActive = session.id === activeSessionId;
|
||||
const time = new Date(session.updatedAt);
|
||||
const timeStr = formatRelativeTime(time, t);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(session.id)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onSelect(session.id); }}
|
||||
className={cn(
|
||||
SESSION_HISTORY_ROW_CLASSNAMES.row,
|
||||
isActive ? 'text-foreground' : 'text-foreground/70 hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<span className={SESSION_HISTORY_ROW_CLASSNAMES.title}>
|
||||
{session.title || t('ai.chat.untitled')}
|
||||
</span>
|
||||
<div className={SESSION_HISTORY_ROW_CLASSNAMES.meta}>
|
||||
<span className={SESSION_HISTORY_ROW_CLASSNAMES.time}>
|
||||
{timeStr}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => onDelete(e, session.id)}
|
||||
className={SESSION_HISTORY_ROW_CLASSNAMES.deleteButton}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function formatRelativeTime(date: Date, t: (key: string) => string): string {
|
||||
const now = Date.now();
|
||||
const diff = now - date.getTime();
|
||||
const minutes = Math.floor(diff / 60_000);
|
||||
const hours = Math.floor(diff / 3_600_000);
|
||||
const days = Math.floor(diff / 86_400_000);
|
||||
|
||||
if (minutes < 1) return t('ai.chat.justNow');
|
||||
if (minutes < 60) return t('ai.chat.minutesAgo').replace('{n}', String(minutes));
|
||||
if (hours < 24) return t('ai.chat.hoursAgo').replace('{n}', String(hours));
|
||||
if (days < 7) return t('ai.chat.daysAgo').replace('{n}', String(days));
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Export
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const AIChatSidePanel = React.memo(AIChatSidePanelInner);
|
||||
AIChatSidePanel.displayName = 'AIChatSidePanel';
|
||||
|
||||
110
components/AIChatSidePanel.types.ts
Normal file
110
components/AIChatSidePanel.types.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type {
|
||||
AIDraft,
|
||||
AIPanelView,
|
||||
AIPermissionMode,
|
||||
AIToolIntegrationMode,
|
||||
AISession,
|
||||
AISessionScope,
|
||||
ChatMessage,
|
||||
ExternalAgentConfig,
|
||||
ProviderConfig,
|
||||
WebSearchConfig,
|
||||
} from '../infrastructure/ai/types';
|
||||
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Props
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export interface AIChatSidePanelProps {
|
||||
// Session state (per-scope)
|
||||
sessions: AISession[];
|
||||
activeSessionIdMap: Record<string, string | null>;
|
||||
draftsByScope: Partial<Record<string, AIDraft>>;
|
||||
panelViewByScope: Partial<Record<string, AIPanelView>>;
|
||||
setActiveSessionId: (scopeKey: string, id: string | null) => void;
|
||||
ensureDraftForScope: (scopeKey: string, agentId: string) => void;
|
||||
updateDraft: (
|
||||
scopeKey: string,
|
||||
fallbackAgentId: string,
|
||||
updater: (draft: AIDraft) => AIDraft,
|
||||
) => void;
|
||||
showDraftView: (scopeKey: string) => void;
|
||||
showSessionView: (scopeKey: string, sessionId: string) => void;
|
||||
clearDraftForScope: (scopeKey: string) => void;
|
||||
addDraftFiles: (scopeKey: string, fallbackAgentId: string, inputFiles: File[]) => Promise<void>;
|
||||
removeDraftFile: (scopeKey: string, fallbackAgentId: string, fileId: string) => void;
|
||||
createSession: (scope: AISessionScope, agentId?: string) => AISession;
|
||||
deleteSession: (sessionId: string, scopeKey?: string) => void;
|
||||
updateSessionTitle: (sessionId: string, title: string) => void;
|
||||
updateSessionExternalSessionId: (sessionId: string, externalSessionId: string | undefined) => void;
|
||||
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
|
||||
updateLastMessage: (
|
||||
sessionId: string,
|
||||
updater: (msg: ChatMessage) => ChatMessage,
|
||||
) => void;
|
||||
updateMessageById: (
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
updater: (msg: ChatMessage) => ChatMessage,
|
||||
) => void;
|
||||
// Provider config
|
||||
providers: ProviderConfig[];
|
||||
activeProviderId: string;
|
||||
activeModelId: string;
|
||||
|
||||
// Agent info
|
||||
defaultAgentId: string;
|
||||
toolIntegrationMode: AIToolIntegrationMode;
|
||||
externalAgents: ExternalAgentConfig[];
|
||||
setExternalAgents?: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void;
|
||||
agentModelMap: Record<string, string>;
|
||||
setAgentModel: (agentId: string, modelId: string) => void;
|
||||
agentProviderMap: Record<string, string>;
|
||||
setAgentProvider: (agentId: string, providerId: string) => void;
|
||||
|
||||
// Safety
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
setGlobalPermissionMode?: (mode: AIPermissionMode) => void;
|
||||
commandBlocklist?: string[];
|
||||
maxIterations?: number;
|
||||
|
||||
// Web search
|
||||
webSearchConfig?: WebSearchConfig | null;
|
||||
|
||||
// Context
|
||||
scopeType: 'terminal' | 'workspace';
|
||||
scopeTargetId?: string;
|
||||
scopeHostIds?: string[];
|
||||
scopeLabel?: string;
|
||||
|
||||
// Terminal session context (from parent)
|
||||
terminalSessions?: Array<{
|
||||
sessionId: string;
|
||||
hostId: string;
|
||||
hostname: string;
|
||||
label: string;
|
||||
os?: string;
|
||||
username?: string;
|
||||
protocol?: string;
|
||||
shellType?: string;
|
||||
deviceType?: string;
|
||||
connected: boolean;
|
||||
}>;
|
||||
resolveExecutorContext?: (scope: {
|
||||
type: 'terminal' | 'workspace';
|
||||
targetId?: string;
|
||||
label?: string;
|
||||
}) => ExecutorContext;
|
||||
|
||||
// Visibility
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Component
|
||||
// -------------------------------------------------------------------
|
||||
30
components/AIChatSidePanelHelpers.ts
Normal file
30
components/AIChatSidePanelHelpers.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { AgentModelPreset, ExternalAgentConfig } from '../infrastructure/ai/types';
|
||||
|
||||
export function modelPresetMatchesId(preset: AgentModelPreset, modelId: string): boolean {
|
||||
if (preset.thinkingLevels?.length) {
|
||||
return preset.thinkingLevels.some((level) => `${preset.id}/${level}` === modelId);
|
||||
}
|
||||
return preset.id === modelId;
|
||||
}
|
||||
|
||||
export function modelPresetsContainId(presets: AgentModelPreset[], modelId: string): boolean {
|
||||
return presets.some((preset) => modelPresetMatchesId(preset, modelId));
|
||||
}
|
||||
|
||||
export function isCopilotAgentConfig(agent?: ExternalAgentConfig): boolean {
|
||||
if (!agent) return false;
|
||||
const tokens = [
|
||||
agent.id,
|
||||
agent.name,
|
||||
agent.icon,
|
||||
agent.command,
|
||||
agent.acpCommand,
|
||||
]
|
||||
.filter((value): value is string => typeof value === 'string' && value.length > 0)
|
||||
.map((value) => value.split('/').pop()?.toLowerCase() ?? value.toLowerCase());
|
||||
return tokens.some((token) => token.includes('copilot'));
|
||||
}
|
||||
|
||||
export function generateId(): string {
|
||||
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { cn } from "../lib/utils";
|
||||
import { ConnectionLog, Host } from "../types";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
interface ConnectionLogsManagerProps {
|
||||
logs: ConnectionLog[];
|
||||
@@ -108,31 +109,39 @@ const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) =
|
||||
|
||||
{/* Saved column */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleSaved(log.id);
|
||||
}}
|
||||
className={cn(
|
||||
"p-1.5 rounded-md transition-colors",
|
||||
log.saved
|
||||
? "text-primary bg-primary/10"
|
||||
: "text-muted-foreground hover:text-primary hover:bg-primary/10"
|
||||
)}
|
||||
title={log.saved ? t("logs.action.unsave") : t("logs.action.save")}
|
||||
>
|
||||
<Bookmark size={16} fill={log.saved ? "currentColor" : "none"} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(log.id);
|
||||
}}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100"
|
||||
title={t("logs.action.delete")}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleSaved(log.id);
|
||||
}}
|
||||
className={cn(
|
||||
"p-1.5 rounded-md transition-colors",
|
||||
log.saved
|
||||
? "text-primary bg-primary/10"
|
||||
: "text-muted-foreground hover:text-primary hover:bg-primary/10"
|
||||
)}
|
||||
>
|
||||
<Bookmark size={16} fill={log.saved ? "currentColor" : "none"} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{log.saved ? t("logs.action.unsave") : t("logs.action.save")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(log.id);
|
||||
}}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("logs.action.delete")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -61,6 +61,7 @@ export const DISTRO_COLORS: Record<string, string> = {
|
||||
fortinet: "bg-[#EE3124]",
|
||||
paloalto: "bg-[#FA582D]",
|
||||
zyxel: "bg-[#00497A]",
|
||||
ruijie: "bg-[#E60012]",
|
||||
default: "bg-slate-600",
|
||||
};
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ interface FileOpenerDialogProps {
|
||||
onSelectSystemApp: () => Promise<SystemAppInfo | null>;
|
||||
}
|
||||
|
||||
export const FileOpenerDialog: React.FC<FileOpenerDialogProps> = ({
|
||||
const FileOpenerDialog: React.FC<FileOpenerDialogProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
fileName,
|
||||
|
||||
@@ -1,29 +1,19 @@
|
||||
import {
|
||||
Check,
|
||||
ChevronRight,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileKey,
|
||||
FolderOpen,
|
||||
Globe,
|
||||
Key,
|
||||
Link2,
|
||||
MoreHorizontal,
|
||||
Palette,
|
||||
Plus,
|
||||
Settings2,
|
||||
Shield,
|
||||
TerminalSquare,
|
||||
Trash2,
|
||||
Variable,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { customThemeStore } from "../application/state/customThemeStore";
|
||||
import { resolveGroupDefaults, resolveGroupTerminalThemeId } from "../domain/groupConfig";
|
||||
import { isCompleteProxyConfig, normalizeManualProxyConfig } from "../domain/proxyProfiles";
|
||||
import { cn } from "../lib/utils";
|
||||
import {
|
||||
EnvVar,
|
||||
GroupConfig,
|
||||
@@ -37,6 +27,8 @@ import ThemeSelectPanel from "./ThemeSelectPanel";
|
||||
import {
|
||||
ChainPanel,
|
||||
EnvVarsPanel,
|
||||
HostDetailsSection,
|
||||
HostDetailsSettingRow,
|
||||
ProxyPanel,
|
||||
} from "./host-details";
|
||||
import {
|
||||
@@ -44,16 +36,14 @@ import {
|
||||
AsidePanelContent,
|
||||
type AsidePanelLayout,
|
||||
} from "./ui/aside-panel";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { Card } from "./ui/card";
|
||||
import { Combobox } from "./ui/combobox";
|
||||
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
|
||||
import { Input } from "./ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { TerminalFontSelect } from "./settings/TerminalFontSelect";
|
||||
import { useAvailableFonts } from "../application/state/fontStore";
|
||||
import { toast } from "./ui/toast";
|
||||
import { GroupSshSettingsSection } from "./GroupSshSettingsSection";
|
||||
|
||||
type SubPanel = "none" | "proxy" | "chain" | "env-vars" | "theme-select";
|
||||
|
||||
@@ -110,7 +100,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
c.protocol === 'ssh' ||
|
||||
c.port !== undefined || !!c.username || !!c.password || !!c.identityFileId ||
|
||||
c.agentForwarding !== undefined || c.authMethod !== undefined || !!c.identityId ||
|
||||
!!c.proxyProfileId || !!c.proxyConfig || !!c.hostChain || !!c.startupCommand || c.legacyAlgorithms !== undefined || c.backspaceBehavior !== undefined ||
|
||||
!!c.proxyProfileId || !!c.proxyConfig || !!c.hostChain || !!c.startupCommand || c.legacyAlgorithms !== undefined || c.skipEcdsaHostKey !== undefined || c.algorithms !== undefined || c.backspaceBehavior !== undefined ||
|
||||
(c.environmentVariables && c.environmentVariables.length > 0) ||
|
||||
c.moshEnabled !== undefined || !!c.moshServerPath ||
|
||||
(c.identityFilePaths && c.identityFilePaths.length > 0);
|
||||
@@ -126,6 +116,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
// Password visibility state
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showTelnetPassword, setShowTelnetPassword] = useState(false);
|
||||
const [showAlgorithmOverrides, setShowAlgorithmOverrides] = useState(false);
|
||||
const [addProtocolOpen, setAddProtocolOpen] = useState(false);
|
||||
|
||||
// Credential selection state
|
||||
@@ -170,6 +161,8 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
delete next.agentForwarding;
|
||||
delete next.startupCommand;
|
||||
delete next.legacyAlgorithms;
|
||||
delete next.skipEcdsaHostKey;
|
||||
delete next.algorithms;
|
||||
delete next.backspaceBehavior;
|
||||
delete next.proxyProfileId;
|
||||
delete next.proxyConfig;
|
||||
@@ -311,6 +304,36 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
if (!parentGroup || groupConfigs.length === 0) return terminalThemeId;
|
||||
return resolveGroupTerminalThemeId(resolveGroupDefaults(parentGroup, groupConfigs), terminalThemeId);
|
||||
}, [groupConfigs, parentGroup, terminalThemeId]);
|
||||
|
||||
// Effective `legacyAlgorithms` for this group, considering inheritance
|
||||
// from the parent chain. Used by the algorithm-overrides editor so the
|
||||
// seed reflects what hosts in this group would actually advertise — if
|
||||
// the parent group already turned legacy mode on, the editor should
|
||||
// include legacy algorithms in its default list even when this group
|
||||
// itself hasn't set the flag.
|
||||
const inheritedLegacyAlgorithms = useMemo(() => {
|
||||
if (!parentGroup || groupConfigs.length === 0) return false;
|
||||
return !!resolveGroupDefaults(parentGroup, groupConfigs).legacyAlgorithms;
|
||||
}, [groupConfigs, parentGroup]);
|
||||
|
||||
// Same idea for the algorithm-override lists themselves: surface what
|
||||
// this group would inherit from its parent so the editor can warn that
|
||||
// a local Reset falls back to the parent's lists, not NetCatty's
|
||||
// defaults.
|
||||
const inheritedAlgorithmOverrides = useMemo(() => {
|
||||
if (!parentGroup || groupConfigs.length === 0) return undefined;
|
||||
return resolveGroupDefaults(parentGroup, groupConfigs).algorithms;
|
||||
}, [groupConfigs, parentGroup]);
|
||||
|
||||
// And for the per-flag toggles below — if the parent already turned
|
||||
// a flag on, the runtime applies it to hosts in this group via
|
||||
// `applyGroupDefaults`, so the local toggle must reflect that. Without
|
||||
// this, a child group would show the flag as off while connections
|
||||
// still negotiated with it.
|
||||
const inheritedSkipEcdsaHostKey = useMemo(() => {
|
||||
if (!parentGroup || groupConfigs.length === 0) return false;
|
||||
return !!resolveGroupDefaults(parentGroup, groupConfigs).skipEcdsaHostKey;
|
||||
}, [groupConfigs, parentGroup]);
|
||||
const effectiveThemeId = form.themeOverride === false
|
||||
? inheritedThemeId
|
||||
: (form.theme || inheritedThemeId);
|
||||
@@ -359,6 +382,8 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
...(form.agentForwarding !== undefined && { agentForwarding: form.agentForwarding }),
|
||||
...(form.startupCommand !== undefined && { startupCommand: form.startupCommand }),
|
||||
...(form.legacyAlgorithms !== undefined && { legacyAlgorithms: form.legacyAlgorithms }),
|
||||
...(form.skipEcdsaHostKey !== undefined && { skipEcdsaHostKey: form.skipEcdsaHostKey }),
|
||||
...(form.algorithms !== undefined && { algorithms: form.algorithms }),
|
||||
...(form.backspaceBehavior !== undefined && { backspaceBehavior: form.backspaceBehavior }),
|
||||
...(form.proxyProfileId !== undefined && { proxyProfileId: form.proxyProfileId }),
|
||||
...(normalizedProxyConfig !== undefined && { proxyConfig: normalizedProxyConfig }),
|
||||
@@ -508,13 +533,10 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
>
|
||||
<AsidePanelContent>
|
||||
{/* General Section */}
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings2 size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">
|
||||
{t("vault.groups.details.general")}
|
||||
</p>
|
||||
</div>
|
||||
<HostDetailsSection
|
||||
icon={<Settings2 size={14} className="text-muted-foreground" />}
|
||||
title={t("vault.groups.details.general")}
|
||||
>
|
||||
<Input
|
||||
placeholder={t("vault.groups.field.name")}
|
||||
value={groupName}
|
||||
@@ -534,445 +556,40 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
placeholder={t("vault.groups.details.parentGroup")}
|
||||
className="w-full"
|
||||
/>
|
||||
</Card>
|
||||
</HostDetailsSection>
|
||||
|
||||
{/* SSH Section (if enabled) */}
|
||||
{sshEnabled && (
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80 overflow-hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
<TerminalSquare size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold flex-1">
|
||||
{t("vault.groups.details.ssh")}
|
||||
</p>
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6">
|
||||
<MoreHorizontal size={14} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownContent align="end" className="min-w-[160px]">
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-destructive hover:bg-secondary rounded-md transition-colors"
|
||||
onClick={removeSsh}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t("vault.groups.details.removeProtocol")}
|
||||
</button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0 h-10 flex items-center gap-2 bg-secondary/70 border border-border/70 rounded-md px-3">
|
||||
<span className="text-xs text-muted-foreground">SSH on</span>
|
||||
<div className="ml-auto w-1/2 min-w-0 flex items-center gap-2 justify-end">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="22"
|
||||
value={form.port ?? ""}
|
||||
onChange={(e) =>
|
||||
update("port", e.target.value ? Number(e.target.value) : undefined)
|
||||
}
|
||||
className="h-8 flex-1 min-w-0 text-center"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.port")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
placeholder={t("hostDetails.username.placeholder")}
|
||||
value={form.username || ""}
|
||||
onChange={(e) => update("username", e.target.value || undefined)}
|
||||
className="h-10"
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder={t("hostDetails.password.placeholder")}
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={form.password || ""}
|
||||
onChange={(e) => update("password", e.target.value || undefined)}
|
||||
className="h-10 pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Selected credential display */}
|
||||
{form.identityFileId && (
|
||||
<div className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
|
||||
{form.authMethod === "certificate" ? (
|
||||
<Shield size={14} className="text-primary" />
|
||||
) : (
|
||||
<Key size={14} className="text-primary" />
|
||||
)}
|
||||
<span className="text-sm flex-1 truncate">
|
||||
{availableKeys.find((k) => k.id === form.identityFileId)?.label || "Key"}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => {
|
||||
update("identityFileId", undefined);
|
||||
update("authMethod", undefined);
|
||||
setSelectedCredentialType(null);
|
||||
}}
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local key file paths display */}
|
||||
{!form.identityFileId && form.identityFilePaths && form.identityFilePaths.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{form.identityFilePaths.map((keyPath, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 h-8 px-2 rounded-md bg-secondary/50 border border-border/60" style={{ maxWidth: '100%' }}>
|
||||
<FileKey size={12} className="text-muted-foreground shrink-0" />
|
||||
<span className="text-xs font-mono truncate" style={{ maxWidth: '320px' }}>{keyPath}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 shrink-0"
|
||||
onClick={() => {
|
||||
const paths = (form.identityFilePaths || []).filter((_, i) => i !== idx);
|
||||
update("identityFilePaths", paths.length > 0 ? paths : undefined);
|
||||
if (paths.length === 0) update("authMethod", undefined);
|
||||
}}
|
||||
>
|
||||
<X size={10} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Credential type selection with inline popover - hidden when credential is selected */}
|
||||
{!form.identityFileId &&
|
||||
!selectedCredentialType && (
|
||||
<Popover
|
||||
open={credentialPopoverOpen}
|
||||
onOpenChange={setCredentialPopoverOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors py-1"
|
||||
>
|
||||
<Plus size={12} />
|
||||
<span>{t("hostDetails.credential.keyCertificate")}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[200px] p-1"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-secondary/80 transition-colors text-left"
|
||||
onClick={() => {
|
||||
setSelectedCredentialType("key");
|
||||
setCredentialPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<Key size={16} className="text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{t("hostDetails.credential.key")}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-secondary/80 transition-colors text-left"
|
||||
onClick={() => {
|
||||
setSelectedCredentialType("certificate");
|
||||
setCredentialPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<Shield size={16} className="text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{t("hostDetails.credential.certificate")}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-secondary/80 transition-colors text-left"
|
||||
onClick={() => {
|
||||
setSelectedCredentialType("localKeyFile");
|
||||
setCredentialPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<FileKey size={16} className="text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{t("hostDetails.credential.localKeyFile")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
{/* Key selection combobox - appears after selecting "Key" type */}
|
||||
{selectedCredentialType === "key" &&
|
||||
!form.identityFileId && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Combobox
|
||||
options={keysByCategory.key.map((k) => ({
|
||||
value: k.id,
|
||||
label: k.label,
|
||||
sublabel: `${k.type}${k.keySize ? ` ${k.keySize}` : ""}`,
|
||||
icon: <Key size={14} className="text-muted-foreground" />,
|
||||
}))}
|
||||
value={form.identityFileId}
|
||||
onValueChange={(val) => {
|
||||
update("identityFileId", val);
|
||||
update("authMethod", "key");
|
||||
setSelectedCredentialType(null);
|
||||
}}
|
||||
placeholder={t("hostDetails.keys.search")}
|
||||
emptyText={t("hostDetails.keys.empty")}
|
||||
icon={<Key size={14} className="text-muted-foreground" />}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setSelectedCredentialType(null)}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Certificate selection combobox - appears after selecting "Certificate" type */}
|
||||
{selectedCredentialType === "certificate" &&
|
||||
!form.identityFileId && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Combobox
|
||||
options={keysByCategory.certificate.map((k) => ({
|
||||
value: k.id,
|
||||
label: k.label,
|
||||
icon: (
|
||||
<Shield size={14} className="text-muted-foreground" />
|
||||
),
|
||||
}))}
|
||||
value={form.identityFileId}
|
||||
onValueChange={(val) => {
|
||||
update("identityFileId", val);
|
||||
update("authMethod", "certificate");
|
||||
setSelectedCredentialType(null);
|
||||
}}
|
||||
placeholder={t("hostDetails.certs.search")}
|
||||
emptyText={t("hostDetails.certs.empty")}
|
||||
icon={
|
||||
<Shield size={14} className="text-muted-foreground" />
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setSelectedCredentialType(null)}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local key file path input - appears after selecting "Local Key File" type */}
|
||||
{!form.identityFileId && selectedCredentialType === "localKeyFile" && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1 w-full">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 w-0 h-8 px-2 text-xs font-mono bg-background border border-border/60 rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
placeholder={t("hostDetails.credential.localKeyFilePlaceholder")}
|
||||
value={newKeyFilePath}
|
||||
onChange={(e) => setNewKeyFilePath(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && newKeyFilePath.trim()) {
|
||||
e.preventDefault();
|
||||
const paths = [...(form.identityFilePaths || []), newKeyFilePath.trim()];
|
||||
update("identityFilePaths", paths);
|
||||
update("identityFileId", undefined);
|
||||
update("authMethod", "key");
|
||||
setNewKeyFilePath("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
title={t("hostDetails.credential.browseKeyFile")}
|
||||
onClick={async () => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
if (!bridge?.selectFile) return;
|
||||
const filePath = await bridge.selectFile(
|
||||
"Select SSH Private Key",
|
||||
undefined,
|
||||
[{ name: "All Files", extensions: ["*"] }]
|
||||
);
|
||||
if (filePath) {
|
||||
const paths = [...(form.identityFilePaths || []), filePath];
|
||||
update("identityFilePaths", paths);
|
||||
update("identityFileId", undefined);
|
||||
update("authMethod", "key");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setSelectedCredentialType(null)}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ToggleRow
|
||||
label={t("hostDetails.agentForwarding")}
|
||||
enabled={!!form.agentForwarding}
|
||||
onToggle={() => update("agentForwarding", !form.agentForwarding)}
|
||||
/>
|
||||
|
||||
{/* Startup Command */}
|
||||
<Input
|
||||
placeholder={t("hostDetails.startupCommand.placeholder")}
|
||||
value={form.startupCommand || ""}
|
||||
onChange={(e) => update("startupCommand", e.target.value || undefined)}
|
||||
className="h-10"
|
||||
/>
|
||||
|
||||
{/* Legacy Algorithms */}
|
||||
<ToggleRow
|
||||
label={t("hostDetails.legacyAlgorithms")}
|
||||
enabled={!!form.legacyAlgorithms}
|
||||
onToggle={() => update("legacyAlgorithms", !form.legacyAlgorithms)}
|
||||
/>
|
||||
|
||||
{/* Backspace behavior */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">{t("hostDetails.backspaceBehavior")}</p>
|
||||
<select
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
|
||||
value={form.backspaceBehavior ?? ""}
|
||||
onChange={(e) => update("backspaceBehavior", e.target.value || undefined)}
|
||||
>
|
||||
<option value="">{t("hostDetails.backspaceBehavior.default")}</option>
|
||||
<option value="ctrl-h">^H (0x08)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Proxy */}
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center justify-between p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer"
|
||||
onClick={() => setActiveSubPanel("proxy")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe size={14} className="text-muted-foreground" />
|
||||
<span className="text-sm">{t("hostDetails.proxy")}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{(form.proxyConfig?.host || form.proxyProfileId) && (
|
||||
<div title={proxySummaryLabel} className="min-w-0">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="max-w-[160px] truncate text-xs"
|
||||
>
|
||||
{proxySummaryLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
<ChevronRight size={14} className="text-muted-foreground" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Host Chaining */}
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center justify-between p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer"
|
||||
onClick={() => setActiveSubPanel("chain")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 size={14} className="text-muted-foreground" />
|
||||
<span className="text-sm">{t("hostDetails.jumpHosts")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{chainedHosts.length > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronRight size={14} className="text-muted-foreground" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Environment Variables */}
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center justify-between p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer"
|
||||
onClick={() => setActiveSubPanel("env-vars")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Variable size={14} className="text-muted-foreground" />
|
||||
<span className="text-sm">{t("hostDetails.envVars")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{(form.environmentVariables?.length || 0) > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{form.environmentVariables!.length}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronRight size={14} className="text-muted-foreground" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Mosh */}
|
||||
<ToggleRow
|
||||
label="Mosh"
|
||||
enabled={!!form.moshEnabled}
|
||||
onToggle={() => update("moshEnabled", !form.moshEnabled)}
|
||||
/>
|
||||
{form.moshEnabled && (
|
||||
<Input
|
||||
placeholder={t("hostDetails.moshServerPath") || "mosh-server path"}
|
||||
value={form.moshServerPath || ""}
|
||||
onChange={(e) => update("moshServerPath", e.target.value || undefined)}
|
||||
className="h-10"
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
<GroupSshSettingsSection
|
||||
sshEnabled={sshEnabled}
|
||||
t={t}
|
||||
removeSsh={removeSsh}
|
||||
form={form}
|
||||
update={update}
|
||||
showPassword={showPassword}
|
||||
setShowPassword={setShowPassword}
|
||||
availableKeys={availableKeys}
|
||||
setSelectedCredentialType={setSelectedCredentialType}
|
||||
selectedCredentialType={selectedCredentialType}
|
||||
credentialPopoverOpen={credentialPopoverOpen}
|
||||
setCredentialPopoverOpen={setCredentialPopoverOpen}
|
||||
keysByCategory={keysByCategory}
|
||||
newKeyFilePath={newKeyFilePath}
|
||||
setNewKeyFilePath={setNewKeyFilePath}
|
||||
inheritedLegacyAlgorithms={inheritedLegacyAlgorithms}
|
||||
inheritedSkipEcdsaHostKey={inheritedSkipEcdsaHostKey}
|
||||
showAlgorithmOverrides={showAlgorithmOverrides}
|
||||
setShowAlgorithmOverrides={setShowAlgorithmOverrides}
|
||||
inheritedAlgorithmOverrides={inheritedAlgorithmOverrides}
|
||||
proxySummaryLabel={proxySummaryLabel}
|
||||
setActiveSubPanel={setActiveSubPanel}
|
||||
chainedHosts={chainedHosts}
|
||||
/>
|
||||
|
||||
{/* Telnet Section (if enabled) */}
|
||||
{telnetEnabled && (
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold flex-1">
|
||||
{t("vault.groups.details.telnet")}
|
||||
</p>
|
||||
<HostDetailsSection
|
||||
icon={<Globe size={14} className="text-muted-foreground" />}
|
||||
title={t("vault.groups.details.telnet")}
|
||||
action={
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6">
|
||||
@@ -989,7 +606,8 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
</button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0 h-10 flex items-center gap-2 bg-secondary/70 border border-border/70 rounded-md px-3">
|
||||
@@ -1033,34 +651,28 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
{showTelnetPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
</HostDetailsSection>
|
||||
)}
|
||||
|
||||
{/* Charset & Appearance — only when at least one protocol is added */}
|
||||
{(sshEnabled || telnetEnabled) && (<>
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">
|
||||
{t("vault.groups.details.advanced")}
|
||||
</p>
|
||||
</div>
|
||||
<HostDetailsSection
|
||||
icon={<Globe size={14} className="text-muted-foreground" />}
|
||||
title={t("vault.groups.details.advanced")}
|
||||
>
|
||||
<Input
|
||||
placeholder="UTF-8"
|
||||
value={form.charset || ""}
|
||||
onChange={(e) => update("charset", e.target.value || undefined)}
|
||||
className="h-10"
|
||||
/>
|
||||
</Card>
|
||||
</HostDetailsSection>
|
||||
|
||||
{/* Appearance Section */}
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">
|
||||
{t("vault.groups.details.appearance")}
|
||||
</p>
|
||||
</div>
|
||||
<HostDetailsSection
|
||||
icon={<Palette size={14} className="text-muted-foreground" />}
|
||||
title={t("vault.groups.details.appearance")}
|
||||
>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@@ -1137,21 +749,23 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
)}
|
||||
|
||||
{/* Font Size */}
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={String(terminalFontSize)}
|
||||
value={form.fontSize ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value ? parseInt(e.target.value) : undefined;
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
fontSize: val,
|
||||
fontSizeOverride: val !== undefined ? true : undefined,
|
||||
}));
|
||||
}}
|
||||
className="h-10"
|
||||
/>
|
||||
</Card>
|
||||
<HostDetailsSettingRow label="Font Size">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={String(terminalFontSize)}
|
||||
value={form.fontSize ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value ? parseInt(e.target.value) : undefined;
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
fontSize: val,
|
||||
fontSizeOverride: val !== undefined ? true : undefined,
|
||||
}));
|
||||
}}
|
||||
className="h-8 w-24 text-center"
|
||||
/>
|
||||
</HostDetailsSettingRow>
|
||||
</HostDetailsSection>
|
||||
</>)}
|
||||
|
||||
{/* Add Protocol Button — always at the bottom */}
|
||||
@@ -1188,29 +802,4 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// --- Internal Components ---
|
||||
|
||||
interface ToggleRowProps {
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
const ToggleRow: React.FC<ToggleRowProps> = ({ label, enabled, onToggle }) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="flex items-center justify-between h-10 px-3 rounded-md border border-border/70 bg-secondary/70">
|
||||
<span className="text-sm">{label}</span>
|
||||
<Button
|
||||
variant={enabled ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className={cn("h-8 min-w-[72px]", enabled && "bg-primary/20")}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{enabled ? t("common.enabled") : t("common.disabled")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupDetailsPanel;
|
||||
|
||||
545
components/GroupSshSettingsSection.tsx
Normal file
545
components/GroupSshSettingsSection.tsx
Normal file
@@ -0,0 +1,545 @@
|
||||
import React from "react";
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Eye, EyeOff, FileKey, FolderOpen, Globe, Key, Link2, MoreHorizontal, Plus, Shield, TerminalSquare, Trash2, Variable, X } from "lucide-react";
|
||||
import { AlgorithmOverridesPanel } from "./host-details/AlgorithmOverridesPanel";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { HostDetailsSection, HostDetailsSettingRow } from "./host-details";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
|
||||
import { Combobox } from "./ui/combobox";
|
||||
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
|
||||
import { Input } from "./ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||
import { Switch } from "./ui/switch";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type GroupSshSettingsSectionProps = Record<string, any>;
|
||||
|
||||
const ToggleRow: React.FC<{ label: string; hint?: React.ReactNode; enabled: boolean; onToggle: () => void }> = ({ label, hint, enabled, onToggle }) => {
|
||||
return (
|
||||
<HostDetailsSettingRow label={label} hint={hint}>
|
||||
<Switch checked={enabled} onCheckedChange={() => onToggle()} />
|
||||
</HostDetailsSettingRow>
|
||||
);
|
||||
};
|
||||
|
||||
export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = ({
|
||||
sshEnabled,
|
||||
t,
|
||||
removeSsh,
|
||||
form,
|
||||
update,
|
||||
showPassword,
|
||||
setShowPassword,
|
||||
availableKeys,
|
||||
setSelectedCredentialType,
|
||||
selectedCredentialType,
|
||||
credentialPopoverOpen,
|
||||
setCredentialPopoverOpen,
|
||||
keysByCategory,
|
||||
newKeyFilePath,
|
||||
setNewKeyFilePath,
|
||||
inheritedLegacyAlgorithms,
|
||||
inheritedSkipEcdsaHostKey,
|
||||
showAlgorithmOverrides,
|
||||
setShowAlgorithmOverrides,
|
||||
inheritedAlgorithmOverrides,
|
||||
proxySummaryLabel,
|
||||
setActiveSubPanel,
|
||||
chainedHosts,
|
||||
}) => {
|
||||
if (!sshEnabled) return null;
|
||||
|
||||
return (
|
||||
<HostDetailsSection
|
||||
icon={<TerminalSquare size={14} className="text-muted-foreground" />}
|
||||
title={t("vault.groups.details.ssh")}
|
||||
className="overflow-hidden"
|
||||
action={
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6">
|
||||
<MoreHorizontal size={14} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownContent align="end" className="min-w-[160px]">
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-destructive hover:bg-secondary rounded-md transition-colors"
|
||||
onClick={removeSsh}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t("vault.groups.details.removeProtocol")}
|
||||
</button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
}
|
||||
>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0 h-10 flex items-center gap-2 bg-secondary/70 border border-border/70 rounded-md px-3">
|
||||
<span className="text-xs text-muted-foreground">SSH on</span>
|
||||
<div className="ml-auto w-1/2 min-w-0 flex items-center gap-2 justify-end">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="22"
|
||||
value={form.port ?? ""}
|
||||
onChange={(e) =>
|
||||
update("port", e.target.value ? Number(e.target.value) : undefined)
|
||||
}
|
||||
className="h-8 flex-1 min-w-0 text-center"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.port")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
placeholder={t("hostDetails.username.placeholder")}
|
||||
value={form.username || ""}
|
||||
onChange={(e) => update("username", e.target.value || undefined)}
|
||||
className="h-10"
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder={t("hostDetails.password.placeholder")}
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={form.password || ""}
|
||||
onChange={(e) => update("password", e.target.value || undefined)}
|
||||
className="h-10 pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Selected credential display */}
|
||||
{form.identityFileId && (
|
||||
<div className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
|
||||
{form.authMethod === "certificate" ? (
|
||||
<Shield size={14} className="text-primary" />
|
||||
) : (
|
||||
<Key size={14} className="text-primary" />
|
||||
)}
|
||||
<span className="text-sm flex-1 truncate">
|
||||
{availableKeys.find((k) => k.id === form.identityFileId)?.label || "Key"}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => {
|
||||
update("identityFileId", undefined);
|
||||
update("authMethod", undefined);
|
||||
setSelectedCredentialType(null);
|
||||
}}
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local key file paths display */}
|
||||
{!form.identityFileId && form.identityFilePaths && form.identityFilePaths.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{form.identityFilePaths.map((keyPath, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 h-8 px-2 rounded-md bg-secondary/50 border border-border/60" style={{ maxWidth: '100%' }}>
|
||||
<FileKey size={12} className="text-muted-foreground shrink-0" />
|
||||
<span className="text-xs font-mono truncate" style={{ maxWidth: '320px' }}>{keyPath}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 shrink-0"
|
||||
onClick={() => {
|
||||
const paths = (form.identityFilePaths || []).filter((_, i) => i !== idx);
|
||||
update("identityFilePaths", paths.length > 0 ? paths : undefined);
|
||||
if (paths.length === 0) update("authMethod", undefined);
|
||||
}}
|
||||
>
|
||||
<X size={10} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Credential type selection with inline popover - hidden when credential is selected */}
|
||||
{!form.identityFileId &&
|
||||
!selectedCredentialType && (
|
||||
<Popover
|
||||
open={credentialPopoverOpen}
|
||||
onOpenChange={setCredentialPopoverOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors py-1"
|
||||
>
|
||||
<Plus size={12} />
|
||||
<span>{t("hostDetails.credential.keyCertificate")}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[200px] p-1"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-secondary/80 transition-colors text-left"
|
||||
onClick={() => {
|
||||
setSelectedCredentialType("key");
|
||||
setCredentialPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<Key size={16} className="text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{t("hostDetails.credential.key")}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-secondary/80 transition-colors text-left"
|
||||
onClick={() => {
|
||||
setSelectedCredentialType("certificate");
|
||||
setCredentialPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<Shield size={16} className="text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{t("hostDetails.credential.certificate")}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-secondary/80 transition-colors text-left"
|
||||
onClick={() => {
|
||||
setSelectedCredentialType("localKeyFile");
|
||||
setCredentialPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<FileKey size={16} className="text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{t("hostDetails.credential.localKeyFile")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
{/* Key selection combobox - appears after selecting "Key" type */}
|
||||
{selectedCredentialType === "key" &&
|
||||
!form.identityFileId && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Combobox
|
||||
options={keysByCategory.key.map((k) => ({
|
||||
value: k.id,
|
||||
label: k.label,
|
||||
sublabel: `${k.type}${k.keySize ? ` ${k.keySize}` : ""}`,
|
||||
icon: <Key size={14} className="text-muted-foreground" />,
|
||||
}))}
|
||||
value={form.identityFileId}
|
||||
onValueChange={(val) => {
|
||||
update("identityFileId", val);
|
||||
update("authMethod", "key");
|
||||
setSelectedCredentialType(null);
|
||||
}}
|
||||
placeholder={t("hostDetails.keys.search")}
|
||||
emptyText={t("hostDetails.keys.empty")}
|
||||
icon={<Key size={14} className="text-muted-foreground" />}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setSelectedCredentialType(null)}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Certificate selection combobox - appears after selecting "Certificate" type */}
|
||||
{selectedCredentialType === "certificate" &&
|
||||
!form.identityFileId && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Combobox
|
||||
options={keysByCategory.certificate.map((k) => ({
|
||||
value: k.id,
|
||||
label: k.label,
|
||||
icon: (
|
||||
<Shield size={14} className="text-muted-foreground" />
|
||||
),
|
||||
}))}
|
||||
value={form.identityFileId}
|
||||
onValueChange={(val) => {
|
||||
update("identityFileId", val);
|
||||
update("authMethod", "certificate");
|
||||
setSelectedCredentialType(null);
|
||||
}}
|
||||
placeholder={t("hostDetails.certs.search")}
|
||||
emptyText={t("hostDetails.certs.empty")}
|
||||
icon={
|
||||
<Shield size={14} className="text-muted-foreground" />
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setSelectedCredentialType(null)}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local key file path input - appears after selecting "Local Key File" type */}
|
||||
{!form.identityFileId && selectedCredentialType === "localKeyFile" && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1 w-full">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 w-0 h-8 px-2 text-xs font-mono bg-background border border-border/60 rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
placeholder={t("hostDetails.credential.localKeyFilePlaceholder")}
|
||||
value={newKeyFilePath}
|
||||
onChange={(e) => setNewKeyFilePath(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && newKeyFilePath.trim()) {
|
||||
e.preventDefault();
|
||||
const paths = [...(form.identityFilePaths || []), newKeyFilePath.trim()];
|
||||
update("identityFilePaths", paths);
|
||||
update("identityFileId", undefined);
|
||||
update("authMethod", "key");
|
||||
setNewKeyFilePath("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={async () => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
if (!bridge?.selectFile) return;
|
||||
const filePath = await bridge.selectFile(
|
||||
"Select SSH Private Key",
|
||||
undefined,
|
||||
[{ name: "All Files", extensions: ["*"] }]
|
||||
);
|
||||
if (filePath) {
|
||||
const paths = [...(form.identityFilePaths || []), filePath];
|
||||
update("identityFilePaths", paths);
|
||||
update("identityFileId", undefined);
|
||||
update("authMethod", "key");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("hostDetails.credential.browseKeyFile")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setSelectedCredentialType(null)}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ToggleRow
|
||||
label={t("hostDetails.agentForwarding")}
|
||||
hint={t("hostDetails.agentForwarding.desc")}
|
||||
enabled={!!form.agentForwarding}
|
||||
onToggle={() => update("agentForwarding", !form.agentForwarding)}
|
||||
/>
|
||||
|
||||
{/* Startup Command — Textarea so multi-line sequences are typeable
|
||||
here just like on the per-host details panel (#1083 follow-up). */}
|
||||
<Textarea
|
||||
placeholder={t("hostDetails.startupCommand.placeholder")}
|
||||
value={form.startupCommand || ""}
|
||||
onChange={(e) => update("startupCommand", e.target.value || undefined)}
|
||||
className="min-h-[80px] font-mono text-sm"
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
{/* Display the *effective* value (this group's field falling
|
||||
back to the resolved parent default). Same rationale as
|
||||
in HostDetailsPanel — without the fallback, a child group
|
||||
that inherits a flag from a parent would show "off" in
|
||||
the UI while connections still applied it. */}
|
||||
<ToggleRow
|
||||
label={t("hostDetails.legacyAlgorithms")}
|
||||
hint={t("hostDetails.legacyAlgorithms.desc")}
|
||||
enabled={!!(form.legacyAlgorithms ?? inheritedLegacyAlgorithms)}
|
||||
onToggle={() => update(
|
||||
"legacyAlgorithms",
|
||||
!(form.legacyAlgorithms ?? inheritedLegacyAlgorithms),
|
||||
)}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
label={t("hostDetails.skipEcdsaHostKey")}
|
||||
hint={t("hostDetails.skipEcdsaHostKey.desc")}
|
||||
enabled={!!(form.skipEcdsaHostKey ?? inheritedSkipEcdsaHostKey)}
|
||||
onToggle={() => update(
|
||||
"skipEcdsaHostKey",
|
||||
!(form.skipEcdsaHostKey ?? inheritedSkipEcdsaHostKey),
|
||||
)}
|
||||
/>
|
||||
<Collapsible open={showAlgorithmOverrides} onOpenChange={setShowAlgorithmOverrides}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-between h-8 px-2 hover:bg-accent/50"
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("hostDetails.algorithms.advanced")}
|
||||
{form.algorithms && Object.keys(form.algorithms).length > 0 && (
|
||||
<span className="ml-1.5 text-[10px] text-yellow-600 dark:text-yellow-400">
|
||||
({t("hostDetails.algorithms.customized")})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{showAlgorithmOverrides
|
||||
? <ChevronUp size={14} className="text-muted-foreground" />
|
||||
: <ChevronDown size={14} className="text-muted-foreground" />}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2">
|
||||
<AlgorithmOverridesPanel
|
||||
value={form.algorithms}
|
||||
legacyEnabled={!!(form.legacyAlgorithms ?? inheritedLegacyAlgorithms)}
|
||||
inheritedFromGroup={inheritedAlgorithmOverrides}
|
||||
onChange={(next) => update("algorithms", next)}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Proxy */}
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex min-h-12 items-center justify-between gap-3 rounded-lg border border-border/60 bg-secondary/40 px-3 py-2 transition-colors hover:bg-secondary/70"
|
||||
onClick={() => setActiveSubPanel("proxy")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe size={14} className="text-muted-foreground" />
|
||||
<span className="text-sm">{t("hostDetails.proxy")}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{(form.proxyConfig?.host || form.proxyProfileId) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="min-w-0 cursor-default">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="max-w-[160px] truncate text-xs"
|
||||
>
|
||||
{proxySummaryLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{proxySummaryLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<ChevronRight size={14} className="text-muted-foreground" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Host Chaining */}
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex min-h-12 items-center justify-between gap-3 rounded-lg border border-border/60 bg-secondary/40 px-3 py-2 transition-colors hover:bg-secondary/70"
|
||||
onClick={() => setActiveSubPanel("chain")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 size={14} className="text-muted-foreground" />
|
||||
<span className="text-sm">{t("hostDetails.jumpHosts")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{chainedHosts.length > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronRight size={14} className="text-muted-foreground" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Environment Variables */}
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex min-h-12 items-center justify-between gap-3 rounded-lg border border-border/60 bg-secondary/40 px-3 py-2 transition-colors hover:bg-secondary/70"
|
||||
onClick={() => setActiveSubPanel("env-vars")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Variable size={14} className="text-muted-foreground" />
|
||||
<span className="text-sm">{t("hostDetails.envVars")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{(form.environmentVariables?.length || 0) > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{form.environmentVariables!.length}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronRight size={14} className="text-muted-foreground" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Mosh */}
|
||||
<ToggleRow
|
||||
label="Mosh"
|
||||
enabled={!!form.moshEnabled}
|
||||
onToggle={() => update("moshEnabled", !form.moshEnabled)}
|
||||
/>
|
||||
{form.moshEnabled && (
|
||||
<Input
|
||||
placeholder={t("hostDetails.moshServerPath") || "mosh-server path"}
|
||||
value={form.moshServerPath || ""}
|
||||
onChange={(e) => update("moshServerPath", e.target.value || undefined)}
|
||||
className="h-10"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Backspace behavior — terminal input mapping, lives at the
|
||||
bottom of the SSH section so it doesn't get visually
|
||||
grouped with the algorithm controls above. */}
|
||||
<HostDetailsSettingRow label={t("hostDetails.backspaceBehavior")}>
|
||||
<Select
|
||||
value={form.backspaceBehavior ?? "default"}
|
||||
onValueChange={(v) => update("backspaceBehavior", v === "default" ? undefined : v)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-auto text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">{t("hostDetails.backspaceBehavior.default")}</SelectItem>
|
||||
<SelectItem value="ctrl-h">^H (0x08)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</HostDetailsSettingRow>
|
||||
</HostDetailsSection>
|
||||
);
|
||||
};
|
||||
583
components/HostDetailsAdvancedSections.tsx
Normal file
583
components/HostDetailsAdvancedSections.tsx
Normal file
@@ -0,0 +1,583 @@
|
||||
import React from "react";
|
||||
import { AlertTriangle, ChevronDown, ChevronUp, Forward, Globe, HeartPulse, Link2, Palette, Plus, Router, ShieldAlert, TerminalSquare, Wifi, X, Variable } from "lucide-react";
|
||||
import { customThemeStore } from "../application/state/customThemeStore";
|
||||
import { clearHostFontSizeOverride, clearHostThemeOverride } from "../domain/terminalAppearance";
|
||||
import { MAX_FONT_SIZE, MIN_FONT_SIZE } from "../infrastructure/config/fonts";
|
||||
import { AlgorithmOverridesPanel } from "./host-details/AlgorithmOverridesPanel";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { HostDetailsSection, HostDetailsSettingRow } from "./host-details";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
|
||||
import { Input } from "./ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||
import { Switch } from "./ui/switch";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type HostDetailsAdvancedSectionsProps = Record<string, any>;
|
||||
|
||||
const ToggleRow: React.FC<{ label: string; hint?: React.ReactNode; enabled: boolean; onToggle: () => void }> = ({ label, hint, enabled, onToggle }) => {
|
||||
return (
|
||||
<HostDetailsSettingRow label={label} hint={hint}>
|
||||
<Switch checked={enabled} onCheckedChange={() => onToggle()} />
|
||||
</HostDetailsSettingRow>
|
||||
);
|
||||
};
|
||||
|
||||
export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsProps> = ({
|
||||
t,
|
||||
form,
|
||||
setForm,
|
||||
update,
|
||||
effectiveThemeId,
|
||||
hasEffectiveThemeOverride,
|
||||
effectiveFontSize,
|
||||
hasEffectiveFontSizeOverride,
|
||||
sshAgentStatus,
|
||||
effectiveGroupDefaults,
|
||||
showAlgorithmOverrides,
|
||||
setShowAlgorithmOverrides,
|
||||
chainedHosts,
|
||||
setActiveSubPanel,
|
||||
clearHostChain,
|
||||
proxySummaryType,
|
||||
proxySummaryLabel,
|
||||
proxySummaryTooltip,
|
||||
clearProxyConfig,
|
||||
groupDefaults,
|
||||
}) => (
|
||||
<>
|
||||
<HostDetailsSection
|
||||
icon={<Palette size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.appearance")}
|
||||
>
|
||||
|
||||
{/* SSH Theme Selection */}
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-3 p-2 rounded-lg bg-secondary/50 hover:bg-secondary transition-colors text-left"
|
||||
onClick={() => setActiveSubPanel("theme-select")}
|
||||
>
|
||||
<div
|
||||
className="w-12 h-8 rounded-md border border-border/60 flex items-center justify-center text-[6px] font-mono overflow-hidden"
|
||||
style={{
|
||||
backgroundColor:
|
||||
customThemeStore.getThemeById(effectiveThemeId)?.colors.background || "#100F0F",
|
||||
color:
|
||||
customThemeStore.getThemeById(effectiveThemeId)?.colors.foreground || "#CECDC3",
|
||||
}}
|
||||
>
|
||||
<div className="p-0.5">
|
||||
<div
|
||||
style={{
|
||||
color: customThemeStore.getThemeById(effectiveThemeId)?.colors.green,
|
||||
}}
|
||||
>
|
||||
$
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm flex-1">
|
||||
{customThemeStore.getThemeById(effectiveThemeId)?.name || "Flexoki Dark"}
|
||||
</span>
|
||||
</button>
|
||||
{hasEffectiveThemeOverride && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-primary"
|
||||
onClick={() => setForm((prev) => clearHostThemeOverride(prev))}
|
||||
>
|
||||
{t("common.useGlobal")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Font Size */}
|
||||
<HostDetailsSettingRow label="Font Size">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (effectiveFontSize > MIN_FONT_SIZE) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
fontSize: effectiveFontSize - 1,
|
||||
fontSizeOverride: true,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
disabled={effectiveFontSize <= MIN_FONT_SIZE}
|
||||
className="h-8 w-8 px-0"
|
||||
>
|
||||
-
|
||||
</Button>
|
||||
<Input
|
||||
type="number"
|
||||
min={MIN_FONT_SIZE}
|
||||
max={MAX_FONT_SIZE}
|
||||
value={effectiveFontSize}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
if (val >= MIN_FONT_SIZE && val <= MAX_FONT_SIZE) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
fontSize: val,
|
||||
fontSizeOverride: true,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
className="h-8 w-16 text-center"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">pt</span>
|
||||
{hasEffectiveFontSizeOverride && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-primary"
|
||||
onClick={() => setForm((prev) => clearHostFontSizeOverride(prev))}
|
||||
>
|
||||
{t("common.useGlobal")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (effectiveFontSize < MAX_FONT_SIZE) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
fontSize: effectiveFontSize + 1,
|
||||
fontSizeOverride: true,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
disabled={effectiveFontSize >= MAX_FONT_SIZE}
|
||||
className="h-8 w-8 px-0"
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
</div>
|
||||
</HostDetailsSettingRow>
|
||||
</HostDetailsSection>
|
||||
|
||||
<HostDetailsSection
|
||||
icon={<Wifi size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.mosh")}
|
||||
>
|
||||
<ToggleRow
|
||||
label="Mosh"
|
||||
enabled={!!form.moshEnabled}
|
||||
onToggle={() => {
|
||||
const enabling = !form.moshEnabled;
|
||||
if (enabling) {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
moshEnabled: true,
|
||||
deviceType: prev.deviceType === 'network' ? undefined : prev.deviceType,
|
||||
x11Forwarding: undefined,
|
||||
}));
|
||||
} else {
|
||||
update("moshEnabled", false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</HostDetailsSection>
|
||||
|
||||
{/* Agent Forwarding */}
|
||||
<HostDetailsSection
|
||||
icon={<Forward size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.agentForwarding")}
|
||||
>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.agentForwarding")}
|
||||
hint={t("hostDetails.agentForwarding.desc")}
|
||||
enabled={!!form.agentForwarding}
|
||||
onToggle={() => update("agentForwarding", !form.agentForwarding)}
|
||||
/>
|
||||
{form.agentForwarding && sshAgentStatus && !sshAgentStatus.running && (
|
||||
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
|
||||
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 font-medium">
|
||||
{t("hostDetails.agentForwarding.agentNotRunning")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.agentForwarding.agentNotRunningHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</HostDetailsSection>
|
||||
|
||||
{/* X11 Forwarding */}
|
||||
{(!form.protocol || form.protocol === "ssh") && !form.moshEnabled && (
|
||||
<HostDetailsSection
|
||||
icon={<TerminalSquare size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.x11Forwarding")}
|
||||
>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.x11Forwarding")}
|
||||
hint={t("hostDetails.x11Forwarding.desc")}
|
||||
enabled={!!form.x11Forwarding}
|
||||
onToggle={() => update("x11Forwarding", !form.x11Forwarding)}
|
||||
/>
|
||||
</HostDetailsSection>
|
||||
)}
|
||||
|
||||
{/* Network Device Mode — only for SSH hosts without Mosh (serial already uses raw mode) */}
|
||||
{(!form.protocol || form.protocol === 'ssh') && !form.moshEnabled && (
|
||||
<HostDetailsSection
|
||||
icon={<Router size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.deviceType")}
|
||||
>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.deviceType")}
|
||||
hint={t("hostDetails.deviceType.desc")}
|
||||
enabled={form.deviceType === 'network'}
|
||||
onToggle={() => update("deviceType", form.deviceType === 'network' ? undefined : 'network')}
|
||||
/>
|
||||
{form.deviceType === 'network' && (
|
||||
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
|
||||
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 break-words">
|
||||
{t("hostDetails.deviceType.warning")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</HostDetailsSection>
|
||||
)}
|
||||
|
||||
{/* SSH Algorithms */}
|
||||
<HostDetailsSection
|
||||
icon={<ShieldAlert size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.sshAlgorithms")}
|
||||
>
|
||||
{/* Display the *effective* value of these toggles (host field
|
||||
falling back to the resolved group default). Without the
|
||||
fallback a host that inherits the flag from its group would
|
||||
show "off" while the runtime applied it anyway, and the
|
||||
toggle's onToggle handler would compute the wrong "next"
|
||||
value from the raw host field. */}
|
||||
<ToggleRow
|
||||
label={t("hostDetails.legacyAlgorithms")}
|
||||
hint={t("hostDetails.legacyAlgorithms.desc")}
|
||||
enabled={!!(form.legacyAlgorithms ?? effectiveGroupDefaults?.legacyAlgorithms)}
|
||||
onToggle={() => update(
|
||||
"legacyAlgorithms",
|
||||
!(form.legacyAlgorithms ?? effectiveGroupDefaults?.legacyAlgorithms),
|
||||
)}
|
||||
/>
|
||||
{(form.legacyAlgorithms ?? effectiveGroupDefaults?.legacyAlgorithms) && (
|
||||
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
|
||||
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 break-words">
|
||||
{t("hostDetails.legacyAlgorithms.warning")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<ToggleRow
|
||||
label={t("hostDetails.skipEcdsaHostKey")}
|
||||
hint={t("hostDetails.skipEcdsaHostKey.desc")}
|
||||
enabled={!!(form.skipEcdsaHostKey ?? effectiveGroupDefaults?.skipEcdsaHostKey)}
|
||||
onToggle={() => update(
|
||||
"skipEcdsaHostKey",
|
||||
!(form.skipEcdsaHostKey ?? effectiveGroupDefaults?.skipEcdsaHostKey),
|
||||
)}
|
||||
/>
|
||||
<Collapsible open={showAlgorithmOverrides} onOpenChange={setShowAlgorithmOverrides}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-between h-8 px-2 hover:bg-accent/50"
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("hostDetails.algorithms.advanced")}
|
||||
{form.algorithms && Object.keys(form.algorithms).length > 0 && (
|
||||
<span className="ml-1.5 text-[10px] text-yellow-600 dark:text-yellow-400">
|
||||
({t("hostDetails.algorithms.customized")})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{showAlgorithmOverrides
|
||||
? <ChevronUp size={14} className="text-muted-foreground" />
|
||||
: <ChevronDown size={14} className="text-muted-foreground" />}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2">
|
||||
<AlgorithmOverridesPanel
|
||||
value={form.algorithms}
|
||||
/* Use the effective legacy flag (host value falling back to
|
||||
the currently selected group's default) so the seed
|
||||
reflects what the host would actually advertise. We
|
||||
read from `effectiveGroupDefaults` (re-resolved on
|
||||
every form.group change), not the `groupDefaults` prop
|
||||
— otherwise switching the host into a different group
|
||||
without saving first would seed from the original
|
||||
group's flag and silently mis-populate the override. */
|
||||
legacyEnabled={!!(form.legacyAlgorithms ?? effectiveGroupDefaults?.legacyAlgorithms)}
|
||||
inheritedFromGroup={effectiveGroupDefaults?.algorithms}
|
||||
onChange={(next) => update("algorithms", next)}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</HostDetailsSection>
|
||||
|
||||
{/* Terminal Behavior — input/output key mappings (backspace, etc.) */}
|
||||
<HostDetailsSection
|
||||
icon={<TerminalSquare size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.terminalBehavior")}
|
||||
>
|
||||
<HostDetailsSettingRow label={t("hostDetails.backspaceBehavior")}>
|
||||
<Select
|
||||
value={form.backspaceBehavior ?? "default"}
|
||||
onValueChange={(v) => update("backspaceBehavior", v === "default" ? undefined : v)}
|
||||
>
|
||||
<SelectTrigger className="h-10 w-36 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">{t("hostDetails.backspaceBehavior.default")}</SelectItem>
|
||||
<SelectItem value="ctrl-h">^H (0x08)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</HostDetailsSettingRow>
|
||||
</HostDetailsSection>
|
||||
|
||||
{/* Per-host keepalive override */}
|
||||
<HostDetailsSection
|
||||
icon={<HeartPulse size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.keepalive")}
|
||||
>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.keepalive.override")}
|
||||
hint={t("hostDetails.keepalive.desc")}
|
||||
enabled={!!form.keepaliveOverride}
|
||||
onToggle={() => {
|
||||
const next = !form.keepaliveOverride;
|
||||
update("keepaliveOverride", next);
|
||||
// Seed sensible per-host defaults the first time the user
|
||||
// turns the override on so the inputs aren't empty.
|
||||
if (next) {
|
||||
if (form.keepaliveInterval == null) update("keepaliveInterval", 0);
|
||||
if (form.keepaliveCountMax == null) update("keepaliveCountMax", 3);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{form.keepaliveOverride && (
|
||||
<div className="space-y-2 pt-1">
|
||||
<HostDetailsSettingRow label={t("hostDetails.keepalive.interval")}>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={3600}
|
||||
className="h-8 w-24 text-xs"
|
||||
value={form.keepaliveInterval ?? 0}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value, 10);
|
||||
if (!Number.isFinite(v)) return;
|
||||
if (v < 0 || v > 3600) return;
|
||||
update("keepaliveInterval", v);
|
||||
}}
|
||||
/>
|
||||
</HostDetailsSettingRow>
|
||||
<HostDetailsSettingRow label={t("hostDetails.keepalive.countMax")}>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
className="h-8 w-24 text-xs"
|
||||
value={form.keepaliveCountMax ?? 3}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value, 10);
|
||||
if (!Number.isFinite(v)) return;
|
||||
if (v < 1 || v > 100) return;
|
||||
update("keepaliveCountMax", v);
|
||||
}}
|
||||
/>
|
||||
</HostDetailsSettingRow>
|
||||
{(form.keepaliveInterval ?? 0) === 0 && (
|
||||
<p className="text-xs text-muted-foreground break-words pl-1">
|
||||
{t("hostDetails.keepalive.disabledHint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</HostDetailsSection>
|
||||
|
||||
{/* Proxy via Hosts (Jump Hosts / ProxyJump) */}
|
||||
<HostDetailsSection
|
||||
icon={<Link2 size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.jumpHosts")}
|
||||
action={
|
||||
chainedHosts.length > 0 ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.jumpHosts.direct")}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
>
|
||||
{chainedHosts.length > 0 && (
|
||||
<button
|
||||
className="w-full flex flex-col items-start gap-1 p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer"
|
||||
onClick={() => setActiveSubPanel("chain")}
|
||||
>
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<div className="flex items-center gap-1 min-w-0 flex-1">
|
||||
<Link2
|
||||
size={14}
|
||||
className="text-muted-foreground flex-shrink-0"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
|
||||
</span>
|
||||
</div>
|
||||
<X
|
||||
size={14}
|
||||
className="text-muted-foreground hover:text-destructive flex-shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
clearHostChain();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full space-y-1 pl-5">
|
||||
{chainedHosts.slice(0, 5).map((h, idx) => (
|
||||
<div key={h.id} className="flex items-center gap-1 text-sm">
|
||||
<span className="text-muted-foreground">{idx + 1}.</span>
|
||||
<span className="truncate">
|
||||
{h.label !== h.hostname ? `${h.hostname} (${h.label})` : h.hostname}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{chainedHosts.length > 5 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
+{chainedHosts.length - 5} more...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
{chainedHosts.length === 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full h-9 justify-start gap-2 text-sm"
|
||||
onClick={() => setActiveSubPanel("chain")}
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("hostDetails.jumpHosts.configure")}
|
||||
</Button>
|
||||
)}
|
||||
</HostDetailsSection>
|
||||
|
||||
{/* Proxy Configuration */}
|
||||
<HostDetailsSection
|
||||
icon={<Globe size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.proxy")}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
{form.proxyConfig?.host || form.proxyProfileId ? (
|
||||
<div className="w-full min-w-0 grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 grid grid-cols-[auto_minmax(0,1fr)] items-center gap-2 p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer overflow-hidden"
|
||||
onClick={() => setActiveSubPanel("proxy")}
|
||||
>
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{proxySummaryType}
|
||||
</Badge>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm">
|
||||
{proxySummaryLabel}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start" className="max-w-xs break-all">
|
||||
{proxySummaryTooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 text-muted-foreground hover:text-destructive shrink-0"
|
||||
aria-label={t("hostDetails.proxyPanel.remove")}
|
||||
onClick={clearProxyConfig}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full h-9 justify-start gap-2 text-sm"
|
||||
onClick={() => setActiveSubPanel("proxy")}
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("hostDetails.proxy.configure")}
|
||||
</Button>
|
||||
)}
|
||||
</HostDetailsSection>
|
||||
|
||||
{/* Environment Variables */}
|
||||
<HostDetailsSection
|
||||
icon={<Variable size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.envVars")}
|
||||
>
|
||||
{(form.environmentVariables?.length || 0) > 0 ? (
|
||||
<button
|
||||
className="w-full flex items-center gap-1 p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer"
|
||||
onClick={() => setActiveSubPanel("env-vars")}
|
||||
>
|
||||
<span className="text-sm truncate">
|
||||
{form.environmentVariables
|
||||
?.slice(0, 2)
|
||||
.map((v) => `${v.name}=${v.value}`)
|
||||
.join(", ")}
|
||||
{(form.environmentVariables?.length || 0) > 2 && "..."}
|
||||
</span>
|
||||
<X
|
||||
size={14}
|
||||
className="text-muted-foreground hover:text-destructive flex-shrink-0 ml-auto"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setForm((prev) => ({ ...prev, environmentVariables: undefined }));
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full h-9 justify-start gap-2 text-sm"
|
||||
onClick={() => setActiveSubPanel("env-vars")}
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("hostDetails.envVars.add")}
|
||||
</Button>
|
||||
)}
|
||||
</HostDetailsSection>
|
||||
|
||||
{/* Startup Command */}
|
||||
<HostDetailsSection
|
||||
icon={<TerminalSquare size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.startupCommand")}
|
||||
hint={t("hostDetails.startupCommand.help")}
|
||||
>
|
||||
<Textarea
|
||||
placeholder={groupDefaults?.startupCommand || t("hostDetails.startupCommand.placeholder")}
|
||||
value={form.startupCommand || ""}
|
||||
onChange={(e) => update("startupCommand", e.target.value)}
|
||||
className="min-h-[80px] font-mono text-sm"
|
||||
rows={3}
|
||||
/>
|
||||
</HostDetailsSection>
|
||||
</>
|
||||
);
|
||||
735
components/HostDetailsConnectionSections.tsx
Normal file
735
components/HostDetailsConnectionSections.tsx
Normal file
@@ -0,0 +1,735 @@
|
||||
import React from "react";
|
||||
import { ChevronDown, Eye, EyeOff, FileKey, FolderLock, FolderOpen, Key, KeyRound, MapPin, Plus, Shield, Trash2, User, X } from "lucide-react";
|
||||
import type { Host } from "../types";
|
||||
import { cn } from "../lib/utils";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import { Button } from "./ui/button";
|
||||
import { Combobox } from "./ui/combobox";
|
||||
import { HostDetailsSection, HostDetailsSettingRow } from "./host-details";
|
||||
import { Input } from "./ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||
import { Switch } from "./ui/switch";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type HostDetailsConnectionSectionsProps = Record<string, any>;
|
||||
|
||||
export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectionsProps> = ({
|
||||
t,
|
||||
form,
|
||||
update,
|
||||
groupDefaults,
|
||||
selectedIdentity,
|
||||
clearIdentity,
|
||||
identities,
|
||||
identitySuggestionsOpen,
|
||||
filteredIdentitySuggestions,
|
||||
setIdentitySuggestionsOpen,
|
||||
availableKeys,
|
||||
applyIdentity,
|
||||
showPassword,
|
||||
setShowPassword,
|
||||
pendingReferenceKeyPath,
|
||||
setPendingReferenceKeyPath,
|
||||
selectedCredentialType,
|
||||
setSelectedCredentialType,
|
||||
credentialPopoverOpen,
|
||||
setCredentialPopoverOpen,
|
||||
keysByCategory,
|
||||
newKeyFilePath,
|
||||
setNewKeyFilePath,
|
||||
addLocalKeyFilePath,
|
||||
handleDistroModeChange,
|
||||
distroOptions,
|
||||
effectiveFormDistro,
|
||||
getDistroOptionLabel,
|
||||
}) => (
|
||||
<>
|
||||
<HostDetailsSection
|
||||
icon={<MapPin size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.address")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<DistroAvatar
|
||||
host={form as Host}
|
||||
fallback={
|
||||
form.label?.slice(0, 2).toUpperCase() ||
|
||||
form.hostname?.slice(0, 2).toUpperCase() ||
|
||||
"H"
|
||||
}
|
||||
className="h-10 w-10"
|
||||
/>
|
||||
<Input
|
||||
placeholder={t("hostDetails.hostname.placeholder")}
|
||||
value={form.hostname}
|
||||
onChange={(e) => update("hostname", e.target.value)}
|
||||
className="h-10 flex-1"
|
||||
/>
|
||||
</div>
|
||||
</HostDetailsSection>
|
||||
|
||||
<HostDetailsSection
|
||||
icon={<KeyRound size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.portCredentials")}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0 h-10 flex items-center gap-2 bg-secondary/70 border border-border/70 rounded-md px-3">
|
||||
<span className="text-xs text-muted-foreground">SSH on</span>
|
||||
<div className="ml-auto w-1/2 min-w-0 flex items-center gap-2 justify-end">
|
||||
<Input
|
||||
type="number"
|
||||
value={form.port ?? ""}
|
||||
onChange={(e) => update("port", e.target.value ? Number(e.target.value) : undefined)}
|
||||
placeholder={groupDefaults?.port ? String(groupDefaults.port) : "22"}
|
||||
className="h-8 flex-1 min-w-0 text-center"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.port")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{selectedIdentity ? (
|
||||
<div className="flex items-center gap-2 h-10 px-3 rounded-md border border-border/70 bg-secondary/60">
|
||||
<User size={16} className="text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{selectedIdentity.label}
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={clearIdentity}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("common.clear")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : form.identityId ? (
|
||||
<div className="flex items-center gap-2 h-10 px-3 rounded-md border border-border/70 bg-secondary/60">
|
||||
<User size={16} className="text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{t("hostDetails.identity.missing")}
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={clearIdentity}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("common.clear")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
(() => {
|
||||
const hasIdentities = identities.length > 0;
|
||||
if (!hasIdentities) {
|
||||
return (
|
||||
<Input
|
||||
placeholder={groupDefaults?.username || t("hostDetails.username.placeholder")}
|
||||
value={form.username}
|
||||
onChange={(e) => update("username", e.target.value)}
|
||||
className="h-10"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={
|
||||
identitySuggestionsOpen &&
|
||||
filteredIdentitySuggestions.length > 0
|
||||
}
|
||||
onOpenChange={setIdentitySuggestionsOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder={groupDefaults?.username || t("hostDetails.username.placeholder")}
|
||||
value={form.username}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
update("username", next);
|
||||
const q = next.toLowerCase().trim();
|
||||
const matches = q
|
||||
? identities.filter(
|
||||
(i) =>
|
||||
i.label.toLowerCase().includes(q) ||
|
||||
i.username.toLowerCase().includes(q),
|
||||
)
|
||||
: identities;
|
||||
setIdentitySuggestionsOpen(matches.length > 0);
|
||||
}}
|
||||
onFocus={() => {
|
||||
const q = (form.username || "").toLowerCase().trim();
|
||||
const matches = q
|
||||
? identities.filter(
|
||||
(i) =>
|
||||
i.label.toLowerCase().includes(q) ||
|
||||
i.username.toLowerCase().includes(q),
|
||||
)
|
||||
: identities;
|
||||
setIdentitySuggestionsOpen(matches.length > 0);
|
||||
}}
|
||||
className="h-10 pr-9"
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => {
|
||||
setIdentitySuggestionsOpen((prev) => {
|
||||
if (prev) return false;
|
||||
const q = (form.username || "")
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
const matches = q
|
||||
? identities.filter(
|
||||
(i) =>
|
||||
i.label.toLowerCase().includes(q) ||
|
||||
i.username.toLowerCase().includes(q),
|
||||
)
|
||||
: identities;
|
||||
return matches.length > 0;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("hostDetails.identity.suggestions")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0 border-border/60"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
>
|
||||
<ScrollArea className="max-h-[280px]">
|
||||
<div className="p-1">
|
||||
{filteredIdentitySuggestions.length === 0 ? (
|
||||
<div className="py-4 text-center text-sm text-muted-foreground">
|
||||
{t("common.noResultsFound")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{filteredIdentitySuggestions.map((identity) => {
|
||||
const keyLabel = identity.keyId
|
||||
? availableKeys.find(
|
||||
(k) => k.id === identity.keyId,
|
||||
)?.label
|
||||
: undefined;
|
||||
const methodLabel =
|
||||
identity.authMethod === "certificate"
|
||||
? t("hostDetails.credential.certificate")
|
||||
: identity.authMethod === "key"
|
||||
? t("hostDetails.credential.key")
|
||||
: t("keychain.identity.method.passwordOnly");
|
||||
const summaryParts = [
|
||||
identity.username,
|
||||
identity.password ? "******" : undefined,
|
||||
keyLabel,
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={identity.id}
|
||||
type="button"
|
||||
className="w-full flex items-center gap-3 px-3 py-2 rounded-md hover:bg-secondary/80 transition-colors text-left"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
applyIdentity(identity);
|
||||
}}
|
||||
>
|
||||
<div className="h-8 w-8 rounded-md bg-green-500/15 text-green-500 flex items-center justify-center shrink-0">
|
||||
<User size={16} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{identity.label}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{methodLabel}
|
||||
{summaryParts.length
|
||||
? ` - ${summaryParts.join(", ")}`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
|
||||
{!selectedIdentity && !form.identityId && (
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder={t("hostDetails.password.placeholder")}
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={form.password || ""}
|
||||
onChange={(e) => update("password", e.target.value)}
|
||||
className="h-10 pr-10"
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{showPassword ? t("hostDetails.password.hide") : t("hostDetails.password.show")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save Password toggle - shown when password is entered */}
|
||||
{!selectedIdentity && !form.identityId && form.password && (
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.password.save")}
|
||||
</span>
|
||||
<Switch
|
||||
checked={form.savePassword ?? true}
|
||||
onCheckedChange={(val) => update("savePassword" as keyof Host, val)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local key file paths display */}
|
||||
{!selectedIdentity && !form.identityFileId && form.identityFilePaths && form.identityFilePaths.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{form.identityFilePaths.map((keyPath, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60 overflow-hidden">
|
||||
<FileKey size={14} className="text-primary shrink-0" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-xs w-0 flex-1 truncate font-mono cursor-default">
|
||||
{keyPath}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{keyPath}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={() => {
|
||||
const paths = form.identityFilePaths?.filter((_, i) => i !== idx) || [];
|
||||
update("identityFilePaths", paths.length > 0 ? paths : undefined);
|
||||
if (keyPath === pendingReferenceKeyPath) {
|
||||
setPendingReferenceKeyPath(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selected credential display */}
|
||||
{!selectedIdentity && form.identityFileId && (
|
||||
<div className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
|
||||
{form.authMethod === "certificate" ? (
|
||||
<Shield size={14} className="text-primary" />
|
||||
) : (
|
||||
<Key size={14} className="text-primary" />
|
||||
)}
|
||||
<span className="text-sm flex-1 truncate">
|
||||
{availableKeys.find((k) => k.id === form.identityFileId)
|
||||
?.label || "Key"}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => {
|
||||
update("identityFileId", undefined);
|
||||
update("authMethod", "password");
|
||||
setPendingReferenceKeyPath(null);
|
||||
setSelectedCredentialType(null);
|
||||
}}
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Credential type selection with inline popover - hidden when credential is selected */}
|
||||
{!selectedIdentity &&
|
||||
!form.identityFileId &&
|
||||
!selectedCredentialType && (
|
||||
<Popover
|
||||
open={credentialPopoverOpen}
|
||||
onOpenChange={setCredentialPopoverOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors py-1"
|
||||
>
|
||||
<Plus size={12} />
|
||||
<span>{t("hostDetails.credential.keyCertificate")}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[200px] p-1"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-secondary/80 transition-colors text-left"
|
||||
onClick={() => {
|
||||
setSelectedCredentialType("key");
|
||||
setCredentialPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<Key size={16} className="text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{t("hostDetails.credential.key")}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-secondary/80 transition-colors text-left"
|
||||
onClick={() => {
|
||||
setSelectedCredentialType("certificate");
|
||||
setCredentialPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<Shield size={16} className="text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{t("hostDetails.credential.certificate")}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-secondary/80 transition-colors text-left"
|
||||
onClick={() => {
|
||||
setSelectedCredentialType("localKeyFile");
|
||||
setCredentialPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<FileKey size={16} className="text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{t("hostDetails.credential.localKeyFile")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
{/* Key selection combobox - appears after selecting "Key" type */}
|
||||
{!selectedIdentity &&
|
||||
selectedCredentialType === "key" &&
|
||||
!form.identityFileId && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Combobox
|
||||
options={keysByCategory.key.map((k) => ({
|
||||
value: k.id,
|
||||
label: k.label,
|
||||
sublabel: `${k.type}${k.keySize ? ` ${k.keySize}` : ""}`,
|
||||
icon: <Key size={14} className="text-muted-foreground" />,
|
||||
}))}
|
||||
value={form.identityFileId}
|
||||
onValueChange={(val) => {
|
||||
update("identityFileId", val);
|
||||
update("authMethod", "key");
|
||||
update("identityFilePaths", undefined);
|
||||
setPendingReferenceKeyPath(null);
|
||||
setSelectedCredentialType(null);
|
||||
}}
|
||||
placeholder={t("hostDetails.keys.search")}
|
||||
emptyText={t("hostDetails.keys.empty")}
|
||||
icon={<Key size={14} className="text-muted-foreground" />}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setSelectedCredentialType(null)}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Certificate selection combobox - appears after selecting "Certificate" type */}
|
||||
{!selectedIdentity &&
|
||||
selectedCredentialType === "certificate" &&
|
||||
!form.identityFileId && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Combobox
|
||||
options={keysByCategory.certificate.map((k) => ({
|
||||
value: k.id,
|
||||
label: k.label,
|
||||
icon: (
|
||||
<Shield size={14} className="text-muted-foreground" />
|
||||
),
|
||||
}))}
|
||||
value={form.identityFileId}
|
||||
onValueChange={(val) => {
|
||||
update("identityFileId", val);
|
||||
update("authMethod", "certificate");
|
||||
update("identityFilePaths", undefined);
|
||||
setPendingReferenceKeyPath(null);
|
||||
setSelectedCredentialType(null);
|
||||
}}
|
||||
placeholder={t("hostDetails.certs.search")}
|
||||
emptyText={t("hostDetails.certs.empty")}
|
||||
icon={
|
||||
<Shield size={14} className="text-muted-foreground" />
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setSelectedCredentialType(null)}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local key file path input - appears after selecting "Local Key File" type */}
|
||||
{!selectedIdentity &&
|
||||
selectedCredentialType === "localKeyFile" &&
|
||||
!form.identityFileId && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 min-w-0 h-8 px-2 text-xs font-mono bg-background border border-border/60 rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
placeholder={t("hostDetails.credential.localKeyFilePlaceholder")}
|
||||
value={newKeyFilePath}
|
||||
onChange={(e) => setNewKeyFilePath(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && newKeyFilePath.trim()) {
|
||||
e.preventDefault();
|
||||
addLocalKeyFilePath(newKeyFilePath);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={async () => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
if (!bridge?.selectFile) return;
|
||||
const filePath = await bridge.selectFile(
|
||||
"Select SSH Private Key",
|
||||
undefined,
|
||||
[{ name: "All Files", extensions: ["*"] }]
|
||||
);
|
||||
if (filePath) {
|
||||
addLocalKeyFilePath(filePath);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("hostDetails.credential.browseKeyFile")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => {
|
||||
setSelectedCredentialType(null);
|
||||
setNewKeyFilePath("");
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</HostDetailsSection>
|
||||
|
||||
<HostDetailsSection
|
||||
icon={<FolderLock size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.sftp")}
|
||||
>
|
||||
<HostDetailsSettingRow
|
||||
label={t("hostDetails.sftp.sudo")}
|
||||
hint={t("hostDetails.sftp.sudo.desc")}
|
||||
>
|
||||
<Switch
|
||||
checked={form.sftpSudo || false}
|
||||
onCheckedChange={(val) => update("sftpSudo", val)}
|
||||
/>
|
||||
</HostDetailsSettingRow>
|
||||
{form.sftpSudo && !form.password && !selectedIdentity?.password && (
|
||||
<p className="text-xs text-amber-500">
|
||||
{t("hostDetails.sftp.sudo.passwordWarning")}
|
||||
</p>
|
||||
)}
|
||||
<HostDetailsSettingRow
|
||||
label={t("hostDetails.sftp.encoding")}
|
||||
hint={t("hostDetails.sftp.encoding.desc")}
|
||||
>
|
||||
<Select
|
||||
value={form.sftpEncoding || "auto"}
|
||||
onValueChange={(val) => update("sftpEncoding", val as Host["sftpEncoding"])}
|
||||
>
|
||||
<SelectTrigger className="h-10 w-32">
|
||||
<SelectValue placeholder={t("sftp.encoding.label")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t("sftp.encoding.auto")}</SelectItem>
|
||||
<SelectItem value="utf-8">{t("sftp.encoding.utf8")}</SelectItem>
|
||||
<SelectItem value="gb18030">{t("sftp.encoding.gb18030")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</HostDetailsSettingRow>
|
||||
</HostDetailsSection>
|
||||
|
||||
{form.os === "linux" && (
|
||||
<HostDetailsSection
|
||||
icon={<img src="/distro/linux.svg" alt="Linux" className="h-3.5 w-3.5 opacity-70 dark:invert" />}
|
||||
title={t("hostDetails.distro.title")}
|
||||
hint={t("hostDetails.distro.desc")}
|
||||
>
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.mode")}</span>
|
||||
<Select
|
||||
value={form.distroMode || "auto"}
|
||||
onValueChange={(val) => handleDistroModeChange(val as "auto" | "manual")}
|
||||
>
|
||||
<SelectTrigger className="h-8" aria-label={t("hostDetails.distro.mode")}>
|
||||
<span className="truncate whitespace-nowrap pr-2 text-left">
|
||||
{form.distroMode === "manual"
|
||||
? t("hostDetails.distro.mode.manual")
|
||||
: t("hostDetails.distro.mode.auto")}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t("hostDetails.distro.mode.auto")}</SelectItem>
|
||||
<SelectItem value="manual">{t("hostDetails.distro.mode.manual")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{form.distroMode === "manual" ? (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.manualLabel")}</span>
|
||||
<Select
|
||||
value={form.manualDistro}
|
||||
onValueChange={(val) => update("manualDistro", val)}
|
||||
>
|
||||
<SelectTrigger className="h-8" aria-label={t("hostDetails.distro.manualLabel")}>
|
||||
{(() => {
|
||||
const selectedOption = distroOptions.find((option) => option.value === form.manualDistro);
|
||||
return selectedOption ? (
|
||||
<div className="flex min-w-0 items-center gap-2 pr-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
|
||||
selectedOption.bgClass,
|
||||
)}
|
||||
>
|
||||
{selectedOption.icon ? (
|
||||
<img
|
||||
src={selectedOption.icon}
|
||||
alt={selectedOption.label}
|
||||
className="h-3 w-3 object-contain invert brightness-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-2 w-2 rounded-full bg-white/70" />
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate whitespace-nowrap">{selectedOption.label}</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue placeholder={t("hostDetails.distro.unknown")} />
|
||||
);
|
||||
})()}
|
||||
</SelectTrigger>
|
||||
<SelectContent className="min-w-[14rem]">
|
||||
{distroOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
|
||||
option.bgClass,
|
||||
)}
|
||||
>
|
||||
{option.icon ? (
|
||||
<img
|
||||
src={option.icon}
|
||||
alt={option.label}
|
||||
className="h-3 w-3 object-contain invert brightness-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-2 w-2 rounded-full bg-white/70" />
|
||||
)}
|
||||
</div>
|
||||
<span className="whitespace-nowrap">{option.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.detectedLabel")}</span>
|
||||
<div className="flex h-8 items-center rounded-md border border-border/60 bg-background/50 px-3 text-sm">
|
||||
{effectiveFormDistro
|
||||
? getDistroOptionLabel(effectiveFormDistro)
|
||||
: t("hostDetails.distro.unknown")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</HostDetailsSection>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
46
components/HostDetailsPanel.helpers.ts
Normal file
46
components/HostDetailsPanel.helpers.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { GroupConfig } from "../domain/models";
|
||||
import type { Host } from "../types";
|
||||
import { LINUX_DISTRO_OPTIONS, NETWORK_DEVICE_OPTIONS } from "../domain/host";
|
||||
|
||||
export const parseOptionalPortInput = (value: string): number | undefined =>
|
||||
value ? Number(value) : undefined;
|
||||
|
||||
export const resolveDetailsTelnetPort = (
|
||||
host: Host,
|
||||
groupDefaults?: Partial<GroupConfig>,
|
||||
): number => {
|
||||
if (host.telnetPort !== undefined && host.telnetPort !== null) return host.telnetPort;
|
||||
if (groupDefaults?.telnetPort !== undefined && groupDefaults.telnetPort !== null) {
|
||||
return groupDefaults.telnetPort;
|
||||
}
|
||||
if (host.protocol === "telnet") {
|
||||
if (host.port !== undefined && host.port !== null) return host.port;
|
||||
if (groupDefaults?.port !== undefined && groupDefaults.port !== null) return groupDefaults.port;
|
||||
}
|
||||
return 23;
|
||||
};
|
||||
|
||||
export const resolveDetailsTelnetUsername = (
|
||||
host: Host,
|
||||
groupDefaults?: Partial<GroupConfig>,
|
||||
): string =>
|
||||
host.telnetUsername !== undefined
|
||||
? host.telnetUsername
|
||||
: groupDefaults?.telnetUsername !== undefined
|
||||
? groupDefaults.telnetUsername
|
||||
: host.username ?? groupDefaults?.username ?? "";
|
||||
|
||||
export const resolveDetailsTelnetPassword = (
|
||||
host: Host,
|
||||
groupDefaults?: Partial<GroupConfig>,
|
||||
): string =>
|
||||
host.telnetPassword !== undefined
|
||||
? host.telnetPassword
|
||||
: groupDefaults?.telnetPassword !== undefined
|
||||
? groupDefaults.telnetPassword
|
||||
: host.password ?? groupDefaults?.password ?? "";
|
||||
|
||||
export const LINUX_DISTRO_OPTION_IDS = [
|
||||
...LINUX_DISTRO_OPTIONS,
|
||||
...NETWORK_DEVICE_OPTIONS,
|
||||
];
|
||||
@@ -6,6 +6,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
|
||||
import type { Host } from "../types.ts";
|
||||
import HostDetailsPanel, { parseOptionalPortInput } from "./HostDetailsPanel.tsx";
|
||||
import { TooltipProvider } from "./ui/tooltip.tsx";
|
||||
|
||||
const hostWithMissingProxyProfile: Host = {
|
||||
id: "host-1",
|
||||
@@ -26,20 +27,24 @@ const renderHostDetails = (initialData: Host = hostWithMissingProxyProfile) =>
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(HostDetailsPanel, {
|
||||
initialData,
|
||||
availableKeys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
groups: [],
|
||||
managedSources: [],
|
||||
allTags: [],
|
||||
allHosts: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
onSave: () => {},
|
||||
onCancel: () => {},
|
||||
}),
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(HostDetailsPanel, {
|
||||
initialData,
|
||||
availableKeys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
groups: [],
|
||||
managedSources: [],
|
||||
allTags: [],
|
||||
allHosts: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
onSave: () => {},
|
||||
onCancel: () => {},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -111,29 +116,33 @@ test("HostDetailsPanel displays inherited telnet port before falling back to 23"
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(HostDetailsPanel, {
|
||||
initialData: {
|
||||
...hostWithMissingProxyProfile,
|
||||
protocol: "telnet",
|
||||
telnetEnabled: true,
|
||||
telnetPort: undefined,
|
||||
port: undefined,
|
||||
group: "network",
|
||||
proxyProfileId: undefined,
|
||||
},
|
||||
availableKeys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
groups: ["network"],
|
||||
managedSources: [],
|
||||
allTags: [],
|
||||
allHosts: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
groupConfigs: [{ path: "network", telnetPort: 2325 }],
|
||||
onSave: () => {},
|
||||
onCancel: () => {},
|
||||
}),
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(HostDetailsPanel, {
|
||||
initialData: {
|
||||
...hostWithMissingProxyProfile,
|
||||
protocol: "telnet",
|
||||
telnetEnabled: true,
|
||||
telnetPort: undefined,
|
||||
port: undefined,
|
||||
group: "network",
|
||||
proxyProfileId: undefined,
|
||||
},
|
||||
availableKeys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
groups: ["network"],
|
||||
managedSources: [],
|
||||
allTags: [],
|
||||
allHosts: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
groupConfigs: [{ path: "network", telnetPort: 2325 }],
|
||||
onSave: () => {},
|
||||
onCancel: () => {},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -145,29 +154,33 @@ test("HostDetailsPanel uses group telnet port instead of ssh port for optional t
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(HostDetailsPanel, {
|
||||
initialData: {
|
||||
...hostWithMissingProxyProfile,
|
||||
protocol: "ssh",
|
||||
telnetEnabled: true,
|
||||
telnetPort: undefined,
|
||||
port: 2222,
|
||||
group: "network",
|
||||
proxyProfileId: undefined,
|
||||
},
|
||||
availableKeys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
groups: ["network"],
|
||||
managedSources: [],
|
||||
allTags: [],
|
||||
allHosts: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
groupConfigs: [{ path: "network", telnetPort: 2325 }],
|
||||
onSave: () => {},
|
||||
onCancel: () => {},
|
||||
}),
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(HostDetailsPanel, {
|
||||
initialData: {
|
||||
...hostWithMissingProxyProfile,
|
||||
protocol: "ssh",
|
||||
telnetEnabled: true,
|
||||
telnetPort: undefined,
|
||||
port: 2222,
|
||||
group: "network",
|
||||
proxyProfileId: undefined,
|
||||
},
|
||||
availableKeys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
groups: ["network"],
|
||||
managedSources: [],
|
||||
allTags: [],
|
||||
allHosts: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
groupConfigs: [{ path: "network", telnetPort: 2325 }],
|
||||
onSave: () => {},
|
||||
onCancel: () => {},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -181,35 +194,39 @@ test("HostDetailsPanel displays inherited telnet credentials", () => {
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(HostDetailsPanel, {
|
||||
initialData: {
|
||||
...hostWithMissingProxyProfile,
|
||||
protocol: "telnet",
|
||||
telnetEnabled: true,
|
||||
telnetUsername: undefined,
|
||||
telnetPassword: undefined,
|
||||
username: "ssh-user",
|
||||
password: "ssh-password",
|
||||
group: "network",
|
||||
proxyProfileId: undefined,
|
||||
},
|
||||
availableKeys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
groups: ["network"],
|
||||
managedSources: [],
|
||||
allTags: [],
|
||||
allHosts: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
groupConfigs: [{
|
||||
path: "network",
|
||||
telnetUsername: "group-telnet-user",
|
||||
telnetPassword: "group-telnet-password",
|
||||
}],
|
||||
onSave: () => {},
|
||||
onCancel: () => {},
|
||||
}),
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(HostDetailsPanel, {
|
||||
initialData: {
|
||||
...hostWithMissingProxyProfile,
|
||||
protocol: "telnet",
|
||||
telnetEnabled: true,
|
||||
telnetUsername: undefined,
|
||||
telnetPassword: undefined,
|
||||
username: "ssh-user",
|
||||
password: "ssh-password",
|
||||
group: "network",
|
||||
proxyProfileId: undefined,
|
||||
},
|
||||
availableKeys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
groups: ["network"],
|
||||
managedSources: [],
|
||||
allTags: [],
|
||||
allHosts: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
groupConfigs: [{
|
||||
path: "network",
|
||||
telnetUsername: "group-telnet-user",
|
||||
telnetPassword: "group-telnet-password",
|
||||
}],
|
||||
onSave: () => {},
|
||||
onCancel: () => {},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ import { GroupConfig, GroupNode, Host } from '../types';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
import { HostNotesIndicator } from './host/HostNotesIndicator';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
interface HostTreeViewProps {
|
||||
@@ -392,7 +393,10 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
<DistroAvatar host={host} fallback={(host.os || "L")[0].toUpperCase()} size="sm" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{host.label}</div>
|
||||
<div className="font-medium truncate flex items-center gap-1.5">
|
||||
<span className="truncate">{host.label}</span>
|
||||
<HostNotesIndicator notes={host.notes} />
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{displayUsername}@{host.hostname}:{displayPort}
|
||||
</div>
|
||||
|
||||
185
components/KeychainEditPanel.tsx
Normal file
185
components/KeychainEditPanel.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import React from "react";
|
||||
import { Eye, EyeOff, FileKey, Info } from "lucide-react";
|
||||
import type { SSHKey } from "../types";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type KeychainEditPanelProps = Record<string, any>;
|
||||
|
||||
export const KeychainEditPanel: React.FC<KeychainEditPanelProps> = ({
|
||||
panel,
|
||||
t,
|
||||
draftKey,
|
||||
setDraftKey,
|
||||
showPassphrase,
|
||||
setShowPassphrase,
|
||||
openKeyExport,
|
||||
onUpdate,
|
||||
closePanel,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("keychain.edit.labelRequired")}</Label>
|
||||
<Input
|
||||
value={draftKey.label || ""}
|
||||
onChange={(e) =>
|
||||
setDraftKey({ ...draftKey, label: e.target.value })
|
||||
}
|
||||
placeholder={t("keychain.edit.keyLabelPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Reference key: show file path read-only */}
|
||||
{draftKey.source === 'reference' && draftKey.filePath && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">
|
||||
{t("keychain.edit.filePath")}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
|
||||
<FileKey size={14} className="text-primary shrink-0" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-xs font-mono truncate cursor-default">
|
||||
{draftKey.filePath}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{draftKey.filePath}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Managed key: show private key editor */}
|
||||
{draftKey.source !== 'reference' && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-destructive">
|
||||
{t("keychain.edit.privateKeyRequired")}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={draftKey.privateKey || ""}
|
||||
onChange={(e) =>
|
||||
setDraftKey({ ...draftKey, privateKey: e.target.value })
|
||||
}
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||
className="min-h-[180px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{draftKey.source !== 'reference' && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">
|
||||
{t("keychain.edit.publicKey")}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={draftKey.publicKey || ""}
|
||||
onChange={(e) =>
|
||||
setDraftKey({ ...draftKey, publicKey: e.target.value })
|
||||
}
|
||||
placeholder="ssh-ed25519 AAAA..."
|
||||
className="min-h-[80px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{draftKey.source !== 'reference' && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">
|
||||
{t("keychain.edit.certificate")}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={draftKey.certificate || ""}
|
||||
onChange={(e) =>
|
||||
setDraftKey({ ...draftKey, certificate: e.target.value })
|
||||
}
|
||||
placeholder={t("keychain.edit.certificatePlaceholder")}
|
||||
className="min-h-[60px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Passphrase section */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t('terminal.auth.passphrase')}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassphrase ? 'text' : 'password'}
|
||||
value={draftKey.passphrase || ''}
|
||||
onChange={(e) =>
|
||||
setDraftKey({ ...draftKey, passphrase: e.target.value })
|
||||
}
|
||||
placeholder={t('keychain.generate.passphrasePlaceholder')}
|
||||
className="pr-10"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8"
|
||||
onClick={() => setShowPassphrase(!showPassphrase)}
|
||||
>
|
||||
{showPassphrase ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="editSavePassphrase"
|
||||
checked={draftKey.savePassphrase || false}
|
||||
onChange={(e) =>
|
||||
setDraftKey({ ...draftKey, savePassphrase: e.target.checked })
|
||||
}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
<Label htmlFor="editSavePassphrase" className="text-sm font-normal cursor-pointer">
|
||||
{t('keychain.generate.savePassphrase')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Key Export section - only for managed keys */}
|
||||
{draftKey.source !== 'reference' && (
|
||||
<div className="pt-4 mt-4 border-t border-border/60">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-sm font-medium">
|
||||
{t("keychain.edit.keyExport")}
|
||||
</span>
|
||||
<div className="h-4 w-4 rounded-full bg-muted flex items-center justify-center">
|
||||
<Info size={10} className="text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full h-11"
|
||||
onClick={() => openKeyExport(panel.key)}
|
||||
>
|
||||
{t("keychain.edit.exportToHost")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save button */}
|
||||
<Button
|
||||
className="w-full h-11 mt-4"
|
||||
disabled={
|
||||
!draftKey.label?.trim() ||
|
||||
(draftKey.source !== 'reference' && !draftKey.privateKey?.trim())
|
||||
}
|
||||
onClick={() => {
|
||||
if (draftKey.id) {
|
||||
onUpdate({
|
||||
...panel.key,
|
||||
...(draftKey as SSHKey),
|
||||
});
|
||||
closePanel();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("common.saveChanges")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
310
components/KeychainExportPanel.tsx
Normal file
310
components/KeychainExportPanel.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import React from "react";
|
||||
import { ChevronRight, Info } from "lucide-react";
|
||||
import { applyGroupDefaults, resolveGroupDefaults } from "../domain/groupConfig";
|
||||
import { sanitizeCredentialValue } from "../domain/credentials";
|
||||
import { resolveBridgeKeyAuth, resolveHostAuth } from "../domain/sshAuth";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "./ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
import { toast } from "./ui/toast";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type KeychainExportPanelProps = Record<string, any>;
|
||||
|
||||
export const KeychainExportPanel: React.FC<KeychainExportPanelProps> = ({
|
||||
panel,
|
||||
t,
|
||||
getKeyIcon,
|
||||
getKeyTypeDisplay,
|
||||
setShowHostSelector,
|
||||
exportHost,
|
||||
exportLocation,
|
||||
setExportLocation,
|
||||
exportFilename,
|
||||
setExportFilename,
|
||||
exportAdvancedOpen,
|
||||
setExportAdvancedOpen,
|
||||
exportScript,
|
||||
setExportScript,
|
||||
isExporting,
|
||||
setIsExporting,
|
||||
keys,
|
||||
identities,
|
||||
groupConfigs,
|
||||
execCommand,
|
||||
onSaveIdentity,
|
||||
onSaveHost,
|
||||
closePanel,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* Key info card */}
|
||||
<div className="flex items-center gap-3 p-3 bg-card border border-border/80 rounded-lg">
|
||||
<div
|
||||
className={cn(
|
||||
"h-10 w-10 rounded-md flex items-center justify-center",
|
||||
panel.key.certificate
|
||||
? "bg-emerald-500/15 text-emerald-500"
|
||||
: "bg-primary/15 text-primary",
|
||||
)}
|
||||
>
|
||||
{getKeyIcon(panel.key)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold truncate">
|
||||
{panel.key.label}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("auth.keyType", { type: getKeyTypeDisplay(panel.key) })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export to field */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-muted-foreground">
|
||||
{t("keychain.export.exportTo")}
|
||||
</Label>
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 text-primary text-sm"
|
||||
onClick={() => setShowHostSelector(true)}
|
||||
>
|
||||
{t("keychain.export.selectHost")}
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
value={exportHost?.label || ""}
|
||||
readOnly
|
||||
placeholder={t("common.selectAHostPlaceholder")}
|
||||
className="bg-muted/50 cursor-pointer"
|
||||
onClick={() => setShowHostSelector(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Location field */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">
|
||||
{t("keychain.export.location")}
|
||||
</Label>
|
||||
<Input
|
||||
value={exportLocation}
|
||||
onChange={(e) => setExportLocation(e.target.value)}
|
||||
placeholder=".ssh"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filename field */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">
|
||||
{t("keychain.export.filename")}
|
||||
</Label>
|
||||
<Input
|
||||
value={exportFilename}
|
||||
onChange={(e) => setExportFilename(e.target.value)}
|
||||
placeholder="authorized_keys"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info note */}
|
||||
<div className="flex items-start gap-2 p-3 bg-muted/50 border border-border/60 rounded-lg">
|
||||
<Info
|
||||
size={14}
|
||||
className="mt-0.5 text-muted-foreground shrink-0"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("keychain.export.note", {
|
||||
unix: "UNIX",
|
||||
advanced: t("common.advanced"),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Advanced collapsible */}
|
||||
<Collapsible
|
||||
open={exportAdvancedOpen}
|
||||
onOpenChange={setExportAdvancedOpen}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-between px-0 h-10 hover:bg-transparent hover:text-current"
|
||||
>
|
||||
<span className="font-medium">{t("common.advanced")}</span>
|
||||
<ChevronRight
|
||||
size={16}
|
||||
className={cn(
|
||||
"transition-transform",
|
||||
exportAdvancedOpen && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-2 pt-2">
|
||||
<Label className="text-muted-foreground">
|
||||
{t("keychain.export.script")}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={exportScript}
|
||||
onChange={(e) => setExportScript(e.target.value)}
|
||||
className="min-h-[180px] font-mono text-xs"
|
||||
placeholder={t("keychain.export.scriptPlaceholder")}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Export button */}
|
||||
<Button
|
||||
className="w-full h-11"
|
||||
disabled={
|
||||
!exportHost ||
|
||||
!exportLocation ||
|
||||
!exportFilename ||
|
||||
isExporting
|
||||
}
|
||||
onClick={async () => {
|
||||
if (!exportHost || !panel.key.publicKey) return;
|
||||
|
||||
setIsExporting(true);
|
||||
|
||||
try {
|
||||
const exportAuth = resolveHostAuth({
|
||||
host: exportHost,
|
||||
keys,
|
||||
identities,
|
||||
});
|
||||
const exportKeyAuth = resolveBridgeKeyAuth({
|
||||
key: exportAuth.key,
|
||||
fallbackIdentityFilePaths: exportAuth.authMethod === "password" || exportAuth.keyId
|
||||
? undefined
|
||||
: exportHost.identityFilePaths,
|
||||
passphrase: exportAuth.passphrase,
|
||||
});
|
||||
const exportPassword = sanitizeCredentialValue(exportAuth.password);
|
||||
|
||||
// Need either password or a usable key to run remote command.
|
||||
if (
|
||||
!exportPassword &&
|
||||
!exportKeyAuth.privateKey &&
|
||||
!exportKeyAuth.identityFilePaths?.length
|
||||
) {
|
||||
throw new Error(
|
||||
t("keychain.export.missingCredentials"),
|
||||
);
|
||||
}
|
||||
|
||||
// Escape the public key for shell (single quotes, escape existing quotes)
|
||||
const escapedPublicKey = panel.key.publicKey.replace(
|
||||
/'/g,
|
||||
"'\\''",
|
||||
);
|
||||
|
||||
// Build the command by replacing $1, $2, $3
|
||||
const scriptWithVars = exportScript
|
||||
.replace(/\$1/g, exportLocation)
|
||||
.replace(/\$2/g, exportFilename)
|
||||
.replace(/\$3/g, `'${escapedPublicKey}'`);
|
||||
|
||||
// Execute the script directly - SSH exec handles multiline commands
|
||||
const command = scriptWithVars;
|
||||
|
||||
// Resolve the effective host (applying group
|
||||
// defaults), so algorithm settings inherited from
|
||||
// the group reach the bridge — the host object on
|
||||
// its own only carries explicitly set fields.
|
||||
const effectiveExportHost = exportHost.group
|
||||
? applyGroupDefaults(
|
||||
exportHost,
|
||||
resolveGroupDefaults(exportHost.group, groupConfigs),
|
||||
)
|
||||
: applyGroupDefaults(exportHost, {});
|
||||
|
||||
// Execute via SSH
|
||||
const result = await execCommand({
|
||||
hostname: effectiveExportHost.hostname,
|
||||
username: exportAuth.username,
|
||||
port: effectiveExportHost.port || 22,
|
||||
password: exportPassword,
|
||||
privateKey: exportKeyAuth.privateKey,
|
||||
certificate: exportAuth.key?.certificate,
|
||||
publicKey: exportAuth.key?.publicKey,
|
||||
keyId: exportAuth.keyId,
|
||||
keySource: exportAuth.key?.source,
|
||||
passphrase: exportKeyAuth.passphrase,
|
||||
identityFilePaths: exportKeyAuth.identityFilePaths,
|
||||
// Carry the effective host's algorithm settings
|
||||
// (host value falling back to its group default)
|
||||
// so the one-off SSH exec honors them just like
|
||||
// the interactive terminal does.
|
||||
legacyAlgorithms: effectiveExportHost.legacyAlgorithms,
|
||||
skipEcdsaHostKey: effectiveExportHost.skipEcdsaHostKey,
|
||||
algorithmOverrides: effectiveExportHost.algorithms,
|
||||
command,
|
||||
timeout: 30000,
|
||||
enableKeyboardInteractive: true,
|
||||
sessionId: `export-key:${effectiveExportHost.id}:${panel.key.id}`,
|
||||
});
|
||||
|
||||
// Check result - code 0, null, or undefined with no stderr is success
|
||||
const exitCode = result?.code;
|
||||
const hasError = result?.stderr?.trim();
|
||||
if (exitCode === 0 || (exitCode == null && !hasError)) {
|
||||
// Update identity (preferred) or host to use this key for authentication
|
||||
if (exportHost.identityId && onSaveIdentity) {
|
||||
const existing = identities.find(
|
||||
(i) => i.id === exportHost.identityId,
|
||||
);
|
||||
if (existing) {
|
||||
onSaveIdentity({
|
||||
...existing,
|
||||
authMethod: "key",
|
||||
keyId: panel.key.id,
|
||||
});
|
||||
}
|
||||
} else if (onSaveHost) {
|
||||
onSaveHost({
|
||||
...exportHost,
|
||||
identityFileId: panel.key.id,
|
||||
authMethod: "key",
|
||||
});
|
||||
}
|
||||
toast.success(
|
||||
t("keychain.export.successMessage", {
|
||||
host: exportHost.label,
|
||||
}),
|
||||
t("keychain.export.successTitle"),
|
||||
);
|
||||
closePanel();
|
||||
} else {
|
||||
const errorMsg =
|
||||
hasError ||
|
||||
result?.stdout?.trim() ||
|
||||
t("keychain.export.exitCode", { code: exitCode });
|
||||
toast.error(
|
||||
t("keychain.export.failedMessage", { error: errorMsg }),
|
||||
t("keychain.export.failedTitle"),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : String(err);
|
||||
toast.error(
|
||||
t("keychain.export.failedPrefix", { error: message }),
|
||||
t("keychain.export.failedTitle"),
|
||||
);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isExporting
|
||||
? t("keychain.export.exporting")
|
||||
: t("keychain.export.exportAndAttach")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,18 +1,13 @@
|
||||
import {
|
||||
BadgeCheck,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Edit2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileKey,
|
||||
Info,
|
||||
ExternalLink,
|
||||
Key,
|
||||
LayoutGrid,
|
||||
List as ListIcon,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
Search,
|
||||
Shield,
|
||||
Trash2,
|
||||
Upload,
|
||||
@@ -21,8 +16,7 @@ import {
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useStoredViewMode } from "../application/state/useStoredViewMode";
|
||||
import { sanitizeCredentialValue } from "../domain/credentials";
|
||||
import { resolveBridgeKeyAuth, resolveHostAuth } from "../domain/sshAuth";
|
||||
import type { GroupConfig } from "../domain/models";
|
||||
import { STORAGE_KEY_VAULT_KEYS_VIEW_MODE } from "../infrastructure/config/storageKeys";
|
||||
import { logger } from "../lib/logger";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -37,11 +31,8 @@ import {
|
||||
AsidePanelContent,
|
||||
} from "./ui/aside-panel";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "./ui/collapsible";
|
||||
|
||||
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@@ -50,10 +41,14 @@ import {
|
||||
ContextMenuTrigger,
|
||||
} from "./ui/context-menu";
|
||||
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
import { toast } from "./ui/toast";
|
||||
import { KeychainExportPanel } from "./KeychainExportPanel";
|
||||
import { KeychainEditPanel } from "./KeychainEditPanel";
|
||||
import {
|
||||
VaultHeaderSearch,
|
||||
VaultPageHeader,
|
||||
vaultHeaderIconButtonClass,
|
||||
} from "./vault/VaultPageHeader";
|
||||
|
||||
// Import utilities and components from keychain module
|
||||
import {
|
||||
@@ -74,6 +69,13 @@ interface KeychainManagerProps {
|
||||
hosts?: Host[];
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
customGroups?: string[];
|
||||
/**
|
||||
* Group default configurations. Needed by the "export public key to
|
||||
* host" flow so per-host SSH algorithm settings (legacy / skipEcdsa /
|
||||
* overrides) that the host inherits from its group are honored when
|
||||
* the export opens its one-off SSH connection.
|
||||
*/
|
||||
groupConfigs?: GroupConfig[];
|
||||
managedSources?: ManagedSource[];
|
||||
onSave: (key: SSHKey) => void;
|
||||
onUpdate: (key: SSHKey) => void;
|
||||
@@ -91,6 +93,7 @@ const KeychainManager: React.FC<KeychainManagerProps> = ({
|
||||
hosts = [],
|
||||
proxyProfiles = [],
|
||||
customGroups = [],
|
||||
groupConfigs = [],
|
||||
managedSources = [],
|
||||
onSave,
|
||||
onUpdate,
|
||||
@@ -525,8 +528,7 @@ echo $3 >> "$FILE"`);
|
||||
panel.type !== "closed" && "mr-[380px]",
|
||||
)}
|
||||
>
|
||||
{/* Toolbar */}
|
||||
<div className="h-14 px-4 py-2 flex items-center gap-3 bg-secondary/80 supports-[backdrop-filter]:backdrop-blur-sm border-b border-border/50 shrink-0">
|
||||
<VaultPageHeader>
|
||||
{/* Filter Tabs */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* KEY button with split interaction: left=switch view, right=dropdown */}
|
||||
@@ -629,25 +631,19 @@ echo $3 >> "$FILE"`);
|
||||
{/* Search and View Mode - hide search when panel is open */}
|
||||
<div className="ml-auto flex items-center gap-2 min-w-0 flex-shrink">
|
||||
{panel.type === "closed" && (
|
||||
<div className="relative flex-shrink min-w-[100px]">
|
||||
<Search
|
||||
size={14}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("common.searchPlaceholder")}
|
||||
className="h-10 pl-9 w-full bg-secondary border-border/60 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<VaultHeaderSearch
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("common.searchPlaceholder")}
|
||||
className="flex-shrink w-64"
|
||||
/>
|
||||
)}
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10 flex-shrink-0"
|
||||
className={cn(vaultHeaderIconButtonClass, "flex-shrink-0")}
|
||||
>
|
||||
{viewMode === "grid" ? (
|
||||
<LayoutGrid size={16} />
|
||||
@@ -675,7 +671,7 @@ echo $3 >> "$FILE"`);
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</VaultPageHeader>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
@@ -843,9 +839,35 @@ echo $3 >> "$FILE"`);
|
||||
</AsideActionMenuItem>
|
||||
</AsideActionMenu>
|
||||
) : panel.type === "view" ? (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal size={16} />
|
||||
</Button>
|
||||
<AsideActionMenu>
|
||||
{panel.key.publicKey ? (
|
||||
<AsideActionMenuItem
|
||||
icon={<Copy size={14} />}
|
||||
onClick={() => copyPublicKey(panel.key)}
|
||||
>
|
||||
{t("action.copyPublicKey")}
|
||||
</AsideActionMenuItem>
|
||||
) : null}
|
||||
<AsideActionMenuItem
|
||||
icon={<ExternalLink size={14} />}
|
||||
onClick={() => openKeyExport(panel.key)}
|
||||
>
|
||||
{t("action.keyExport")}
|
||||
</AsideActionMenuItem>
|
||||
<AsideActionMenuItem
|
||||
icon={<Edit2 size={14} />}
|
||||
onClick={() => openKeyEdit(panel.key)}
|
||||
>
|
||||
{t("action.edit")}
|
||||
</AsideActionMenuItem>
|
||||
<AsideActionMenuItem
|
||||
variant="destructive"
|
||||
icon={<Trash2 size={14} />}
|
||||
onClick={() => handleDelete(panel.key.id)}
|
||||
>
|
||||
{t("action.delete")}
|
||||
</AsideActionMenuItem>
|
||||
</AsideActionMenu>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
@@ -894,414 +916,46 @@ echo $3 >> "$FILE"`);
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Key Export Panel */}
|
||||
{panel.type === "export" && !showHostSelector && (
|
||||
<>
|
||||
{/* Key info card */}
|
||||
<div className="flex items-center gap-3 p-3 bg-card border border-border/80 rounded-lg">
|
||||
<div
|
||||
className={cn(
|
||||
"h-10 w-10 rounded-md flex items-center justify-center",
|
||||
panel.key.certificate
|
||||
? "bg-emerald-500/15 text-emerald-500"
|
||||
: "bg-primary/15 text-primary",
|
||||
)}
|
||||
>
|
||||
{getKeyIcon(panel.key)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold truncate">
|
||||
{panel.key.label}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("auth.keyType", { type: getKeyTypeDisplay(panel.key) })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export to field */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-muted-foreground">
|
||||
{t("keychain.export.exportTo")}
|
||||
</Label>
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 text-primary text-sm"
|
||||
onClick={() => setShowHostSelector(true)}
|
||||
>
|
||||
{t("keychain.export.selectHost")}
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
value={exportHost?.label || ""}
|
||||
readOnly
|
||||
placeholder={t("common.selectAHostPlaceholder")}
|
||||
className="bg-muted/50 cursor-pointer"
|
||||
onClick={() => setShowHostSelector(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Location field */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">
|
||||
{t("keychain.export.location")}
|
||||
</Label>
|
||||
<Input
|
||||
value={exportLocation}
|
||||
onChange={(e) => setExportLocation(e.target.value)}
|
||||
placeholder=".ssh"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filename field */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">
|
||||
{t("keychain.export.filename")}
|
||||
</Label>
|
||||
<Input
|
||||
value={exportFilename}
|
||||
onChange={(e) => setExportFilename(e.target.value)}
|
||||
placeholder="authorized_keys"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info note */}
|
||||
<div className="flex items-start gap-2 p-3 bg-muted/50 border border-border/60 rounded-lg">
|
||||
<Info
|
||||
size={14}
|
||||
className="mt-0.5 text-muted-foreground shrink-0"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("keychain.export.note", {
|
||||
unix: "UNIX",
|
||||
advanced: t("common.advanced"),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Advanced collapsible */}
|
||||
<Collapsible
|
||||
open={exportAdvancedOpen}
|
||||
onOpenChange={setExportAdvancedOpen}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-between px-0 h-10 hover:bg-transparent hover:text-current"
|
||||
>
|
||||
<span className="font-medium">{t("common.advanced")}</span>
|
||||
<ChevronRight
|
||||
size={16}
|
||||
className={cn(
|
||||
"transition-transform",
|
||||
exportAdvancedOpen && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-2 pt-2">
|
||||
<Label className="text-muted-foreground">
|
||||
{t("keychain.export.script")}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={exportScript}
|
||||
onChange={(e) => setExportScript(e.target.value)}
|
||||
className="min-h-[180px] font-mono text-xs"
|
||||
placeholder={t("keychain.export.scriptPlaceholder")}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Export button */}
|
||||
<Button
|
||||
className="w-full h-11"
|
||||
disabled={
|
||||
!exportHost ||
|
||||
!exportLocation ||
|
||||
!exportFilename ||
|
||||
isExporting
|
||||
}
|
||||
onClick={async () => {
|
||||
if (!exportHost || !panel.key.publicKey) return;
|
||||
|
||||
setIsExporting(true);
|
||||
|
||||
try {
|
||||
const exportAuth = resolveHostAuth({
|
||||
host: exportHost,
|
||||
keys,
|
||||
identities,
|
||||
});
|
||||
const exportKeyAuth = resolveBridgeKeyAuth({
|
||||
key: exportAuth.key,
|
||||
fallbackIdentityFilePaths: exportAuth.authMethod === "password" || exportAuth.keyId
|
||||
? undefined
|
||||
: exportHost.identityFilePaths,
|
||||
passphrase: exportAuth.passphrase,
|
||||
});
|
||||
const exportPassword = sanitizeCredentialValue(exportAuth.password);
|
||||
|
||||
// Need either password or a usable key to run remote command.
|
||||
if (
|
||||
!exportPassword &&
|
||||
!exportKeyAuth.privateKey &&
|
||||
!exportKeyAuth.identityFilePaths?.length
|
||||
) {
|
||||
throw new Error(
|
||||
t("keychain.export.missingCredentials"),
|
||||
);
|
||||
}
|
||||
|
||||
// Escape the public key for shell (single quotes, escape existing quotes)
|
||||
const escapedPublicKey = panel.key.publicKey.replace(
|
||||
/'/g,
|
||||
"'\\''",
|
||||
);
|
||||
|
||||
// Build the command by replacing $1, $2, $3
|
||||
const scriptWithVars = exportScript
|
||||
.replace(/\$1/g, exportLocation)
|
||||
.replace(/\$2/g, exportFilename)
|
||||
.replace(/\$3/g, `'${escapedPublicKey}'`);
|
||||
|
||||
// Execute the script directly - SSH exec handles multiline commands
|
||||
const command = scriptWithVars;
|
||||
|
||||
// Execute via SSH
|
||||
const result = await execCommand({
|
||||
hostname: exportHost.hostname,
|
||||
username: exportAuth.username,
|
||||
port: exportHost.port || 22,
|
||||
password: exportPassword,
|
||||
privateKey: exportKeyAuth.privateKey,
|
||||
certificate: exportAuth.key?.certificate,
|
||||
publicKey: exportAuth.key?.publicKey,
|
||||
keyId: exportAuth.keyId,
|
||||
keySource: exportAuth.key?.source,
|
||||
passphrase: exportKeyAuth.passphrase,
|
||||
identityFilePaths: exportKeyAuth.identityFilePaths,
|
||||
command,
|
||||
timeout: 30000,
|
||||
enableKeyboardInteractive: true,
|
||||
sessionId: `export-key:${exportHost.id}:${panel.key.id}`,
|
||||
});
|
||||
|
||||
// Check result - code 0, null, or undefined with no stderr is success
|
||||
const exitCode = result?.code;
|
||||
const hasError = result?.stderr?.trim();
|
||||
if (exitCode === 0 || (exitCode == null && !hasError)) {
|
||||
// Update identity (preferred) or host to use this key for authentication
|
||||
if (exportHost.identityId && onSaveIdentity) {
|
||||
const existing = identities.find(
|
||||
(i) => i.id === exportHost.identityId,
|
||||
);
|
||||
if (existing) {
|
||||
onSaveIdentity({
|
||||
...existing,
|
||||
authMethod: "key",
|
||||
keyId: panel.key.id,
|
||||
});
|
||||
}
|
||||
} else if (onSaveHost) {
|
||||
onSaveHost({
|
||||
...exportHost,
|
||||
identityFileId: panel.key.id,
|
||||
authMethod: "key",
|
||||
});
|
||||
}
|
||||
toast.success(
|
||||
t("keychain.export.successMessage", {
|
||||
host: exportHost.label,
|
||||
}),
|
||||
t("keychain.export.successTitle"),
|
||||
);
|
||||
closePanel();
|
||||
} else {
|
||||
const errorMsg =
|
||||
hasError ||
|
||||
result?.stdout?.trim() ||
|
||||
t("keychain.export.exitCode", { code: exitCode });
|
||||
toast.error(
|
||||
t("keychain.export.failedMessage", { error: errorMsg }),
|
||||
t("keychain.export.failedTitle"),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : String(err);
|
||||
toast.error(
|
||||
t("keychain.export.failedPrefix", { error: message }),
|
||||
t("keychain.export.failedTitle"),
|
||||
);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isExporting
|
||||
? t("keychain.export.exporting")
|
||||
: t("keychain.export.exportAndAttach")}
|
||||
</Button>
|
||||
</>
|
||||
<KeychainExportPanel
|
||||
panel={panel}
|
||||
t={t}
|
||||
getKeyIcon={getKeyIcon}
|
||||
getKeyTypeDisplay={getKeyTypeDisplay}
|
||||
setShowHostSelector={setShowHostSelector}
|
||||
exportHost={exportHost}
|
||||
exportLocation={exportLocation}
|
||||
setExportLocation={setExportLocation}
|
||||
exportFilename={exportFilename}
|
||||
setExportFilename={setExportFilename}
|
||||
exportAdvancedOpen={exportAdvancedOpen}
|
||||
setExportAdvancedOpen={setExportAdvancedOpen}
|
||||
exportScript={exportScript}
|
||||
setExportScript={setExportScript}
|
||||
isExporting={isExporting}
|
||||
setIsExporting={setIsExporting}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
groupConfigs={groupConfigs}
|
||||
execCommand={execCommand}
|
||||
onSaveIdentity={onSaveIdentity}
|
||||
onSaveHost={onSaveHost}
|
||||
closePanel={closePanel}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit Key Panel */}
|
||||
{panel.type === "edit" && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("keychain.edit.labelRequired")}</Label>
|
||||
<Input
|
||||
value={draftKey.label || ""}
|
||||
onChange={(e) =>
|
||||
setDraftKey({ ...draftKey, label: e.target.value })
|
||||
}
|
||||
placeholder={t("keychain.edit.keyLabelPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Reference key: show file path read-only */}
|
||||
{draftKey.source === 'reference' && draftKey.filePath && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">
|
||||
{t("keychain.edit.filePath")}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
|
||||
<FileKey size={14} className="text-primary shrink-0" />
|
||||
<span className="text-xs font-mono truncate" title={draftKey.filePath}>
|
||||
{draftKey.filePath}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Managed key: show private key editor */}
|
||||
{draftKey.source !== 'reference' && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-destructive">
|
||||
{t("keychain.edit.privateKeyRequired")}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={draftKey.privateKey || ""}
|
||||
onChange={(e) =>
|
||||
setDraftKey({ ...draftKey, privateKey: e.target.value })
|
||||
}
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||
className="min-h-[180px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{draftKey.source !== 'reference' && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">
|
||||
{t("keychain.edit.publicKey")}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={draftKey.publicKey || ""}
|
||||
onChange={(e) =>
|
||||
setDraftKey({ ...draftKey, publicKey: e.target.value })
|
||||
}
|
||||
placeholder="ssh-ed25519 AAAA..."
|
||||
className="min-h-[80px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{draftKey.source !== 'reference' && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">
|
||||
{t("keychain.edit.certificate")}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={draftKey.certificate || ""}
|
||||
onChange={(e) =>
|
||||
setDraftKey({ ...draftKey, certificate: e.target.value })
|
||||
}
|
||||
placeholder={t("keychain.edit.certificatePlaceholder")}
|
||||
className="min-h-[60px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Passphrase section */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t('terminal.auth.passphrase')}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassphrase ? 'text' : 'password'}
|
||||
value={draftKey.passphrase || ''}
|
||||
onChange={(e) =>
|
||||
setDraftKey({ ...draftKey, passphrase: e.target.value })
|
||||
}
|
||||
placeholder={t('keychain.generate.passphrasePlaceholder')}
|
||||
className="pr-10"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8"
|
||||
onClick={() => setShowPassphrase(!showPassphrase)}
|
||||
>
|
||||
{showPassphrase ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="editSavePassphrase"
|
||||
checked={draftKey.savePassphrase || false}
|
||||
onChange={(e) =>
|
||||
setDraftKey({ ...draftKey, savePassphrase: e.target.checked })
|
||||
}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
<Label htmlFor="editSavePassphrase" className="text-sm font-normal cursor-pointer">
|
||||
{t('keychain.generate.savePassphrase')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Key Export section - only for managed keys */}
|
||||
{draftKey.source !== 'reference' && (
|
||||
<div className="pt-4 mt-4 border-t border-border/60">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-sm font-medium">
|
||||
{t("keychain.edit.keyExport")}
|
||||
</span>
|
||||
<div className="h-4 w-4 rounded-full bg-muted flex items-center justify-center">
|
||||
<Info size={10} className="text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full h-11"
|
||||
onClick={() => openKeyExport(panel.key)}
|
||||
>
|
||||
{t("keychain.edit.exportToHost")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save button */}
|
||||
<Button
|
||||
className="w-full h-11 mt-4"
|
||||
disabled={
|
||||
!draftKey.label?.trim() ||
|
||||
(draftKey.source !== 'reference' && !draftKey.privateKey?.trim())
|
||||
}
|
||||
onClick={() => {
|
||||
if (draftKey.id) {
|
||||
onUpdate({
|
||||
...panel.key,
|
||||
...(draftKey as SSHKey),
|
||||
});
|
||||
closePanel();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("common.saveChanges")}
|
||||
</Button>
|
||||
</>
|
||||
<KeychainEditPanel
|
||||
panel={panel}
|
||||
t={t}
|
||||
draftKey={draftKey}
|
||||
setDraftKey={setDraftKey}
|
||||
showPassphrase={showPassphrase}
|
||||
setShowPassphrase={setShowPassphrase}
|
||||
openKeyExport={openKeyExport}
|
||||
onUpdate={onUpdate}
|
||||
closePanel={closePanel}
|
||||
/>
|
||||
)}
|
||||
</AsidePanelContent>
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
LayoutGrid,
|
||||
List as ListIcon,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Server,
|
||||
Shield,
|
||||
Trash2,
|
||||
@@ -22,6 +21,7 @@ import React, {
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useKnownHostsBackend } from "../application/state/useKnownHostsBackend";
|
||||
import { useStoredViewMode, ViewMode } from "../application/state/useStoredViewMode";
|
||||
import { fingerprintFromPublicKey } from "../domain/knownHosts";
|
||||
import { STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE } from "../infrastructure/config/storageKeys";
|
||||
import { logger } from "../lib/logger";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -34,10 +34,16 @@ import {
|
||||
ContextMenuTrigger,
|
||||
} from "./ui/context-menu";
|
||||
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
|
||||
import { Input } from "./ui/input";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import { SortDropdown, SortMode } from "./ui/sort-dropdown";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
import { toast } from "./ui/toast";
|
||||
import {
|
||||
VaultHeaderSearch,
|
||||
VaultPageHeader,
|
||||
vaultHeaderIconButtonClass,
|
||||
vaultHeaderSecondaryButtonClass,
|
||||
} from "./vault/VaultPageHeader";
|
||||
|
||||
interface KnownHostsManagerProps {
|
||||
knownHosts: KnownHost[];
|
||||
@@ -79,12 +85,20 @@ const parseKnownHostsFile = (content: string): KnownHost[] => {
|
||||
hostname = "(hashed)";
|
||||
}
|
||||
|
||||
const fullPublicKey = `${keyType} ${publicKey}`;
|
||||
// Compute the fingerprint up front so the SSH host verifier can match
|
||||
// against this record directly instead of re-deriving on every connect —
|
||||
// the re-derivation path is where the false "fingerprint changed"
|
||||
// warnings in #972 originated.
|
||||
const fingerprint = fingerprintFromPublicKey(fullPublicKey);
|
||||
|
||||
parsed.push({
|
||||
id: `kh-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
hostname,
|
||||
port,
|
||||
keyType,
|
||||
publicKey: `${keyType} ${publicKey}`,
|
||||
publicKey: fullPublicKey,
|
||||
fingerprint: fingerprint || undefined,
|
||||
discoveredAt: Date.now(),
|
||||
});
|
||||
} catch {
|
||||
@@ -122,27 +136,35 @@ const HostItem = React.memo<HostItemProps>(
|
||||
{/* Quick action buttons on hover */}
|
||||
<div className="absolute top-1 right-1 flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{!converted && (
|
||||
<button
|
||||
className="p-1 rounded hover:bg-primary/20 text-primary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onConvertToHost(knownHost);
|
||||
}}
|
||||
title={t("action.convertToHost")}
|
||||
>
|
||||
<ArrowRight size={12} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-primary/20 text-primary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onConvertToHost(knownHost);
|
||||
}}
|
||||
>
|
||||
<ArrowRight size={12} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("action.convertToHost")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<button
|
||||
className="p-1 rounded hover:bg-destructive/20 text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(knownHost.id);
|
||||
}}
|
||||
title={t("action.remove")}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-destructive/20 text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(knownHost.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("action.remove")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
<div className="h-11 w-11 rounded-xl bg-primary/10 text-primary flex items-center justify-center flex-shrink-0">
|
||||
@@ -193,18 +215,22 @@ const HostItem = React.memo<HostItemProps>(
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{!converted && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onConvertToHost(knownHost);
|
||||
}}
|
||||
title={t("action.convertToHost")}
|
||||
>
|
||||
<ArrowRight size={14} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onConvertToHost(knownHost);
|
||||
}}
|
||||
>
|
||||
<ArrowRight size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("action.convertToHost")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -454,27 +480,20 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="h-14 px-4 py-2 flex items-center gap-3 border-b border-border/50 bg-secondary/80 supports-[backdrop-filter]:backdrop-blur-sm">
|
||||
<VaultPageHeader>
|
||||
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<Search
|
||||
size={14}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
placeholder={t("knownHosts.search.placeholder")}
|
||||
className="pl-9 h-10 bg-secondary border-border/60 text-sm"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<VaultHeaderSearch
|
||||
placeholder={t("knownHosts.search.placeholder")}
|
||||
className="flex-1 max-w-xs"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* View Mode Toggle */}
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10">
|
||||
<Button variant="ghost" size="icon" className={vaultHeaderIconButtonClass}>
|
||||
{viewMode === "grid" ? (
|
||||
<LayoutGrid size={16} />
|
||||
) : (
|
||||
@@ -505,14 +524,14 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
<SortDropdown
|
||||
value={sortMode}
|
||||
onChange={setSortMode}
|
||||
className="h-10 w-10"
|
||||
className={vaultHeaderIconButtonClass}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-px h-5 bg-border/50" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="h-10 px-3 bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
|
||||
className={vaultHeaderSecondaryButtonClass}
|
||||
onClick={() => handleScanSystem()}
|
||||
disabled={isScanning}
|
||||
>
|
||||
@@ -531,14 +550,14 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="h-10 px-3 bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
|
||||
className={vaultHeaderSecondaryButtonClass}
|
||||
onClick={openFilePicker}
|
||||
>
|
||||
<Import size={14} className="mr-2" />
|
||||
{t("knownHosts.action.importFile")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</VaultPageHeader>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollArea className="flex-1">
|
||||
|
||||
@@ -277,7 +277,6 @@ const LogViewComponent: React.FC<LogViewProps> = ({
|
||||
className="gap-1.5 h-8 px-2"
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
title={t("logView.export")}
|
||||
>
|
||||
<Download size={14} />
|
||||
<span className="text-xs">{t("logView.export")}</span>
|
||||
@@ -290,7 +289,6 @@ const LogViewComponent: React.FC<LogViewProps> = ({
|
||||
size="sm"
|
||||
className="gap-1.5 h-8 px-2"
|
||||
onClick={() => setThemeModalOpen(true)}
|
||||
title={t("logView.customizeAppearance")}
|
||||
>
|
||||
<Palette size={14} />
|
||||
<span className="text-xs">{t("logView.appearance")}</span>
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Globe,
|
||||
LayoutGrid,
|
||||
List as ListIcon,
|
||||
Search,
|
||||
Server,
|
||||
Shuffle,
|
||||
Zap,
|
||||
@@ -41,9 +40,14 @@ import {
|
||||
DialogTitle,
|
||||
} from "./ui/dialog";
|
||||
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
|
||||
import { Input } from "./ui/input";
|
||||
import { SortDropdown } from "./ui/sort-dropdown";
|
||||
import { toast } from "./ui/toast";
|
||||
import {
|
||||
VaultHeaderSearch,
|
||||
VaultPageHeader,
|
||||
vaultHeaderIconButtonClass,
|
||||
vaultHeaderSecondaryButtonClass,
|
||||
} from "./vault/VaultPageHeader";
|
||||
|
||||
// Import components and utilities from port-forwarding module
|
||||
import {
|
||||
@@ -586,13 +590,12 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
showWizard || showEditPanel || showNewForm ? "mr-[360px]" : "",
|
||||
)}
|
||||
>
|
||||
{/* Toolbar */}
|
||||
<div className="h-14 px-4 py-2 flex items-center gap-3 bg-secondary/80 supports-[backdrop-filter]:backdrop-blur-sm border-b border-border/50 relative z-20">
|
||||
<VaultPageHeader className="z-20">
|
||||
<Dropdown open={showNewMenu} onOpenChange={setShowNewMenu}>
|
||||
<DropdownTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="h-10 px-3 gap-2 bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
|
||||
className={vaultHeaderSecondaryButtonClass}
|
||||
>
|
||||
<Zap size={14} />
|
||||
{t("pf.action.newForwarding")}
|
||||
@@ -634,23 +637,17 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
</Dropdown>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={14}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
placeholder={t("common.searchPlaceholder")}
|
||||
className="h-10 pl-9 w-44 bg-secondary border-border/60 text-sm"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<VaultHeaderSearch
|
||||
placeholder={t("common.searchPlaceholder")}
|
||||
className="w-64"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* View mode toggle */}
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10">
|
||||
<Button variant="ghost" size="icon" className={vaultHeaderIconButtonClass}>
|
||||
{viewMode === "grid" ? (
|
||||
<LayoutGrid size={16} />
|
||||
) : (
|
||||
@@ -687,10 +684,10 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
<SortDropdown
|
||||
value={sortMode}
|
||||
onChange={setSortMode}
|
||||
className="h-10 w-10"
|
||||
className={vaultHeaderIconButtonClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</VaultPageHeader>
|
||||
|
||||
{/* Rules List */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
List as ListIcon,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Route,
|
||||
Settings2,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
@@ -48,6 +48,12 @@ import {
|
||||
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
|
||||
import { Input } from "./ui/input";
|
||||
import { toast } from "./ui/toast";
|
||||
import {
|
||||
VaultHeaderSearch,
|
||||
VaultPageHeader,
|
||||
vaultHeaderIconButtonClass,
|
||||
vaultHeaderSecondaryButtonClass,
|
||||
} from "./vault/VaultPageHeader";
|
||||
|
||||
interface ProxyProfilesManagerProps {
|
||||
proxyProfiles: ProxyProfile[];
|
||||
@@ -83,6 +89,23 @@ const getProfileUsageCount = (
|
||||
|
||||
type ProxyProfilesViewMode = "grid" | "list";
|
||||
|
||||
const proxyProtocolMeta = {
|
||||
http: {
|
||||
label: "HTTP",
|
||||
Icon: Globe,
|
||||
iconClassName: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
|
||||
},
|
||||
socks5: {
|
||||
label: "SOCKS5",
|
||||
Icon: Route,
|
||||
iconClassName: "bg-sky-500/10 text-sky-600 dark:text-sky-400",
|
||||
},
|
||||
} satisfies Record<ProxyConfig["type"], {
|
||||
label: string;
|
||||
Icon: React.ComponentType<{ size?: number; className?: string }>;
|
||||
iconClassName: string;
|
||||
}>;
|
||||
|
||||
interface ProxyProfileCardProps {
|
||||
profile: ProxyProfile;
|
||||
usageCount: number;
|
||||
@@ -106,7 +129,9 @@ const ProxyProfileCard: React.FC<ProxyProfileCardProps> = ({
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const usageLabel = t("proxyProfiles.usage", { count: usageCount });
|
||||
const accessibleLabel = `${profile.label}, ${profile.config.type.toUpperCase()}, ${profile.config.host}:${profile.config.port}, ${usageLabel}`;
|
||||
const protocol = proxyProtocolMeta[profile.config.type];
|
||||
const ProtocolIcon = protocol.Icon;
|
||||
const accessibleLabel = `${profile.label}, ${protocol.label}, ${profile.config.host}:${profile.config.port}, ${usageLabel}`;
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
@@ -124,19 +149,22 @@ const ProxyProfileCard: React.FC<ProxyProfileCardProps> = ({
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
<div className="h-11 w-11 rounded-xl bg-primary/15 text-primary flex items-center justify-center">
|
||||
<Globe size={18} />
|
||||
<div
|
||||
className={cn(
|
||||
"h-11 w-11 rounded-xl flex items-center justify-center",
|
||||
protocol.iconClassName,
|
||||
)}
|
||||
title={protocol.label}
|
||||
>
|
||||
<ProtocolIcon size={18} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="text-sm font-semibold truncate">{profile.label}</div>
|
||||
<Badge variant="secondary" className="text-[10px] shrink-0">
|
||||
{profile.config.type.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-[11px] font-mono text-muted-foreground truncate">
|
||||
{profile.config.host}:{profile.config.port} -{" "}
|
||||
{usageLabel}
|
||||
{protocol.label}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -289,34 +317,30 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
|
||||
return (
|
||||
<div className="h-full flex relative">
|
||||
<div className={cn("flex-1 flex flex-col min-h-0 transition-all duration-200", draft && "mr-[380px]")}>
|
||||
<header className="border-b border-border/50 bg-secondary/80 supports-[backdrop-filter]:backdrop-blur-sm shrink-0">
|
||||
<div className="h-14 px-4 py-2 flex items-center gap-3">
|
||||
<VaultPageHeader>
|
||||
<Button
|
||||
onClick={openCreate}
|
||||
variant="secondary"
|
||||
className="h-10 px-3 gap-2 bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
|
||||
className={vaultHeaderSecondaryButtonClass}
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("proxyProfiles.action.add")}
|
||||
</Button>
|
||||
<div className="ml-auto flex items-center gap-2 min-w-0 flex-shrink">
|
||||
<div className="relative flex-shrink min-w-[100px]">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label={t("proxyProfiles.search.placeholder")}
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t("proxyProfiles.search.placeholder")}
|
||||
className="h-10 pl-9 w-full bg-secondary border-border/60 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<VaultHeaderSearch
|
||||
aria-label={t("proxyProfiles.search.placeholder")}
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t("proxyProfiles.search.placeholder")}
|
||||
className="flex-shrink w-64"
|
||||
/>
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<Button
|
||||
aria-label={t("proxyProfiles.viewMode")}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10 flex-shrink-0"
|
||||
className={cn(vaultHeaderIconButtonClass, "flex-shrink-0")}
|
||||
>
|
||||
{proxyProfilesViewMode === "grid" ? (
|
||||
<LayoutGrid size={16} />
|
||||
@@ -344,8 +368,7 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</VaultPageHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="space-y-3 p-3">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user