Compare commits

...

18 Commits

Author SHA1 Message Date
陈大猫
368c31e48d Remove default host seed data
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
Remove seeded example hosts so new installs start with an empty vault.
2026-06-01 21:09:04 +08:00
陈大猫
0fa926de26 Polish vault UI (#1177) 2026-06-01 21:03:43 +08:00
陈大猫
b9b7db2a4e fix kimi tool stream parsing (#1176) 2026-06-01 19:42:42 +08:00
陈大猫
e3f68e1a3f [codex] fix cloud sync master key refresh (#1175)
* fix cloud sync master key refresh

* Protect first sync from default settings

* Avoid recovery prompt for settings-only sync

* Cancel stale syncs after master key changes
2026-06-01 19:29:53 +08:00
陈大猫
3c4746aea0 [codex] add sync reliability metadata (#1174)
* feat: add sync reliability metadata

* fix: preserve tombstones from checked remotes

* fix: keep conflict change counts typed
2026-06-01 18:40:19 +08:00
陈大猫
463dd4464f [codex] add cloud sync strategies (#1171)
* feat: add cloud sync strategies

* chore: clarify cloud sync strategy options

* chore: keep selected sync strategy concise

* fix: sync cloud-wins payload to remaining providers

* fix: apply cloud sync strategy during startup

* fix: retry partial startup cloud syncs
2026-06-01 17:07:07 +08:00
陈大猫
63b95bb68e [codex] add terminal font size shortcuts 2026-06-01 15:25:31 +08:00
陈大猫
ea41389842 Fix Ruijie network device detection (#1168) 2026-06-01 13:52:22 +08:00
Jerry
429cb8d6e9 fix: keep macOS Dock icon consistent with bundle icon (#1166) 2026-06-01 13:05:07 +08:00
陈大猫
55faae767a feat(vault): show/hide toggle for the host Telnet password (#1164)
* feat(vault): show/hide toggle for the host Telnet password

Issue #1099 (part 2): the host-level Telnet password field was a bare type="password" with no way to reveal it. Add an eye toggle matching the SSH password field and the group-level Telnet password.

Refs #1099

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(vault): reset Telnet password reveal when the edited host changes

HostDetailsPanel is reused across hosts without remounting, so the new showTelnetPassword state has to be cleared in the initialData effect alongside showPassword — otherwise revealing one host's Telnet password leaves the next host's shown unmasked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 12:56:51 +08:00
陈大猫
94b8f298ae feat(vault): connect with the host's default protocol, prompt only for Telnet (#1163)
Issue #1099: a Telnet host shows the protocol picker on every connect. Connect resolution is now explicit, and the picker only appears when it's genuinely a choice:

- Telnet set as default (protocol = telnet) -> connect Telnet (single-click and batch)
- Telnet enabled but not the default -> show the picker (unchanged)
- Mosh enabled -> connect Mosh
- otherwise -> connect SSH

A "Connect with Telnet by default" switch in the Telnet card sets the host's primary protocol. SSH+Mosh hosts now connect Mosh directly instead of prompting every time. Addresses part 1 of #1099.

Refs #1099

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 12:38:14 +08:00
陈大猫
1ef3f9f979 feat(vault): batch-connect selected hosts (#1162)
The multi-select toolbar already supported batch delete; add a "Connect" button next to it so connecting many hosts no longer has to be done one by one. Connects each selected host in list order with its configured protocol (multi-protocol hosts use their default rather than prompting per host).

Closes #870

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 11:16:01 +08:00
陈大猫
e88313eb84 fix(sftp): confirm delete/overwrite dialogs on Enter key (#1160)
The delete and overwrite confirmation dialogs are opened from a context menu, whose focus-return could leave focus outside the dialog, so pressing Enter did nothing. Focus the confirm button when each dialog opens via onOpenAutoFocus so Enter activates it. Esc and Tab+Enter behavior is unchanged.

Fixes #1150

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 10:21:55 +08:00
pplulee
03cd9bc968 feat(snippets): implement snippet variable handling and UI prompts (#1159) 2026-05-31 18:01:43 +08:00
pplulee
4d7c56e537 主机备注功能与脚本编辑体验优化 (#1158) 2026-05-31 15:39:26 +08:00
Jerry
4769668ff9 docs: add CLAUDE.md and untrack from .gitignore (#1149) 2026-05-29 16:23:59 +08:00
陈大猫
8ca36a695b fix(ssh): repair mangled PEM private keys before parsing (#1147)
* fix(ssh): repair mangled PEM private keys before parsing

A valid PEM key whose framing was damaged in transit — newlines
collapsed to spaces, turned into literal "\n", or lines indented —
fails ssh2's parser with "Unsupported key format" even though the key
material is intact. This commonly happens when a key is copy/pasted
through a field or app that strips line breaks. (follow-up to #1139)

When parsing fails, rebuild clean PEM framing from the BEGIN/END markers
(which survive newline loss) and the base64 body, then retry through the
existing parse and PKCS#8 conversion paths. The body is preserved
byte-for-byte and a repaired key is only used if it re-validates, so
this can never produce a different or invalid key. Encrypted legacy PEM
(Proc-Type/DEK-Info) and truncated keys are left untouched.

Refs #1139

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ssh): detect encryption on mangled OpenSSH keys

A mangled encrypted OpenSSH key (line breaks flattened to literal "\n")
was not recognized as encrypted: the literal escapes corrupt the base64
decode used to read the cipher name, so isKeyEncrypted() returned false
and preparePrivateKeyForAuth routed the key to the unencrypted branch
with no passphrase prompt — and the repaired candidate was discarded
because it can't parse without one.

Repair the PEM framing before reading the OpenSSH cipher name, so such
keys are detected as encrypted and reach the passphrase prompt, where
normalizePrivateKeyForSsh2(key, passphrase) already repairs and
validates them. Addresses Codex review feedback on #1147.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 15:45:56 +08:00
陈大猫
053a976d37 fix(ssh): support PKCS#8 private keys by converting for ssh2 (#1146)
ssh2's key parser only accepts OpenSSH, legacy PKCS#1/SEC1 and PuTTY
keys, so PKCS#8 keys (-----BEGIN PRIVATE KEY----- / BEGIN ENCRYPTED
PRIVATE KEY) fail with "Cannot parse privateKey: Unsupported key
format" even though they are valid and work in other clients such as
Termius. (#1139)

Add privateKeyNormalizer: when ssh2 can't parse a key but it is PKCS#8,
read it with Node's crypto and re-export RSA->PKCS#1 / EC->SEC1, the
legacy PEM forms ssh2 understands. Encrypted PKCS#8 is decrypted with
the passphrase first. Ed25519 (and other) PKCS#8 keys, which have no
legacy PEM form, now surface a clear "convert with ssh-keygen" message
instead of ssh2's opaque error.

Wired into sshAuthHelper's preparePrivateKeyForAuth and
loadIdentityFileForAuth, so SSH, SFTP and port-forwarding all benefit.
Also fixes a latent bug where a correct passphrase on an encrypted
PKCS#8 key was rejected and re-prompted indefinitely.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 15:09:52 +08:00
103 changed files with 5488 additions and 970 deletions

1
.gitignore vendored
View File

@@ -40,7 +40,6 @@ coverage
# Codex
/.codex/
/CLAUDE.md
# AI / Superpowers generated docs (local only)
/docs/superpowers/

14
App.tsx
View File

@@ -59,7 +59,8 @@ import { KeyboardInteractiveRequest } from './components/KeyboardInteractiveModa
import { PassphraseRequest } from './components/PassphraseModal';
import { classifyLocalShellType } from './lib/localShell';
import { useDiscoveredShells, resolveShellSetting } from './lib/useDiscoveredShells';
import { Host, HostProtocol, KnownHost, SerialConfig, SSHKey, TerminalSession, TerminalTheme } from './types';
import { Host, HostProtocol, KnownHost, SerialConfig, Snippet, SSHKey, TerminalSession, TerminalTheme } from './types';
import { resolveSnippetCommand } from './components/SnippetExecutionProvider';
import { AppView } from './application/app/AppView';
import { useAppStartupEffects } from './application/app/useAppStartupEffects';
import { LogViewWrapper, SftpViewMount, TerminalLayerMount, VaultViewContainer } from './application/app/AppMounts';
@@ -244,6 +245,15 @@ function App({ settings }: { settings: SettingsState }) {
copySession,
} = useSessionState();
const handleRunSnippet = useCallback(
async (snippet: Snippet, targetHosts: Host[]) => {
const command = await resolveSnippetCommand(snippet);
if (command === null) return;
runSnippet(snippet, targetHosts, command);
},
[runSnippet],
);
// isMacClient is used for window controls styling
const isMacClient = typeof navigator !== 'undefined' && /Mac|Macintosh/.test(navigator.userAgent);
@@ -932,7 +942,7 @@ function App({ settings }: { settings: SettingsState }) {
[orderedTabs, editorTabs],
);
return <AppView ctx={{ accentMode, activeTabId, activeTerminalTheme, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet, 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 }} />;
return <AppView ctx={{ accentMode, activeTabId, activeTerminalTheme, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, 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 }} />;
}
function AppWithProviders() {

62
CLAUDE.md Normal file
View 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`).

View 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);
});

View File

@@ -2,8 +2,10 @@
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();
@@ -147,8 +149,7 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
if (binding.category === 'sftp') {
continue;
}
const terminalActions = ['copy', 'paste', 'pasteSelection', 'selectAll', 'clearBuffer', 'searchTerminal'];
if (terminalActions.includes(binding.action)) {
if (TERMINAL_PASSTHROUGH_ACTIONS.has(binding.action)) {
if (isTerminalElement) {
return;
}
@@ -736,17 +737,11 @@ export function handleTerminalDataCaptureImpl(getCtx: AppContextGetter, sessionI
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);
let count = 0;
// SSH is always available as base protocol (unless explicitly set to something else)
if (effective.protocol === 'ssh' || !effective.protocol) count++;
// Mosh adds another option
if (effective.moshEnabled) count++;
// Telnet adds another option
if (effective.telnetEnabled) count++;
// If protocol is explicitly telnet (not ssh), count it
if (effective.protocol === 'telnet' && !effective.telnetEnabled) count++;
return count > 1;
return Boolean(effective.telnetEnabled) && effective.protocol !== 'telnet';
}
}

View File

@@ -12,6 +12,7 @@ import { KeyboardInteractiveModal } from '../../components/KeyboardInteractiveMo
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';
@@ -55,6 +56,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
} = ctx;
return (
<SnippetExecutionProvider>
<UnsavedChangesProvider>
{({ prompt }) => {
// Helper: close an editor tab and activate the neighbor (left-preference), or vault.
@@ -549,5 +551,6 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
);
}}
</UnsavedChangesProvider>
</SnippetExecutionProvider>
);
}

View 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}`);
}
}
});

View File

@@ -191,6 +191,7 @@ export const enCoreMessages: Messages = {
// 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',
@@ -641,6 +642,8 @@ export const enCoreMessages: Messages = {
'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.',

View File

@@ -252,6 +252,14 @@ export const enTerminalMessages: Messages = {
'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',
@@ -342,6 +350,18 @@ export const enTerminalMessages: Messages = {
'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',
@@ -544,6 +564,18 @@ export const enTerminalMessages: Messages = {
'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',

View File

@@ -440,6 +440,12 @@ export const enVaultMessages: Messages = {
'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',
@@ -475,6 +481,7 @@ export const enVaultMessages: Messages = {
'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',
@@ -586,6 +593,7 @@ export const enVaultMessages: Messages = {
'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',

View File

@@ -191,6 +191,7 @@ export const ruCoreMessages: Messages = {
// Vault Sidebar
'vault.sidebar.collapse': 'Свернуть боковую панель',
'vault.sidebar.expand': 'Развернуть боковую панель',
'vault.sidebar.resize': 'Изменить ширину боковой панели',
// Settings > Application
'settings.application.checkUpdates': 'Проверить обновления',

View File

@@ -273,6 +273,14 @@ export const ruTerminalMessages: Messages = {
'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': 'Удалённая версия',
@@ -363,6 +371,18 @@ export const ruTerminalMessages: Messages = {
'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': 'Завершите авторизацию в браузере',

View File

@@ -30,6 +30,8 @@ export const ruVaultMessages: Messages = {
'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': 'Сохраняйте хосты, чтобы быстро подключаться к серверам, виртуальным машинам и контейнерам.',
@@ -473,6 +475,12 @@ export const ruVaultMessages: Messages = {
'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': 'Порт и учётные данные',
@@ -508,6 +516,7 @@ export const ruVaultMessages: Messages = {
'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': 'Пароль',
@@ -618,6 +627,7 @@ export const ruVaultMessages: Messages = {
'hostDetails.telnet.password': 'Пароль Telnet',
'hostDetails.charset.placeholder': 'Кодировка (например, UTF-8)',
'hostDetails.telnet.add': 'Добавить протокол Telnet',
'hostDetails.telnet.setDefault': 'Подключаться по Telnet по умолчанию',
'hostDetails.tags': 'Теги',
'hostDetails.group': 'Группа',
'hostDetails.selectGroup': 'Выберите группу',

View File

@@ -175,6 +175,7 @@ export const zhCNCoreMessages: Messages = {
// Vault Sidebar
'vault.sidebar.collapse': '收起侧边栏',
'vault.sidebar.expand': '展开侧边栏',
'vault.sidebar.resize': '调整侧边栏宽度',
// Settings > Application
'settings.application.checkUpdates': '检查更新',
@@ -420,6 +421,8 @@ export const zhCNCoreMessages: Messages = {
'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': '保存主机以快速连接到你的服务器、虚拟机和容器。',

View File

@@ -545,6 +545,18 @@ export const zhCNTerminalMessages: Messages = {
'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': '连接串口',

View File

@@ -34,6 +34,12 @@ export const zhCNVaultMessages: Messages = {
'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': '端口与凭据',
@@ -69,6 +75,7 @@ export const zhCNVaultMessages: Messages = {
'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': '密码',
@@ -146,6 +153,7 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.telnet.password': 'Telnet 密码',
'hostDetails.charset.placeholder': '字符集(例如 UTF-8',
'hostDetails.telnet.add': '添加 Telnet 协议',
'hostDetails.telnet.setDefault': '默认用 Telnet 连接',
'hostDetails.tags': '标签',
'hostDetails.group': '分组',
'hostDetails.selectGroup': '选择分组',
@@ -449,6 +457,14 @@ export const zhCNVaultMessages: Messages = {
'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': '远端版本',
@@ -538,6 +554,18 @@ export const zhCNVaultMessages: Messages = {
'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': '请在浏览器中完成授权',

View File

@@ -0,0 +1,4 @@
export {
readSnippetVariableValuesForSnippet,
saveSnippetVariableValues,
} from '../../infrastructure/persistence/snippetVariableValuesStorage';

View File

@@ -17,11 +17,13 @@ import {
} 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 {
@@ -319,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'));
@@ -403,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
@@ -449,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);
@@ -464,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({
@@ -491,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'));
@@ -507,6 +526,58 @@ export const useAutoSync = (config: AutoSyncConfig) => {
return;
}
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
@@ -518,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
@@ -536,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
@@ -548,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);
@@ -574,6 +651,14 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// 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

View 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);
});

View File

@@ -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(),

View File

@@ -49,5 +49,8 @@ export const getTerminalPassthroughActions = (): Set<string> => {
'selectAll',
'clearBuffer',
'searchTerminal',
'increaseTerminalFontSize',
'decreaseTerminalFontSize',
'resetTerminalFontSize',
]);
};

View File

@@ -731,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 => ({
@@ -760,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,
}));

View File

@@ -41,7 +41,9 @@ const {
applySyncPayload,
buildLocalVaultPayload,
buildSyncPayload,
hasCloudSyncEntityData,
hasMeaningfulCloudSyncData,
shouldPromptCloudVaultRecovery,
} = await import("./syncPayload.ts");
const storageKeys = await import("../infrastructure/config/storageKeys.ts");
@@ -534,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")]));

View File

@@ -123,6 +123,22 @@ export function hasMeaningfulCloudSyncData(payload: SyncPayload): boolean {
);
}
/**
* 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 {

View File

@@ -527,6 +527,11 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
// Apply merged data if a three-way merge happened
if (result.mergedPayload && onApplyPayload) {
await Promise.resolve(onApplyPayload(result.mergedPayload));
if (result.remoteFile) {
await sync.commitRemoteInspection(result.provider, result.remoteFile, result.mergedPayload, {
recordDownload: true,
});
}
}
toast.success(t('cloudSync.sync.success', { provider }));
} else if (result.conflictDetected) {
@@ -542,14 +547,20 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
// Resolve conflict
const handleResolveConflict = async (resolution: 'USE_LOCAL' | 'USE_REMOTE') => {
try {
const payload = await sync.resolveConflict(resolution);
if (payload && resolution === 'USE_REMOTE') {
const remoteResult = await sync.resolveConflict(resolution);
if (remoteResult && resolution === 'USE_REMOTE') {
// USE_REMOTE applies cloud data over local — same data-loss
// shape as a local backup restore, so gate auto-sync in
// every other window the same way.
await withRestoreBarrier(async () => {
await Promise.resolve(onApplyPayload(payload));
await Promise.resolve(onApplyPayload(remoteResult.payload));
});
await sync.commitRemoteInspection(
remoteResult.provider,
remoteResult.remoteFile,
remoteResult.payload,
{ recordDownload: true },
);
toast.success(t('cloudSync.resolve.downloaded'));
} else if (resolution === 'USE_LOCAL') {
// Re-sync with local data. Hold the same cross-window
@@ -575,6 +586,11 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
for (const result of (results as Map<CloudProvider, SyncResult>).values()) {
if (result.mergedPayload) {
await Promise.resolve(onApplyPayload(result.mergedPayload));
if (result.remoteFile) {
await sync.commitRemoteInspection(result.provider, result.remoteFile, result.mergedPayload, {
recordDownload: true,
});
}
break;
}
}

View File

@@ -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",
};

View File

@@ -27,6 +27,8 @@ import ThemeSelectPanel from "./ThemeSelectPanel";
import {
ChainPanel,
EnvVarsPanel,
HostDetailsSection,
HostDetailsSettingRow,
ProxyPanel,
} from "./host-details";
import {
@@ -35,7 +37,6 @@ import {
type AsidePanelLayout,
} from "./ui/aside-panel";
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";
@@ -532,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}
@@ -558,7 +556,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
placeholder={t("vault.groups.details.parentGroup")}
className="w-full"
/>
</Card>
</HostDetailsSection>
<GroupSshSettingsSection
sshEnabled={sshEnabled}
@@ -588,12 +586,10 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
{/* 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">
@@ -610,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">
@@ -654,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"
@@ -758,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 */}

View File

@@ -1,37 +1,27 @@
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 { useI18n } from "../application/i18n/I18nProvider";
import { AlgorithmOverridesPanel } from "./host-details/AlgorithmOverridesPanel";
import { cn } from "../lib/utils";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { Card } from "./ui/card";
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; enabled: boolean; onToggle: () => void }> = ({ label, enabled, onToggle }) => {
const { t } = useI18n();
const ToggleRow: React.FC<{ label: string; hint?: React.ReactNode; enabled: boolean; onToggle: () => void }> = ({ label, hint, enabled, onToggle }) => {
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>
<HostDetailsSettingRow label={label} hint={hint}>
<Switch checked={enabled} onCheckedChange={() => onToggle()} />
</HostDetailsSettingRow>
);
};
@@ -63,12 +53,11 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
if (!sshEnabled) return null;
return (
<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>
<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">
@@ -85,7 +74,8 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
</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">
@@ -380,6 +370,7 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
<ToggleRow
label={t("hostDetails.agentForwarding")}
hint={t("hostDetails.agentForwarding.desc")}
enabled={!!form.agentForwarding}
onToggle={() => update("agentForwarding", !form.agentForwarding)}
/>
@@ -401,6 +392,7 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
the UI while connections still applied it. */}
<ToggleRow
label={t("hostDetails.legacyAlgorithms")}
hint={t("hostDetails.legacyAlgorithms.desc")}
enabled={!!(form.legacyAlgorithms ?? inheritedLegacyAlgorithms)}
onToggle={() => update(
"legacyAlgorithms",
@@ -410,15 +402,13 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
<ToggleRow
label={t("hostDetails.skipEcdsaHostKey")}
hint={t("hostDetails.skipEcdsaHostKey.desc")}
enabled={!!(form.skipEcdsaHostKey ?? inheritedSkipEcdsaHostKey)}
onToggle={() => update(
"skipEcdsaHostKey",
!(form.skipEcdsaHostKey ?? inheritedSkipEcdsaHostKey),
)}
/>
<p className="text-xs text-muted-foreground break-words">
{t("hostDetails.skipEcdsaHostKey.desc")}
</p>
<Collapsible open={showAlgorithmOverrides} onOpenChange={setShowAlgorithmOverrides}>
<CollapsibleTrigger asChild>
<Button
@@ -451,7 +441,7 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
{/* 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"
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">
@@ -481,7 +471,7 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
{/* 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"
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">
@@ -501,7 +491,7 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
{/* 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"
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">
@@ -536,8 +526,7 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
{/* 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. */}
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">{t("hostDetails.backspaceBehavior")}</p>
<HostDetailsSettingRow label={t("hostDetails.backspaceBehavior")}>
<Select
value={form.backspaceBehavior ?? "default"}
onValueChange={(v) => update("backspaceBehavior", v === "default" ? undefined : v)}
@@ -550,7 +539,7 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
<SelectItem value="ctrl-h">^H (0x08)</SelectItem>
</SelectContent>
</Select>
</div>
</Card>
</HostDetailsSettingRow>
</HostDetailsSection>
);
};

View File

@@ -6,32 +6,22 @@ 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 { Card } from "./ui/card";
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";
import { cn } from "../lib/utils";
import { useI18n } from "../application/i18n/I18nProvider";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type HostDetailsAdvancedSectionsProps = Record<string, any>;
const ToggleRow: React.FC<{ label: string; enabled: boolean; onToggle: () => void }> = ({ label, enabled, onToggle }) => {
const { t } = useI18n();
const ToggleRow: React.FC<{ label: string; hint?: React.ReactNode; enabled: boolean; onToggle: () => void }> = ({ label, hint, enabled, onToggle }) => {
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>
<HostDetailsSettingRow label={label} hint={hint}>
<Switch checked={enabled} onCheckedChange={() => onToggle()} />
</HostDetailsSettingRow>
);
};
@@ -58,13 +48,10 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
groupDefaults,
}) => (
<>
<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("hostDetails.section.appearance")}
</p>
</div>
<HostDetailsSection
icon={<Palette size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.appearance")}
>
{/* SSH Theme Selection */}
<button
@@ -107,78 +94,78 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
)}
{/* Font Size */}
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Font Size:</span>
<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="px-2 h-8"
>
-
</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="w-16 text-center h-8"
/>
<span className="text-sm text-muted-foreground">pt</span>
{hasEffectiveFontSizeOverride && (
<HostDetailsSettingRow label="Font Size">
<div className="flex items-center gap-2">
<Button
variant="ghost"
variant="outline"
size="sm"
className="ml-auto h-8 text-primary"
onClick={() => setForm((prev) => clearHostFontSizeOverride(prev))}
onClick={() => {
if (effectiveFontSize > MIN_FONT_SIZE) {
setForm((prev) => ({
...prev,
fontSize: effectiveFontSize - 1,
fontSizeOverride: true,
}));
}
}}
disabled={effectiveFontSize <= MIN_FONT_SIZE}
className="h-8 w-8 px-0"
>
{t("common.useGlobal")}
-
</Button>
)}
<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="px-2 h-8"
>
+
</Button>
</div>
</Card>
<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>
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center gap-2">
<Wifi size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("hostDetails.section.mosh")}</p>
</div>
<HostDetailsSection
icon={<Wifi size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.mosh")}
>
<ToggleRow
label="Mosh"
enabled={!!form.moshEnabled}
@@ -196,22 +183,19 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
}
}}
/>
</Card>
</HostDetailsSection>
{/* Agent Forwarding */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center gap-2">
<Forward size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("hostDetails.section.agentForwarding")}</p>
</div>
<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)}
/>
<p className="text-xs text-muted-foreground">
{t("hostDetails.agentForwarding.desc")}
</p>
{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" />
@@ -225,41 +209,35 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
</div>
</div>
)}
</Card>
</HostDetailsSection>
{/* X11 Forwarding */}
{(!form.protocol || form.protocol === "ssh") && !form.moshEnabled && (
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center gap-2">
<TerminalSquare size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("hostDetails.section.x11Forwarding")}</p>
</div>
<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)}
/>
<p className="text-xs text-muted-foreground">
{t("hostDetails.x11Forwarding.desc")}
</p>
</Card>
</HostDetailsSection>
)}
{/* Network Device Mode — only for SSH hosts without Mosh (serial already uses raw mode) */}
{(!form.protocol || form.protocol === 'ssh') && !form.moshEnabled && (
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center gap-2">
<Router size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("hostDetails.section.deviceType")}</p>
</div>
<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')}
/>
<p className="text-xs text-muted-foreground break-words">
{t("hostDetails.deviceType.desc")}
</p>
{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" />
@@ -268,15 +246,14 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
</p>
</div>
)}
</Card>
</HostDetailsSection>
)}
{/* SSH Algorithms */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center gap-2">
<ShieldAlert size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("hostDetails.section.sshAlgorithms")}</p>
</div>
<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
@@ -285,15 +262,13 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
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),
)}
/>
<p className="text-xs text-muted-foreground break-words">
{t("hostDetails.legacyAlgorithms.desc")}
</p>
{(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" />
@@ -304,15 +279,13 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
)}
<ToggleRow
label={t("hostDetails.skipEcdsaHostKey")}
hint={t("hostDetails.skipEcdsaHostKey.desc")}
enabled={!!(form.skipEcdsaHostKey ?? effectiveGroupDefaults?.skipEcdsaHostKey)}
onToggle={() => update(
"skipEcdsaHostKey",
!(form.skipEcdsaHostKey ?? effectiveGroupDefaults?.skipEcdsaHostKey),
)}
/>
<p className="text-xs text-muted-foreground break-words">
{t("hostDetails.skipEcdsaHostKey.desc")}
</p>
<Collapsible open={showAlgorithmOverrides} onOpenChange={setShowAlgorithmOverrides}>
<CollapsibleTrigger asChild>
<Button
@@ -349,21 +322,19 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
/>
</CollapsibleContent>
</Collapsible>
</Card>
</HostDetailsSection>
{/* Terminal Behavior — input/output key mappings (backspace, etc.) */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center gap-2">
<TerminalSquare size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("hostDetails.section.terminalBehavior")}</p>
</div>
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">{t("hostDetails.backspaceBehavior")}</p>
<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-8 w-auto text-xs">
<SelectTrigger className="h-10 w-36 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -371,17 +342,17 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
<SelectItem value="ctrl-h">^H (0x08)</SelectItem>
</SelectContent>
</Select>
</div>
</Card>
</HostDetailsSettingRow>
</HostDetailsSection>
{/* Per-host keepalive override */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center gap-2">
<HeartPulse size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("hostDetails.section.keepalive")}</p>
</div>
<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;
@@ -394,18 +365,14 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
}
}}
/>
<p className="text-xs text-muted-foreground break-words">
{t("hostDetails.keepalive.desc")}
</p>
{form.keepaliveOverride && (
<div className="space-y-2 pt-1">
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">{t("hostDetails.keepalive.interval")}</p>
<input
<HostDetailsSettingRow label={t("hostDetails.keepalive.interval")}>
<Input
type="number"
min={0}
max={3600}
className="h-8 w-24 rounded-md border border-input bg-background px-2 text-xs"
className="h-8 w-24 text-xs"
value={form.keepaliveInterval ?? 0}
onChange={(e) => {
const v = parseInt(e.target.value, 10);
@@ -414,14 +381,13 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
update("keepaliveInterval", v);
}}
/>
</div>
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">{t("hostDetails.keepalive.countMax")}</p>
<input
</HostDetailsSettingRow>
<HostDetailsSettingRow label={t("hostDetails.keepalive.countMax")}>
<Input
type="number"
min={1}
max={100}
className="h-8 w-24 rounded-md border border-input bg-background px-2 text-xs"
className="h-8 w-24 text-xs"
value={form.keepaliveCountMax ?? 3}
onChange={(e) => {
const v = parseInt(e.target.value, 10);
@@ -430,7 +396,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
update("keepaliveCountMax", v);
}}
/>
</div>
</HostDetailsSettingRow>
{(form.keepaliveInterval ?? 0) === 0 && (
<p className="text-xs text-muted-foreground break-words pl-1">
{t("hostDetails.keepalive.disabledHint")}
@@ -438,30 +404,24 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
)}
</div>
)}
</Card>
</HostDetailsSection>
{/* Proxy via Hosts (Jump Hosts / ProxyJump) */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Link2 size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">
{t("hostDetails.jumpHosts")}
</p>
</div>
{chainedHosts.length > 0 ? (
<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"
>
<Badge variant="outline" className="text-xs text-muted-foreground">
{t("hostDetails.jumpHosts.direct")}
</Badge>
)}
</div>
)
}
>
{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"
@@ -513,14 +473,14 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
{t("hostDetails.jumpHosts.configure")}
</Button>
)}
</Card>
</HostDetailsSection>
{/* Proxy Configuration */}
<Card className="p-3 space-y-2 bg-card border-border/80 overflow-hidden">
<div className="flex items-center gap-2">
<Globe size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("hostDetails.proxy")}</p>
</div>
<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
@@ -565,16 +525,13 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
{t("hostDetails.proxy.configure")}
</Button>
)}
</Card>
</HostDetailsSection>
{/* Environment Variables */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Variable size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("hostDetails.envVars")}</p>
</div>
</div>
<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"
@@ -606,14 +563,14 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
{t("hostDetails.envVars.add")}
</Button>
)}
</Card>
</HostDetailsSection>
{/* Startup Command */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center gap-2">
<TerminalSquare size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("hostDetails.startupCommand")}</p>
</div>
<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 || ""}
@@ -621,9 +578,6 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
className="min-h-[80px] font-mono text-sm"
rows={3}
/>
<p className="text-xs text-muted-foreground">
{t("hostDetails.startupCommand.help")}
</p>
</Card>
</HostDetailsSection>
</>
);

View File

@@ -4,8 +4,8 @@ import type { Host } from "../types";
import { cn } from "../lib/utils";
import { DistroAvatar } from "./DistroAvatar";
import { Button } from "./ui/button";
import { Card } from "./ui/card";
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";
@@ -47,13 +47,10 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
getDistroOptionLabel,
}) => (
<>
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center gap-2">
<MapPin size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">
{t("hostDetails.section.address")}
</p>
</div>
<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}
@@ -71,15 +68,13 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
className="h-10 flex-1"
/>
</div>
</Card>
</HostDetailsSection>
<Card className="p-3 space-y-3 bg-card border-border/80 overflow-hidden">
<div className="flex items-center gap-2">
<KeyRound size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">
{t("hostDetails.section.portCredentials")}
</p>
</div>
<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>
@@ -594,48 +589,35 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
</div>
)}
</div>
</Card>
</HostDetailsSection>
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center gap-2">
<FolderLock size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">
{t("hostDetails.section.sftp")}
</p>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<div className="text-sm font-medium">
{t("hostDetails.sftp.sudo")}
</div>
<div className="text-xs text-muted-foreground">
{t("hostDetails.sftp.sudo.desc")}
</div>
</div>
<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)}
/>
</div>
</HostDetailsSettingRow>
{form.sftpSudo && !form.password && !selectedIdentity?.password && (
<p className="text-xs text-amber-500">
{t("hostDetails.sftp.sudo.passwordWarning")}
</p>
)}
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<div className="text-sm font-medium">
{t("hostDetails.sftp.encoding")}
</div>
<div className="text-xs text-muted-foreground">
{t("hostDetails.sftp.encoding.desc")}
</div>
</div>
<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-8 w-28">
<SelectTrigger className="h-10 w-32">
<SelectValue placeholder={t("sftp.encoding.label")} />
</SelectTrigger>
<SelectContent>
@@ -644,17 +626,15 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
<SelectItem value="gb18030">{t("sftp.encoding.gb18030")}</SelectItem>
</SelectContent>
</Select>
</div>
</Card>
</HostDetailsSettingRow>
</HostDetailsSection>
{form.os === "linux" && (
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center gap-2">
<img src="/distro/linux.svg" alt="Linux" className="h-3.5 w-3.5 opacity-70 dark:invert" />
<p className="text-xs font-semibold">{t("hostDetails.distro.title")}</p>
</div>
<p className="text-xs text-muted-foreground">{t("hostDetails.distro.desc")}</p>
<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>
@@ -749,7 +729,7 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
</div>
)}
</div>
</Card>
</HostDetailsSection>
)}
</>
);

View File

@@ -1,5 +1,8 @@
import {
Check,
Eye,
EyeOff,
FileText,
FolderPlus,
Plus,
Settings2,
@@ -36,17 +39,19 @@ import { HostDetailsConnectionSections } from "./HostDetailsConnectionSections";
import { LINUX_DISTRO_OPTION_IDS, parseOptionalPortInput, resolveDetailsTelnetPassword, resolveDetailsTelnetPort, resolveDetailsTelnetUsername } from "./HostDetailsPanel.helpers";
export { parseOptionalPortInput } from "./HostDetailsPanel.helpers";
import { Button } from "./ui/button";
import { Card } from "./ui/card";
import { Combobox, ComboboxOption, MultiCombobox } from "./ui/combobox";
import { Input } from "./ui/input";
import { Switch } from "./ui/switch";
import { toast } from "./ui/toast";
import {
ChainPanel,
CreateGroupPanel,
HostDetailsSection,
EnvVarsPanel,
ProxyPanel,
} from "./host-details";
import { HostNotesEditor } from "./host/HostNotesEditor";
type CredentialType = "sshid" | "key" | "certificate" | "localKeyFile" | null;
type SubPanel =
@@ -132,6 +137,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
const [identitySuggestionsOpen, setIdentitySuggestionsOpen] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [showTelnetPassword, setShowTelnetPassword] = useState(false);
const [showAlgorithmOverrides, setShowAlgorithmOverrides] = useState(false);
const [newKeyFilePath, setNewKeyFilePath] = useState("");
@@ -162,6 +168,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
setGroupInputValue(initialData.group || "");
setPendingReferenceKeyPath(null);
setShowPassword(false);
setShowTelnetPassword(false);
}
}, [initialData]);
@@ -388,6 +395,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
label: finalLabel,
group: finalGroup,
tags: form.tags || [],
notes: form.notes?.trim() || undefined,
port: finalPort,
password: form.savePassword === false ? undefined : form.password,
managedSourceId: finalManagedSourceId,
@@ -696,13 +704,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
}
>
<AsidePanelContent>
<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("hostDetails.section.general")}
</p>
</div>
<HostDetailsSection
icon={<Settings2 size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.general")}
>
<Input
placeholder={t("hostDetails.label.placeholder")}
value={form.label}
@@ -759,7 +764,20 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
triggerClassName="flex-1 min-h-10"
/>
</div>
</Card>
</HostDetailsSection>
<HostDetailsSection
icon={<FileText size={14} className="text-muted-foreground shrink-0" />}
title={t("hostDetails.notes.label")}
hint={t("hostDetails.notes.help")}
>
<HostNotesEditor
panelKey={form.id}
value={form.notes ?? ""}
onChange={(notes) => update("notes", notes)}
showHeader={false}
/>
</HostDetailsSection>
<HostDetailsConnectionSections
t={t}
@@ -822,7 +840,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</div>
{form.telnetEnabled || form.protocol === "telnet" ? (
<Card className="p-3 space-y-3 bg-card border-border/80">
<HostDetailsSection
icon={<Plus size={14} className="text-muted-foreground" />}
title="Telnet"
>
<div className="flex items-center justify-between">
<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">{t("hostDetails.telnetOn")}</span>
@@ -848,6 +869,14 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
)}
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-xs text-muted-foreground">{t("hostDetails.telnet.setDefault")}</span>
<Switch
checked={form.protocol === "telnet"}
onCheckedChange={(checked) => update("protocol", checked ? "telnet" : "ssh")}
/>
</div>
<p className="text-xs font-semibold">{t("hostDetails.telnet.credentials")}</p>
<Input
placeholder={t("hostDetails.telnet.username")}
@@ -857,15 +886,24 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
}
className="h-10"
/>
<Input
placeholder={t("hostDetails.telnet.password")}
type="password"
value={effectiveTelnetPassword}
onChange={(e) =>
update("telnetPassword" as keyof Host, e.target.value)
}
className="h-10"
/>
<div className="relative">
<Input
placeholder={t("hostDetails.telnet.password")}
type={showTelnetPassword ? "text" : "password"}
value={effectiveTelnetPassword}
onChange={(e) =>
update("telnetPassword" as keyof Host, e.target.value)
}
className="h-10 pr-10"
/>
<button
type="button"
onClick={() => setShowTelnetPassword(!showTelnetPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
>
{showTelnetPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
<Input
placeholder={groupDefaults?.charset || t("hostDetails.charset.placeholder")}
@@ -902,7 +940,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
{customThemeStore.getThemeById(effectiveTelnetThemeId)?.name || "Flexoki Dark"}
</span>
</button>
</Card>
</HostDetailsSection>
) : (
<Button
variant="ghost"

View File

@@ -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>

View File

@@ -7,7 +7,6 @@ import {
List as ListIcon,
MoreHorizontal,
Plus,
Search,
Shield,
Trash2,
Upload,
@@ -41,10 +40,14 @@ import {
ContextMenuTrigger,
} from "./ui/context-menu";
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
import { Input } from "./ui/input";
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 {
@@ -524,8 +527,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 */}
@@ -628,25 +630,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} />
@@ -674,7 +670,7 @@ echo $3 >> "$FILE"`);
</DropdownContent>
</Dropdown>
</div>
</div>
</VaultPageHeader>
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto">

View File

@@ -6,7 +6,6 @@ import {
LayoutGrid,
List as ListIcon,
RefreshCw,
Search,
Server,
Shield,
Trash2,
@@ -35,11 +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[];
@@ -476,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} />
) : (
@@ -527,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}
>
@@ -553,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">

View File

@@ -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">

View File

@@ -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">

View File

@@ -24,7 +24,7 @@ import {
} from './ui/dialog';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Textarea } from './ui/textarea';
import { CodeTextarea } from './ui/code-textarea';
export interface QuickAddSnippetDialogProps {
snippets: Snippet[];
@@ -170,6 +170,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
onChange={(e) => setLabel(e.target.value)}
placeholder={t('snippets.field.descriptionPlaceholder')}
className="h-9"
spellCheck={false}
/>
</div>
@@ -177,13 +178,12 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
<Label htmlFor="quick-add-snippet-command" className="text-xs">
{t('snippets.field.scriptRequired')}
</Label>
<Textarea
<CodeTextarea
id="quick-add-snippet-command"
value={command}
onChange={(e) => setCommand(e.target.value)}
placeholder="echo hello"
className="min-h-[120px] font-mono text-xs"
spellCheck={false}
className="min-h-[120px]"
/>
</div>

View File

@@ -25,7 +25,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/t
interface ScriptsSidePanelProps {
snippets: Snippet[];
packages: string[];
onSnippetClick: (command: string, noAutoRun?: boolean) => void;
onSnippetClick: (snippet: Snippet) => void;
isVisible?: boolean;
}
@@ -203,8 +203,8 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
}, [normalizedPackages, snippets, expandedPaths, searchMatches]);
const handleSnippetClick = useCallback(
(command: string, noAutoRun?: boolean) => {
onSnippetClick(command, noAutoRun);
(snippet: Snippet) => {
onSnippetClick(snippet);
},
[onSnippetClick],
);
@@ -282,7 +282,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
snippet={s}
depth={0}
subtitle={s.package || t('terminal.toolbar.library')}
onClick={() => handleSnippetClick(s.command, s.noAutoRun)}
onClick={() => handleSnippetClick(s)}
onEdit={() => handleEditSnippet(s)}
onDelete={() => handleDeleteSnippet(s.id)}
editLabel={t('action.edit')}
@@ -305,7 +305,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
key={`snip:${row.id}`}
snippet={row.snippet}
depth={row.depth}
onClick={() => handleSnippetClick(row.snippet.command, row.snippet.noAutoRun)}
onClick={() => handleSnippetClick(row.snippet)}
onEdit={() => handleEditSnippet(row.snippet)}
onDelete={() => handleDeleteSnippet(row.snippet.id)}
editLabel={t('action.edit')}

View File

@@ -165,7 +165,8 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
(h) =>
h.label.toLowerCase().includes(q) ||
h.hostname.toLowerCase().includes(q) ||
h.username.toLowerCase().includes(q),
h.username.toLowerCase().includes(q) ||
(h.notes?.toLowerCase().includes(q) ?? false),
);
}

View File

@@ -20,6 +20,7 @@ import {
AsidePanelFooter,
type AsidePanelLayout,
} from './ui/aside-panel';
import { HostNotesEditor } from './host/HostNotesEditor';
interface SerialPort {
path: string;
@@ -73,6 +74,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
const [charset, setCharset] = useState(initialData.charset || 'UTF-8');
const [tags, setTags] = useState<string[]>(initialData.tags || []);
const [group, setGroup] = useState(initialData.group || '');
const [notes, setNotes] = useState(initialData.notes ?? '');
const loadPorts = useCallback(async () => {
setIsLoadingPorts(true);
@@ -114,6 +116,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
group,
charset,
serialConfig: config,
notes: notes.trim() || undefined,
};
onSave(updatedHost);
@@ -257,6 +260,12 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
/>
</div>
<HostNotesEditor
panelKey={initialData.id}
value={notes}
onChange={setNotes}
/>
{/* Group */}
<div className="space-y-2">
<Label>{t('hostDetails.group')}</Label>

View File

@@ -0,0 +1,234 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import type { Snippet } from '../domain/models';
import {
applySnippetVariables,
parseSnippetVariables,
previewSnippetCommand,
snippetHasVariables,
type SnippetVariableDef,
} from '../domain/snippetVariables';
import {
readSnippetVariableValuesForSnippet,
saveSnippetVariableValues,
} from '../application/state/snippetVariableValues';
import { Button } from './ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { ScrollArea } from './ui/scroll-area';
interface PendingPrompt {
snippet: Snippet;
variables: SnippetVariableDef[];
resolve: (values: Record<string, string> | null) => void;
}
function buildInitialValues(
snippet: Snippet,
variables: SnippetVariableDef[],
): Record<string, string> {
const cached = readSnippetVariableValuesForSnippet(snippet.id);
const values: Record<string, string> = {};
for (const def of variables) {
if (cached[def.name] !== undefined) {
values[def.name] = cached[def.name];
} else if (def.defaultValue !== undefined) {
values[def.name] = def.defaultValue;
} else {
values[def.name] = '';
}
}
return values;
}
function isFormValid(
variables: SnippetVariableDef[],
values: Record<string, string>,
): boolean {
for (const def of variables) {
const raw = values[def.name] ?? '';
if (raw.trim() === '' && def.defaultValue === undefined) {
return false;
}
}
return true;
}
export const SnippetExecutionProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const { t } = useI18n();
const [pending, setPending] = useState<PendingPrompt | null>(null);
const [values, setValues] = useState<Record<string, string>>({});
const pendingRef = useRef<PendingPrompt | null>(null);
pendingRef.current = pending;
const prompt = useCallback(
(snippet: Snippet) =>
new Promise<Record<string, string> | null>((resolve) => {
const prior = pendingRef.current;
if (prior) prior.resolve(null);
const variables = parseSnippetVariables(snippet.command);
if (variables.length === 0) {
resolve({});
return;
}
setValues(buildInitialValues(snippet, variables));
setPending({ snippet, variables, resolve });
}),
[],
);
useEffect(() => {
promptSnippetVariablesSingleton = prompt;
return () => {
promptSnippetVariablesSingleton = null;
};
}, [prompt]);
useEffect(() => () => {
const prior = pendingRef.current;
if (prior) {
prior.resolve(null);
pendingRef.current = null;
}
}, []);
const preview = useMemo(() => {
if (!pending) return '';
return previewSnippetCommand(pending.snippet.command, values);
}, [pending, values]);
const canSubmit = pending ? isFormValid(pending.variables, values) : false;
const closeWith = useCallback((result: Record<string, string> | null) => {
if (!pending) return;
pending.resolve(result);
setPending(null);
}, [pending]);
const handleSubmit = useCallback(() => {
if (!pending || !canSubmit) return;
const result = applySnippetVariables(pending.snippet.command, values);
if (!result.ok) return;
saveSnippetVariableValues(pending.snippet.id, values);
closeWith(values);
}, [pending, canSubmit, values, closeWith]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey && canSubmit) {
e.preventDefault();
handleSubmit();
}
},
[canSubmit, handleSubmit],
);
return (
<>
{children}
<Dialog open={!!pending} onOpenChange={(open) => { if (!open) closeWith(null); }}>
<DialogContent className="sm:max-w-[520px]" onKeyDown={handleKeyDown}>
<DialogHeader>
<DialogTitle>{t('snippets.variables.dialogTitle')}</DialogTitle>
<DialogDescription>
{t('snippets.variables.dialogDesc', { label: pending?.snippet.label ?? '' })}
</DialogDescription>
</DialogHeader>
{pending && (
<div className="space-y-4 py-1">
<p className="text-xs text-muted-foreground">{t('snippets.variables.hint')}</p>
<div className="space-y-3">
{pending.variables.map((def) => {
const raw = values[def.name] ?? '';
const invalid = raw.trim() === '' && def.defaultValue === undefined;
return (
<div key={def.name} className="space-y-1.5">
<Label htmlFor={`snippet-var-${def.name}`}>{def.name}</Label>
<Input
id={`snippet-var-${def.name}`}
value={raw}
placeholder={
def.defaultValue !== undefined
? t('snippets.variables.placeholderDefault', { value: def.defaultValue })
: t('snippets.variables.placeholder')
}
onChange={(e) => {
const next = e.target.value;
setValues((prev) => ({ ...prev, [def.name]: next }));
}}
className={invalid ? 'border-destructive' : undefined}
autoFocus={def === pending.variables[0]}
/>
{invalid && (
<p className="text-xs text-destructive">{t('snippets.variables.required')}</p>
)}
</div>
);
})}
</div>
<div className="space-y-1.5">
<p className="text-xs font-semibold text-muted-foreground">
{t('snippets.variables.preview')}
</p>
<ScrollArea className="max-h-32 rounded-md border border-border/60 bg-muted/30">
<pre className="p-3 text-[11px] font-mono whitespace-pre-wrap break-all">
{preview}
</pre>
</ScrollArea>
</div>
</div>
)}
<DialogFooter className="gap-2">
<Button variant="ghost" onClick={() => closeWith(null)}>
{t('common.cancel')}
</Button>
<Button variant="default" disabled={!canSubmit} onClick={handleSubmit}>
{t('snippets.variables.run')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
let promptSnippetVariablesSingleton:
| ((snippet: Snippet) => Promise<Record<string, string> | null>)
| null = null;
export async function resolveSnippetCommand(snippet: Snippet): Promise<string | null> {
if (!snippetHasVariables(snippet.command)) {
return snippet.command;
}
const promptFn = promptSnippetVariablesSingleton;
if (!promptFn) {
return snippet.command;
}
const values = await promptFn(snippet);
if (values === null) {
return null;
}
const result = applySnippetVariables(snippet.command, values);
if (!result.ok) {
return null;
}
return result.command;
}

View File

@@ -10,11 +10,16 @@ import { Button } from './ui/button';
import { ComboboxOption } from './ui/combobox';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from './ui/context-menu';
import { Dropdown, DropdownContent, DropdownTrigger } from './ui/dropdown';
import { Input } from './ui/input';
import { SortDropdown, SortMode } from './ui/sort-dropdown';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
import { SnippetsRightPanel } from './SnippetsRightPanel';
import { SnippetsPackageDialogs } from './SnippetsPackageDialogs';
import {
VaultHeaderSearch,
VaultPageHeader,
vaultHeaderIconButtonClass,
vaultHeaderSecondaryButtonClass,
} from './vault/VaultPageHeader';
interface SnippetsManagerProps {
snippets: Snippet[];
@@ -695,18 +700,14 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
<TooltipProvider delayDuration={300}>
<div className="h-full min-h-0 flex relative">
<div className="flex-1 flex flex-col min-h-0 min-w-0 overflow-hidden">
<header className="border-b border-border/50 bg-secondary/80 supports-[backdrop-filter]:backdrop-blur-sm">
<div className="h-14 px-4 py-2 flex items-center gap-3">
<div className="relative w-64">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder={t('snippets.searchPlaceholder')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-10 pl-9 bg-secondary border-border/60 text-sm"
/>
</div>
<Button onClick={() => handleEdit()} size="sm" className="h-10">
<VaultPageHeader>
<VaultHeaderSearch
placeholder={t('snippets.searchPlaceholder')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-64"
/>
<Button onClick={() => handleEdit()} size="sm" className="h-10 px-3">
<Plus size={14} className="mr-2" /> {t('snippets.action.newSnippet')}
</Button>
<Button
@@ -716,14 +717,17 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
}}
size="sm"
variant="secondary"
className="h-10 gap-2 bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
className={vaultHeaderSecondaryButtonClass}
>
<FolderPlus size={14} className="mr-1" /> {t('snippets.action.newPackage')}
</Button>
<Button
variant={rightPanelMode === 'history' ? 'secondary' : 'ghost'}
variant="secondary"
size="sm"
className="h-10 gap-2"
className={cn(
vaultHeaderSecondaryButtonClass,
rightPanelMode === 'history' && "bg-foreground/10 hover:bg-foreground/15",
)}
onClick={() => setRightPanelMode(rightPanelMode === 'history' ? 'none' : 'history')}
>
<Clock size={14} /> {t('snippets.history.title')}
@@ -731,7 +735,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
<div className="flex items-center gap-1 ml-auto">
<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} /> : <ListIcon size={16} />}
<ChevronDown size={10} className="ml-0.5" />
</Button>
@@ -756,11 +760,10 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
<SortDropdown
value={sortMode}
onChange={setSortMode}
className="h-10 w-10"
className={vaultHeaderIconButtonClass}
/>
</div>
</div>
</header>
</VaultPageHeader>
<div className="flex items-center gap-2 text-sm font-semibold px-4 py-2">
<button className="text-primary hover:underline" onClick={() => setSelectedPackage(null)}>{t('snippets.breadcrumb.allPackages')}</button>
{breadcrumb.map((b) => (

View File

@@ -1,4 +1,5 @@
import React from 'react';
import React, { useMemo } from 'react';
import { parseSnippetVariables } from '../domain/snippetVariables';
import { Check, Clock, Keyboard, Loader2, Package, RotateCcw, Trash2 } from 'lucide-react';
import { cn } from '../lib/utils';
import SelectHostPanel from './SelectHostPanel';
@@ -6,7 +7,7 @@ import { AsidePanel, AsidePanelContent, AsidePanelFooter } from './ui/aside-pane
import { Button } from './ui/button';
import { Card } from './ui/card';
import { Input } from './ui/input';
import { Textarea } from './ui/textarea';
import { CodeTextarea } from './ui/code-textarea';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { Combobox } from './ui/combobox';
import { DistroAvatar } from './DistroAvatar';
@@ -54,6 +55,11 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
isLoadingMore,
loadMoreHistory,
}) => {
const detectedVariables = useMemo(
() => parseSnippetVariables(editingSnippet?.command || ''),
[editingSnippet?.command],
);
if (rightPanelMode === 'select-targets') {
return (
<SelectHostPanel
@@ -127,6 +133,7 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
value={editingSnippet.label || ''}
onChange={(e) => setEditingSnippet({ ...editingSnippet, label: e.target.value })}
className="h-10"
spellCheck={false}
/>
</Card>
@@ -159,12 +166,35 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
{/* Script */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<p className="text-xs font-semibold text-muted-foreground">{t('snippets.field.scriptRequired')}</p>
<Textarea
<CodeTextarea
placeholder="ls -l"
className="min-h-[120px] font-mono text-xs"
className="min-h-[120px]"
value={editingSnippet.command || ''}
onChange={(e) => setEditingSnippet({ ...editingSnippet, command: e.target.value })}
/>
<p className="text-[11px] text-muted-foreground leading-relaxed">
{t('snippets.field.variablesHelp')}
</p>
{detectedVariables.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<span className="text-[10px] font-semibold text-muted-foreground shrink-0">
{t('snippets.field.variablesDetected')}:
</span>
{detectedVariables.map((variable) => (
<span
key={variable.name}
className="text-[10px] px-2 py-0.5 rounded-full bg-primary/10 text-primary font-mono"
>
{variable.name}
{variable.defaultValue !== undefined && (
<span className="text-muted-foreground font-sans ml-1">
({t('snippets.field.variableDefault', { value: variable.defaultValue })})
</span>
)}
</span>
))}
</div>
)}
</Card>
{/* No Auto Run */}

View File

@@ -11,8 +11,10 @@ import { logger } from "../lib/logger";
import { cn, normalizeLineEndings, wrapBracketedPaste } from "../lib/utils";
import {
Host,
Snippet,
TerminalSession,
} from "../types";
import { resolveSnippetCommand } from "./SnippetExecutionProvider";
import {
shouldEnableNativeUserInputAutoScroll,
shouldScrollOnTerminalInput,
@@ -101,6 +103,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
hotkeyScheme = "disabled",
keyBindings = [],
onHotkeyAction,
onTerminalFontSizeChange,
onStatusChange,
onSessionExit,
onTerminalDataCapture,
@@ -745,6 +748,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
term.focus();
}, [scrollToBottomAfterProgrammaticInput, terminalBackend, sessionId]);
const executeSnippet = useCallback(async (snippet: Snippet) => {
const command = await resolveSnippetCommand(snippet);
if (command === null) return;
executeSnippetCommand(command, snippet.noAutoRun);
}, [executeSnippetCommand]);
const onSnippetShortkeyRef = useRef(executeSnippet);
onSnippetShortkeyRef.current = executeSnippet;
const terminalContextActions = useTerminalContextActions({
termRef,
sourceSessionId: sessionId,
@@ -975,9 +987,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
['--terminal-ui-toolbar-btn-active' as never]: `var(--terminal-preview-toolbar-btn-active, color-mix(in srgb, ${effectiveTheme.colors.cursor} 78%, ${effectiveTheme.colors.background} 22%))`,
}), [effectiveTheme.colors.background, effectiveTheme.colors.cursor, effectiveTheme.colors.foreground]);
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBroadcastEnabledRef, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onHotkeyActionRef, onSnippetExecutorChange, onTerminalCwdChange, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, primaryFontFamily, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef });
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBroadcastEnabledRef, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, primaryFontFamily, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef });
return <TerminalView ctx={{ ArrowDownToLine, ArrowUpFromLine, Button, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, containerRef, effectiveTheme, error, executeSnippetCommand, formatNetSpeed, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSearchOpen, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onBroadcastInput, onCloseSession, onExpandToFocus, onSplitHorizontal, onSplitVertical, onToggleBroadcast, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, scrollToBottomAfterProgrammaticInput, searchMatchCount, serverStats, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
return <TerminalView ctx={{ ArrowDownToLine, ArrowUpFromLine, Button, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, containerRef, effectiveTheme, error, executeSnippet, executeSnippetCommand, formatNetSpeed, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSearchOpen, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onBroadcastInput, onCloseSession, onExpandToFocus, onSplitHorizontal, onSplitVertical, onToggleBroadcast, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, scrollToBottomAfterProgrammaticInput, searchMatchCount, serverStats, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
};
const Terminal = memo(TerminalComponent);

View File

@@ -28,6 +28,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { useI18n } from '../application/i18n/I18nProvider';
import { SftpSidePanel } from './SftpSidePanel';
import { ScriptsSidePanel } from './ScriptsSidePanel';
import { resolveSnippetCommand } from './SnippetExecutionProvider';
import type { Snippet } from '../types';
import { ThemeSidePanel } from './terminal/ThemeSidePanel';
import { focusTerminalSessionInput } from './terminal/focusTerminalSession';
import { TerminalComposeBar } from './terminal/TerminalComposeBar';
@@ -479,6 +481,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
for (const h of hosts) map.set(h.id, h);
return map;
}, [hosts]);
const hostMapRef = useRef(hostMap);
hostMapRef.current = hostMap;
const proxyProfileIdSet = useMemo(
() => new Set(proxyProfiles.map((profile) => profile.id)),
[proxyProfiles],
@@ -587,6 +591,19 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}, [sessions, sessionHostsMap, hostMap, groupConfigs, proxyProfileIdSet, proxyProfiles]);
const sessionHostsMapRef = useRef(sessionHostsMap);
sessionHostsMapRef.current = sessionHostsMap;
const handleTerminalFontSizeChange = useCallback((sessionId: string, nextFontSize: number) => {
const sessionHost = sessionHostsMapRef.current.get(sessionId);
if (!sessionHost) return;
const rawHost = hostMapRef.current.get(sessionHost.id);
const usesGlobalFontSize = sessionHost.protocol === 'local' || sessionHost.id?.startsWith('local-') || !rawHost;
if (usesGlobalFontSize) {
onUpdateTerminalFontSize?.(nextFontSize);
return;
}
onUpdateHost({ ...rawHost, fontSize: nextFontSize, fontSizeOverride: true });
}, [onUpdateHost, onUpdateTerminalFontSize]);
const validAIScopeTargetIds = useMemo(() => {
const ids = new Set<string>();
@@ -846,6 +863,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const textarea = pane?.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null;
textarea?.focus();
}, [activeWorkspace?.focusedSessionId, activeSession?.id, terminalBackend]);
const handleSnippetFromPanel = useCallback(async (snippet: Snippet) => {
const command = await resolveSnippetCommand(snippet);
if (command === null) return;
handleSnippetClickForFocusedSession(command, snippet.noAutoRun);
}, [handleSnippetClickForFocusedSession]);
const {
activeTopTabsThemeId,
appliedPreviewSessionRef,
@@ -958,7 +981,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const prevFocusedSessionIdRef = useRef<string | undefined>(undefined);
useTerminalLayerEffects({ activeSidePanelTab, activeTabId, activeTabIdRef, activeTopTabsThemeId, activeWorkspace, activityTrackedSessions, appliedPreviewSessionRef, applyTerminalPreviewVars, applyTopTabsPreviewVars, cancelAnimationFrame, ChunkedEscapeFilter, clearTerminalPreviewVars, clearTimeout, clearTopTabsPreviewVars, document, dropHint, filterTabsMap, focusedSessionId, followAppTerminalTheme, getSessionActivityIdsToClear, handleOpenAI, handleToggleScriptsSidePanel, handleToggleSidePanel, hasNotifiableTerminalOutput, isFocusMode, isTerminalLayerVisible, lastSidePanelTabRef, Map, Math, onSessionData, onSplitSessionRef, onToggleBroadcastRef, onToggleWorkspaceViewModeRef, onUpdateSplitSizes, prevFocusedSessionIdRef, previewTargetSessionId, requestAnimationFrame, ResizeObserver, resizing, sessionActivityStore, sessions, Set, setDropHint, setResizing, setSftpHostForTab, setSftpInitialLocationForTab, setSftpPendingUploadsForTab, setSidePanelOpenTabs, setThemePreview, setTimeout, setupMcpApprovalBridge, setWorkspaceArea, sftpActiveHost, sftpHostForTab, shouldMarkSessionActivity, sidePanelOpenTabs, splitHorizontalHandlersRef, splitVerticalHandlersRef, terminalRendererCwdBySessionRef, themeCommitTimerRef, themePreview, toggleScriptsSidePanelRef, toggleSidePanelRef, validAIScopeTargetIds, validSessionActivityIds, visibleFocusedThemeId, window, workspaceBroadcastHandlersRef, workspaceFocusHandlersRef, workspaceInnerRef, workspaces });
return <TerminalLayerView ctx={{ accentMode, activeResizers, activeSidePanelTab, activeTabId, activeWorkspace, AIChatPanelsHost, aiContextsByTabId, AIStateMaintenanceHost, AIStateProvider, Array, Button, cn, composeBarThemeColors, computeSplitHint, customAccent, draggingSessionId, dropHint, editorWordWrap, effectiveHosts, findSplitNode, focusedFontFamilyId, focusedFontFamilyOverridden, focusedFontSize, focusedFontSizeOverridden, focusedFontWeight, focusedFontWeightOverridden, focusedSessionId, focusedThemeOverridden, FolderTree, followAppTerminalTheme, fontSize, getTerminalCwd, handleAddKnownHost, handleBroadcastInput, handleCloseSession, handleCloseSidePanel, handleCommandExecuted, handleComposeSend, handleFontFamilyChangeForFocusedSession, handleFontFamilyResetForFocusedSession, handleFontSizeChangeForFocusedSession, handleFontSizeResetForFocusedSession, handleFontWeightChangeForFocusedSession, handleFontWeightResetForFocusedSession, handleOpenAI, handleOpenScripts, handleOpenSftp, handleOpenTheme, handleOsDetected, handlePendingUploadHandled, handleSessionExit, handleSftpInitialLocationApplied, handleSidePanelResizeStart, handleSnippetClickForFocusedSession, handleSnippetExecutorChange, handleStatusChange, handleTerminalCwdChange, handleTerminalDataCapture, handleThemeChangeForFocusedSession, handleThemeResetForFocusedSession, handleToggleSftpFromBar, handleToggleWorkspaceComposeBar, handleUpdateHost, handleWorkspaceDrop, hosts, hotkeyScheme, identities, isBroadcastEnabled, isComposeBarOpen, isFocusMode, isSidePanelOpenForCurrentTab, isTerminalLayerVisible, keyBindings, keys, knownHosts, MessageSquare, mountedAiTabIds, mountedSftpTabIds, onHotkeyAction, onSetWorkspaceFocusedSession, onSplitSession, Palette, PanelLeft, PanelRight, previewedOrVisibleThemeId, refocusActiveTerminalSession, refocusTerminalSession, renderFocusModeSidebar, resizing, resolveAIExecutorContext, resolvedPreviewTheme, ScriptsSidePanel, sessionChainHostsMap, sessionHostsMap, sessionLogConfig, sessions, setDropHint, setEditorWordWrap, setIsComposeBarOpen, setResizing, setSidePanelPosition, sftpActiveHost, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpInitialLocationForTab, sftpPendingUploadsForTab, sftpShowHiddenFiles, SftpSidePanel, sftpUseCompressedUpload, sidePanelPosition, sidePanelWidth, snippetPackages, snippets, splitHorizontalHandlersRef, splitVerticalHandlersRef, t, TerminalComposeBar, terminalFontFamilyId, TerminalPanesHost, terminalSettings, terminalTheme, themePreview, ThemeSidePanel, Tooltip, TooltipContent, TooltipTrigger, updateHosts, validAIScopeTargetIds, workspaceBroadcastHandlersRef, workspaceById, workspaceFocusHandlersRef, workspaceInnerRef, workspaceOuterRef, workspaceOverlayRef, workspaceRectsById, X, Zap }} />;
return <TerminalLayerView ctx={{ accentMode, activeResizers, activeSidePanelTab, activeTabId, activeWorkspace, AIChatPanelsHost, aiContextsByTabId, AIStateMaintenanceHost, AIStateProvider, Array, Button, cn, composeBarThemeColors, computeSplitHint, customAccent, draggingSessionId, dropHint, editorWordWrap, effectiveHosts, findSplitNode, focusedFontFamilyId, focusedFontFamilyOverridden, focusedFontSize, focusedFontSizeOverridden, focusedFontWeight, focusedFontWeightOverridden, focusedSessionId, focusedThemeOverridden, FolderTree, followAppTerminalTheme, fontSize, getTerminalCwd, handleAddKnownHost, handleBroadcastInput, handleCloseSession, handleCloseSidePanel, handleCommandExecuted, handleComposeSend, handleFontFamilyChangeForFocusedSession, handleFontFamilyResetForFocusedSession, handleFontSizeChangeForFocusedSession, handleFontSizeResetForFocusedSession, handleFontWeightChangeForFocusedSession, handleFontWeightResetForFocusedSession, handleOpenAI, handleOpenScripts, handleOpenSftp, handleOpenTheme, handleOsDetected, handlePendingUploadHandled, handleSessionExit, handleSftpInitialLocationApplied, handleSidePanelResizeStart, handleSnippetClickForFocusedSession, handleSnippetFromPanel, handleSnippetExecutorChange, handleStatusChange, handleTerminalCwdChange, handleTerminalDataCapture, handleTerminalFontSizeChange, handleThemeChangeForFocusedSession, handleThemeResetForFocusedSession, handleToggleSftpFromBar, handleToggleWorkspaceComposeBar, handleUpdateHost, handleWorkspaceDrop, hosts, hotkeyScheme, identities, isBroadcastEnabled, isComposeBarOpen, isFocusMode, isSidePanelOpenForCurrentTab, isTerminalLayerVisible, keyBindings, keys, knownHosts, MessageSquare, mountedAiTabIds, mountedSftpTabIds, onHotkeyAction, onSetWorkspaceFocusedSession, onSplitSession, Palette, PanelLeft, PanelRight, previewedOrVisibleThemeId, refocusActiveTerminalSession, refocusTerminalSession, renderFocusModeSidebar, resizing, resolveAIExecutorContext, resolvedPreviewTheme, ScriptsSidePanel, sessionChainHostsMap, sessionHostsMap, sessionLogConfig, sessions, setDropHint, setEditorWordWrap, setIsComposeBarOpen, setResizing, setSidePanelPosition, sftpActiveHost, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpInitialLocationForTab, sftpPendingUploadsForTab, sftpShowHiddenFiles, SftpSidePanel, sftpUseCompressedUpload, sidePanelPosition, sidePanelWidth, snippetPackages, snippets, splitHorizontalHandlersRef, splitVerticalHandlersRef, t, TerminalComposeBar, terminalFontFamilyId, TerminalPanesHost, terminalSettings, terminalTheme, themePreview, ThemeSidePanel, Tooltip, TooltipContent, TooltipTrigger, updateHosts, validAIScopeTargetIds, workspaceBroadcastHandlersRef, workspaceById, workspaceFocusHandlersRef, workspaceInnerRef, workspaceOuterRef, workspaceOverlayRef, workspaceRectsById, X, Zap }} />;
};
export const TerminalLayer = memo(TerminalLayerInner, terminalLayerAreEqual);

View File

@@ -35,6 +35,7 @@ import React, { Suspense, lazy, memo, startTransition, useCallback, useEffect, u
import { useI18n } from "../application/i18n/I18nProvider";
import { useStoredViewMode } from "../application/state/useStoredViewMode";
import { useStoredBoolean } from "../application/state/useStoredBoolean";
import { useStoredNumber } from "../application/state/useStoredNumber";
import { useStoredString } from "../application/state/useStoredString";
import { useTreeExpandedState } from "../application/state/useTreeExpandedState";
import { sanitizeCredentialValue } from "../domain/credentials";
@@ -53,6 +54,7 @@ import {
STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED,
STORAGE_KEY_VAULT_HOSTS_VIEW_MODE,
STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED,
STORAGE_KEY_VAULT_SIDEBAR_WIDTH,
} from "../infrastructure/config/storageKeys";
import { cn } from "../lib/utils";
import { useInstantThemeSwitch } from "../lib/useInstantThemeSwitch";
@@ -119,6 +121,11 @@ const LazyConnectionLogsManager = lazy(() => import("./ConnectionLogsManager"));
export type VaultSection = "hosts" | "keys" | "proxies" | "snippets" | "port" | "knownhosts" | "logs";
const VAULT_SIDEBAR_MIN_WIDTH = 56;
const VAULT_SIDEBAR_DEFAULT_WIDTH = 208;
const VAULT_SIDEBAR_MAX_WIDTH = 320;
const VAULT_SIDEBAR_LABEL_THRESHOLD = 132;
const isSortMode = (value: string): value is SortMode =>
value === "az" ||
value === "za" ||
@@ -254,10 +261,34 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
useInstantThemeSwitch(rootRef);
// Sidebar collapsed state with localStorage persistence
const [sidebarCollapsed, setSidebarCollapsed] = useStoredBoolean(
const [storedSidebarCollapsed, setStoredSidebarCollapsed] = useStoredBoolean(
STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED,
false,
);
const [sidebarWidth, setSidebarWidth, persistSidebarWidth] = useStoredNumber(
STORAGE_KEY_VAULT_SIDEBAR_WIDTH,
storedSidebarCollapsed ? VAULT_SIDEBAR_MIN_WIDTH : VAULT_SIDEBAR_DEFAULT_WIDTH,
{
min: VAULT_SIDEBAR_MIN_WIDTH,
max: VAULT_SIDEBAR_MAX_WIDTH,
},
);
const sidebarCollapsed = sidebarWidth < VAULT_SIDEBAR_LABEL_THRESHOLD;
const setSidebarCollapsed = useCallback((nextCollapsed: boolean) => {
const nextWidth = nextCollapsed ? VAULT_SIDEBAR_MIN_WIDTH : VAULT_SIDEBAR_DEFAULT_WIDTH;
setSidebarWidth(nextWidth);
persistSidebarWidth(nextWidth);
setStoredSidebarCollapsed(nextCollapsed);
}, [persistSidebarWidth, setSidebarWidth, setStoredSidebarCollapsed]);
const handleSidebarWidthCommit = useCallback((nextWidth: number) => {
const clampedWidth = Math.max(
VAULT_SIDEBAR_MIN_WIDTH,
Math.min(VAULT_SIDEBAR_MAX_WIDTH, nextWidth),
);
setSidebarWidth(clampedWidth);
persistSidebarWidth(clampedWidth);
setStoredSidebarCollapsed(clampedWidth < VAULT_SIDEBAR_LABEL_THRESHOLD);
}, [persistSidebarWidth, setSidebarWidth, setStoredSidebarCollapsed]);
// Handle external navigation requests
useEffect(() => {
@@ -370,37 +401,27 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
[isSearchQuickConnect, handleConnectClick],
);
// Check if host has multiple protocols enabled (using effective/resolved host)
const hasMultipleProtocols = useCallback((host: Host) => {
const effective = host.group
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
let count = 0;
// SSH is always available as base protocol (unless explicitly set to something else)
if (effective.protocol === "ssh" || !effective.protocol) count++;
// Mosh adds another option
if (effective.moshEnabled) count++;
// Telnet adds another option
if (effective.telnetEnabled) count++;
// If protocol is explicitly telnet (not ssh), count it
if (effective.protocol === "telnet" && !effective.telnetEnabled) count++;
return count > 1;
}, [groupConfigs, proxyProfileIdSet]);
// Handle host connect with protocol selection
// Handle host connect. Resolution order:
// Telnet set as default (protocol === 'telnet') -> connect Telnet
// Telnet enabled but not the default -> ask (protocol picker)
// Mosh enabled -> connect Mosh
// otherwise -> connect SSH
const handleHostConnect = useCallback(
(host: Host) => {
if (hasMultipleProtocols(host)) {
// Pass effective host to protocol dialog so it shows correct ports/protocols
const effective = host.group
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
const effective = host.group
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
// Only prompt when Telnet is available but isn't the host's default protocol.
if (effective.telnetEnabled && effective.protocol !== "telnet") {
setProtocolSelectHost(effective);
} else if (effective.protocol === "telnet") {
// Telnet-as-default wins over a stray moshEnabled flag.
onConnect({ ...host, moshEnabled: false });
} else {
onConnect(host);
}
},
[hasMultipleProtocols, onConnect, groupConfigs, proxyProfileIdSet],
[onConnect, groupConfigs, proxyProfileIdSet],
);
// Handle protocol selection
@@ -586,6 +607,17 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
clearHostSelection();
toast.success(t("vault.hosts.deleteMultiple.success", { count: selectedHostIds.size }));
}, [selectedHostIds, hosts, onUpdateHosts, clearHostSelection, t]);
const connectSelectedHosts = useCallback(() => {
if (selectedHostIds.size === 0) return;
// Connect each selected host in list order with its default protocol.
// We call onConnect directly (not handleHostConnect) so multi-protocol hosts
// connect with their configured protocol instead of opening a per-host dialog.
const targets = hosts.filter(h => selectedHostIds.has(h.id));
targets.forEach(host => onConnect(host));
clearHostSelection();
toast.success(t("vault.hosts.connectMultiple.success", { count: targets.length }));
}, [selectedHostIds, hosts, onConnect, clearHostSelection, t]);
const { handleImportFileSelected } = useVaultImportHandlers({
customGroups,
hosts,
@@ -941,7 +973,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
observer.observe(el);
return () => observer.disconnect();
}, []);
return <VaultViewLayout ctx={{ Activity, allGroupPaths, allTags, AppLogo, Array, Badge, BookMarked, Boolean, Button, CheckSquare, ChevronDown, clearHostSelection, ClipboardCopy, Clock, cn, connectionLogs, ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, Copy, currentSection, customGroups, deleteGroupPath, deleteGroupWithHosts, deleteSelectedHosts, deleteTargetPath, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, displayedGroups, displayedHosts, DistroAvatar, Download, Dropdown, DropdownContent, DropdownTrigger, Edit2, editingGroupPath, editingHost, editingHostGroupDefaults, FileCode, FileSymlink, FolderPlus, FolderTree, getDropTargetClasses, getEffectiveHostDistro, Globe, groupConfigs, GroupDetailsPanel, groupedDisplayHosts, handleConnectClick, handleCopyCredentials, handleDeleteTag, handleDuplicateHost, handleEditGroupConfig, handleEditHost, handleEditTag, handleExportHosts, handleHostConnect, handleImportFileSelected, handleNewHost, handleProtocolSelect, handleQuickConnect, handleQuickConnectSaveHost, handleSaveGroupConfig, handleSearchKeyDown, handleUnmanageGroup, hasHostsSidePanel, HostDetailsPanel, hostListScrollRef, hosts, HostTreeView, hotkeyScheme, identities, ImportVaultDialog, Input, isDeleteGroupOpen, isGroupPanelOpen, isHostPanelOpen, isHostsSectionActive, isImportOpen, isMultiSelectMode, isNewFolderOpen, isQuickConnectOpen, isRenameGroupOpen, isSearchQuickConnect, isSerialModalOpen, Key, keyBindings, KeychainManager, keys, knownHostsManagerElement, Label, lastPinnedId, LayoutGrid, LazyConnectionLogsManager, LazyProtocolSelectDialog, List, managedGroupPaths, managedSources, moveGroup, moveHostToGroup, Network, newFolderName, newHostGroupPath, onClearUnsavedConnectionLogs, onConnectSerial, onCreateLocalTerminal, onDeleteConnectionLog, onDeleteHost, onImportOrReuseKey, onOpenLogView, onOpenSettings, onRunSnippet, onToggleConnectionLogSaved, onUpdateCustomGroups, onUpdateGroupConfigs, onUpdateHosts, onUpdateIdentities, onUpdateKeys, onUpdateProxyProfiles, onUpdateSnippetPackages, onUpdateSnippets, Pin, pinnedHosts, pinnedRecentIds, Plug, Plus, PortForwarding, protocolSelectHost, proxyProfiles, ProxyProfilesManager, quickConnectTarget, quickConnectWarnings, QuickConnectWizard, recentHosts, renameGroupError, renameGroupName, renameTargetPath, RippleButton, rootRef, sanitizeHost, search, Search, selectedGroupPath, selectedHostIds, selectedTags, SerialConnectModal, SerialHostDetailsPanel, sessionCount, Set, setCurrentSection, setDeleteGroupWithHosts, setDeleteTargetPath, setDragOverDropTarget, setEditingGroupPath, setEditingHost, setGroupDragOverDropTarget, setIsDeleteGroupOpen, setIsGroupPanelOpen, setIsHostPanelOpen, setIsImportOpen, setIsMultiSelectMode, setIsNewFolderOpen, setIsQuickConnectOpen, setIsRenameGroupOpen, setIsSerialModalOpen, setLastPinnedId, setNewFolderName, setNewHostGroupPath, setProtocolSelectHost, setQuickConnectTarget, setQuickConnectWarnings, setRenameGroupError, setRenameGroupName, setRenameTargetPath, setSearch, setSelectedGroupPath, setSelectedHostIds, setSelectedTags, setSidebarCollapsed, setSortMode, setTargetParentPath, Settings, setViewMode, shellHistory, shouldHideEmptyRootHostsSection, showRecentHosts, sidebarCollapsed, snippetPackages, snippets, SnippetsManager, SortDropdown, sortMode, splitViewGridStyle, Square, Star, submitNewFolder, submitRenameGroup, Suspense, t, TagFilterDropdown, targetParentPath, terminalFontSize, terminalSettings, TerminalSquare, terminalThemeId, toggleHostPinned, toggleHostSelection, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, Trash2, treeExpandedState, treeViewGroupTree, treeViewHosts, Upload, upsertHostById, Usb, viewMode, visibleDisplayedHosts, X, Zap }} />;
return <VaultViewLayout ctx={{ Activity, allGroupPaths, allTags, AppLogo, Array, Badge, BookMarked, Boolean, Button, CheckSquare, ChevronDown, clearHostSelection, ClipboardCopy, Clock, cn, connectionLogs, connectSelectedHosts, ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, Copy, currentSection, customGroups, deleteGroupPath, deleteGroupWithHosts, deleteSelectedHosts, deleteTargetPath, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, displayedGroups, displayedHosts, DistroAvatar, Download, Dropdown, DropdownContent, DropdownTrigger, Edit2, editingGroupPath, editingHost, editingHostGroupDefaults, FileCode, FileSymlink, FolderPlus, FolderTree, getDropTargetClasses, getEffectiveHostDistro, Globe, groupConfigs, GroupDetailsPanel, groupedDisplayHosts, handleConnectClick, handleCopyCredentials, handleDeleteTag, handleDuplicateHost, handleEditGroupConfig, handleEditHost, handleEditTag, handleExportHosts, handleHostConnect, handleImportFileSelected, handleNewHost, handleProtocolSelect, handleQuickConnect, handleQuickConnectSaveHost, handleSaveGroupConfig, handleSearchKeyDown, handleUnmanageGroup, hasHostsSidePanel, HostDetailsPanel, hostListScrollRef, hosts, HostTreeView, hotkeyScheme, identities, ImportVaultDialog, Input, isDeleteGroupOpen, isGroupPanelOpen, isHostPanelOpen, isHostsSectionActive, isImportOpen, isMultiSelectMode, isNewFolderOpen, isQuickConnectOpen, isRenameGroupOpen, isSearchQuickConnect, isSerialModalOpen, Key, keyBindings, KeychainManager, keys, knownHostsManagerElement, Label, lastPinnedId, LayoutGrid, LazyConnectionLogsManager, LazyProtocolSelectDialog, List, managedGroupPaths, managedSources, moveGroup, moveHostToGroup, Network, newFolderName, newHostGroupPath, onClearUnsavedConnectionLogs, onConnectSerial, onCreateLocalTerminal, onDeleteConnectionLog, onDeleteHost, onImportOrReuseKey, onOpenLogView, onOpenSettings, onRunSnippet, onToggleConnectionLogSaved, onUpdateCustomGroups, onUpdateGroupConfigs, onUpdateHosts, onUpdateIdentities, onUpdateKeys, onUpdateProxyProfiles, onUpdateSnippetPackages, onUpdateSnippets, Pin, pinnedHosts, pinnedRecentIds, Plug, Plus, PortForwarding, protocolSelectHost, proxyProfiles, ProxyProfilesManager, quickConnectTarget, quickConnectWarnings, QuickConnectWizard, recentHosts, renameGroupError, renameGroupName, renameTargetPath, RippleButton, rootRef, sanitizeHost, search, Search, selectedGroupPath, selectedHostIds, selectedTags, SerialConnectModal, SerialHostDetailsPanel, sessionCount, Set, setCurrentSection, setDeleteGroupWithHosts, setDeleteTargetPath, setDragOverDropTarget, setEditingGroupPath, setEditingHost, setGroupDragOverDropTarget, setIsDeleteGroupOpen, setIsGroupPanelOpen, setIsHostPanelOpen, setIsImportOpen, setIsMultiSelectMode, setIsNewFolderOpen, setIsQuickConnectOpen, setIsRenameGroupOpen, setIsSerialModalOpen, setLastPinnedId, setNewFolderName, setNewHostGroupPath, setProtocolSelectHost, setQuickConnectTarget, setQuickConnectWarnings, setRenameGroupError, setRenameGroupName, setRenameTargetPath, setSearch, setSelectedGroupPath, setSelectedHostIds, setSelectedTags, setSidebarCollapsed, setSidebarWidth, handleSidebarWidthCommit, setSortMode, setTargetParentPath, Settings, setViewMode, shellHistory, shouldHideEmptyRootHostsSection, showRecentHosts, sidebarCollapsed, sidebarWidth, snippetPackages, snippets, SnippetsManager, SortDropdown, sortMode, splitViewGridStyle, Square, Star, submitNewFolder, submitRenameGroup, Suspense, t, TagFilterDropdown, targetParentPath, terminalFontSize, terminalSettings, TerminalSquare, terminalThemeId, toggleHostPinned, toggleHostSelection, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, Trash2, treeExpandedState, treeViewGroupTree, treeViewHosts, Upload, upsertHostById, Usb, viewMode, visibleDisplayedHosts, X, Zap }} />;
};
// Only re-render when data props change - isActive is now managed internally via store subscription

View File

@@ -32,7 +32,7 @@ import { useI18n } from '../../application/i18n/I18nProvider';
import { useCloudSync } from '../../application/state/useCloudSync';
import { type CloudProvider, type ConflictInfo } from '../../domain/sync';
import { type CloudProvider, type ConflictInfo, type SyncChangeEntityKey, type SyncEntityChangeCounts } from '../../domain/sync';
import { cn } from '../../lib/utils';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
@@ -532,6 +532,19 @@ interface ConflictModalProps {
onClose: () => void;
}
const CONFLICT_ENTITY_LABEL_KEYS: Record<SyncChangeEntityKey, string> = {
hosts: 'cloudSync.conflict.entity.hosts',
keys: 'cloudSync.conflict.entity.keys',
identities: 'cloudSync.conflict.entity.identities',
proxyProfiles: 'cloudSync.conflict.entity.proxyProfiles',
snippets: 'cloudSync.conflict.entity.snippets',
customGroups: 'cloudSync.conflict.entity.customGroups',
snippetPackages: 'cloudSync.conflict.entity.snippetPackages',
portForwardingRules: 'cloudSync.conflict.entity.portForwardingRules',
groupConfigs: 'cloudSync.conflict.entity.groupConfigs',
settings: 'cloudSync.conflict.entity.settings',
};
export const ConflictModal: React.FC<ConflictModalProps> = ({
open,
conflict,
@@ -545,10 +558,23 @@ export const ConflictModal: React.FC<ConflictModalProps> = ({
const formatDate = (timestamp: number) => {
return new Date(timestamp).toLocaleString(resolvedLocale || undefined);
};
const changeRows = conflict.changeSummary
? (Object.entries(conflict.changeSummary.byEntity) as Array<[SyncChangeEntityKey, SyncEntityChangeCounts | undefined]>)
.flatMap(([entityType, counts]) => {
if (!counts) return [];
return [{
entityType,
localTotal: counts.added.local + counts.modified.local + counts.deleted.local,
remoteTotal: counts.added.remote + counts.modified.remote + counts.deleted.remote,
conflictTotal: conflict.changeSummary?.conflicts.filter((item) => item.entityType === entityType).length ?? 0,
}];
})
.filter((row) => row.localTotal > 0 || row.remoteTotal > 0 || row.conflictTotal > 0)
: [];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-background rounded-lg shadow-xl w-full max-w-lg p-6 relative">
<div className="bg-background rounded-lg shadow-xl w-full max-w-lg max-h-[calc(100vh-2rem)] p-6 relative flex flex-col">
<button
onClick={onClose}
className="absolute top-4 right-4 text-muted-foreground hover:text-foreground"
@@ -556,47 +582,73 @@ export const ConflictModal: React.FC<ConflictModalProps> = ({
<X size={18} />
</button>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-amber-500/10 flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-amber-500" />
<div className="flex-1 overflow-y-auto pr-1">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-amber-500/10 flex items-center justify-center shrink-0">
<AlertTriangle className="w-5 h-5 text-amber-500" />
</div>
<div className="min-w-0">
<h3 className="text-lg font-semibold">{t('cloudSync.conflict.title')}</h3>
<p className="text-sm text-muted-foreground">
{t('cloudSync.conflict.desc')}
</p>
</div>
</div>
<div>
<h3 className="text-lg font-semibold">{t('cloudSync.conflict.title')}</h3>
<p className="text-sm text-muted-foreground">
{t('cloudSync.conflict.desc')}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
<div className="p-4 rounded-lg border bg-muted/30 min-w-0">
<div className="text-xs font-medium text-muted-foreground mb-2">{t('cloudSync.conflict.local')}</div>
<div className="text-sm font-medium">v{conflict.localVersion}</div>
<div className="text-xs text-muted-foreground mt-1 break-words">
{formatDate(conflict.localUpdatedAt)}
</div>
{conflict.localDeviceName && (
<div className="text-xs text-muted-foreground break-words">
{conflict.localDeviceName}
</div>
)}
</div>
<div className="p-4 rounded-lg border bg-muted/30 min-w-0">
<div className="text-xs font-medium text-muted-foreground mb-2">{t('cloudSync.conflict.cloud')}</div>
<div className="text-sm font-medium">v{conflict.remoteVersion}</div>
<div className="text-xs text-muted-foreground mt-1 break-words">
{formatDate(conflict.remoteUpdatedAt)}
</div>
{conflict.remoteDeviceName && (
<div className="text-xs text-muted-foreground break-words">
{conflict.remoteDeviceName}
</div>
)}
</div>
</div>
{changeRows.length > 0 && (
<div className="rounded-lg border bg-muted/20 p-3 mb-6 space-y-2">
<div className="text-xs font-medium text-muted-foreground">
{t('cloudSync.conflict.detailsTitle')}
</div>
<div className="space-y-2">
{changeRows.map((row) => (
<div key={row.entityType} className="grid gap-1 text-sm">
<span className="font-medium break-words">
{t(CONFLICT_ENTITY_LABEL_KEYS[row.entityType])}
</span>
<span className="text-xs text-muted-foreground break-words">
{t('cloudSync.conflict.detailsCounts', {
local: row.localTotal,
cloud: row.remoteTotal,
conflicts: row.conflictTotal,
})}
</span>
</div>
))}
</div>
</div>
)}
</div>
<div className="grid grid-cols-2 gap-4 mb-6">
<div className="p-4 rounded-lg border bg-muted/30">
<div className="text-xs font-medium text-muted-foreground mb-2">{t('cloudSync.conflict.local')}</div>
<div className="text-sm font-medium">v{conflict.localVersion}</div>
<div className="text-xs text-muted-foreground mt-1">
{formatDate(conflict.localUpdatedAt)}
</div>
{conflict.localDeviceName && (
<div className="text-xs text-muted-foreground">
{conflict.localDeviceName}
</div>
)}
</div>
<div className="p-4 rounded-lg border bg-muted/30">
<div className="text-xs font-medium text-muted-foreground mb-2">{t('cloudSync.conflict.cloud')}</div>
<div className="text-sm font-medium">v{conflict.remoteVersion}</div>
<div className="text-xs text-muted-foreground mt-1">
{formatDate(conflict.remoteUpdatedAt)}
</div>
{conflict.remoteDeviceName && (
<div className="text-xs text-muted-foreground">
{conflict.remoteDeviceName}
</div>
)}
</div>
</div>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-2 pt-4 shrink-0">
<Button
variant="outline"
className="w-full gap-2"

View File

@@ -5,6 +5,7 @@ import type { useCloudSync } from '../../application/state/useCloudSync';
import { isProviderReadyForSync } from '../../domain/sync';
import { cn } from '../../lib/utils';
import { Button } from '../ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger } from '../ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { GoogleDriveIcon, OneDriveIcon, ProviderCard, Toggle } from './CloudSyncControls';
@@ -175,6 +176,56 @@ export const CloudSyncDashboardTabs: React.FC<CloudSyncDashboardTabsProps> = ({
</div>
</div>
<div className="p-4 rounded-lg border bg-card space-y-3">
<div>
<div className="text-sm font-medium">{t('cloudSync.strategy.title')}</div>
<div className="text-xs text-muted-foreground">
{t('cloudSync.strategy.desc')}
</div>
</div>
<Select
value={sync.syncStrategy}
onValueChange={(value) => sync.setSyncStrategy(value as typeof sync.syncStrategy)}
>
<SelectTrigger
aria-label={t('cloudSync.strategy.title')}
className="h-10"
>
{sync.syncStrategy === 'preferCloud'
? t('cloudSync.strategy.preferCloud')
: sync.syncStrategy === 'preferLocal'
? t('cloudSync.strategy.preferLocal')
: t('cloudSync.strategy.smartMerge')}
</SelectTrigger>
<SelectContent className="max-w-[min(520px,var(--radix-select-trigger-width))]">
<SelectItem value="smartMerge" className="items-start py-2">
<div className="space-y-0.5">
<div>{t('cloudSync.strategy.smartMerge')}</div>
<div className="text-xs text-muted-foreground leading-snug">
{t('cloudSync.strategy.smartMergeDesc')}
</div>
</div>
</SelectItem>
<SelectItem value="preferCloud" className="items-start py-2">
<div className="space-y-0.5">
<div>{t('cloudSync.strategy.preferCloud')}</div>
<div className="text-xs text-muted-foreground leading-snug">
{t('cloudSync.strategy.preferCloudDesc')}
</div>
</div>
</SelectItem>
<SelectItem value="preferLocal" className="items-start py-2">
<div className="space-y-0.5">
<div>{t('cloudSync.strategy.preferLocal')}</div>
<div className="text-xs text-muted-foreground leading-snug">
{t('cloudSync.strategy.preferLocalDesc')}
</div>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{sync.hasAnyConnectedProvider && (
<div className="space-y-3">
{/* Version Info Cards */}

View File

@@ -1,6 +1,6 @@
import React, { type Dispatch, type MutableRefObject, type SetStateAction } from 'react';
import { AlertTriangle, Cloud, Database, Download, History, Key, Loader2, ShieldCheck, Trash2 } from 'lucide-react';
import type { CloudProvider, ConflictResolution, SyncPayload, WebDAVAuthType } from '../../domain/sync';
import type { CloudProvider, ConflictResolution, SyncPayload, SyncResult, WebDAVAuthType } from '../../domain/sync';
import type { ShrinkFinding } from '../../domain/syncGuards';
import type { useCloudSync } from '../../application/state/useCloudSync';
import { cn } from '../../lib/utils';
@@ -873,17 +873,23 @@ export const CloudSyncDialogs: React.FC<CloudSyncDialogsProps> = ({
for (const result of results.values()) {
if (result.mergedPayload) {
await Promise.resolve(onApplyPayload(result.mergedPayload));
if (result.remoteFile) {
await sync.commitRemoteInspection(result.provider, result.remoteFile, result.mergedPayload, {
recordDownload: true,
});
}
break; // All providers share the same merged payload
}
}
const allOk = Array.from(results.values()).every((r) => r.success);
const syncResults = Array.from(results.values()) as SyncResult[];
const allOk = syncResults.every((r) => r.success);
if (allOk) {
setBlockedFinding(null);
} else {
// Surface the failure but KEEP the banner so the user can retry or
// restore. Find the first error string to display.
const firstError = Array.from(results.values())
const firstError = syncResults
.find((r) => !r.success)
?.error ?? t('sync.toast.errorTitle');
toast.error(firstError, t('sync.toast.errorTitle'));

View File

@@ -0,0 +1,94 @@
import { HelpCircle } from "lucide-react";
import React from "react";
import { cn } from "../../lib/utils";
import { Card } from "../ui/card";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
export function HostDetailsHelp({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
if (!children) return null;
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
"relative -top-px flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground [&>svg]:block",
className,
)}
aria-label={typeof children === "string" ? children : undefined}
>
<HelpCircle size={13} />
</button>
</TooltipTrigger>
<TooltipContent className="max-w-[260px] text-left leading-relaxed">
{children}
</TooltipContent>
</Tooltip>
);
}
export function HostDetailsSection({
icon,
title,
hint,
children,
className,
action,
}: {
icon: React.ReactNode;
title: React.ReactNode;
hint?: React.ReactNode;
children: React.ReactNode;
className?: string;
action?: React.ReactNode;
}) {
return (
<Card className={cn("p-3 space-y-3 bg-card border-border/80 shadow-sm", className)}>
<div className="flex min-h-5 items-center gap-1.5">
<span className="flex h-5 w-4 shrink-0 items-center justify-center text-muted-foreground [&>img]:block [&>img]:h-4 [&>img]:w-4 [&>img]:object-contain [&>svg]:block [&>svg]:h-4 [&>svg]:w-4">
{icon}
</span>
<p className="flex min-h-5 items-center text-xs font-semibold leading-5 text-foreground">
{title}
</p>
{hint && <HostDetailsHelp>{hint}</HostDetailsHelp>}
{action && <div className="ml-auto flex items-center">{action}</div>}
</div>
{children}
</Card>
);
}
export function HostDetailsSettingRow({
label,
hint,
children,
className,
}: {
label: React.ReactNode;
hint?: React.ReactNode;
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"flex min-h-12 items-center justify-between gap-3 rounded-lg border border-border/60 bg-secondary/40 px-3 py-2",
className,
)}
>
<div className="flex min-h-5 min-w-0 items-center gap-1.5">
<span className="truncate text-sm font-medium leading-5 text-foreground">{label}</span>
{hint && <HostDetailsHelp>{hint}</HostDetailsHelp>}
</div>
<div className="shrink-0">{children}</div>
</div>
);
}

View File

@@ -14,3 +14,9 @@ export type { ChainPanelProps } from './ChainPanel';
export { EnvVarsPanel } from './EnvVarsPanel';
export type { EnvVarsPanelProps } from './EnvVarsPanel';
export {
HostDetailsHelp,
HostDetailsSection,
HostDetailsSettingRow,
} from './HostDetailsSection';

View File

@@ -0,0 +1,90 @@
import { FileText } from "lucide-react";
import React, { useEffect, useState } from "react";
import { useI18n } from "../../application/i18n/I18nProvider";
import { MessageResponse } from "../ai-elements/message";
import { ScrollArea } from "../ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { Textarea } from "../ui/textarea";
import { cn } from "../../lib/utils";
const PREVIEW_PROSE_CLASS =
"text-sm text-foreground/90 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0";
function defaultNotesTab(notes: string): "edit" | "preview" {
return notes.trim() ? "preview" : "edit";
}
export interface HostNotesEditorProps {
value: string;
onChange: (value: string) => void;
/** Changes when opening a different host (e.g. host id) to reset the active tab */
panelKey?: string;
className?: string;
showHeader?: boolean;
}
export const HostNotesEditor: React.FC<HostNotesEditorProps> = ({
value,
onChange,
panelKey,
className,
showHeader = true,
}) => {
const { t } = useI18n();
const [tab, setTab] = useState<"edit" | "preview">(() => defaultNotesTab(value));
useEffect(() => {
if (panelKey === undefined) return;
setTab(defaultNotesTab(value));
// Only reset tab when opening another host, not while editing notes.
// eslint-disable-next-line react-hooks/exhaustive-deps -- value read on panelKey change
}, [panelKey]);
const trimmed = value.trim();
return (
<div className={cn("space-y-2", className)}>
{showHeader && (
<>
<div className="flex items-center gap-2">
<FileText size={14} className="text-muted-foreground shrink-0" />
<p className="text-xs font-semibold">
{t("hostDetails.notes.label")}
</p>
</div>
<p className="text-xs text-muted-foreground">{t("hostDetails.notes.help")}</p>
</>
)}
<Tabs value={tab} onValueChange={(v) => setTab(v as "edit" | "preview")}>
<TabsList className="h-8 w-full">
<TabsTrigger value="edit" className="flex-1 text-xs">
{t("hostDetails.notes.tab.edit")}
</TabsTrigger>
<TabsTrigger value="preview" className="flex-1 text-xs">
{t("hostDetails.notes.tab.preview")}
</TabsTrigger>
</TabsList>
<TabsContent value="edit" className="mt-2">
<Textarea
placeholder={t("hostDetails.notes.placeholder")}
value={value}
onChange={(e) => onChange(e.target.value)}
className="min-h-[120px] text-sm"
rows={5}
/>
</TabsContent>
<TabsContent value="preview" className="mt-2">
<ScrollArea className="h-[120px] rounded-md border border-border/60 bg-muted/20 p-3">
{trimmed ? (
<MessageResponse className={PREVIEW_PROSE_CLASS}>{trimmed}</MessageResponse>
) : (
<p className="text-sm text-muted-foreground">
{t("hostDetails.notes.preview.empty")}
</p>
)}
</ScrollArea>
</TabsContent>
</Tabs>
</div>
);
};

View File

@@ -0,0 +1,50 @@
import { FileText } from "lucide-react";
import React from "react";
import { cn } from "../../lib/utils";
import { MessageResponse } from "../ai-elements/message";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "../ui/hover-card";
export interface HostNotesIndicatorProps {
notes?: string;
className?: string;
}
export const HostNotesIndicator: React.FC<HostNotesIndicatorProps> = ({
notes,
className,
}) => {
const trimmed = notes?.trim();
if (!trimmed) return null;
return (
<HoverCard openDelay={180} closeDelay={80}>
<HoverCardTrigger asChild>
<button
type="button"
className={cn(
"inline-flex h-4 w-4 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-muted-foreground transition-colors hover:text-foreground",
className,
)}
aria-label="Host notes"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<FileText size={12} className="text-muted-foreground" aria-hidden />
</button>
</HoverCardTrigger>
<HoverCardContent
side="top"
align="start"
className="w-[320px] max-w-[calc(100vw-32px)] p-3"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<div className="max-h-[240px] overflow-y-auto pr-1">
<MessageResponse className="text-xs leading-relaxed text-popover-foreground/90 [&_h1]:text-sm [&_h1]:mt-2 [&_h1]:mb-1 [&_h2]:text-sm [&_h2]:mt-2 [&_h2]:mb-1 [&_h3]:text-xs [&_h3]:mt-1.5 [&_h3]:mb-1 [&_p]:my-1 [&_ul]:my-1 [&_ol]:my-1">
{trimmed}
</MessageResponse>
</div>
</HoverCardContent>
</HoverCard>
);
};

View File

@@ -111,6 +111,11 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
onConnect,
onDisconnect,
}) => {
// Focus the confirm button when a confirmation dialog opens so Enter confirms it.
// These dialogs are opened from a context menu, whose focus-return can otherwise
// leave focus outside the dialog, making Enter do nothing.
const deleteConfirmButtonRef = React.useRef<HTMLButtonElement>(null);
const overwriteConfirmButtonRef = React.useRef<HTMLButtonElement>(null);
const isSingleDeleteTarget = deleteTargets.length === 1;
const deletePath = (() => {
if (isSingleDeleteTarget) {
@@ -225,7 +230,13 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
{/* Overwrite Confirmation Dialog */}
<Dialog open={showOverwriteConfirm} onOpenChange={setShowOverwriteConfirm}>
<DialogContent className="max-w-sm">
<DialogContent
className="max-w-sm"
onOpenAutoFocus={(e) => {
e.preventDefault();
overwriteConfirmButtonRef.current?.focus();
}}
>
<DialogHeader>
<HostHint label={hostLabel} />
<DialogTitle>{t("sftp.overwrite.title")}</DialogTitle>
@@ -241,6 +252,7 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
{t("common.cancel")}
</Button>
<Button
ref={overwriteConfirmButtonRef}
variant="destructive"
onClick={handleOverwriteConfirm}
>
@@ -289,7 +301,13 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
</Dialog>
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent className="max-w-sm">
<DialogContent
className="max-w-sm"
onOpenAutoFocus={(e) => {
e.preventDefault();
deleteConfirmButtonRef.current?.focus();
}}
>
<DialogHeader>
<DialogTitle>
{t("sftp.deleteConfirm.title", { count: deleteTargets.length })}
@@ -337,6 +355,7 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
{t("common.cancel")}
</Button>
<Button
ref={deleteConfirmButtonRef}
variant="destructive"
onClick={handleDelete}
disabled={isDeleting}

View File

@@ -4,7 +4,7 @@ import React from 'react';
type TerminalViewContext = Record<string, any>;
export function TerminalView({ ctx }: { ctx: TerminalViewContext }) {
const { ArrowDownToLine, ArrowUpFromLine, Button, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, containerRef, effectiveTheme, error, executeSnippetCommand, formatNetSpeed, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSearchOpen, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onBroadcastInput, onCloseSession, onExpandToFocus, onSplitHorizontal, onSplitVertical, onToggleBroadcast, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, scrollToBottomAfterProgrammaticInput, searchMatchCount, serverStats, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem } = ctx;
const { ArrowDownToLine, ArrowUpFromLine, Button, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, containerRef, effectiveTheme, error, executeSnippet, formatNetSpeed, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSearchOpen, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onBroadcastInput, onCloseSession, onExpandToFocus, onSplitHorizontal, onSplitVertical, onToggleBroadcast, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, scrollToBottomAfterProgrammaticInput, searchMatchCount, serverStats, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem } = ctx;
return (
<TerminalContextMenu
hasSelection={hasSelection}
@@ -503,7 +503,7 @@ export function TerminalView({ ctx }: { ctx: TerminalViewContext }) {
getCwd={() => terminalCwdTracker.getRendererCwd() ?? knownCwdRef.current}
onAcceptText={(text) => autocompleteAcceptTextRef.current?.(text)}
snippets={snippets}
onAcceptSnippet={(snippet) => executeSnippetCommand(snippet.command, snippet.noAutoRun)}
onAcceptSnippet={(snippet) => void executeSnippet(snippet)}
visible={isVisible}
themeColors={effectiveTheme.colors}
containerRef={containerRef}

View File

@@ -29,7 +29,7 @@ import {
resolveHostTerminalFontWeight,
} from "../../../domain/terminalAppearance";
import { logger } from "../../../lib/logger";
import { isMacPlatform, normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
import { isMacPlatform } from "../../../lib/utils";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import {
clearTerminalViewport,
@@ -47,6 +47,10 @@ import { terminalAltKeyOptions } from "./altKeyOptions";
import { optionArrowWordJumpSequence } from "./optionArrowWordJump";
import { watchDevicePixelRatio } from "./rendererDprWatch";
import { handleSerialLineModeInput } from "./serialLineInput";
import {
nextTerminalFontSizeForAction,
nextTerminalFontSizeForWheel,
} from "./terminalFontZoom";
import {
markExpectedTerminalCursorPositionReport,
pasteTextIntoTerminal,
@@ -64,7 +68,7 @@ import type {
TerminalSettings,
TerminalTheme,
} from "../../../types";
import { matchesKeyBinding } from "../../../domain/models";
import { matchesKeyBinding, type Snippet } from "../../../domain/models";
type TerminalBackendApi = {
openExternalAvailable: () => boolean;
@@ -106,6 +110,7 @@ export type CreateXTermRuntimeContext = {
onHotkeyActionRef: RefObject<
((action: string, event: KeyboardEvent) => void) | undefined
>;
onTerminalFontSizeChange?: (fontSize: number) => void;
isBroadcastEnabledRef: RefObject<boolean | undefined>;
onBroadcastInputRef: RefObject<
@@ -113,7 +118,8 @@ export type CreateXTermRuntimeContext = {
>;
// Snippets for shortkey support
snippetsRef?: RefObject<{ id: string; command: string; shortkey?: string }[]>;
snippetsRef?: RefObject<{ id: string; command: string; shortkey?: string; noAutoRun?: boolean }[]>;
onSnippetShortkeyRef?: RefObject<((snippet: Snippet) => void) | undefined>;
sessionId: string;
statusRef: RefObject<TerminalSession["status"]>;
@@ -488,6 +494,39 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
term.scrollToBottom();
}
};
const currentTerminalFontSize = () => {
const optionFontSize = term.options.fontSize;
return typeof optionFontSize === "number" ? optionFontSize : effectiveFontSize;
};
const applyTerminalFontSize = (nextFontSize: number | null): boolean => {
if (nextFontSize === null) return false;
const currentFontSize = currentTerminalFontSize();
if (nextFontSize !== currentFontSize) {
term.options.fontSize = nextFontSize;
clearWebglTextureAtlas();
try {
fitAddon.fit();
} catch (err) {
logger.warn("[XTerm] fit after font size change failed", err);
}
ctx.onTerminalFontSizeChange?.(nextFontSize);
}
return true;
};
const handleFontSizeWheel = (event: WheelEvent) => {
const currentScheme = ctx.hotkeySchemeRef.current;
const isMac = currentScheme === "mac" || (currentScheme === "disabled" && isMacPlatform());
const nextFontSize = nextTerminalFontSizeForWheel(
event,
currentTerminalFontSize(),
isMac,
);
if (nextFontSize === null) return;
event.preventDefault();
event.stopPropagation();
applyTerminalFontSize(nextFontSize);
};
ctx.container.addEventListener("wheel", handleFontSizeWheel, { passive: false });
term.attachCustomKeyEventHandler((e: KeyboardEvent) => {
// Preserve mouse selection across keystrokes when enabled. xterm.js
@@ -555,25 +594,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
if (id && ctx.statusRef.current === "connected") {
e.preventDefault();
e.stopPropagation();
// Send the snippet command to the terminal
let snippetData = normalizeLineEndings(snippet.command);
if (!snippet.noAutoRun) snippetData = `${snippetData}\r`;
// Broadcast the normalized (un-wrapped) data so each target
// session can apply its own bracket paste state
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
ctx.onBroadcastInputRef.current(snippetData, ctx.sessionId);
}
// Wrap for this terminal only, after broadcasting
const snippetIsMultiLine = snippetData.includes("\n");
if (snippetIsMultiLine && term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste) snippetData = wrapBracketedPaste(snippetData);
// Notify autocomplete with the final (possibly bracket-wrapped)
// bytes so its keystroke buffer can tell literal multi-line
// paste ("\x1b[200~...\x1b[201~") from the non-bracketed path
// where each \n executes an intermediate command (#814 P2).
ctx.onAutocompleteInput?.(snippetData);
ctx.terminalBackend.writeToSession(id, snippetData);
if (!snippet.noAutoRun) {
recordTerminalCommandExecution(snippet.command, ctx, term);
const runSnippet = ctx.onSnippetShortkeyRef?.current;
if (runSnippet) {
void runSnippet(snippet as Snippet);
}
return false;
}
@@ -636,6 +659,14 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
ctx.setIsSearchOpen(true);
break;
}
case "increaseTerminalFontSize":
case "decreaseTerminalFontSize":
case "resetTerminalFontSize": {
applyTerminalFontSize(
nextTerminalFontSizeForAction(action, currentTerminalFontSize()),
);
break;
}
}
return false;
}
@@ -953,6 +984,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
keywordHighlighter,
clearTextureAtlas: clearWebglTextureAtlas,
dispose: () => {
ctx.container.removeEventListener("wheel", handleFontSizeWheel);
cleanupMiddleClick?.();
stopDprWatch();
keywordHighlighter.dispose();

View File

@@ -0,0 +1,36 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
registerConnectionToken,
runDistroDetection,
} from "./terminalDistroDetection.ts";
test("runDistroDetection skips POSIX probes for manually marked network devices", async () => {
let remoteInfoCalls = 0;
let distroProbeCalls = 0;
const token = registerConnectionToken("ssh-session");
await runDistroDetection({
host: {
id: "host-1",
label: "Ruijie AP",
hostname: "192.168.2.2",
username: "root",
deviceType: "network",
},
terminalBackend: {
getSessionRemoteInfo: async () => {
remoteInfoCalls += 1;
return { success: true, remoteSshVersion: "RGOS_SSH" };
},
getSessionDistroInfo: async () => {
distroProbeCalls += 1;
return { success: false, error: "network device closed the extra channel" };
},
},
} as never, "ssh-session", token);
assert.equal(remoteInfoCalls, 0);
assert.equal(distroProbeCalls, 0);
});

View File

@@ -1,4 +1,4 @@
import { detectVendorFromSshVersion } from "../../../domain/host";
import { classifyDistroId, detectVendorFromSshVersion } from "../../../domain/host";
import { logger } from "../../../lib/logger";
import type { TerminalSessionStartersContext } from "./createTerminalSessionStarters.types";
@@ -44,6 +44,12 @@ export const runDistroDetection = async (
const isStillCurrent = () => isConnectionTokenCurrent(sessionId, connectionToken);
if (!isStillCurrent()) return;
if (
ctx.host.deviceType === "network" ||
classifyDistroId(ctx.host.distro) === "network-device"
) {
return;
}
// Step 1: try to classify from the SSH server identification string
// captured at handshake time. This is free (no extra channel) and
@@ -92,4 +98,3 @@ export const runDistroDetection = async (
logger.warn("OS probe failed", err);
}
};

View File

@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
nextTerminalFontSizeForAction,
nextTerminalFontSizeForWheel,
} from './terminalFontZoom.ts';
test('terminal font size actions step and reset within bounds', () => {
assert.equal(nextTerminalFontSizeForAction('increaseTerminalFontSize', 14), 15);
assert.equal(nextTerminalFontSizeForAction('decreaseTerminalFontSize', 14), 13);
assert.equal(nextTerminalFontSizeForAction('resetTerminalFontSize', 18), 14);
assert.equal(nextTerminalFontSizeForAction('increaseTerminalFontSize', 32), 32);
assert.equal(nextTerminalFontSizeForAction('decreaseTerminalFontSize', 10), 10);
assert.equal(nextTerminalFontSizeForAction('copy', 14), null);
});
test('wheel adjusts terminal font size with the platform modifier only', () => {
assert.equal(nextTerminalFontSizeForWheel({ ctrlKey: true, metaKey: false, deltaY: -1 }, 14, false), 15);
assert.equal(nextTerminalFontSizeForWheel({ ctrlKey: true, metaKey: false, deltaY: 1 }, 14, false), 13);
assert.equal(nextTerminalFontSizeForWheel({ ctrlKey: false, metaKey: true, deltaY: -1 }, 14, true), 15);
assert.equal(nextTerminalFontSizeForWheel({ ctrlKey: false, metaKey: true, deltaY: 1 }, 14, true), 13);
assert.equal(nextTerminalFontSizeForWheel({ ctrlKey: false, metaKey: true, deltaY: -1 }, 14, false), null);
assert.equal(nextTerminalFontSizeForWheel({ ctrlKey: true, metaKey: false, deltaY: -1 }, 14, true), null);
assert.equal(nextTerminalFontSizeForWheel({ ctrlKey: false, metaKey: false, deltaY: -1 }, 14, false), null);
assert.equal(nextTerminalFontSizeForWheel({ ctrlKey: true, metaKey: false, deltaY: 0 }, 14, false), null);
});

View File

@@ -0,0 +1,38 @@
import {
DEFAULT_FONT_SIZE,
MAX_FONT_SIZE,
MIN_FONT_SIZE,
} from "../../../infrastructure/config/fonts";
type WheelLike = Pick<WheelEvent, "ctrlKey" | "metaKey" | "deltaY">;
export const clampTerminalFontSize = (fontSize: number): number =>
Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, fontSize));
export const nextTerminalFontSizeForAction = (
action: string,
currentFontSize: number,
): number | null => {
switch (action) {
case "increaseTerminalFontSize":
return clampTerminalFontSize(currentFontSize + 1);
case "decreaseTerminalFontSize":
return clampTerminalFontSize(currentFontSize - 1);
case "resetTerminalFontSize":
return DEFAULT_FONT_SIZE;
default:
return null;
}
};
export const nextTerminalFontSizeForWheel = (
event: WheelLike,
currentFontSize: number,
isMac: boolean,
): number | null => {
const hasZoomModifier = isMac
? event.metaKey && !event.ctrlKey
: event.ctrlKey && !event.metaKey;
if (!hasZoomModifier || event.deltaY === 0) return null;
return clampTerminalFontSize(currentFontSize + (event.deltaY < 0 ? 1 : -1));
};

View File

@@ -89,6 +89,7 @@ export interface TerminalProps {
hotkeyScheme?: "disabled" | "mac" | "pc";
keyBindings?: KeyBinding[];
onHotkeyAction?: (action: string, event: KeyboardEvent) => void;
onTerminalFontSizeChange?: (fontSize: number) => void;
onStatusChange?: (sessionId: string, status: TerminalSession["status"]) => void;
onSessionExit?: (sessionId: string, evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void;
onTerminalDataCapture?: (sessionId: string, data: string) => void;

View File

@@ -3,7 +3,7 @@
type TerminalEffectsContext = Record<string, any>;
export function useTerminalEffects(ctx: TerminalEffectsContext) {
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBroadcastEnabledRef, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onHotkeyActionRef, onSnippetExecutorChange, onTerminalCwdChange, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, primaryFontFamily, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef } = ctx;
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBroadcastEnabledRef, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onHotkeyActionRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, primaryFontFamily, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, onSnippetShortkeyRef, snippetsRef, status, statusRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef } = ctx;
useEffect(() => {
@@ -186,9 +186,11 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
hotkeySchemeRef,
keyBindingsRef,
onHotkeyActionRef,
onTerminalFontSizeChange,
isBroadcastEnabledRef,
onBroadcastInputRef,
snippetsRef,
onSnippetShortkeyRef,
sessionId,
statusRef,
onCommandExecuted,

View File

@@ -450,6 +450,7 @@ interface TerminalPaneProps {
isComposeBarOpen: boolean;
sessionLog?: { enabled: true; directory: string; format: string };
onHotkeyAction?: (action: string, event: KeyboardEvent) => void;
onTerminalFontSizeChange?: (sessionId: string, fontSize: number) => void;
onOpenSftp: (
host: Host,
initialPath?: string,
@@ -516,6 +517,7 @@ const terminalPanePropsAreEqual = (
prev.isComposeBarOpen === next.isComposeBarOpen &&
prev.sessionLog === next.sessionLog &&
prev.onHotkeyAction === next.onHotkeyAction &&
prev.onTerminalFontSizeChange === next.onTerminalFontSizeChange &&
prev.onOpenSftp === next.onOpenSftp &&
prev.onTerminalCwdChange === next.onTerminalCwdChange &&
prev.onOpenScripts === next.onOpenScripts &&
@@ -565,6 +567,7 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
isComposeBarOpen,
sessionLog,
onHotkeyAction,
onTerminalFontSizeChange,
onOpenSftp,
onTerminalCwdChange,
onOpenScripts,
@@ -641,6 +644,9 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
onSetWorkspaceFocusedSession?.(activeWorkspaceId, session.id);
}
}, [activeWorkspaceId, isFocusMode, onSetWorkspaceFocusedSession, session.id]);
const handleTerminalFontSizeChange = useCallback((nextFontSize: number) => {
onTerminalFontSizeChange?.(session.id, nextFontSize);
}, [onTerminalFontSizeChange, session.id]);
return (
<div
@@ -681,6 +687,7 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
onHotkeyAction={onHotkeyAction}
onTerminalFontSizeChange={handleTerminalFontSizeChange}
onOpenSftp={onOpenSftp}
onTerminalCwdChange={onTerminalCwdChange}
onOpenScripts={onOpenScripts}
@@ -738,6 +745,7 @@ interface TerminalPanesHostProps {
isComposeBarOpen: boolean;
sessionLog?: { enabled: true; directory: string; format: string };
onHotkeyAction?: (action: string, event: KeyboardEvent) => void;
onTerminalFontSizeChange?: TerminalPaneProps['onTerminalFontSizeChange'];
onOpenSftp: TerminalPaneProps['onOpenSftp'];
onTerminalCwdChange: TerminalPaneProps['onTerminalCwdChange'];
onOpenScripts: () => void;

View File

@@ -4,7 +4,7 @@ import React from 'react';
type TerminalLayerViewContext = Record<string, any>;
export function TerminalLayerView({ ctx }: { ctx: TerminalLayerViewContext }) {
const { accentMode, activeResizers, activeSidePanelTab, activeTabId, activeWorkspace, AIChatPanelsHost, aiContextsByTabId, AIStateMaintenanceHost, AIStateProvider, Array, Button, cn, composeBarThemeColors, computeSplitHint, customAccent, draggingSessionId, dropHint, editorWordWrap, effectiveHosts, findSplitNode, focusedFontFamilyId, focusedFontFamilyOverridden, focusedFontSize, focusedFontSizeOverridden, focusedFontWeight, focusedFontWeightOverridden, focusedSessionId, focusedThemeOverridden, FolderTree, followAppTerminalTheme, fontSize, getTerminalCwd, handleAddKnownHost, handleBroadcastInput, handleCloseSession, handleCloseSidePanel, handleCommandExecuted, handleComposeSend, handleFontFamilyChangeForFocusedSession, handleFontFamilyResetForFocusedSession, handleFontSizeChangeForFocusedSession, handleFontSizeResetForFocusedSession, handleFontWeightChangeForFocusedSession, handleFontWeightResetForFocusedSession, handleOpenAI, handleOpenScripts, handleOpenSftp, handleOpenTheme, handleOsDetected, handlePendingUploadHandled, handleSessionExit, handleSftpInitialLocationApplied, handleSidePanelResizeStart, handleSnippetClickForFocusedSession, handleSnippetExecutorChange, handleStatusChange, handleTerminalCwdChange, handleTerminalDataCapture, handleThemeChangeForFocusedSession, handleThemeResetForFocusedSession, handleToggleSftpFromBar, handleToggleWorkspaceComposeBar, handleUpdateHost, handleWorkspaceDrop, hosts, hotkeyScheme, identities, isBroadcastEnabled, isComposeBarOpen, isFocusMode, isSidePanelOpenForCurrentTab, isTerminalLayerVisible, keyBindings, keys, knownHosts, MessageSquare, mountedAiTabIds, mountedSftpTabIds, onHotkeyAction, onSetWorkspaceFocusedSession, onSplitSession, Palette, PanelLeft, PanelRight, previewedOrVisibleThemeId, refocusActiveTerminalSession, refocusTerminalSession, renderFocusModeSidebar, resizing, resolveAIExecutorContext, resolvedPreviewTheme, ScriptsSidePanel, sessionChainHostsMap, sessionHostsMap, sessionLogConfig, sessions, setDropHint, setEditorWordWrap, setIsComposeBarOpen, setResizing, setSidePanelPosition, sftpActiveHost, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpInitialLocationForTab, sftpPendingUploadsForTab, sftpShowHiddenFiles, SftpSidePanel, sftpUseCompressedUpload, sidePanelPosition, sidePanelWidth, snippetPackages, snippets, splitHorizontalHandlersRef, splitVerticalHandlersRef, t, TerminalComposeBar, terminalFontFamilyId, TerminalPanesHost, terminalSettings, terminalTheme, themePreview, ThemeSidePanel, Tooltip, TooltipContent, TooltipTrigger, updateHosts, validAIScopeTargetIds, workspaceBroadcastHandlersRef, workspaceById, workspaceFocusHandlersRef, workspaceInnerRef, workspaceOuterRef, workspaceOverlayRef, workspaceRectsById, X, Zap } = ctx;
const { accentMode, activeResizers, activeSidePanelTab, activeTabId, activeWorkspace, AIChatPanelsHost, aiContextsByTabId, AIStateMaintenanceHost, AIStateProvider, Array, Button, cn, composeBarThemeColors, computeSplitHint, customAccent, draggingSessionId, dropHint, editorWordWrap, effectiveHosts, findSplitNode, focusedFontFamilyId, focusedFontFamilyOverridden, focusedFontSize, focusedFontSizeOverridden, focusedFontWeight, focusedFontWeightOverridden, focusedSessionId, focusedThemeOverridden, FolderTree, followAppTerminalTheme, fontSize, getTerminalCwd, handleAddKnownHost, handleBroadcastInput, handleCloseSession, handleCloseSidePanel, handleCommandExecuted, handleComposeSend, handleFontFamilyChangeForFocusedSession, handleFontFamilyResetForFocusedSession, handleFontSizeChangeForFocusedSession, handleFontSizeResetForFocusedSession, handleFontWeightChangeForFocusedSession, handleFontWeightResetForFocusedSession, handleOpenAI, handleOpenScripts, handleOpenSftp, handleOpenTheme, handleOsDetected, handlePendingUploadHandled, handleSessionExit, handleSftpInitialLocationApplied, handleSidePanelResizeStart, handleSnippetFromPanel, handleSnippetExecutorChange, handleStatusChange, handleTerminalCwdChange, handleTerminalDataCapture, handleTerminalFontSizeChange, handleThemeChangeForFocusedSession, handleThemeResetForFocusedSession, handleToggleSftpFromBar, handleToggleWorkspaceComposeBar, handleUpdateHost, handleWorkspaceDrop, hosts, hotkeyScheme, identities, isBroadcastEnabled, isComposeBarOpen, isFocusMode, isSidePanelOpenForCurrentTab, isTerminalLayerVisible, keyBindings, keys, knownHosts, MessageSquare, mountedAiTabIds, mountedSftpTabIds, onHotkeyAction, onSetWorkspaceFocusedSession, onSplitSession, Palette, PanelLeft, PanelRight, previewedOrVisibleThemeId, refocusActiveTerminalSession, refocusTerminalSession, renderFocusModeSidebar, resizing, resolveAIExecutorContext, resolvedPreviewTheme, ScriptsSidePanel, sessionChainHostsMap, sessionHostsMap, sessionLogConfig, sessions, setDropHint, setEditorWordWrap, setIsComposeBarOpen, setResizing, setSidePanelPosition, sftpActiveHost, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpInitialLocationForTab, sftpPendingUploadsForTab, sftpShowHiddenFiles, SftpSidePanel, sftpUseCompressedUpload, sidePanelPosition, sidePanelWidth, snippetPackages, snippets, splitHorizontalHandlersRef, splitVerticalHandlersRef, t, TerminalComposeBar, terminalFontFamilyId, TerminalPanesHost, terminalSettings, terminalTheme, themePreview, ThemeSidePanel, Tooltip, TooltipContent, TooltipTrigger, updateHosts, validAIScopeTargetIds, workspaceBroadcastHandlersRef, workspaceById, workspaceFocusHandlersRef, workspaceInnerRef, workspaceOuterRef, workspaceOverlayRef, workspaceRectsById, X, Zap } = ctx;
return (
<AIStateProvider>
<AIStateMaintenanceHost validAIScopeTargetIds={validAIScopeTargetIds} />
@@ -243,7 +243,7 @@ export function TerminalLayerView({ ctx }: { ctx: TerminalLayerViewContext }) {
<ScriptsSidePanel
snippets={snippets}
packages={snippetPackages}
onSnippetClick={handleSnippetClickForFocusedSession}
onSnippetClick={handleSnippetFromPanel}
/>
</div>
)}
@@ -361,6 +361,7 @@ export function TerminalLayerView({ ctx }: { ctx: TerminalLayerViewContext }) {
isComposeBarOpen={isComposeBarOpen}
sessionLog={sessionLogConfig}
onHotkeyAction={onHotkeyAction}
onTerminalFontSizeChange={handleTerminalFontSizeChange}
onOpenSftp={handleOpenSftp}
onTerminalCwdChange={handleTerminalCwdChange}
onOpenScripts={handleOpenScripts}

View File

@@ -0,0 +1,96 @@
import * as React from "react";
import { cn } from "@/lib/utils.ts";
export interface CodeTextareaProps
extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, "spellCheck"> {
showLineNumbers?: boolean;
}
function countLines(value: string): number {
if (!value) return 1;
return value.split("\n").length;
}
const CodeTextarea = React.forwardRef<HTMLTextAreaElement, CodeTextareaProps>(
({ className, value, showLineNumbers = true, onScroll, ...props }, ref) => {
const gutterRef = React.useRef<HTMLDivElement>(null);
const text = typeof value === "string" ? value : String(value ?? "");
const lineCount = countLines(text);
const lineNumbers = React.useMemo(
() => Array.from({ length: lineCount }, (_, i) => i + 1),
[lineCount],
);
const gutterWidthCh = Math.max(2, String(lineCount).length) + 1;
const handleScroll = (e: React.UIEvent<HTMLTextAreaElement>) => {
if (gutterRef.current) {
gutterRef.current.scrollTop = e.currentTarget.scrollTop;
}
onScroll?.(e);
};
const editorClass = cn(
"w-full flex-1 resize-none border-0 bg-transparent px-2 py-2 font-mono text-xs leading-5",
"placeholder:text-muted-foreground focus-visible:outline-none",
"whitespace-pre overflow-auto",
className,
);
if (!showLineNumbers) {
return (
<textarea
ref={ref}
value={value}
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs leading-5",
"placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
"whitespace-pre",
className,
)}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
data-gramm="false"
onScroll={onScroll}
{...props}
/>
);
}
return (
<div
className={cn(
"flex w-full overflow-hidden rounded-md border border-input bg-background",
"focus-within:ring-1 focus-within:ring-ring",
)}
>
<div
ref={gutterRef}
aria-hidden
className="shrink-0 overflow-hidden border-r border-border/60 bg-muted/30 py-2 pl-2 pr-1.5 select-none"
style={{ width: `${gutterWidthCh}ch` }}
>
<pre className="font-mono text-[11px] leading-5 text-muted-foreground text-right m-0">
{lineNumbers.join("\n")}
</pre>
</div>
<textarea
ref={ref}
value={value}
className={editorClass}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
data-gramm="false"
onScroll={handleScroll}
{...props}
/>
</div>
);
},
);
CodeTextarea.displayName = "CodeTextarea";
export { CodeTextarea };

View File

@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React from "react";
import { HostNotesIndicator } from "../host/HostNotesIndicator";
type VaultHostListSectionContext = Record<string, any>;
@@ -151,9 +152,12 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
)}
<DistroAvatar host={safeHost} fallback={distroBadge.text} />
<div className="min-w-0 flex flex-col justify-center gap-0.5 flex-1">
<span className="text-sm font-semibold truncate leading-5">
{safeHost.label}
</span>
<div className="flex items-center gap-1.5">
<span className="text-sm font-semibold truncate leading-5">
{safeHost.label}
</span>
<HostNotesIndicator notes={safeHost.notes} />
</div>
<div className="text-[11px] text-muted-foreground font-mono truncate leading-4">
{safeHost.username}@{safeHost.hostname}
</div>
@@ -256,9 +260,12 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
)}
<DistroAvatar host={safeHost} fallback={distroBadge.text} />
<div className="min-w-0 flex flex-col justify-center gap-0.5 flex-1">
<span className="text-sm font-semibold truncate leading-5">
{safeHost.label}
</span>
<div className="flex items-center gap-1.5">
<span className="text-sm font-semibold truncate leading-5">
{safeHost.label}
</span>
<HostNotesIndicator notes={safeHost.notes} />
</div>
<div className="text-[11px] text-muted-foreground font-mono truncate leading-4">
{safeHost.username}@{safeHost.hostname}
</div>
@@ -592,6 +599,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
managed
</Badge>
)}
<HostNotesIndicator notes={safeHost.notes} />
</div>
<div className="text-[11px] text-muted-foreground font-mono truncate leading-4">
{safeHost.username}@{safeHost.hostname}
@@ -737,6 +745,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
managed
</Badge>
)}
<HostNotesIndicator notes={safeHost.notes} />
</div>
<div className="text-[11px] text-muted-foreground font-mono truncate leading-4">
{safeHost.username}@{safeHost.hostname}

View File

@@ -0,0 +1,78 @@
import { Search } from "lucide-react";
import React from "react";
import { cn } from "../../lib/utils";
import { Input } from "../ui/input";
interface VaultPageHeaderProps {
children: React.ReactNode;
className?: string;
contentClassName?: string;
dataSection?: string;
}
export function VaultPageHeader({
children,
className,
contentClassName,
dataSection,
}: VaultPageHeaderProps) {
return (
<header
className={cn(
"relative shrink-0 bg-background/95 app-drag after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:h-px after:origin-bottom after:[transform:scaleY(.5)] after:bg-border/40 after:content-['']",
className,
)}
data-section={dataSection}
>
<div
className={cn(
"h-14 px-4 py-2 flex items-center gap-3 app-no-drag",
contentClassName,
)}
>
{children}
</div>
</header>
);
}
interface VaultHeaderSearchProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "className"> {
className?: string;
inputClassName?: string;
rightAdornment?: React.ReactNode;
}
export function VaultHeaderSearch({
className,
inputClassName,
rightAdornment,
...props
}: VaultHeaderSearchProps) {
return (
<div className={cn("relative min-w-[100px]", className)}>
<Search
size={14}
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
{...props}
className={cn(
"pl-9 h-10 bg-secondary border-border/60 text-sm",
rightAdornment && "pr-9",
inputClassName,
)}
/>
{rightAdornment && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
{rightAdornment}
</div>
)}
</div>
);
}
export const vaultHeaderSecondaryButtonClass =
"h-10 px-3 gap-2 bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40";
export const vaultHeaderIconButtonClass = "h-10 w-10";

View File

@@ -1,20 +1,69 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React from "react";
import { VaultHostListSection } from "./VaultHostListSection";
import {
VaultHeaderSearch,
VaultPageHeader,
vaultHeaderIconButtonClass,
vaultHeaderSecondaryButtonClass,
} from "./VaultPageHeader";
type VaultViewLayoutContext = Record<string, any>;
export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
const { Activity, allGroupPaths, allTags, AppLogo, Array, Badge, BookMarked, Boolean, Button, CheckSquare, ChevronDown, clearHostSelection, ClipboardCopy, Clock, cn, connectionLogs, ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, Copy, currentSection, customGroups, deleteGroupPath, deleteGroupWithHosts, deleteSelectedHosts, deleteTargetPath, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, displayedGroups, displayedHosts, DistroAvatar, Download, Dropdown, DropdownContent, DropdownTrigger, Edit2, editingGroupPath, editingHost, editingHostGroupDefaults, FileCode, FileSymlink, FolderPlus, FolderTree, getDropTargetClasses, getEffectiveHostDistro, Globe, groupConfigs, GroupDetailsPanel, groupedDisplayHosts, handleConnectClick, handleCopyCredentials, handleDeleteTag, handleDuplicateHost, handleEditGroupConfig, handleEditHost, handleEditTag, handleExportHosts, handleHostConnect, handleImportFileSelected, handleNewHost, handleProtocolSelect, handleQuickConnect, handleQuickConnectSaveHost, handleSaveGroupConfig, handleSearchKeyDown, handleUnmanageGroup, hasHostsSidePanel, HostDetailsPanel, hostListScrollRef, hosts, HostTreeView, hotkeyScheme, identities, ImportVaultDialog, Input, isDeleteGroupOpen, isGroupPanelOpen, isHostPanelOpen, isHostsSectionActive, isImportOpen, isMultiSelectMode, isNewFolderOpen, isQuickConnectOpen, isRenameGroupOpen, isSearchQuickConnect, isSerialModalOpen, Key, keyBindings, KeychainManager, keys, knownHostsManagerElement, Label, lastPinnedId, LayoutGrid, LazyConnectionLogsManager, LazyProtocolSelectDialog, List, managedGroupPaths, managedSources, moveGroup, moveHostToGroup, Network, newFolderName, newHostGroupPath, onClearUnsavedConnectionLogs, onConnectSerial, onCreateLocalTerminal, onDeleteConnectionLog, onDeleteHost, onImportOrReuseKey, onOpenLogView, onOpenSettings, onRunSnippet, onToggleConnectionLogSaved, onUpdateCustomGroups, onUpdateGroupConfigs, onUpdateHosts, onUpdateIdentities, onUpdateKeys, onUpdateProxyProfiles, onUpdateSnippetPackages, onUpdateSnippets, Pin, pinnedHosts, pinnedRecentIds, Plug, Plus, PortForwarding, protocolSelectHost, proxyProfiles, ProxyProfilesManager, quickConnectTarget, quickConnectWarnings, QuickConnectWizard, recentHosts, renameGroupError, renameGroupName, renameTargetPath, RippleButton, rootRef, sanitizeHost, search, Search, selectedGroupPath, selectedHostIds, selectedTags, SerialConnectModal, SerialHostDetailsPanel, sessionCount, Set, setCurrentSection, setDeleteGroupWithHosts, setDeleteTargetPath, setDragOverDropTarget, setEditingGroupPath, setEditingHost, setGroupDragOverDropTarget, setIsDeleteGroupOpen, setIsGroupPanelOpen, setIsHostPanelOpen, setIsImportOpen, setIsMultiSelectMode, setIsNewFolderOpen, setIsQuickConnectOpen, setIsRenameGroupOpen, setIsSerialModalOpen, setLastPinnedId, setNewFolderName, setNewHostGroupPath, setProtocolSelectHost, setQuickConnectTarget, setQuickConnectWarnings, setRenameGroupError, setRenameGroupName, setRenameTargetPath, setSearch, setSelectedGroupPath, setSelectedHostIds, setSelectedTags, setSidebarCollapsed, setSortMode, setTargetParentPath, Settings, setViewMode, shellHistory, shouldHideEmptyRootHostsSection, showRecentHosts, sidebarCollapsed, snippetPackages, snippets, SnippetsManager, SortDropdown, sortMode, splitViewGridStyle, Square, Star, submitNewFolder, submitRenameGroup, Suspense, t, TagFilterDropdown, targetParentPath, terminalFontSize, terminalSettings, TerminalSquare, terminalThemeId, toggleHostPinned, toggleHostSelection, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, Trash2, treeExpandedState, treeViewGroupTree, treeViewHosts, Upload, upsertHostById, Usb, viewMode, visibleDisplayedHosts, X, Zap } = ctx;
const { Activity, allGroupPaths, allTags, AppLogo, Array, Badge, BookMarked, Boolean, Button, CheckSquare, ChevronDown, clearHostSelection, ClipboardCopy, Clock, cn, connectionLogs, connectSelectedHosts, ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, Copy, currentSection, customGroups, deleteGroupPath, deleteGroupWithHosts, deleteSelectedHosts, deleteTargetPath, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, displayedGroups, displayedHosts, DistroAvatar, Download, Dropdown, DropdownContent, DropdownTrigger, Edit2, editingGroupPath, editingHost, editingHostGroupDefaults, FileCode, FileSymlink, FolderPlus, FolderTree, getDropTargetClasses, getEffectiveHostDistro, Globe, groupConfigs, GroupDetailsPanel, groupedDisplayHosts, handleConnectClick, handleCopyCredentials, handleDeleteTag, handleDuplicateHost, handleEditGroupConfig, handleEditHost, handleEditTag, handleExportHosts, handleHostConnect, handleImportFileSelected, handleNewHost, handleProtocolSelect, handleQuickConnect, handleQuickConnectSaveHost, handleSaveGroupConfig, handleSearchKeyDown, handleUnmanageGroup, handleSidebarWidthCommit, hasHostsSidePanel, HostDetailsPanel, hostListScrollRef, hosts, HostTreeView, hotkeyScheme, identities, ImportVaultDialog, Input, isDeleteGroupOpen, isGroupPanelOpen, isHostPanelOpen, isHostsSectionActive, isImportOpen, isMultiSelectMode, isNewFolderOpen, isQuickConnectOpen, isRenameGroupOpen, isSearchQuickConnect, isSerialModalOpen, Key, keyBindings, KeychainManager, keys, knownHostsManagerElement, Label, lastPinnedId, LayoutGrid, LazyConnectionLogsManager, LazyProtocolSelectDialog, List, managedGroupPaths, managedSources, moveGroup, moveHostToGroup, Network, newFolderName, newHostGroupPath, onClearUnsavedConnectionLogs, onConnectSerial, onCreateLocalTerminal, onDeleteConnectionLog, onDeleteHost, onImportOrReuseKey, onOpenLogView, onOpenSettings, onRunSnippet, onToggleConnectionLogSaved, onUpdateCustomGroups, onUpdateGroupConfigs, onUpdateHosts, onUpdateIdentities, onUpdateKeys, onUpdateProxyProfiles, onUpdateSnippetPackages, onUpdateSnippets, Pin, pinnedHosts, pinnedRecentIds, Plug, Plus, PortForwarding, protocolSelectHost, proxyProfiles, ProxyProfilesManager, quickConnectTarget, quickConnectWarnings, QuickConnectWizard, recentHosts, renameGroupError, renameGroupName, renameTargetPath, RippleButton, rootRef, sanitizeHost, search, selectedGroupPath, selectedHostIds, selectedTags, SerialConnectModal, SerialHostDetailsPanel, sessionCount, Set, setCurrentSection, setDeleteGroupWithHosts, setDeleteTargetPath, setDragOverDropTarget, setEditingGroupPath, setEditingHost, setGroupDragOverDropTarget, setIsDeleteGroupOpen, setIsGroupPanelOpen, setIsHostPanelOpen, setIsImportOpen, setIsMultiSelectMode, setIsNewFolderOpen, setIsQuickConnectOpen, setIsRenameGroupOpen, setIsSerialModalOpen, setLastPinnedId, setNewFolderName, setNewHostGroupPath, setProtocolSelectHost, setQuickConnectTarget, setQuickConnectWarnings, setRenameGroupError, setRenameGroupName, setRenameTargetPath, setSearch, setSelectedGroupPath, setSelectedHostIds, setSelectedTags, setSidebarCollapsed, setSidebarWidth, setSortMode, setTargetParentPath, Settings, setViewMode, shellHistory, shouldHideEmptyRootHostsSection, showRecentHosts, sidebarCollapsed, sidebarWidth, snippetPackages, snippets, SnippetsManager, SortDropdown, sortMode, splitViewGridStyle, Square, Star, submitNewFolder, submitRenameGroup, Suspense, t, TagFilterDropdown, targetParentPath, terminalFontSize, terminalSettings, TerminalSquare, terminalThemeId, toggleHostPinned, toggleHostSelection, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, Trash2, treeExpandedState, treeViewGroupTree, treeViewHosts, Upload, upsertHostById, Usb, viewMode, visibleDisplayedHosts, X, Zap } = ctx;
const [isSidebarResizing, setIsSidebarResizing] = React.useState(false);
const sidebarMinWidth = 56;
const sidebarMaxWidth = 320;
const effectiveSidebarWidth = Math.max(
sidebarMinWidth,
Math.min(sidebarMaxWidth, Number(sidebarWidth) || 208),
);
const handleSidebarResizeStart = React.useCallback((event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
const startX = event.clientX;
const startWidth = effectiveSidebarWidth;
const previousCursor = document.body.style.cursor;
const previousUserSelect = document.body.style.userSelect;
setIsSidebarResizing(true);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
const clampWidth = (value: number) =>
Math.max(sidebarMinWidth, Math.min(sidebarMaxWidth, value));
const handlePointerMove = (moveEvent: PointerEvent) => {
setSidebarWidth(clampWidth(startWidth + moveEvent.clientX - startX));
};
const handlePointerUp = (upEvent: PointerEvent) => {
const nextWidth = clampWidth(startWidth + upEvent.clientX - startX);
setSidebarWidth(nextWidth);
handleSidebarWidthCommit(nextWidth);
setIsSidebarResizing(false);
document.body.style.cursor = previousCursor;
document.body.style.userSelect = previousUserSelect;
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerUp);
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
}, [effectiveSidebarWidth, handleSidebarWidthCommit, setSidebarWidth]);
return (
<div ref={rootRef} className="absolute inset-0 min-h-0 flex" data-section="vault-view">
<div ref={rootRef} className="absolute inset-0 min-h-0 flex bg-secondary" data-section="vault-view">
{/* Sidebar */}
<TooltipProvider delayDuration={100}>
<div
className={cn(
"bg-secondary border-r border-border/60 flex flex-col transition-all duration-200",
sidebarCollapsed ? "w-14" : "w-52"
"relative shrink-0 bg-secondary flex flex-col",
isSidebarResizing ? "transition-none" : "transition-[width] duration-200",
)}
style={{ width: effectiveSidebarWidth }}
data-section="vault-sidebar"
>
<div className={cn(
@@ -197,50 +246,57 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
{sidebarCollapsed && <TooltipContent side="right">{t("common.settings")}</TooltipContent>}
</Tooltip>
</div>
<div
role="separator"
aria-orientation="vertical"
aria-label={t("vault.sidebar.resize")}
className={cn(
"app-no-drag absolute right-0 top-0 z-20 h-full w-2 translate-x-1/2 cursor-col-resize",
"after:absolute after:right-1/2 after:top-2 after:h-[calc(100%-16px)] after:w-px after:translate-x-1/2 after:bg-border/0 after:transition-colors",
"hover:after:bg-border/70",
isSidebarResizing && "after:bg-primary/70",
)}
onPointerDown={handleSidebarResizeStart}
/>
</div>
</TooltipProvider>
{/* Main Area */}
<div
className="flex-1 min-w-0 flex flex-col min-h-0 relative"
data-section="vault-main"
>
<header
className={cn(
"border-b border-border/50 bg-secondary/80 backdrop-blur app-drag",
!isHostsSectionActive && "hidden",
)}
data-section="vault-hosts-header"
<div className="flex min-w-0 flex-1 p-2 pl-1" data-section="vault-stage">
<div
className="relative flex min-h-0 flex-1 overflow-hidden rounded-xl border border-border/60 bg-background shadow-sm"
data-section="vault-surface"
>
<div className="h-14 px-4 py-2 flex items-center gap-3">
<div className="relative flex-1 app-no-drag">
<Search
size={14}
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
{/* Main Area */}
<div
className="flex-1 min-w-0 flex flex-col min-h-0 relative"
data-section="vault-main"
>
<VaultPageHeader
className={cn(!isHostsSectionActive && "hidden")}
dataSection="vault-hosts-header"
>
<VaultHeaderSearch
placeholder={t("vault.hosts.search.placeholder")}
className={cn(
"pl-9 h-10 bg-secondary border-border/60 text-sm",
className="flex-1"
inputClassName={cn(
isSearchQuickConnect &&
"border-primary/50 ring-1 ring-primary/20",
)}
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={handleSearchKeyDown}
rightAdornment={
isSearchQuickConnect ? (
<Zap size={14} className="text-primary" />
) : null
}
/>
{isSearchQuickConnect && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<Zap size={14} className="text-primary" />
</div>
)}
</div>
<Button
variant={isSearchQuickConnect ? "default" : "secondary"}
className={cn(
"h-10 px-4 app-no-drag",
"h-10 px-4",
!isSearchQuickConnect &&
"bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40",
vaultHeaderSecondaryButtonClass,
)}
onClick={handleConnectClick}
>
@@ -250,7 +306,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
<div className="flex items-center gap-1 app-no-drag">
<Dropdown>
<DropdownTrigger asChild>
<Button variant="ghost" size="icon" className="h-10 w-10 app-no-drag">
<Button variant="ghost" size="icon" className={vaultHeaderIconButtonClass}>
{viewMode === "grid" ? (
<LayoutGrid size={16} />
) : viewMode === "list" ? (
@@ -291,19 +347,19 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
onChange={setSelectedTags}
onEditTag={handleEditTag}
onDeleteTag={handleDeleteTag}
className="h-10 w-10"
className={vaultHeaderIconButtonClass}
/>
<SortDropdown
value={sortMode}
onChange={setSortMode}
className="h-10 w-10"
className={vaultHeaderIconButtonClass}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={isMultiSelectMode ? "secondary" : "ghost"}
size="icon"
className="h-10 w-10"
className={vaultHeaderIconButtonClass}
onClick={() => {
if (isMultiSelectMode) {
clearHostSelection();
@@ -334,7 +390,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
<div className="flex items-center rounded-md bg-primary text-primary-foreground">
<Button
size="sm"
className="h-10 px-3 rounded-r-none bg-transparent hover:bg-white/10 shadow-none app-no-drag"
className="h-10 px-3 rounded-r-none bg-transparent hover:bg-white/10 shadow-none"
onClick={handleNewHost}
tabIndex={isHostPanelOpen ? -1 : 0}
>
@@ -343,7 +399,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
<DropdownTrigger asChild>
<Button
size="sm"
className="h-10 px-2 rounded-l-none bg-transparent hover:bg-white/10 border-l border-primary-foreground/20 shadow-none app-no-drag"
className="h-10 px-2 rounded-l-none bg-transparent hover:bg-white/10 border-l border-primary-foreground/20 shadow-none"
tabIndex={isHostPanelOpen ? -1 : 0}
>
<ChevronDown size={14} />
@@ -396,7 +452,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
<Button
size="sm"
variant="secondary"
className="h-10 px-3 app-no-drag bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
className={vaultHeaderSecondaryButtonClass}
onClick={onCreateLocalTerminal}
tabIndex={isHostPanelOpen ? -1 : 0}
>
@@ -405,15 +461,14 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
<Button
size="sm"
variant="secondary"
className="h-10 px-3 app-no-drag bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
className={vaultHeaderSecondaryButtonClass}
onClick={() => setIsSerialModalOpen(true)}
tabIndex={isHostPanelOpen ? -1 : 0}
>
<Usb size={14} className="mr-2" /> {t("serial.button")}
</Button>
</div>
</div>
</header>
</VaultPageHeader>
{isMultiSelectMode && isHostsSectionActive && (
<div className="px-4 py-1.5 bg-background border-b border-border/40 flex items-center gap-2">
@@ -440,6 +495,16 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
>
{t("vault.hosts.deselectAll")}
</Button>
<Button
variant="default"
size="sm"
className="h-7 px-2 text-xs"
disabled={selectedHostIds.size === 0}
onClick={connectSelectedHosts}
>
<Plug size={12} className="mr-1" />
{t("vault.hosts.connectSelected", { count: selectedHostIds.size })}
</Button>
<Button
variant="destructive"
size="sm"
@@ -616,7 +681,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
/>
)}
{/* Host Details Panel - positioned at VaultView root level for correct top alignment */}
{/* Host Details Panel */}
{currentSection === "hosts" && isHostPanelOpen && editingHost?.protocol !== 'serial' && (
<HostDetailsPanel
initialData={editingHost}
@@ -673,6 +738,8 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
layout="inline"
/>
)}
</div>
</div>
<Dialog open={isNewFolderOpen} onOpenChange={(open) => {
setIsNewFolderOpen(open);

View File

@@ -146,7 +146,8 @@ export function useVaultHostCollections({
(h) =>
h.label.toLowerCase().includes(s) ||
h.hostname.toLowerCase().includes(s) ||
h.tags.some((t) => t.toLowerCase().includes(s)),
h.tags.some((t) => t.toLowerCase().includes(s)) ||
(h.notes?.toLowerCase().includes(s) ?? false),
);
}
// Apply tag filter
@@ -189,7 +190,8 @@ export function useVaultHostCollections({
(h) =>
h.label.toLowerCase().includes(s) ||
h.hostname.toLowerCase().includes(s) ||
h.tags.some((t) => t.toLowerCase().includes(s)),
h.tags.some((t) => t.toLowerCase().includes(s)) ||
(h.notes?.toLowerCase().includes(s) ?? false),
);
}
if (selectedTags.length > 0) {
@@ -211,7 +213,8 @@ export function useVaultHostCollections({
(h) =>
h.label.toLowerCase().includes(s) ||
h.hostname.toLowerCase().includes(s) ||
h.tags.some((t) => t.toLowerCase().includes(s)),
h.tags.some((t) => t.toLowerCase().includes(s)) ||
(h.notes?.toLowerCase().includes(s) ?? false),
);
}
if (selectedTags.length > 0) {
@@ -242,7 +245,8 @@ export function useVaultHostCollections({
(h) =>
h.label.toLowerCase().includes(s) ||
h.hostname.toLowerCase().includes(s) ||
h.tags.some((t) => t.toLowerCase().includes(s)),
h.tags.some((t) => t.toLowerCase().includes(s)) ||
(h.notes?.toLowerCase().includes(s) ?? false),
);
}
// Apply tag filter

View File

@@ -165,6 +165,11 @@ test("detectVendorFromSshVersion recognizes legacy Huawei VRP dash banner", () =
assert.equal(detectVendorFromSshVersion("SSH-2.0--"), "huawei");
});
test("detectVendorFromSshVersion recognizes Ruijie RGOS banner", () => {
assert.equal(detectVendorFromSshVersion("RGOS_SSH"), "ruijie");
assert.equal(detectVendorFromSshVersion("SSH-2.0-RGOS_SSH"), "ruijie");
});
test("shouldProbeSessionCwd allows the probe on a plain Linux host", () => {
assert.equal(
shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: "OpenSSH_9.6" }),

View File

@@ -34,6 +34,7 @@ export const NETWORK_DEVICE_OPTIONS = [
'fortinet',
'paloalto',
'zyxel',
'ruijie',
] as const;
export type NetworkDeviceVendor = typeof NETWORK_DEVICE_OPTIONS[number];
@@ -118,6 +119,9 @@ export const detectVendorFromSshVersion = (softwareVersion?: string): '' | Netwo
// ZyXEL ZyWALL
if (/^Zyxel\s*SSH/i.test(s)) return 'zyxel';
// Ruijie RGOS
if (/^RGOS_SSH\b/i.test(s)) return 'ruijie';
return '';
};
@@ -254,11 +258,13 @@ export const sanitizeHost = (host: Host): Host => {
? 'auto'
: undefined;
const migrated = migrateDeprecatedFontOverride(host);
const cleanNotes = host.notes?.trim() || undefined;
return {
...migrated,
hostname: cleanHostname,
distro: cleanDistro,
distroMode: cleanDistroMode,
manualDistro: cleanManualDistro || undefined,
notes: cleanNotes,
};
};

View File

@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { DEFAULT_KEY_BINDINGS } from './models/keyBindings.ts';
import { getTerminalPassthroughActions } from '../application/state/useGlobalHotkeys.ts';
test('default shortcuts include terminal font size controls', () => {
const byAction = new Map(DEFAULT_KEY_BINDINGS.map((binding) => [binding.action, binding]));
assert.equal(byAction.get('increaseTerminalFontSize')?.pc, 'Ctrl + =');
assert.equal(byAction.get('decreaseTerminalFontSize')?.pc, 'Ctrl + -');
assert.equal(byAction.get('resetTerminalFontSize')?.pc, 'Ctrl + 0');
assert.equal(byAction.get('increaseTerminalFontSize')?.category, 'terminal');
assert.equal(byAction.get('decreaseTerminalFontSize')?.category, 'terminal');
assert.equal(byAction.get('resetTerminalFontSize')?.category, 'terminal');
});
test('terminal font size shortcuts are handled inside xterm', () => {
const actions = getTerminalPassthroughActions();
assert.equal(actions.has('increaseTerminalFontSize'), true);
assert.equal(actions.has('decreaseTerminalFontSize'), true);
assert.equal(actions.has('resetTerminalFontSize'), true);
});

View File

@@ -176,6 +176,8 @@ export interface Host {
localShellArgs?: string[];
localShellName?: string;
localShellIcon?: string;
/** User-authored Markdown notes (project, hardware, region, etc.) */
notes?: string;
}
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519';

View File

@@ -206,6 +206,9 @@ export const DEFAULT_KEY_BINDINGS: KeyBinding[] = [
{ id: 'select-all', action: 'selectAll', label: 'Select All in Terminal', mac: '⌘ + A', pc: 'Ctrl + Shift + A', category: 'terminal' },
{ id: 'clear-buffer', action: 'clearBuffer', label: 'Clear Terminal Buffer', mac: '⌘ + ⌃ + K', pc: 'Ctrl + Shift + K', category: 'terminal' },
{ id: 'search-terminal', action: 'searchTerminal', label: 'Open Terminal Search', mac: '⌘ + F', pc: 'Ctrl + F', category: 'terminal' },
{ id: 'increase-terminal-font-size', action: 'increaseTerminalFontSize', label: 'Increase Terminal Font Size', mac: '⌘ + =', pc: 'Ctrl + =', category: 'terminal' },
{ id: 'decrease-terminal-font-size', action: 'decreaseTerminalFontSize', label: 'Decrease Terminal Font Size', mac: '⌘ + -', pc: 'Ctrl + -', category: 'terminal' },
{ id: 'reset-terminal-font-size', action: 'resetTerminalFontSize', label: 'Reset Terminal Font Size', mac: '⌘ + 0', pc: 'Ctrl + 0', category: 'terminal' },
// Navigation / Split View
{ id: 'move-focus', action: 'moveFocus', label: 'Move focus between Split View panes', mac: '⌘ + ⌥ + arrows', pc: 'Ctrl + Alt + arrows', category: 'navigation' },

View File

@@ -0,0 +1,76 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
applySnippetVariables,
parseSnippetVariables,
previewSnippetCommand,
snippetHasVariables,
} from "./snippetVariables.ts";
test("parseSnippetVariables finds all vars after snippetHasVariables (shared-regex lastIndex)", () => {
const command = "echo '{{test}}'\necho '{{test2}}'";
assert.equal(snippetHasVariables(command), true);
assert.deepEqual(parseSnippetVariables(command).map((v) => v.name), ["test", "test2"]);
});
test("parseSnippetVariables returns empty for plain command", () => {
assert.deepEqual(parseSnippetVariables("ls -la"), []);
assert.equal(snippetHasVariables("ls -la"), false);
});
test("parseSnippetVariables dedupes by first occurrence order", () => {
assert.deepEqual(parseSnippetVariables("echo {{a}} and {{b}} and {{a}}"), [
{ name: "a" },
{ name: "b" },
]);
});
test("parseSnippetVariables reads default after colon", () => {
assert.deepEqual(parseSnippetVariables("fallocate -l {{内存大小:4}}G"), [
{ name: "内存大小", defaultValue: "4" },
]);
});
test("applySnippetVariables replaces all occurrences", () => {
const result = applySnippetVariables(
"fallocate -l {{内存大小:4}}G\nswapon {{内存大小:4}}",
{ : "8" },
);
assert.equal(result.ok, true);
if (result.ok) {
assert.equal(result.command, "fallocate -l 8G\nswapon 8");
}
});
test("applySnippetVariables uses default when value empty", () => {
const result = applySnippetVariables("size {{n:2}}", { n: "" });
assert.equal(result.ok, true);
if (result.ok) assert.equal(result.command, "size 2");
});
test("applySnippetVariables reports missing required vars", () => {
const result = applySnippetVariables("echo {{name}}", {});
assert.equal(result.ok, false);
if (!result.ok) assert.deepEqual(result.missing, ["name"]);
});
test("applySnippetVariables passes through command without variables", () => {
const result = applySnippetVariables("uptime", { x: "1" });
assert.equal(result.ok, true);
if (result.ok) assert.equal(result.command, "uptime");
});
test("previewSnippetCommand keeps placeholder for unfilled required", () => {
assert.equal(
previewSnippetCommand("echo {{a}}", {}),
"echo {{a}}",
);
});
test("previewSnippetCommand shows resolved values", () => {
assert.equal(
previewSnippetCommand("echo {{a:hi}}", {}),
"echo hi",
);
});

117
domain/snippetVariables.ts Normal file
View File

@@ -0,0 +1,117 @@
/**
* Parse and substitute {{variable}} / {{variable:default}} placeholders in snippet commands.
*/
/** Non-global: safe to reuse; avoids lastIndex side effects across calls. */
const VARIABLE_TOKEN = /\{\{([^}:]+)(?::([^}]*))?\}\}/;
function variablePattern(): RegExp {
return /\{\{([^}:]+)(?::([^}]*))?\}\}/g;
}
export interface SnippetVariableDef {
name: string;
defaultValue?: string;
}
export function snippetHasVariables(command: string): boolean {
return VARIABLE_TOKEN.test(String(command ?? ""));
}
export function parseSnippetVariables(command: string): SnippetVariableDef[] {
const text = String(command ?? "");
const seen = new Set<string>();
const result: SnippetVariableDef[] = [];
for (const match of text.matchAll(variablePattern())) {
const name = match[1]?.trim() ?? "";
if (!name || seen.has(name)) continue;
seen.add(name);
const defaultRaw = match[2];
result.push({
name,
...(defaultRaw !== undefined ? { defaultValue: defaultRaw } : {}),
});
}
return result;
}
export type ApplySnippetVariablesResult =
| { ok: true; command: string }
| { ok: false; missing: string[] };
function resolveVariableValue(
def: SnippetVariableDef,
values: Record<string, string>,
): string | undefined {
const raw = values[def.name];
if (raw !== undefined && raw.trim() !== "") {
return raw;
}
if (def.defaultValue !== undefined) {
return def.defaultValue;
}
return undefined;
}
export function applySnippetVariables(
command: string,
values: Record<string, string>,
): ApplySnippetVariablesResult {
const defs = parseSnippetVariables(command);
if (defs.length === 0) {
return { ok: true, command: String(command ?? "") };
}
const missing: string[] = [];
const resolved: Record<string, string> = {};
for (const def of defs) {
const value = resolveVariableValue(def, values);
if (value === undefined) {
missing.push(def.name);
} else {
resolved[def.name] = value;
}
}
if (missing.length > 0) {
return { ok: false, missing };
}
let output = String(command ?? "");
for (const def of defs) {
const value = resolved[def.name];
const escapedName = def.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = new RegExp(
`\\{\\{${escapedName}(?::[^}]*)?\\}\\}`,
"g",
);
output = output.replace(pattern, value);
}
return { ok: true, command: output };
}
/** Preview resolved command for UI; unfilled required vars stay as placeholders. */
export function previewSnippetCommand(
command: string,
values: Record<string, string>,
): string {
const defs = parseSnippetVariables(command);
if (defs.length === 0) return String(command ?? "");
let output = String(command ?? "");
for (const def of defs) {
const value = resolveVariableValue(def, values);
const replacement = value ?? `{{${def.name}}}`;
const escapedName = def.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = new RegExp(
`\\{\\{${escapedName}(?::[^}]*)?\\}\\}`,
"g",
);
output = output.replace(pattern, replacement);
}
return output;
}

View File

@@ -242,6 +242,9 @@ export interface SyncPayload {
// Sync metadata
syncedAt: number; // When this payload was created
// Reliability metadata used to make sync decisions auditable across devices.
syncMeta?: SyncReliabilityMeta;
}
export const SYNC_PAYLOAD_ENTITY_KEYS = [
@@ -271,6 +274,55 @@ export const CLOUD_SYNC_PAYLOAD_ENTITY_KEYS = [
export type SyncPayloadEntityKey = typeof SYNC_PAYLOAD_ENTITY_KEYS[number];
export type CloudSyncPayloadEntityKey = typeof CLOUD_SYNC_PAYLOAD_ENTITY_KEYS[number];
export type SyncChangeEntityKey = CloudSyncPayloadEntityKey | 'settings';
export interface SyncEntityChangeCounts {
added: { local: number; remote: number };
modified: { local: number; remote: number };
deleted: { local: number; remote: number };
}
export interface SyncConflictDetail {
entityType: SyncChangeEntityKey;
id?: string;
kind:
| 'both-added'
| 'both-modified'
| 'local-deleted-remote-modified'
| 'remote-deleted-local-modified';
}
export interface SyncChangeSummary {
hasLocalChanges: boolean;
hasRemoteChanges: boolean;
hasConflicts: boolean;
byEntity: Partial<Record<SyncChangeEntityKey, SyncEntityChangeCounts>>;
conflicts: SyncConflictDetail[];
}
export interface SyncDeletionRecord {
entityType: CloudSyncPayloadEntityKey;
id: string;
deletedAt: number;
deviceId?: string;
}
export interface SyncReliabilityMeta {
schemaVersion: 1;
generatedAt: number;
deviceId?: string;
baseSyncedAt?: number;
localChanged: boolean;
deletions: SyncDeletionRecord[];
changeSummary: SyncChangeSummary;
}
export interface SyncSnapshotEntry {
id: string;
timestamp: number;
provider?: CloudProvider;
payload: SyncPayload;
}
export function hasSyncPayloadEntityData(
payload: SyncPayload,
@@ -348,14 +400,22 @@ export interface SyncResult {
version?: number;
error?: string;
conflictDetected?: boolean;
/** Present when action === 'merge'; caller should apply this to update local state */
/** Present when sync produced or selected a payload that caller should apply locally */
mergedPayload?: import('./sync').SyncPayload;
/** Present with a downloaded payload so callers can commit the remote anchor after local apply succeeds. */
remoteFile?: SyncedFile;
/** True when a shrink-detection guard blocked the upload */
shrinkBlocked?: boolean;
/** The finding that triggered the shrink block or force-push */
finding?: ShrinkFinding;
}
export interface RemoteSyncPayload {
provider: CloudProvider;
payload: SyncPayload;
remoteFile: SyncedFile;
}
/**
* Conflict information for UI
*/
@@ -367,6 +427,7 @@ export interface ConflictInfo {
remoteVersion: number;
remoteUpdatedAt: number;
remoteDeviceName?: string;
changeSummary?: SyncChangeSummary;
}
/**

View File

@@ -2,6 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict";
import { mergeSyncPayloads } from "./syncMerge.ts";
import { withSyncReliabilityMeta } from "./syncReliability.ts";
import type { SyncPayload } from "./sync.ts";
function payload(overrides: Partial<SyncPayload> = {}): SyncPayload {
@@ -136,3 +137,138 @@ test("mergeSyncPayloads keeps missing proxy references visible to connection gua
assert.equal(result.payload.hosts[0]?.proxyProfileId, "proxy-1");
assert.equal(result.payload.groupConfigs?.[0]?.proxyProfileId, "proxy-1");
});
test("mergeSyncPayloads honors remote deletion records when base is unavailable", () => {
const result = mergeSyncPayloads(
null,
payload({
hosts: [{
id: "host-1",
label: "Stale local copy",
hostname: "old.example.com",
username: "root",
tags: [],
os: "linux",
}],
}),
payload({
syncMeta: {
schemaVersion: 1,
generatedAt: 123,
localChanged: true,
deletions: [{
entityType: "hosts",
id: "host-1",
deletedAt: 123,
deviceId: "remote-device",
}],
changeSummary: {
hasLocalChanges: true,
hasRemoteChanges: false,
hasConflicts: false,
byEntity: {},
conflicts: [],
},
},
}),
);
assert.deepEqual(result.payload.hosts, []);
assert.equal(result.summary.deleted.remote, 1);
});
test("mergeSyncPayloads carries deletion records forward after applying a tombstone", () => {
const remote = payload({
syncMeta: {
schemaVersion: 1,
generatedAt: 123,
localChanged: true,
deletions: [{
entityType: "hosts",
id: "host-1",
deletedAt: 123,
deviceId: "remote-device",
}],
changeSummary: {
hasLocalChanges: true,
hasRemoteChanges: false,
hasConflicts: false,
byEntity: {},
conflicts: [],
},
},
});
const result = mergeSyncPayloads(
null,
payload({
hosts: [{
id: "host-1",
label: "Stale local copy",
hostname: "old.example.com",
username: "root",
tags: [],
os: "linux",
}],
}),
remote,
);
const enriched = withSyncReliabilityMeta(result.payload, null, {
deviceId: "local-device",
now: 456,
});
assert.deepEqual(enriched.syncMeta?.deletions, [{
entityType: "hosts",
id: "host-1",
deletedAt: 123,
deviceId: "remote-device",
}]);
});
test("mergeSyncPayloads treats missing optional arrays as legacy payloads, not deletions", () => {
const identity = {
id: "identity-1",
label: "Prod",
username: "root",
authMethod: "password" as const,
created: 1,
};
const rule = {
id: "rule-1",
name: "Web",
hostId: "host-1",
type: "local" as const,
localHost: "127.0.0.1",
localPort: 8080,
remoteHost: "127.0.0.1",
remotePort: 80,
enabled: true,
createdAt: 1,
};
const base = payload({
identities: [identity],
snippetPackages: ["ops"],
portForwardingRules: [rule],
groupConfigs: [{ path: "prod", username: "root" }],
});
const local = payload({
identities: [identity],
snippetPackages: ["ops"],
portForwardingRules: [rule],
groupConfigs: [{ path: "prod", username: "root" }],
});
const remote = payload();
delete remote.identities;
delete remote.snippetPackages;
delete remote.portForwardingRules;
delete remote.groupConfigs;
const result = mergeSyncPayloads(base, local, remote);
assert.deepEqual(result.payload.identities, [identity]);
assert.deepEqual(result.payload.snippetPackages, ["ops"]);
assert.deepEqual(result.payload.portForwardingRules, [rule]);
assert.deepEqual(result.payload.groupConfigs, [{ path: "prod", username: "root" }]);
});

View File

@@ -19,7 +19,8 @@
* merge by entity ID, preferring local for duplicates.
*/
import type { SyncPayload } from './sync';
import { carryForwardSyncDeletions, getDeletedEntityIds } from './syncReliability';
import type { CloudSyncPayloadEntityKey, SyncPayload } from './sync';
// ---------------------------------------------------------------------------
// Public types
@@ -38,6 +39,14 @@ interface MergeResult {
summary: MergeSummary;
}
const OPTIONAL_ENTITY_KEYS = new Set<CloudSyncPayloadEntityKey>([
'identities',
'proxyProfiles',
'snippetPackages',
'portForwardingRules',
'groupConfigs',
]);
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -58,6 +67,21 @@ function fingerprint(value: unknown): string {
});
}
function entityArray<T>(
payload: SyncPayload,
key: CloudSyncPayloadEntityKey,
fallback: T[],
): T[] {
if (
OPTIONAL_ENTITY_KEYS.has(key)
&& !Object.prototype.hasOwnProperty.call(payload, key)
) {
return fallback;
}
const value = payload[key];
return Array.isArray(value) ? value as T[] : [];
}
// ---------------------------------------------------------------------------
// Entity-array merge (hosts, keys, identities, snippets, etc.)
// ---------------------------------------------------------------------------
@@ -74,6 +98,7 @@ function mergeEntityArrays<T extends { id: string }>(
base: T[],
local: T[],
remote: T[],
tombstones?: { local: Set<string>; remote: Set<string> },
): EntityMergeResult<T> {
const baseMap = new Map(base.map((e) => [e.id, e]));
const localMap = new Map(local.map((e) => [e.id, e]));
@@ -100,7 +125,14 @@ function mergeEntityArrays<T extends { id: string }>(
const inLocal = localItem !== undefined;
const inRemote = remoteItem !== undefined;
if (!inBase && inLocal && !inRemote) {
if (!inBase && inLocal && !inRemote && tombstones?.remote.has(id)) {
// Remote explicitly records this entity as deleted. When no base is
// available, this tombstone is the only durable signal that absence is
// intentional rather than an old client omitting the entity.
deleted.remote++;
} else if (!inBase && !inLocal && inRemote && tombstones?.local.has(id)) {
deleted.local++;
} else if (!inBase && inLocal && !inRemote) {
// Local addition
merged.push(localItem);
added.local++;
@@ -171,6 +203,7 @@ function mergeStringArrays(
base: string[],
local: string[],
remote: string[],
tombstones?: { local: Set<string>; remote: Set<string> },
): string[] {
const baseSet = new Set(base);
const localSet = new Set(local);
@@ -186,7 +219,11 @@ function mergeStringArrays(
const inLocal = localSet.has(value);
const inRemote = remoteSet.has(value);
if (!inBase) {
if (!inBase && inLocal && !inRemote && tombstones?.remote.has(value)) {
// Remote tombstone wins over a stale local value when no base exists.
} else if (!inBase && !inLocal && inRemote && tombstones?.local.has(value)) {
// Local tombstone wins over a stale remote value when no base exists.
} else if (!inBase) {
// Addition — keep if either side added it
if (inLocal || inRemote) result.add(value);
} else {
@@ -359,22 +396,35 @@ export function mergeSyncPayloads(
deleted: { local: 0, remote: 0 },
modified: { local: 0, remote: 0, conflicts: 0 },
};
const tombstones = (entityType: CloudSyncPayloadEntityKey) => ({
local: getDeletedEntityIds(local, entityType),
remote: getDeletedEntityIds(remote, entityType),
});
// Merge each entity type
const hosts = mergeEntityArrays(b.hosts ?? [], local.hosts ?? [], remote.hosts ?? []);
const keys = mergeEntityArrays(b.keys ?? [], local.keys ?? [], remote.keys ?? []);
const identities = mergeEntityArrays(b.identities ?? [], local.identities ?? [], remote.identities ?? []);
const hosts = mergeEntityArrays(b.hosts ?? [], local.hosts ?? [], remote.hosts ?? [], tombstones('hosts'));
const keys = mergeEntityArrays(b.keys ?? [], local.keys ?? [], remote.keys ?? [], tombstones('keys'));
const baseIdentities = b.identities ?? [];
const identities = mergeEntityArrays(
baseIdentities,
entityArray(local, 'identities', baseIdentities),
entityArray(remote, 'identities', baseIdentities),
tombstones('identities'),
);
const baseProxyProfiles = b.proxyProfiles ?? [];
const proxyProfiles = mergeEntityArrays(
baseProxyProfiles,
local.proxyProfiles ?? baseProxyProfiles,
remote.proxyProfiles ?? baseProxyProfiles,
entityArray(local, 'proxyProfiles', baseProxyProfiles),
entityArray(remote, 'proxyProfiles', baseProxyProfiles),
tombstones('proxyProfiles'),
);
const snippets = mergeEntityArrays(b.snippets ?? [], local.snippets ?? [], remote.snippets ?? []);
const snippets = mergeEntityArrays(b.snippets ?? [], local.snippets ?? [], remote.snippets ?? [], tombstones('snippets'));
const basePortForwardingRules = b.portForwardingRules ?? [];
const portForwardingRules = mergeEntityArrays(
b.portForwardingRules ?? [],
local.portForwardingRules ?? [],
remote.portForwardingRules ?? [],
basePortForwardingRules,
entityArray(local, 'portForwardingRules', basePortForwardingRules),
entityArray(remote, 'portForwardingRules', basePortForwardingRules),
tombstones('portForwardingRules'),
);
// Merge group configs (keyed by path — wrap with virtual id for entity merge)
@@ -386,8 +436,9 @@ export function mergeSyncPayloads(
const baseGroupConfigs = b.groupConfigs ?? [];
const groupConfigsResult = mergeEntityArrays(
wrapGC(baseGroupConfigs),
wrapGC(local.groupConfigs ?? baseGroupConfigs),
wrapGC(remote.groupConfigs ?? baseGroupConfigs),
wrapGC(entityArray(local, 'groupConfigs', baseGroupConfigs)),
wrapGC(entityArray(remote, 'groupConfigs', baseGroupConfigs)),
tombstones('groupConfigs'),
);
// Aggregate stats
@@ -408,11 +459,14 @@ export function mergeSyncPayloads(
b.customGroups ?? [],
local.customGroups ?? [],
remote.customGroups ?? [],
tombstones('customGroups'),
);
const baseSnippetPackages = b.snippetPackages ?? [];
const snippetPackages = mergeStringArrays(
b.snippetPackages ?? [],
local.snippetPackages ?? [],
remote.snippetPackages ?? [],
baseSnippetPackages,
entityArray<string>(local, 'snippetPackages', baseSnippetPackages),
entityArray<string>(remote, 'snippetPackages', baseSnippetPackages),
tombstones('snippetPackages'),
);
// Merge settings
@@ -430,7 +484,7 @@ export function mergeSyncPayloads(
const groupConfigs = unwrapGC(groupConfigsResult.merged);
const payload: SyncPayload = {
const payload: SyncPayload = carryForwardSyncDeletions({
hosts: hosts.merged,
keys: keys.merged,
identities: identities.merged,
@@ -442,7 +496,7 @@ export function mergeSyncPayloads(
groupConfigs,
settings,
syncedAt: Date.now(),
};
}, [local, remote]);
return {
payload,

View File

@@ -0,0 +1,253 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
collectSyncDeletions,
summarizeSyncChanges,
withSyncReliabilityMeta,
} from "./syncReliability.ts";
import type { SyncPayload } from "./sync.ts";
function payload(overrides: Partial<SyncPayload> = {}): SyncPayload {
return {
hosts: [],
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
customGroups: [],
snippetPackages: [],
portForwardingRules: [],
groupConfigs: [],
settings: undefined,
syncedAt: 0,
...overrides,
};
}
test("summarizeSyncChanges records whether local data changed", () => {
const base = payload({
hosts: [{
id: "host-1",
label: "Old",
hostname: "old.example.com",
username: "root",
tags: [],
os: "linux",
}],
});
const local = payload({
hosts: [{
id: "host-1",
label: "New",
hostname: "old.example.com",
username: "root",
tags: [],
os: "linux",
}],
});
const summary = summarizeSyncChanges(base, local);
assert.equal(summary.hasLocalChanges, true);
assert.equal(summary.byEntity.hosts.modified.local, 1);
});
test("collectSyncDeletions records deleted entities explicitly", () => {
const base = payload({
hosts: [{
id: "host-1",
label: "Old",
hostname: "old.example.com",
username: "root",
tags: [],
os: "linux",
}],
customGroups: ["prod"],
});
const deletions = collectSyncDeletions(base, payload(), {
deletedAt: 123,
deviceId: "device-a",
});
assert.deepEqual(deletions, [
{ entityType: "hosts", id: "host-1", deletedAt: 123, deviceId: "device-a" },
{ entityType: "customGroups", id: "prod", deletedAt: 123, deviceId: "device-a" },
]);
});
test("collectSyncDeletions records group config deletions by path", () => {
const deletions = collectSyncDeletions(
payload({ groupConfigs: [{ path: "prod", username: "root" }] }),
payload({ groupConfigs: [] }),
{ deletedAt: 123 },
);
assert.deepEqual(deletions, [{
entityType: "groupConfigs",
id: "prod",
deletedAt: 123,
}]);
});
test("summarizeSyncChanges treats missing optional arrays as legacy payloads", () => {
const base = payload({
groupConfigs: [{ path: "prod", username: "root" }],
});
const remote = payload();
delete remote.groupConfigs;
const summary = summarizeSyncChanges(base, base, remote);
assert.equal(summary.hasRemoteChanges, false);
assert.equal(summary.byEntity.groupConfigs, undefined);
});
test("summarizeSyncChanges reports conflict categories", () => {
const base = payload({
hosts: [{
id: "host-1",
label: "Base",
hostname: "base.example.com",
username: "root",
tags: [],
os: "linux",
}],
});
const local = payload({
hosts: [{
id: "host-1",
label: "Local",
hostname: "base.example.com",
username: "root",
tags: [],
os: "linux",
}],
});
const remote = payload({
hosts: [{
id: "host-1",
label: "Remote",
hostname: "base.example.com",
username: "root",
tags: [],
os: "linux",
}],
});
const summary = summarizeSyncChanges(base, local, remote);
assert.equal(summary.hasConflicts, true);
assert.deepEqual(summary.conflicts, [{
entityType: "hosts",
id: "host-1",
kind: "both-modified",
}]);
});
test("summarizeSyncChanges reports both-added conflicts by entity type", () => {
const local = payload({
hosts: [{
id: "host-1",
label: "Local",
hostname: "local.example.com",
username: "root",
tags: [],
os: "linux",
}],
});
const remote = payload({
hosts: [{
id: "host-1",
label: "Remote",
hostname: "remote.example.com",
username: "root",
tags: [],
os: "linux",
}],
});
const summary = summarizeSyncChanges(payload(), local, remote);
assert.equal(summary.hasConflicts, true);
assert.deepEqual(summary.conflicts, [{
entityType: "hosts",
id: "host-1",
kind: "both-added",
}]);
});
test("withSyncReliabilityMeta carries old deletion records until the entity is recreated", () => {
const current = payload({
syncMeta: {
schemaVersion: 1,
generatedAt: 100,
localChanged: true,
deletions: [{
entityType: "hosts",
id: "host-1",
deletedAt: 100,
deviceId: "device-a",
}],
changeSummary: {
hasLocalChanges: true,
hasRemoteChanges: false,
hasConflicts: false,
byEntity: {},
conflicts: [],
},
},
});
const enriched = withSyncReliabilityMeta(current, payload(), {
deviceId: "device-a",
now: 200,
});
assert.deepEqual(enriched.syncMeta?.deletions, [{
entityType: "hosts",
id: "host-1",
deletedAt: 100,
deviceId: "device-a",
}]);
const recreated = withSyncReliabilityMeta(
payload({
hosts: [{
id: "host-1",
label: "Recreated",
hostname: "new.example.com",
username: "root",
tags: [],
os: "linux",
}],
syncMeta: enriched.syncMeta,
}),
payload(),
{ deviceId: "device-a", now: 300 },
);
assert.deepEqual(recreated.syncMeta?.deletions, []);
});
test("withSyncReliabilityMeta attaches change summary and deletion records", () => {
const base = payload({
snippets: [{ id: "snippet-1", label: "Old", command: "ls" }],
});
const current = payload();
const enriched = withSyncReliabilityMeta(current, base, {
deviceId: "device-a",
now: 456,
});
assert.equal(enriched.syncMeta?.schemaVersion, 1);
assert.equal(enriched.syncMeta?.localChanged, true);
assert.deepEqual(enriched.syncMeta?.deletions, [{
entityType: "snippets",
id: "snippet-1",
deletedAt: 456,
deviceId: "device-a",
}]);
});

351
domain/syncReliability.ts Normal file
View File

@@ -0,0 +1,351 @@
import {
CLOUD_SYNC_PAYLOAD_ENTITY_KEYS,
type CloudSyncPayloadEntityKey,
type SyncChangeEntityKey,
type SyncChangeSummary,
type SyncDeletionRecord,
type SyncEntityChangeCounts,
type SyncPayload,
type SyncReliabilityMeta,
} from './sync';
type EntityValue = { id?: string; path?: string } | string;
export const SYNC_SNAPSHOT_LIMIT = 5;
const EMPTY_COUNTS = (): SyncEntityChangeCounts => ({
added: { local: 0, remote: 0 },
modified: { local: 0, remote: 0 },
deleted: { local: 0, remote: 0 },
});
const OPTIONAL_ENTITY_KEYS = new Set<CloudSyncPayloadEntityKey>([
'identities',
'proxyProfiles',
'snippetPackages',
'portForwardingRules',
'groupConfigs',
]);
function fingerprint(value: unknown): string {
return JSON.stringify(value, (_key, v) => {
if (v && typeof v === 'object' && !Array.isArray(v)) {
return Object.keys(v).sort().reduce<Record<string, unknown>>((acc, k) => {
acc[k] = (v as Record<string, unknown>)[k];
return acc;
}, {});
}
return v;
});
}
function entityId(value: EntityValue): string {
return typeof value === 'string' ? value : value.id ?? value.path ?? '';
}
function entityMap(values: unknown): Map<string, EntityValue> {
if (!Array.isArray(values)) return new Map();
return new Map(
values
.filter((value): value is EntityValue =>
typeof value === 'string'
|| (
Boolean(value)
&& typeof value === 'object'
&& (
typeof (value as { id?: unknown }).id === 'string'
|| typeof (value as { path?: unknown }).path === 'string'
)
),
)
.map((value) => [entityId(value), value]),
);
}
function payloadValues(
payload: SyncPayload,
entityType: CloudSyncPayloadEntityKey,
base?: SyncPayload,
): unknown {
if (
base
&& OPTIONAL_ENTITY_KEYS.has(entityType)
&& !Object.prototype.hasOwnProperty.call(payload, entityType)
) {
return base[entityType];
}
return payload[entityType];
}
function incrementEntity(
summary: SyncChangeSummary,
entityType: SyncChangeEntityKey,
updater: (counts: SyncEntityChangeCounts) => void,
): void {
const counts = summary.byEntity[entityType] ?? EMPTY_COUNTS();
updater(counts);
summary.byEntity[entityType] = counts;
}
function recordEntityChanges(
summary: SyncChangeSummary,
entityType: CloudSyncPayloadEntityKey,
baseValues: unknown,
localValues: unknown,
remoteValues?: unknown,
): void {
const base = entityMap(baseValues);
const local = entityMap(localValues);
const remote = remoteValues === undefined ? null : entityMap(remoteValues);
const ids = new Set([
...base.keys(),
...local.keys(),
...(remote ? remote.keys() : []),
]);
for (const id of ids) {
const baseItem = base.get(id);
const localItem = local.get(id);
const remoteItem = remote?.get(id);
const localAdded = !baseItem && Boolean(localItem);
const localDeleted = Boolean(baseItem) && !localItem;
const localModified = Boolean(baseItem && localItem)
&& fingerprint(baseItem) !== fingerprint(localItem);
if (localAdded) {
summary.hasLocalChanges = true;
incrementEntity(summary, entityType, (counts) => { counts.added.local += 1; });
}
if (localDeleted) {
summary.hasLocalChanges = true;
incrementEntity(summary, entityType, (counts) => { counts.deleted.local += 1; });
}
if (localModified) {
summary.hasLocalChanges = true;
incrementEntity(summary, entityType, (counts) => { counts.modified.local += 1; });
}
if (!remote) continue;
const remoteAdded = !baseItem && Boolean(remoteItem);
const remoteDeleted = Boolean(baseItem) && !remoteItem;
const remoteModified = Boolean(baseItem && remoteItem)
&& fingerprint(baseItem) !== fingerprint(remoteItem);
if (remoteAdded) {
summary.hasRemoteChanges = true;
incrementEntity(summary, entityType, (counts) => { counts.added.remote += 1; });
}
if (remoteDeleted) {
summary.hasRemoteChanges = true;
incrementEntity(summary, entityType, (counts) => { counts.deleted.remote += 1; });
}
if (remoteModified) {
summary.hasRemoteChanges = true;
incrementEntity(summary, entityType, (counts) => { counts.modified.remote += 1; });
}
if (localAdded && remoteAdded && fingerprint(localItem) !== fingerprint(remoteItem)) {
summary.hasConflicts = true;
summary.conflicts.push({ entityType, id, kind: 'both-added' });
} else if (localModified && remoteModified && fingerprint(localItem) !== fingerprint(remoteItem)) {
summary.hasConflicts = true;
summary.conflicts.push({ entityType, id, kind: 'both-modified' });
} else if (localDeleted && remoteModified) {
summary.hasConflicts = true;
summary.conflicts.push({ entityType, id, kind: 'local-deleted-remote-modified' });
} else if (remoteDeleted && localModified) {
summary.hasConflicts = true;
summary.conflicts.push({ entityType, id, kind: 'remote-deleted-local-modified' });
}
}
}
function recordSettingsChanges(
summary: SyncChangeSummary,
base: SyncPayload,
local: SyncPayload,
remote?: SyncPayload,
): void {
const localChanged = fingerprint(local.settings) !== fingerprint(base.settings);
const remoteChanged = remote !== undefined
&& fingerprint(remote.settings) !== fingerprint(base.settings);
if (localChanged) {
summary.hasLocalChanges = true;
incrementEntity(summary, 'settings', (counts) => { counts.modified.local += 1; });
}
if (remoteChanged) {
summary.hasRemoteChanges = true;
incrementEntity(summary, 'settings', (counts) => { counts.modified.remote += 1; });
}
if (
localChanged
&& remoteChanged
&& fingerprint(local.settings) !== fingerprint(remote?.settings)
) {
summary.hasConflicts = true;
summary.conflicts.push({ entityType: 'settings', kind: 'both-modified' });
}
}
export function summarizeSyncChanges(
base: SyncPayload | null,
local: SyncPayload,
remote?: SyncPayload,
): SyncChangeSummary {
const reference = base ?? {
hosts: [],
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
customGroups: [],
snippetPackages: [],
portForwardingRules: [],
groupConfigs: [],
settings: undefined,
syncedAt: 0,
};
const summary: SyncChangeSummary = {
hasLocalChanges: false,
hasRemoteChanges: false,
hasConflicts: false,
byEntity: {},
conflicts: [],
};
for (const entityType of CLOUD_SYNC_PAYLOAD_ENTITY_KEYS) {
recordEntityChanges(
summary,
entityType,
reference[entityType],
payloadValues(local, entityType, reference),
remote ? payloadValues(remote, entityType, reference) : undefined,
);
}
recordSettingsChanges(summary, reference, local, remote);
return summary;
}
export function collectSyncDeletions(
base: SyncPayload | null,
current: SyncPayload,
opts: { deletedAt: number; deviceId?: string },
): SyncDeletionRecord[] {
if (!base) return [];
const deletions: SyncDeletionRecord[] = [];
for (const entityType of CLOUD_SYNC_PAYLOAD_ENTITY_KEYS) {
const baseItems = entityMap(base[entityType]);
const currentItems = entityMap(payloadValues(current, entityType, base));
for (const id of baseItems.keys()) {
if (!currentItems.has(id)) {
deletions.push({
entityType,
id,
deletedAt: opts.deletedAt,
...(opts.deviceId ? { deviceId: opts.deviceId } : {}),
});
}
}
}
return deletions;
}
function mergeDeletionRecords(
payload: SyncPayload,
newDeletions: SyncDeletionRecord[],
): SyncDeletionRecord[] {
const byKey = new Map<string, SyncDeletionRecord>();
for (const record of [...(payload.syncMeta?.deletions ?? []), ...newDeletions]) {
const currentItems = entityMap(payload[record.entityType]);
if (currentItems.has(record.id)) continue;
const key = `${record.entityType}:${record.id}`;
const previous = byKey.get(key);
if (!previous || record.deletedAt >= previous.deletedAt) {
byKey.set(key, record);
}
}
return Array.from(byKey.values()).sort((a, b) =>
a.entityType.localeCompare(b.entityType) || a.id.localeCompare(b.id),
);
}
export function withSyncReliabilityMeta(
payload: SyncPayload,
base: SyncPayload | null,
opts: { deviceId?: string; now?: number } = {},
): SyncPayload {
const generatedAt = opts.now ?? Date.now();
const changeSummary = summarizeSyncChanges(base, payload);
const deletions = mergeDeletionRecords(
payload,
collectSyncDeletions(base, payload, {
deletedAt: generatedAt,
...(opts.deviceId ? { deviceId: opts.deviceId } : {}),
}),
);
const meta: SyncReliabilityMeta = {
schemaVersion: 1,
generatedAt,
...(opts.deviceId ? { deviceId: opts.deviceId } : {}),
...(base?.syncedAt ? { baseSyncedAt: base.syncedAt } : {}),
localChanged: changeSummary.hasLocalChanges,
deletions,
changeSummary,
};
return {
...payload,
syncMeta: meta,
};
}
export function carryForwardSyncDeletions(
payload: SyncPayload,
sources: SyncPayload[],
opts: { generatedAt?: number; deviceId?: string } = {},
): SyncPayload {
const sourceDeletions = sources.flatMap((source) => source.syncMeta?.deletions ?? []);
const deletions = mergeDeletionRecords(
{
...payload,
syncMeta: {
schemaVersion: 1,
generatedAt: opts.generatedAt ?? Date.now(),
...(opts.deviceId ? { deviceId: opts.deviceId } : {}),
localChanged: false,
deletions: sourceDeletions,
changeSummary: summarizeSyncChanges(null, payload),
},
},
[],
);
if (deletions.length === 0) return payload;
return {
...payload,
syncMeta: {
schemaVersion: 1,
generatedAt: opts.generatedAt ?? Date.now(),
...(opts.deviceId ? { deviceId: opts.deviceId } : {}),
localChanged: false,
deletions,
changeSummary: summarizeSyncChanges(null, payload),
},
};
}
export function getDeletedEntityIds(
payload: SyncPayload,
entityType: CloudSyncPayloadEntityKey,
): Set<string> {
return new Set(
(payload.syncMeta?.deletions ?? [])
.filter((record) => record.entityType === entityType)
.map((record) => record.id),
);
}

View File

@@ -0,0 +1,56 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
DEFAULT_CLOUD_SYNC_STRATEGY,
normalizeCloudSyncStrategy,
resolveCloudSyncConflictAction,
} from "./syncStrategy.ts";
test("normalizeCloudSyncStrategy falls back to smart merge for unknown values", () => {
assert.equal(normalizeCloudSyncStrategy("preferCloud"), "preferCloud");
assert.equal(normalizeCloudSyncStrategy("preferLocal"), "preferLocal");
assert.equal(normalizeCloudSyncStrategy("smartMerge"), "smartMerge");
assert.equal(normalizeCloudSyncStrategy("downloadOnly"), DEFAULT_CLOUD_SYNC_STRATEGY);
assert.equal(normalizeCloudSyncStrategy(undefined), DEFAULT_CLOUD_SYNC_STRATEGY);
});
test("resolveCloudSyncConflictAction keeps current merge behavior by default", () => {
assert.equal(
resolveCloudSyncConflictAction("smartMerge", {
hasConflict: true,
hasRemoteFile: true,
}),
"smart-merge",
);
});
test("resolveCloudSyncConflictAction lets cloud data win when requested", () => {
assert.equal(
resolveCloudSyncConflictAction("preferCloud", {
hasConflict: true,
hasRemoteFile: true,
}),
"download-remote",
);
});
test("resolveCloudSyncConflictAction lets local data win when requested", () => {
assert.equal(
resolveCloudSyncConflictAction("preferLocal", {
hasConflict: true,
hasRemoteFile: true,
}),
"upload-local",
);
});
test("resolveCloudSyncConflictAction uploads normally when no remote conflict exists", () => {
assert.equal(
resolveCloudSyncConflictAction("preferCloud", {
hasConflict: false,
hasRemoteFile: true,
}),
"upload-local",
);
});

36
domain/syncStrategy.ts Normal file
View File

@@ -0,0 +1,36 @@
export type CloudSyncStrategy = 'smartMerge' | 'preferCloud' | 'preferLocal';
export type CloudSyncConflictAction = 'smart-merge' | 'download-remote' | 'upload-local';
export const DEFAULT_CLOUD_SYNC_STRATEGY: CloudSyncStrategy = 'smartMerge';
const CLOUD_SYNC_STRATEGIES: readonly CloudSyncStrategy[] = [
'smartMerge',
'preferCloud',
'preferLocal',
] as const;
export function normalizeCloudSyncStrategy(value: unknown): CloudSyncStrategy {
return CLOUD_SYNC_STRATEGIES.includes(value as CloudSyncStrategy)
? value as CloudSyncStrategy
: DEFAULT_CLOUD_SYNC_STRATEGY;
}
export function resolveCloudSyncConflictAction(
strategy: CloudSyncStrategy,
remoteState: { hasConflict: boolean; hasRemoteFile: boolean },
): CloudSyncConflictAction {
if (!remoteState.hasConflict || !remoteState.hasRemoteFile) {
return 'upload-local';
}
if (strategy === 'preferCloud') {
return 'download-remote';
}
if (strategy === 'preferLocal') {
return 'upload-local';
}
return 'smart-merge';
}

View File

@@ -157,8 +157,10 @@ const createHost = (input: {
protocol?: Exclude<HostProtocol, "mosh">;
group?: string;
tags?: string[];
notes?: string;
}): Host => {
const now = Date.now();
const notes = input.notes?.trim() || undefined;
return {
id: crypto.randomUUID(),
label: input.label?.trim() || input.hostname,
@@ -171,6 +173,7 @@ const createHost = (input: {
os: "linux",
protocol: input.protocol ?? "ssh",
createdAt: now,
...(notes ? { notes } : {}),
};
};
@@ -259,6 +262,7 @@ const importFromCsv = (text: string): VaultImportResult => {
const groupsIdx = findHeaderIndex(header, ["groups", "group", "folder", "path"]);
const labelIdx = findHeaderIndex(header, ["label", "name"]);
const tagsIdx = findHeaderIndex(header, ["tags", "tag"]);
const notesIdx = findHeaderIndex(header, ["notes", "note", "remark", "description", "memo"]);
const hostnameIdx = findHeaderIndex(header, ["hostname", "host", "server"]);
const protocolIdx = findHeaderIndex(header, ["protocol", "proto", "scheme"]);
const portIdx = findHeaderIndex(header, ["port"]);
@@ -303,6 +307,8 @@ const importFromCsv = (text: string): VaultImportResult => {
const group = groupsIdx >= 0 ? normalizeGroupPath(row[groupsIdx]) : undefined;
const label = labelIdx >= 0 ? row[labelIdx] : undefined;
const tags = tagsIdx >= 0 ? splitTags(row[tagsIdx]) : [];
const notesRaw = notesIdx >= 0 ? row[notesIdx] : undefined;
const notes = notesRaw?.trim() || undefined;
const protocol =
normalizeProtocol(protocolIdx >= 0 ? row[protocolIdx] : undefined) ??
target.protocol ??
@@ -321,6 +327,7 @@ const importFromCsv = (text: string): VaultImportResult => {
protocol,
group,
tags,
notes,
}),
);
}

View File

@@ -8,12 +8,12 @@ export const getVaultCsvTemplate = (
opts: VaultCsvTemplateOptions = {},
): string => {
const includeExampleRows = opts.includeExampleRows !== false;
const header = ["Groups", "Label", "Tags", "Hostname/IP", "Protocol", "Port", "Username", "Password"];
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password"];
const rows: string[][] = [header];
if (includeExampleRows) {
rows.push(["Project/Dev", "Web Server (dev)", "dev,web", "192.168.1.10", "ssh", "22", "root", ""]);
rows.push(["Project/Prod", "Web Server (prod)", "prod,web", "server-a.example.com", "ssh", "22", "ubuntu", ""]);
rows.push(["Database", "DB", "db,mysql", "db.example.com", "ssh", "4567", "admin", ""]);
rows.push(["Project/Dev", "Web Server (dev)", "dev,web", "Dev web tier", "192.168.1.10", "ssh", "22", "root", ""]);
rows.push(["Project/Prod", "Web Server (prod)", "prod,web", "Production", "server-a.example.com", "ssh", "22", "ubuntu", ""]);
rows.push(["Database", "DB", "db,mysql", "MySQL primary", "db.example.com", "ssh", "4567", "admin", ""]);
}
const escapeCsv = (value: string) => {
@@ -26,7 +26,7 @@ export const getVaultCsvTemplate = (
};
const exportHostsToCsv = (hosts: Host[]): string => {
const header = ["Groups", "Label", "Tags", "Hostname/IP", "Protocol", "Port", "Username", "Password"];
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password"];
const rows: string[][] = [header];
const escapeCsv = (value: string, skipFormulaGuard = false) => {
@@ -71,6 +71,7 @@ const exportHostsToCsv = (hosts: Host[]): string => {
host.group ?? "",
host.label ?? "",
(host.tags ?? []).join(","),
host.notes ?? "",
formatHostname(host.hostname),
host.protocol ?? "ssh",
String(effectivePort),

View File

@@ -0,0 +1,148 @@
/**
* Private key normalizer.
*
* ssh2's key parser only understands OpenSSH, legacy PKCS#1/SEC1
* (`BEGIN RSA/DSA/EC PRIVATE KEY`) and PuTTY keys. It rejects PKCS#8
* (`-----BEGIN PRIVATE KEY-----` / `-----BEGIN ENCRYPTED PRIVATE KEY-----`)
* with "Unsupported key format", even though such keys are valid and accepted
* by other clients (e.g. Termius). See issue #1139.
*
* Node's crypto can read PKCS#8 and re-export RSA/EC keys in the legacy PEM
* forms ssh2 accepts, so we transparently convert them before handing the key
* to ssh2. Ed25519 (and other) PKCS#8 keys have no legacy PEM representation
* and surface a clear, actionable error instead of ssh2's opaque one.
*/
const crypto = require("node:crypto");
const { utils: sshUtils } = require("ssh2");
const PKCS8_HEADER_RE = /-----BEGIN (?:ENCRYPTED )?PRIVATE KEY-----/;
// Node asymmetricKeyType -> legacy PEM export type that ssh2 can parse.
const LEGACY_EXPORT_TYPE = {
rsa: "pkcs1",
ec: "sec1",
};
class PrivateKeyPassphraseError extends Error {
constructor(message) {
super(message || "Incorrect passphrase for private key");
this.name = "PrivateKeyPassphraseError";
this.code = "ERR_PRIVATE_KEY_PASSPHRASE";
}
}
class UnsupportedPrivateKeyError extends Error {
constructor(message) {
super(message);
this.name = "UnsupportedPrivateKeyError";
this.code = "ERR_PRIVATE_KEY_UNSUPPORTED";
}
}
// Matches a private-key PEM block by its BEGIN/END markers (which survive even
// when the surrounding newlines are lost), capturing the label and raw body.
const PEM_BLOCK_RE =
/-----BEGIN ((?:RSA |DSA |EC |OPENSSH |ENCRYPTED )?PRIVATE KEY)-----([\s\S]*?)-----END \1-----/;
/**
* Rebuild clean PEM framing for a key whose text was mangled in transit —
* newlines collapsed to spaces, turned into literal "\n", or lines indented.
* Returns the repaired PEM, or null when it isn't a recoverable block.
*
* The base64 body is preserved byte-for-byte (only non-base64 characters are
* stripped before re-wrapping), so this can never produce a different key.
* Encrypted legacy PEM (Proc-Type / DEK-Info header lines inside the body) is
* left alone — those lines aren't base64 and can't be safely re-wrapped.
*/
function repairMalformedPem(text) {
// Newlines flattened into literal "\n" / "\r\n" escape sequences.
const unescaped = text.replace(/\\r\\n|\\n|\\r/g, "\n");
const match = PEM_BLOCK_RE.exec(unescaped);
if (!match) return null;
const label = match[1];
const body = match[2];
if (/Proc-Type:|DEK-Info:/i.test(body)) return null;
const base64 = body.replace(/[^A-Za-z0-9+/=]/g, "");
if (!base64) return null;
const wrapped = base64.replace(/.{1,64}/g, "$&\n").trimEnd();
return `-----BEGIN ${label}-----\n${wrapped}\n-----END ${label}-----\n`;
}
/**
* Normalize a private key into a form ssh2 can parse.
*
* @param {string} privateKey - PEM private key contents.
* @param {string} [passphrase] - Passphrase, if the key is encrypted.
* @returns {{ privateKey: string, passphrase: string|undefined, converted: boolean }}
* @throws {PrivateKeyPassphraseError} Encrypted PKCS#8 with a wrong/missing passphrase.
* @throws {UnsupportedPrivateKeyError} PKCS#8 key whose type has no legacy PEM form (e.g. Ed25519).
*/
function normalizePrivateKeyForSsh2(privateKey, passphrase) {
if (typeof privateKey !== "string" || privateKey.length === 0) {
return { privateKey, passphrase, converted: false };
}
// If ssh2 already understands the key, leave it exactly as-is.
const parsed = sshUtils.parseKey(privateKey, passphrase);
if (parsed && !(parsed instanceof Error)) {
return { privateKey, passphrase, converted: false };
}
// The key text may have been mangled before it reached us — newlines lost,
// turned into literal "\n", or lines indented. Rebuild clean PEM framing and
// retry; a repaired key also feeds cleanly into the PKCS#8 path below.
const repaired = repairMalformedPem(privateKey);
if (repaired && repaired !== privateKey) {
const reparsed = sshUtils.parseKey(repaired, passphrase);
if (reparsed && !(reparsed instanceof Error)) {
return { privateKey: repaired, passphrase, converted: true };
}
}
const candidate = repaired || privateKey;
// We can only rescue PKCS#8 keys, which Node's crypto can read.
if (!PKCS8_HEADER_RE.test(candidate)) {
return { privateKey, passphrase, converted: false };
}
const encrypted = candidate.includes("-----BEGIN ENCRYPTED PRIVATE KEY-----");
let keyObject;
try {
keyObject = crypto.createPrivateKey(
passphrase ? { key: candidate, passphrase } : candidate,
);
} catch (err) {
if (encrypted) {
throw new PrivateKeyPassphraseError(
"Could not decrypt the PKCS#8 private key with the provided passphrase",
);
}
throw new UnsupportedPrivateKeyError(
`Unable to read the PKCS#8 private key: ${err.message}. ` +
"Convert it with `ssh-keygen -p -m PEM -f <key>` and try again.",
);
}
const exportType = LEGACY_EXPORT_TYPE[keyObject.asymmetricKeyType];
if (!exportType) {
throw new UnsupportedPrivateKeyError(
`Private keys of type "${keyObject.asymmetricKeyType}" in PKCS#8 format are not supported. ` +
"Convert it to OpenSSH format with `ssh-keygen -p -f <key>` and try again.",
);
}
const converted = keyObject.export({ type: exportType, format: "pem" }).toString();
return { privateKey: converted, passphrase: undefined, converted: true };
}
module.exports = {
normalizePrivateKeyForSsh2,
repairMalformedPem,
PrivateKeyPassphraseError,
UnsupportedPrivateKeyError,
};

View File

@@ -0,0 +1,141 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const { utils: sshUtils } = require("ssh2");
const {
normalizePrivateKeyForSsh2,
PrivateKeyPassphraseError,
UnsupportedPrivateKeyError,
} = require("./privateKeyNormalizer.cjs");
function genRsa(encoding) {
return crypto.generateKeyPairSync("rsa", {
modulusLength: 2048,
privateKeyEncoding: encoding,
publicKeyEncoding: { type: "spki", format: "pem" },
}).privateKey;
}
const rsaPkcs8 = () => genRsa({ type: "pkcs8", format: "pem" });
const rsaPkcs1 = () => genRsa({ type: "pkcs1", format: "pem" });
const encryptedRsaPkcs8 = (passphrase) =>
genRsa({ type: "pkcs8", format: "pem", cipher: "aes-256-cbc", passphrase });
const ecPkcs8 = () =>
crypto.generateKeyPairSync("ec", {
namedCurve: "prime256v1",
privateKeyEncoding: { type: "pkcs8", format: "pem" },
publicKeyEncoding: { type: "spki", format: "pem" },
}).privateKey;
const ed25519Pkcs8 = () =>
crypto.generateKeyPairSync("ed25519", {
privateKeyEncoding: { type: "pkcs8", format: "pem" },
publicKeyEncoding: { type: "spki", format: "pem" },
}).privateKey;
function parseOk(key) {
const r = sshUtils.parseKey(key);
return r && !(r instanceof Error) ? r : null;
}
test("converts unencrypted RSA PKCS#8 into an ssh2-parseable key", () => {
const result = normalizePrivateKeyForSsh2(rsaPkcs8());
assert.equal(result.converted, true);
const parsed = parseOk(result.privateKey);
assert.ok(parsed, "converted key should be parseable by ssh2");
assert.equal(parsed.type, "ssh-rsa");
});
test("converts unencrypted EC PKCS#8 into an ssh2-parseable key", () => {
const result = normalizePrivateKeyForSsh2(ecPkcs8());
assert.equal(result.converted, true);
const parsed = parseOk(result.privateKey);
assert.ok(parsed);
assert.equal(parsed.type, "ecdsa-sha2-nistp256");
});
test("leaves an already-supported PKCS#1 key untouched", () => {
const key = rsaPkcs1();
const result = normalizePrivateKeyForSsh2(key);
assert.equal(result.converted, false);
assert.equal(result.privateKey, key);
});
test("decrypts and converts an encrypted RSA PKCS#8 key with the correct passphrase", () => {
const result = normalizePrivateKeyForSsh2(encryptedRsaPkcs8("secret"), "secret");
assert.equal(result.converted, true);
assert.equal(result.passphrase, undefined);
const parsed = parseOk(result.privateKey);
assert.ok(parsed);
assert.equal(parsed.type, "ssh-rsa");
});
test("throws PrivateKeyPassphraseError for encrypted PKCS#8 with a wrong passphrase", () => {
assert.throws(
() => normalizePrivateKeyForSsh2(encryptedRsaPkcs8("secret"), "wrong"),
(err) => err instanceof PrivateKeyPassphraseError,
);
});
test("throws UnsupportedPrivateKeyError for Ed25519 PKCS#8 with a conversion hint", () => {
assert.throws(
() => normalizePrivateKeyForSsh2(ed25519Pkcs8()),
(err) => err instanceof UnsupportedPrivateKeyError && /ssh-keygen/.test(err.message),
);
});
test("passes through content that is not a PKCS#8 key", () => {
const junk = "not a private key";
const result = normalizePrivateKeyForSsh2(junk);
assert.equal(result.converted, false);
assert.equal(result.privateKey, junk);
});
const indentLines = (key) =>
key.split("\n").map((line) => (line ? " " + line : line)).join("\n");
const dropEndLine = (key) => key.split("\n").slice(0, -2).join("\n");
const legacyEncryptedRsa = (passphrase) =>
crypto.generateKeyPairSync("rsa", {
modulusLength: 2048,
privateKeyEncoding: { type: "pkcs1", format: "pem", cipher: "aes-128-cbc", passphrase },
publicKeyEncoding: { type: "spki", format: "pem" },
}).privateKey;
test("repairs an RSA key whose newlines were collapsed to spaces", () => {
const result = normalizePrivateKeyForSsh2(rsaPkcs1().replace(/\n/g, " "));
assert.equal(result.converted, true);
assert.ok(parseOk(result.privateKey), "repaired key should be parseable by ssh2");
});
test("repairs an RSA key whose newlines became literal backslash-n", () => {
const result = normalizePrivateKeyForSsh2(rsaPkcs1().replace(/\n/g, "\\n"));
assert.equal(result.converted, true);
assert.ok(parseOk(result.privateKey));
});
test("repairs an RSA key whose lines are indented", () => {
const result = normalizePrivateKeyForSsh2(indentLines(rsaPkcs1()));
assert.equal(result.converted, true);
assert.ok(parseOk(result.privateKey));
});
test("repairs and converts a collapsed PKCS#8 key", () => {
const result = normalizePrivateKeyForSsh2(rsaPkcs8().replace(/\n/g, " "));
assert.equal(result.converted, true);
const parsed = parseOk(result.privateKey);
assert.ok(parsed);
assert.equal(parsed.type, "ssh-rsa");
});
test("cannot repair a truncated key and leaves it unchanged", () => {
const truncated = dropEndLine(rsaPkcs1());
const result = normalizePrivateKeyForSsh2(truncated);
assert.equal(result.converted, false);
assert.equal(result.privateKey, truncated);
});
test("does not attempt to repair an encrypted legacy PEM (DEK-Info)", () => {
const collapsed = legacyEncryptedRsa("secret").replace(/\n/g, " ");
const result = normalizePrivateKeyForSsh2(collapsed, "secret");
assert.equal(result.converted, false);
});

View File

@@ -10,6 +10,11 @@ const { exec } = require("node:child_process");
const { utils: sshUtils } = require("ssh2");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const passphraseHandler = require("./passphraseHandler.cjs");
const {
normalizePrivateKeyForSsh2,
repairMalformedPem,
PrivateKeyPassphraseError,
} = require("./privateKeyNormalizer.cjs");
// Default SSH key names in priority order
const PREFERRED_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
@@ -82,8 +87,11 @@ function isKeyEncrypted(keyContent) {
// Check for OpenSSH format keys
if (keyContent.includes("-----BEGIN OPENSSH PRIVATE KEY-----")) {
try {
// Repair mangled framing (lost or escaped newlines) first, so the cipher
// name can be read from the base64 blob even when the key was flattened.
const source = repairMalformedPem(keyContent) || keyContent;
// Extract the base64 content between the markers
const base64Match = keyContent.match(
const base64Match = source.match(
/-----BEGIN OPENSSH PRIVATE KEY-----\s*([\s\S]*?)\s*-----END OPENSSH PRIVATE KEY-----/
);
if (base64Match) {
@@ -133,6 +141,30 @@ function notifyPassphraseAuthFailed(sender, keyPath, resolvedPath, keyIds) {
}
}
/**
* Resolve a private key (and optional passphrase) into a form ssh2 can parse.
* PKCS#8 keys, which ssh2 rejects, are transparently converted to a legacy PEM
* (see privateKeyNormalizer.cjs).
*
* @returns {{ privateKey: string, passphrase: string|undefined } | null}
* The usable key, or null when the passphrase is wrong / the key can't be parsed.
* @throws {UnsupportedPrivateKeyError} When a PKCS#8 key has no convertible legacy form.
*/
function resolveKeyForAuth(privateKey, passphrase) {
let normalized;
try {
normalized = normalizePrivateKeyForSsh2(privateKey, passphrase);
} catch (err) {
if (err instanceof PrivateKeyPassphraseError) return null;
throw err;
}
const parsed = sshUtils.parseKey(normalized.privateKey, normalized.passphrase);
if (parsed && !(parsed instanceof Error)) {
return { privateKey: normalized.privateKey, passphrase: normalized.passphrase };
}
return null;
}
async function preparePrivateKeyForAuth({
sender,
privateKey,
@@ -147,7 +179,8 @@ async function preparePrivateKeyForAuth({
if (!privateKey) return null;
if (!isKeyEncrypted(privateKey)) {
return { privateKey, keyPath, keyName };
const resolved = resolveKeyForAuth(privateKey, undefined);
return { privateKey: resolved ? resolved.privateKey : privateKey, keyPath, keyName };
}
const promptKeyPath = keyPath || `SSH key for ${keyName || hostname || "connection"}`;
@@ -155,9 +188,9 @@ async function preparePrivateKeyForAuth({
let passphraseInvalid = false;
if (initialPassphrase) {
const parsed = sshUtils.parseKey(privateKey, initialPassphrase);
if (parsed && !(parsed instanceof Error)) {
return { privateKey, keyPath, keyName, passphrase: initialPassphrase };
const resolved = resolveKeyForAuth(privateKey, initialPassphrase);
if (resolved) {
return { privateKey: resolved.privateKey, keyPath, keyName, passphrase: resolved.passphrase };
}
console.log(`${logPrefix} Stored passphrase failed for private key`, { keyPath: promptKeyPath });
notifyPassphraseAuthFailed(sender, promptKeyPath, undefined, keyId ? [keyId] : undefined);
@@ -184,9 +217,9 @@ async function preparePrivateKeyForAuth({
return null;
}
const parsed = sshUtils.parseKey(privateKey, result.passphrase);
if (parsed && !(parsed instanceof Error)) {
return { privateKey, keyPath, keyName, passphrase: result.passphrase };
const resolved = resolveKeyForAuth(privateKey, result.passphrase);
if (resolved) {
return { privateKey: resolved.privateKey, keyPath, keyName, passphrase: resolved.passphrase };
}
console.log(`${logPrefix} Entered passphrase failed for private key`, { keyPath: promptKeyPath });
@@ -208,14 +241,15 @@ async function loadIdentityFileForAuth({
const keyName = path.basename(resolvedPath);
if (!isKeyEncrypted(privateKey)) {
return { privateKey, keyPath: resolvedPath, keyName };
const resolved = resolveKeyForAuth(privateKey, undefined);
return { privateKey: resolved ? resolved.privateKey : privateKey, keyPath: resolvedPath, keyName };
}
let passphraseInvalid = false;
if (initialPassphrase) {
const parsed = sshUtils.parseKey(privateKey, initialPassphrase);
if (parsed && !(parsed instanceof Error)) {
return { privateKey, keyPath: resolvedPath, keyName, passphrase: initialPassphrase };
const resolved = resolveKeyForAuth(privateKey, initialPassphrase);
if (resolved) {
return { privateKey: resolved.privateKey, keyPath: resolvedPath, keyName, passphrase: resolved.passphrase };
}
console.log(`${logPrefix} Stored passphrase failed for identity file`, { keyPath: resolvedPath });
notifyPassphraseAuthFailed(sender, keyPath, resolvedPath);
@@ -242,9 +276,9 @@ async function loadIdentityFileForAuth({
return null;
}
const parsed = sshUtils.parseKey(privateKey, result.passphrase);
if (parsed && !(parsed instanceof Error)) {
return { privateKey, keyPath: resolvedPath, keyName, passphrase: result.passphrase };
const resolved = resolveKeyForAuth(privateKey, result.passphrase);
if (resolved) {
return { privateKey: resolved.privateKey, keyPath: resolvedPath, keyName, passphrase: resolved.passphrase };
}
console.log(`${logPrefix} Entered passphrase failed for identity file`, { keyPath: resolvedPath });

View File

@@ -0,0 +1,128 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const { utils: sshUtils } = require("ssh2");
const {
preparePrivateKeyForAuth,
loadIdentityFileForAuth,
} = require("./sshAuthHelper.cjs");
const passphraseHandler = require("./passphraseHandler.cjs");
function genRsaPkcs8(passphrase) {
return crypto.generateKeyPairSync("rsa", {
modulusLength: 2048,
privateKeyEncoding: passphrase
? { type: "pkcs8", format: "pem", cipher: "aes-256-cbc", passphrase }
: { type: "pkcs8", format: "pem" },
publicKeyEncoding: { type: "spki", format: "pem" },
}).privateKey;
}
function isParseable(key) {
const parsed = sshUtils.parseKey(key);
return !!parsed && !(parsed instanceof Error);
}
const sender = { isDestroyed: () => false, send: () => {} };
test("preparePrivateKeyForAuth converts an unencrypted PKCS#8 key for ssh2", async () => {
const result = await preparePrivateKeyForAuth({
sender,
privateKey: genRsaPkcs8(),
keyName: "oracle.key",
hostname: "example.test",
logPrefix: "[Test]",
});
assert.ok(result, "expected a prepared key");
assert.ok(isParseable(result.privateKey), "prepared key should be parseable by ssh2");
});
test("preparePrivateKeyForAuth decrypts and converts an encrypted PKCS#8 key", async (t) => {
const original = passphraseHandler.requestPassphrase;
t.after(() => {
passphraseHandler.requestPassphrase = original;
});
let calls = 0;
passphraseHandler.requestPassphrase = async () => {
calls += 1;
return calls === 1 ? { passphrase: "secret" } : { passphrase: null };
};
const result = await preparePrivateKeyForAuth({
sender,
privateKey: genRsaPkcs8("secret"),
keyName: "oracle.key",
hostname: "example.test",
logPrefix: "[Test]",
});
assert.ok(result, "expected a prepared key");
assert.equal(calls, 1, "correct passphrase should not be re-prompted");
assert.ok(isParseable(result.privateKey), "prepared key should be parseable by ssh2");
assert.equal(result.passphrase, undefined, "passphrase is unnecessary after conversion");
});
test("loadIdentityFileForAuth converts an unencrypted PKCS#8 identity file", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-pkcs8-"));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
const keyPath = path.join(dir, "oracle.key");
fs.writeFileSync(keyPath, genRsaPkcs8(), "utf8");
const result = await loadIdentityFileForAuth({
sender,
keyPath,
hostname: "example.test",
logPrefix: "[Test]",
});
assert.ok(result, "expected a loaded identity file");
assert.ok(isParseable(result.privateKey), "prepared key should be parseable by ssh2");
});
test("preparePrivateKeyForAuth recovers a mangled encrypted OpenSSH key via passphrase prompt", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mangled-openssh-"));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
const keyPath = path.join(dir, "id_ed25519");
const gen = spawnSync(
"ssh-keygen",
["-q", "-t", "ed25519", "-N", "secret", "-f", keyPath, "-C", "netcatty-test"],
{ encoding: "utf8" },
);
if (gen.status !== 0) {
t.skip("ssh-keygen is unavailable");
return;
}
// Simulate a key whose line breaks were flattened into literal "\n" on paste.
const mangled = fs.readFileSync(keyPath, "utf8").replace(/\n/g, "\\n");
const originalRequest = passphraseHandler.requestPassphrase;
t.after(() => {
passphraseHandler.requestPassphrase = originalRequest;
});
let prompts = 0;
passphraseHandler.requestPassphrase = async () => {
prompts += 1;
return { passphrase: "secret" };
};
const result = await preparePrivateKeyForAuth({
sender,
privateKey: mangled,
keyName: "id_ed25519",
hostname: "example.test",
logPrefix: "[Test]",
});
assert.ok(result, "expected a prepared key");
assert.equal(prompts, 1, "the encrypted key should trigger exactly one passphrase prompt");
assert.equal(result.passphrase, "secret");
const parsed = sshUtils.parseKey(result.privateKey, result.passphrase);
assert.ok(parsed && !(parsed instanceof Error), "prepared key + passphrase should parse in ssh2");
});

View File

@@ -596,15 +596,6 @@ if (!gotLock) {
console.warn("[Main] Failed to install permission handlers:", err);
}
// Set dock icon on macOS
if (isMac && appIcon && app.dock?.setIcon) {
try {
app.dock.setIcon(appIcon);
} catch (err) {
console.warn("Failed to set dock icon", err);
}
}
// Build and set application menu. A broken menu should not take down
// the entire app — fall back to no custom menu and continue startup.
try {

View File

@@ -491,3 +491,219 @@ test('continues OpenAI-compatible tool streams when the introductory tool chunk
assert.ok(assistantMessage.tool_calls?.[0]?.id?.startsWith('call_netcatty_'));
assert.equal(toolMessage.tool_call_id, assistantMessage.tool_calls?.[0]?.id);
});
test('continues OpenAI-compatible streams when provider chunks omit the top-level id', async (t) => {
const originalWindow = (globalThis as typeof globalThis & { window?: unknown }).window;
t.after(() => {
(globalThis as typeof globalThis & { window?: unknown }).window = originalWindow;
});
const dataHandlers = new Map<string, (data: string) => void>();
const endHandlers = new Map<string, () => void>();
const sentBodies: Array<Record<string, unknown>> = [];
const emitChatChunk = (emit: (data: string) => void, delta: Record<string, unknown>, finishReason?: string) => {
emit(JSON.stringify({
object: 'chat.completion.chunk',
created: 1777600000,
model: 'kimi-k2.6',
choices: [{ index: 0, delta, finish_reason: finishReason ?? null }],
}));
};
(globalThis as typeof globalThis & { window?: unknown }).window = {
netcatty: {
aiFetch: async () => ({ ok: true, status: 200, data: '{}' }),
aiChatCancel: async () => true,
onAiStreamData: (requestId: string, cb: (data: string) => void) => {
dataHandlers.set(requestId, cb);
return () => dataHandlers.delete(requestId);
},
onAiStreamEnd: (requestId: string, cb: () => void) => {
endHandlers.set(requestId, cb);
return () => endHandlers.delete(requestId);
},
onAiStreamError: () => () => undefined,
aiChatStream: async (
requestId: string,
_url: string,
_headers: Record<string, string>,
body: string,
) => {
sentBodies.push(JSON.parse(body));
const requestNumber = sentBodies.length;
setTimeout(() => {
const emit = dataHandlers.get(requestId);
assert.ok(emit, 'stream data handler should be registered before aiChatStream starts');
if (requestNumber === 1) {
emitChatChunk(emit, {
tool_calls: [{
index: 0,
type: 'function',
function: { name: 'terminal_exec', arguments: '' },
}],
});
emitChatChunk(emit, {
tool_calls: [{
index: 0,
function: { arguments: '{}' },
}],
});
emitChatChunk(emit, {}, 'tool_calls');
} else {
emitChatChunk(emit, { content: 'tool completed' });
emitChatChunk(emit, {}, 'stop');
}
endHandlers.get(requestId)?.();
}, 0);
return { ok: true, statusCode: 200, statusText: 'OK' };
},
},
};
const model = createModelFromConfig({
id: 'kimi-custom',
providerId: 'custom',
name: 'Kimi',
apiKey: 'test-key',
baseURL: 'https://api.moonshot.cn/v1',
defaultModel: 'kimi-k2.6',
enabled: true,
});
const result = streamText({
model,
messages: [{ role: 'user', content: 'inspect the host' }],
tools: {
terminal_exec: tool({
inputSchema: z.object({}),
execute: async () => ({ ok: true }),
}),
},
stopWhen: stepCountIs(2),
});
let text = '';
for await (const chunk of result.fullStream) {
if (chunk.type === 'text-delta') {
text += chunk.text;
}
}
assert.equal(text, 'tool completed');
const followUpMessages = sentBodies[1].messages as Array<Record<string, unknown>>;
const assistantMessage = followUpMessages[1] as { tool_calls?: Array<{ id?: string }> };
const toolMessage = followUpMessages[2] as { tool_call_id?: string };
assert.ok(assistantMessage.tool_calls?.[0]?.id?.startsWith('call_netcatty_'));
assert.equal(toolMessage.tool_call_id, assistantMessage.tool_calls?.[0]?.id);
});
test('continues OpenAI-compatible tool streams when arguments arrive before the tool id and name', async (t) => {
const originalWindow = (globalThis as typeof globalThis & { window?: unknown }).window;
t.after(() => {
(globalThis as typeof globalThis & { window?: unknown }).window = originalWindow;
});
const dataHandlers = new Map<string, (data: string) => void>();
const endHandlers = new Map<string, () => void>();
const sentBodies: Array<Record<string, unknown>> = [];
const emitChatChunk = (emit: (data: string) => void, delta: Record<string, unknown>, finishReason?: string) => {
emit(JSON.stringify({
id: 'chatcmpl-kimi-test',
object: 'chat.completion.chunk',
created: 1777600000,
model: 'kimi-k2.6',
choices: [{ index: 0, delta, finish_reason: finishReason ?? null }],
}));
};
(globalThis as typeof globalThis & { window?: unknown }).window = {
netcatty: {
aiFetch: async () => ({ ok: true, status: 200, data: '{}' }),
aiChatCancel: async () => true,
onAiStreamData: (requestId: string, cb: (data: string) => void) => {
dataHandlers.set(requestId, cb);
return () => dataHandlers.delete(requestId);
},
onAiStreamEnd: (requestId: string, cb: () => void) => {
endHandlers.set(requestId, cb);
return () => endHandlers.delete(requestId);
},
onAiStreamError: () => () => undefined,
aiChatStream: async (
requestId: string,
_url: string,
_headers: Record<string, string>,
body: string,
) => {
sentBodies.push(JSON.parse(body));
const requestNumber = sentBodies.length;
setTimeout(() => {
const emit = dataHandlers.get(requestId);
assert.ok(emit, 'stream data handler should be registered before aiChatStream starts');
if (requestNumber === 1) {
emitChatChunk(emit, {
tool_calls: [{
index: 0,
type: 'function',
function: { arguments: '{"command":' },
}],
});
emitChatChunk(emit, {
tool_calls: [{
index: 0,
type: 'function',
function: { name: 'terminal_exec', arguments: '"which docker"}' },
}],
});
emitChatChunk(emit, {}, 'tool_calls');
} else {
emitChatChunk(emit, { content: 'tool completed' });
emitChatChunk(emit, {}, 'stop');
}
endHandlers.get(requestId)?.();
}, 0);
return { ok: true, statusCode: 200, statusText: 'OK' };
},
},
};
const executedCommands: string[] = [];
const model = createModelFromConfig({
id: 'kimi-custom',
providerId: 'custom',
name: 'Kimi',
apiKey: 'test-key',
baseURL: 'https://api.moonshot.cn/v1',
defaultModel: 'kimi-k2.6',
enabled: true,
});
const result = streamText({
model,
messages: [{ role: 'user', content: 'inspect docker' }],
tools: {
terminal_exec: tool({
inputSchema: z.object({ command: z.string() }),
execute: async ({ command }) => {
executedCommands.push(command);
return { ok: true };
},
}),
},
stopWhen: stepCountIs(2),
});
let text = '';
for await (const chunk of result.fullStream) {
if (chunk.type === 'text-delta') {
text += chunk.text;
}
}
assert.deepEqual(executedCommands, ['which docker']);
assert.equal(text, 'tool completed');
const followUpMessages = sentBodies[1].messages as Array<Record<string, unknown>>;
const assistantMessage = followUpMessages[1] as { tool_calls?: Array<{ id?: string; function?: { arguments?: string } }> };
assert.ok(assistantMessage.tool_calls?.[0]?.id?.startsWith('call_netcatty_'));
assert.equal(assistantMessage.tool_calls?.[0]?.function?.arguments, '{"command":"which docker"}');
});

View File

@@ -118,6 +118,7 @@ function createOpenAIChatStreamFieldCapture(
function createOpenAIChatToolCallNormalizer(requestId: string): (data: string) => string {
const toolCallIdsByChoiceAndIndex = new Map<string, string>();
const pendingToolCallsByChoiceAndIndex = new Map<string, Record<string, unknown>>();
const requestIdToken = requestId.replace(/[^a-zA-Z0-9_-]/g, '_');
return (data: string): string => {
@@ -146,28 +147,48 @@ function createOpenAIChatToolCallNormalizer(requestId: string): (data: string) =
const choiceIndex = typeof choiceRecord.index === 'number' ? choiceRecord.index : choicePosition;
let deltaChanged = false;
const normalizedToolCalls = deltaRecord.tool_calls.map((toolCall, toolCallPosition) => {
if (!toolCall || typeof toolCall !== 'object') return toolCall;
const normalizedToolCalls: unknown[] = [];
for (const [toolCallPosition, toolCall] of deltaRecord.tool_calls.entries()) {
if (!toolCall || typeof toolCall !== 'object') {
normalizedToolCalls.push(toolCall);
continue;
}
const toolCallRecord = toolCall as Record<string, unknown>;
const toolCallIndex = typeof toolCallRecord.index === 'number' ? toolCallRecord.index : toolCallPosition;
const key = `${choiceIndex}:${toolCallIndex}`;
const existingId = toolCallIdsByChoiceAndIndex.get(key);
const pendingToolCall = pendingToolCallsByChoiceAndIndex.get(key);
const candidateToolCall = pendingToolCall
? mergeOpenAIChatToolCallDeltas(pendingToolCall, toolCallRecord)
: toolCallRecord;
if (typeof toolCallRecord.id === 'string' && toolCallRecord.id) {
toolCallIdsByChoiceAndIndex.set(key, toolCallRecord.id);
return toolCall;
if (existingId) {
normalizedToolCalls.push(toolCall);
continue;
}
if (existingId || !hasFunctionName(toolCallRecord)) {
return toolCall;
if (!hasFunctionName(candidateToolCall)) {
pendingToolCallsByChoiceAndIndex.set(key, candidateToolCall);
changed = true;
deltaChanged = true;
continue;
}
const toolCallId = typeof candidateToolCall.id === 'string' && candidateToolCall.id
? candidateToolCall.id
: `call_netcatty_${requestIdToken}_${choiceIndex}_${toolCallIndex}`;
toolCallIdsByChoiceAndIndex.set(key, toolCallId);
pendingToolCallsByChoiceAndIndex.delete(key);
if (candidateToolCall === toolCallRecord && toolCallId === toolCallRecord.id) {
normalizedToolCalls.push(toolCall);
continue;
}
const syntheticId = `call_netcatty_${requestIdToken}_${choiceIndex}_${toolCallIndex}`;
toolCallIdsByChoiceAndIndex.set(key, syntheticId);
changed = true;
deltaChanged = true;
return { ...toolCallRecord, id: syntheticId };
});
normalizedToolCalls.push({ ...candidateToolCall, id: toolCallId });
}
if (!deltaChanged) return choice;
return {
@@ -187,6 +208,35 @@ function createOpenAIChatToolCallNormalizer(requestId: string): (data: string) =
};
}
function mergeOpenAIChatToolCallDeltas(
current: Record<string, unknown>,
incoming: Record<string, unknown>,
): Record<string, unknown> {
const currentFn = current.function;
const incomingFn = incoming.function;
const currentFunction = currentFn && typeof currentFn === 'object'
? currentFn as Record<string, unknown>
: undefined;
const incomingFunction = incomingFn && typeof incomingFn === 'object'
? incomingFn as Record<string, unknown>
: undefined;
const mergedFunction = {
...(currentFunction ?? {}),
...(incomingFunction ?? {}),
};
const currentArgs = currentFunction?.arguments;
const incomingArgs = incomingFunction?.arguments;
if (typeof currentArgs === 'string' && typeof incomingArgs === 'string') {
mergedFunction.arguments = currentArgs + incomingArgs;
}
return {
...current,
...incoming,
function: mergedFunction,
};
}
function hasFunctionName(toolCall: Record<string, unknown>): boolean {
const fn = toolCall.function;
return Boolean(

View File

@@ -1,9 +1,6 @@
import { Host,Snippet } from '../../domain/models';
import { Host, Snippet } from '../../domain/models';
export const INITIAL_HOSTS: Host[] = [
{ id: '1', label: 'Production Web', hostname: '10.0.0.12', port: 22, username: 'ubuntu', group: 'AWS/Production', tags: ['prod', 'web'], os: 'linux' },
{ id: '2', label: 'DB Master', hostname: 'db-01.internal', port: 22, username: 'admin', group: 'AWS/Production', tags: ['prod', 'db'], os: 'linux' },
];
export const INITIAL_HOSTS: Host[] = [];
export const INITIAL_SNIPPETS: Snippet[] = [
{ id: '1', label: 'Check Disk Space', command: 'df -h', tags: [] },

View File

@@ -4,6 +4,8 @@ export const STORAGE_KEY_GROUPS = 'netcatty_groups_v1';
export const STORAGE_KEY_CUSTOM_GROUPS = STORAGE_KEY_GROUPS;
export const STORAGE_KEY_SNIPPETS = 'netcatty_snippets_v1';
export const STORAGE_KEY_SNIPPET_PACKAGES = 'netcatty_snippet_packages_v1';
/** Last-filled values per snippet id for {{variable}} placeholders. */
export const STORAGE_KEY_SNIPPET_VAR_VALUES = 'netcatty_snippet_var_values_v1';
export const STORAGE_KEY_THEME = 'netcatty_theme_v1';
export const STORAGE_KEY_COLOR = 'netcatty_color_v1';
export const STORAGE_KEY_ACCENT_MODE = 'netcatty_accent_mode_v1';
@@ -35,6 +37,7 @@ export const STORAGE_KEY_VAULT_HOSTS_VIEW_MODE = 'netcatty_vault_hosts_view_mode
export const STORAGE_KEY_VAULT_HOSTS_SORT_MODE = 'netcatty_vault_hosts_sort_mode_v1';
export const STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED = 'netcatty_vault_hosts_tree_expanded_v1';
export const STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED = 'netcatty_vault_sidebar_collapsed_v1';
export const STORAGE_KEY_VAULT_SIDEBAR_WIDTH = 'netcatty_vault_sidebar_width_v1';
export const STORAGE_KEY_VAULT_KEYS_VIEW_MODE = 'netcatty_vault_keys_view_mode_v1';
export const STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE = 'netcatty_vault_proxy_profiles_view_mode_v1';
export const STORAGE_KEY_VAULT_SNIPPETS_VIEW_MODE = 'netcatty_vault_snippets_view_mode_v1';

View File

@@ -0,0 +1,21 @@
import { STORAGE_KEY_SNIPPET_VAR_VALUES } from '../config/storageKeys';
import { localStorageAdapter } from './localStorageAdapter';
export type SnippetVariableValuesStore = Record<string, Record<string, string>>;
export function readSnippetVariableValuesStore(): SnippetVariableValuesStore {
return localStorageAdapter.read<SnippetVariableValuesStore>(STORAGE_KEY_SNIPPET_VAR_VALUES) ?? {};
}
export function readSnippetVariableValuesForSnippet(snippetId: string): Record<string, string> {
return readSnippetVariableValuesStore()[snippetId] ?? {};
}
export function saveSnippetVariableValues(
snippetId: string,
values: Record<string, string>,
): void {
const store = readSnippetVariableValuesStore();
store[snippetId] = { ...store[snippetId], ...values };
localStorageAdapter.write(STORAGE_KEY_SNIPPET_VAR_VALUES, store);
}

View File

@@ -20,13 +20,16 @@ import {
type MasterKeyConfig,
type UnlockedMasterKey,
type ProviderConnection,
type RemoteSyncPayload,
type ProviderAccount,
type SyncEvent,
type SyncHistoryEntry,
type SyncSnapshotEntry,
type WebDAVConfig,
type S3Config,
type SyncedFile,
} from '../../domain/sync';
import type { CloudSyncConflictAction, CloudSyncStrategy } from '../../domain/syncStrategy';
import { type CloudAdapter } from './adapters';
import type { DeviceFlowState } from './adapters/GitHubAdapter';
@@ -81,11 +84,13 @@ import {
stopAutoSyncImpl,
saveSyncConfigImpl,
syncBaseKeyImpl,
syncSnapshotsKeyImpl,
providerAccountIdKeyImpl,
loadProviderAccountIdImpl,
saveProviderAccountIdImpl,
saveSyncBaseImpl,
loadSyncBaseImpl,
loadSyncSnapshotsImpl,
clearSyncBaseImpl,
addSyncHistoryEntryImpl,
resetLocalVersionImpl,
@@ -129,6 +134,7 @@ export interface SyncManagerState {
lastError: string | null;
autoSyncEnabled: boolean;
autoSyncInterval: number;
syncStrategy: CloudSyncStrategy;
syncHistory: SyncHistoryEntry[];
/** Last shrink finding that put us into BLOCKED state, retained until
* a sync actually succeeds (SYNC_COMPLETED with result.success) or
@@ -171,6 +177,7 @@ export class CloudSyncManager {
private stateChangeListeners: Set<() => void> = new Set(); // For useSyncExternalStore
private autoSyncTimer: ReturnType<typeof setInterval> | null = null;
private masterPassword: string | null = null; // In memory only!
private syncSecurityGeneration = 0;
private hasStorageListener = false;
// Promise that resolves once startup provider secret decryption finishes.
// Awaited by getConnectedAdapter() to prevent using still-encrypted tokens.
@@ -316,6 +323,25 @@ export class CloudSyncManager {
this.stateChangeListeners.forEach(cb => cb());
}
private bumpSyncSecurityGeneration(): void {
this.syncSecurityGeneration += 1;
}
private getSyncSecurityGeneration(): number {
return this.syncSecurityGeneration;
}
private assertSyncSecurityGeneration(expectedGeneration?: number): void {
if (expectedGeneration === undefined) return;
if (
expectedGeneration !== this.syncSecurityGeneration
|| this.state.securityState !== 'UNLOCKED'
|| !this.masterPassword
) {
throw new Error('Sync cancelled because master key changed');
}
}
// ==========================================================================
// Public API - State Accessors
// ==========================================================================
@@ -577,8 +603,9 @@ export class CloudSyncManager {
provider: CloudProvider,
remoteFile: SyncedFile,
payload: SyncPayload,
opts: { recordDownload?: boolean } = {},
): Promise<void> {
return commitRemoteInspectionImpl.call(this, provider, remoteFile, payload);
return commitRemoteInspectionImpl.call(this, provider, remoteFile, payload, opts);
}
/**
@@ -599,8 +626,9 @@ export class CloudSyncManager {
adapter: CloudAdapter,
syncedFile: SyncedFile,
payloadForBase?: SyncPayload,
syncSecurityGeneration?: number,
): Promise<SyncResult> {
return uploadToProviderImpl.call(this, provider, adapter, syncedFile, payloadForBase);
return uploadToProviderImpl.call(this, provider, adapter, syncedFile, payloadForBase, syncSecurityGeneration);
}
/**
@@ -633,7 +661,7 @@ export class CloudSyncManager {
/**
* Download and apply data from a provider
*/
async downloadFromProvider(provider: CloudProvider): Promise<SyncPayload | null> {
async downloadFromProvider(provider: CloudProvider): Promise<RemoteSyncPayload | null> {
return downloadFromProviderImpl.call(this, provider);
}
@@ -674,7 +702,7 @@ export class CloudSyncManager {
/**
* Resolve a sync conflict
*/
async resolveConflict(resolution: ConflictResolution): Promise<SyncPayload | null> {
async resolveConflict(resolution: ConflictResolution): Promise<RemoteSyncPayload | null> {
return resolveConflictImpl.call(this, resolution);
}
@@ -715,7 +743,7 @@ export class CloudSyncManager {
*/
async syncAllProviders(
inputPayload?: SyncPayload,
opts: { overrideShrink?: boolean } = {},
opts: { overrideShrink?: boolean; conflictActionOverride?: CloudSyncConflictAction } = {},
): Promise<Map<CloudProvider, SyncResult>> {
return syncAllProvidersImpl.call(this, inputPayload, opts);
}
@@ -732,6 +760,12 @@ export class CloudSyncManager {
return setAutoSyncImpl.call(this, enabled, intervalMinutes);
}
setSyncStrategy(strategy: CloudSyncStrategy): void {
this.state.syncStrategy = strategy;
this.saveSyncConfig();
this.notifyStateChange();
}
private startAutoSync(): void {
return startAutoSyncImpl.call(this);
}
@@ -752,6 +786,10 @@ export class CloudSyncManager {
return syncBaseKeyImpl.call(this, provider);
}
private syncSnapshotsKey(provider?: CloudProvider): string {
return syncSnapshotsKeyImpl.call(this, provider);
}
private providerAccountIdKey(provider: CloudProvider): string {
return providerAccountIdKeyImpl.call(this, provider);
}
@@ -772,6 +810,10 @@ export class CloudSyncManager {
return loadSyncBaseImpl.call(this, provider);
}
async loadSyncSnapshots(provider?: CloudProvider): Promise<SyncSnapshotEntry[]> {
return loadSyncSnapshotsImpl.call(this, provider);
}
private clearSyncBase(): void {
return clearSyncBaseImpl.call(this);
}

View File

@@ -554,6 +554,7 @@ export async function commitRemoteInspectionImpl(this: any,
provider: CloudProvider,
remoteFile: SyncedFile,
payload: SyncPayload,
opts: { recordDownload?: boolean } = {},
): Promise<void> {
const adapter = await this.getConnectedAdapter(provider);
const resourceId = adapter.resourceId || this.state.providers[provider].resourceId || null;
@@ -572,9 +573,20 @@ export async function commitRemoteInspectionImpl(this: any,
this.state.providers[provider].lastSync = Date.now();
this.state.providers[provider].lastSyncVersion = remoteFile.meta.version;
await this.saveSyncBase(payload, provider);
this.saveSyncConfig();
await this.saveSyncAnchor(provider, remoteFile, resourceId);
await this.saveSyncBase(payload, provider);
await this.saveProviderConnection(provider, this.state.providers[provider]);
if (opts.recordDownload === true) {
this.addSyncHistoryEntry({
timestamp: Date.now(),
provider,
action: 'download',
success: true,
localVersion: remoteFile.meta.version,
remoteVersion: remoteFile.meta.version,
deviceName: remoteFile.meta.deviceName,
});
}
this.notifyStateChange();
}

View File

@@ -4,26 +4,153 @@
import packageJson from '../../../package.json';
import { EncryptionService } from '../EncryptionService';
import { mergeSyncPayloads } from '../../../domain/syncMerge';
import { summarizeSyncChanges, withSyncReliabilityMeta } from '../../../domain/syncReliability';
import { detectSuspiciousShrink, type ShrinkFinding } from '../../../domain/syncGuards';
import { resolveCloudSyncConflictAction } from '../../../domain/syncStrategy';
import type { CloudAdapter } from '../adapters';
import type GitHubAdapter from '../adapters/GitHubAdapter';
import type {
CloudProvider,
ConflictResolution,
RemoteSyncPayload,
SyncedFile,
SyncFileMeta,
SyncPayload,
SyncResult,
} from '../../../domain/sync';
function getSyncSecurityGeneration(manager: any): number | undefined {
return typeof manager.getSyncSecurityGeneration === 'function'
? manager.getSyncSecurityGeneration()
: undefined;
}
function assertSyncSecurityGeneration(manager: any, generation?: number): void {
if (typeof manager.assertSyncSecurityGeneration === 'function') {
manager.assertSyncSecurityGeneration(generation);
}
}
async function uploadLocalPayloadImpl(this: any,
provider: CloudProvider,
adapter: CloudAdapter,
payload: SyncPayload,
opts: { overrideShrink?: boolean },
baseVersion: number,
remoteFile?: SyncedFile | null,
syncSecurityGeneration?: number,
): Promise<SyncResult> {
const overrideShrinkRequested = opts.overrideShrink === true;
const directBase = await this.loadSyncBase(provider);
assertSyncSecurityGeneration(this, syncSecurityGeneration);
let directRemoteRef: SyncPayload | null = null;
if (!directBase && remoteFile) {
assertSyncSecurityGeneration(this, syncSecurityGeneration);
try {
directRemoteRef = await EncryptionService.decryptPayload(
remoteFile,
this.masterPassword,
);
} catch {
directRemoteRef = null;
}
assertSyncSecurityGeneration(this, syncSecurityGeneration);
}
const metadataBase = directBase ?? directRemoteRef;
const payloadForUpload = withSyncReliabilityMeta(payload, metadataBase, {
deviceId: this.state.deviceId,
now: Date.now(),
});
const directShrink = detectSuspiciousShrink(payloadForUpload, directBase, directRemoteRef);
const shouldBlockDirect = directShrink.suspicious && !overrideShrinkRequested;
const shouldForceDirect = directShrink.suspicious && overrideShrinkRequested;
if (shouldBlockDirect) {
this.state.syncState = 'BLOCKED';
this.state.lastShrinkFinding = directShrink;
this.emit({ type: 'SYNC_BLOCKED_SHRINK', provider, finding: directShrink });
this.updateProviderStatus(provider, 'error', 'Sync blocked: would delete too much');
return {
success: false,
provider,
action: 'none',
shrinkBlocked: true,
finding: directShrink,
};
}
if (shouldForceDirect) {
this.emit({ type: 'SYNC_FORCED', provider, finding: directShrink });
}
const syncedFile = await EncryptionService.encryptPayload(
payloadForUpload,
this.masterPassword,
this.state.deviceId,
this.state.deviceName,
packageJson.version,
baseVersion,
);
assertSyncSecurityGeneration(this, syncSecurityGeneration);
const result = await this.uploadToProvider(provider, adapter, syncedFile, payloadForUpload, syncSecurityGeneration);
if (result.success) {
this.exitBlockedState();
this.state.syncState = 'IDLE';
this.state.lastShrinkFinding = undefined;
} else {
this.state.syncState = 'ERROR';
if (result.error) {
this.state.lastError = result.error;
}
}
return result;
}
async function downloadRemoteConflictPayloadImpl(this: any,
provider: CloudProvider,
remoteFile: SyncedFile,
syncSecurityGeneration?: number,
): Promise<SyncResult> {
let remotePayload: SyncPayload;
assertSyncSecurityGeneration(this, syncSecurityGeneration);
try {
remotePayload = await EncryptionService.decryptPayload(
remoteFile,
this.masterPassword,
);
} catch (decryptError) {
throw new Error(`Decryption failed (master password may differ between devices): ${decryptError instanceof Error ? decryptError.message : String(decryptError)}`);
}
assertSyncSecurityGeneration(this, syncSecurityGeneration);
this.exitBlockedState();
this.state.syncState = 'IDLE';
this.state.lastError = null;
this.updateProviderStatus(provider, 'connected');
const result: SyncResult = {
success: true,
provider,
action: 'download',
version: remoteFile.meta.version,
mergedPayload: remotePayload,
remoteFile,
};
this.emit({ type: 'SYNC_COMPLETED', provider, result });
return result;
}
export async function uploadToProviderImpl(this: any,
provider: CloudProvider,
adapter: CloudAdapter,
syncedFile: SyncedFile,
payloadForBase?: SyncPayload,
syncSecurityGeneration?: number,
): Promise<SyncResult> {
try {
assertSyncSecurityGeneration(this, syncSecurityGeneration);
const resourceId = await adapter.upload(syncedFile);
assertSyncSecurityGeneration(this, syncSecurityGeneration);
this.state.lastError = null;
// Update local state (safe to do multiple times if values are same)
@@ -140,6 +267,7 @@ export async function syncToProviderImpl(this: any,
}
const overrideShrinkRequested = opts.overrideShrink === true;
const syncSecurityGeneration = getSyncSecurityGeneration(this);
let adapter: CloudAdapter;
try {
@@ -152,6 +280,7 @@ export async function syncToProviderImpl(this: any,
error: 'Provider not connected',
};
}
assertSyncSecurityGeneration(this, syncSecurityGeneration);
this.updateProviderStatus(provider, 'syncing');
this.state.lastError = null;
@@ -164,8 +293,39 @@ export async function syncToProviderImpl(this: any,
// SYNC_ERROR path — so we never reach the upload branch with an
// unknown remote state.
const checkResult = await this.checkProviderConflict(provider, adapter);
assertSyncSecurityGeneration(this, syncSecurityGeneration);
if (checkResult.conflict && checkResult.remoteFile) {
const conflictAction = resolveCloudSyncConflictAction(this.state.syncStrategy, {
hasConflict: checkResult.conflict,
hasRemoteFile: Boolean(checkResult.remoteFile),
});
if (conflictAction === 'download-remote') {
return await downloadRemoteConflictPayloadImpl.call(
this,
provider,
checkResult.remoteFile,
syncSecurityGeneration,
);
}
if (conflictAction === 'upload-local') {
return await uploadLocalPayloadImpl.call(
this,
provider,
adapter,
payload,
opts,
checkResult.remoteFile.meta.version,
checkResult.remoteFile,
syncSecurityGeneration,
);
}
let remotePayloadForConflict: SyncPayload | null = null;
let baseForConflict: SyncPayload | null = null;
// Remote is newer — attempt three-way merge instead of blocking
try {
let remotePayload: SyncPayload;
@@ -174,11 +334,18 @@ export async function syncToProviderImpl(this: any,
checkResult.remoteFile,
this.masterPassword,
);
remotePayloadForConflict = remotePayload;
} catch (decryptError) {
throw new Error(`Decryption failed (master password may differ between devices): ${decryptError instanceof Error ? decryptError.message : String(decryptError)}`);
}
assertSyncSecurityGeneration(this, syncSecurityGeneration);
const base = await this.loadSyncBase(provider);
baseForConflict = base;
const mergeResult = mergeSyncPayloads(base, payload, remotePayload);
const mergedPayload = withSyncReliabilityMeta(mergeResult.payload, base, {
deviceId: this.state.deviceId,
now: Date.now(),
});
console.info('[CloudSyncManager] Three-way merge completed', mergeResult.summary);
@@ -186,7 +353,7 @@ export async function syncToProviderImpl(this: any,
// entities we still have in base. The merge itself is correct if local
// state is trustworthy — but a degraded local (keychain failure,
// partial load) can make merge produce a smaller-than-expected result.
const mergedShrink = detectSuspiciousShrink(mergeResult.payload, base, remotePayload);
const mergedShrink = detectSuspiciousShrink(mergedPayload, base, remotePayload);
const shouldBlockMerged = mergedShrink.suspicious && !overrideShrinkRequested;
const shouldForceMerged = mergedShrink.suspicious && overrideShrinkRequested;
if (shouldBlockMerged) {
@@ -208,19 +375,21 @@ export async function syncToProviderImpl(this: any,
// Encrypt and upload merged payload
const mergedSyncedFile = await EncryptionService.encryptPayload(
mergeResult.payload,
mergedPayload,
this.masterPassword,
this.state.deviceId,
this.state.deviceName,
packageJson.version,
checkResult.remoteFile.meta.version, // base on remote version
);
assertSyncSecurityGeneration(this, syncSecurityGeneration);
const uploadResult = await this.uploadToProvider(
provider,
adapter,
mergedSyncedFile,
mergeResult.payload,
mergedPayload,
syncSecurityGeneration,
);
if (uploadResult.success) {
@@ -243,7 +412,7 @@ export async function syncToProviderImpl(this: any,
return {
...uploadResult,
action: 'merge',
mergedPayload: mergeResult.payload,
mergedPayload,
};
}
@@ -252,6 +421,7 @@ export async function syncToProviderImpl(this: any,
this.state.lastError = uploadResult.error || 'Upload failed after merge';
return uploadResult;
} catch (mergeError) {
assertSyncSecurityGeneration(this, syncSecurityGeneration);
// Merge failed — fall back to conflict UI
console.error('[CloudSyncManager] Merge failed, falling back to conflict UI', mergeError);
const remoteFile = checkResult.remoteFile;
@@ -264,6 +434,9 @@ export async function syncToProviderImpl(this: any,
remoteVersion: remoteFile.meta.version,
remoteUpdatedAt: remoteFile.meta.updatedAt,
remoteDeviceName: remoteFile.meta.deviceName,
...(remotePayloadForConflict
? { changeSummary: summarizeSyncChanges(baseForConflict, payload, remotePayloadForConflict) }
: {}),
};
this.emit({
@@ -280,75 +453,17 @@ export async function syncToProviderImpl(this: any,
}
}
// Shrink guard (no-conflict path): same rationale as the merge branch —
// refuse a payload that drops entities versus the stored base. When the
// stored base is absent (first sync, re-auth, or decrypt failure) fall
// back to the current remote payload if one exists — the guard must
// have *some* reference to catch a degraded local from wiping the
// cloud (#779).
const directBase = await this.loadSyncBase(provider);
let directRemoteRef: SyncPayload | null = null;
if (!directBase && checkResult.remoteFile) {
try {
directRemoteRef = await EncryptionService.decryptPayload(
checkResult.remoteFile,
this.masterPassword,
);
} catch {
// Decrypt failure means we can't trust the remote contents as a
// reference; leave `null` and let the guard return not-suspicious
// rather than block on garbage. The upload itself will likely fail
// downstream if the password mismatch is real.
directRemoteRef = null;
}
}
const directShrink = detectSuspiciousShrink(payload, directBase, directRemoteRef);
const shouldBlockDirect = directShrink.suspicious && !overrideShrinkRequested;
const shouldForceDirect = directShrink.suspicious && overrideShrinkRequested;
if (shouldBlockDirect) {
this.state.syncState = 'BLOCKED';
this.state.lastShrinkFinding = directShrink;
this.emit({ type: 'SYNC_BLOCKED_SHRINK', provider, finding: directShrink });
this.updateProviderStatus(provider, 'error', 'Sync blocked: would delete too much');
return {
success: false,
provider,
action: 'none',
shrinkBlocked: true,
finding: directShrink,
};
}
if (shouldForceDirect) {
this.emit({ type: 'SYNC_FORCED', provider, finding: directShrink });
}
// 2. Encrypt
const syncedFile = await EncryptionService.encryptPayload(
return await uploadLocalPayloadImpl.call(
this,
provider,
adapter,
payload,
this.masterPassword,
this.state.deviceId,
this.state.deviceName,
packageJson.version,
this.state.localVersion
opts,
this.state.localVersion,
checkResult.remoteFile,
syncSecurityGeneration,
);
// 3. Upload — base is persisted inside uploadToProvider before
// the anchor advances so a crash between them cannot leave the
// base pointing at a pre-upload snapshot.
const result = await this.uploadToProvider(provider, adapter, syncedFile, payload);
if (result.success) {
this.exitBlockedState();
this.state.syncState = 'IDLE';
this.state.lastShrinkFinding = undefined;
} else {
this.state.syncState = 'ERROR';
if (result.error) {
this.state.lastError = result.error;
}
}
return result;
} catch (error) {
this.state.syncState = 'ERROR';
this.state.lastError = String(error);
@@ -376,7 +491,7 @@ export async function syncToProviderImpl(this: any,
}
}
export async function downloadFromProviderImpl(this: any,provider: CloudProvider): Promise<SyncPayload | null> {
export async function downloadFromProviderImpl(this: any,provider: CloudProvider): Promise<RemoteSyncPayload | null> {
if (this.state.securityState !== 'UNLOCKED' || !this.masterPassword) {
throw new Error('Vault is locked');
}
@@ -402,20 +517,7 @@ export async function downloadFromProviderImpl(this: any,provider: CloudProvider
throw new Error(`Decryption failed (master password may differ between devices): ${decryptError instanceof Error ? decryptError.message : String(decryptError)}`);
}
await this.commitRemoteInspection(provider, remoteFile, payload);
// Add to sync history
this.addSyncHistoryEntry({
timestamp: Date.now(),
provider,
action: 'download',
success: true,
localVersion: remoteFile.meta.version,
remoteVersion: remoteFile.meta.version,
deviceName: remoteFile.meta.deviceName,
});
return payload;
return { provider, payload, remoteFile };
} catch (error) {
// Add to sync history
this.addSyncHistoryEntry({
@@ -479,7 +581,7 @@ export async function downloadGistRevisionImpl(this: any,sha: string): Promise<{
};
}
export async function resolveConflictImpl(this: any,resolution: ConflictResolution): Promise<SyncPayload | null> {
export async function resolveConflictImpl(this: any,resolution: ConflictResolution): Promise<RemoteSyncPayload | null> {
if (!this.state.currentConflict) {
throw new Error('No conflict to resolve');
}

View File

@@ -5,6 +5,10 @@ import {
generateDeviceId,
getDefaultDeviceName,
} from '../../../domain/sync';
import {
DEFAULT_CLOUD_SYNC_STRATEGY,
normalizeCloudSyncStrategy,
} from '../../../domain/syncStrategy';
import { EncryptionService } from '../EncryptionService';
import { createAdapter } from '../adapters';
import { localStorageAdapter } from '../../persistence/localStorageAdapter';
@@ -43,6 +47,7 @@ export function loadInitialStateImpl(this: any): SyncManagerState {
localUpdatedAt: number;
remoteVersion: number;
remoteUpdatedAt: number;
syncStrategy?: unknown;
}>(SYNC_STORAGE_KEYS.SYNC_CONFIG);
// Load sync history
@@ -80,6 +85,7 @@ export function loadInitialStateImpl(this: any): SyncManagerState {
lastError: null,
autoSyncEnabled: syncConfig?.autoSync || false,
autoSyncInterval: syncConfig?.interval || SYNC_CONSTANTS.DEFAULT_AUTO_SYNC_INTERVAL,
syncStrategy: normalizeCloudSyncStrategy(syncConfig?.syncStrategy ?? DEFAULT_CLOUD_SYNC_STRATEGY),
syncHistory,
};
}
@@ -183,13 +189,28 @@ export function handleStorageEventImpl(this: any, event: StorageEvent): void {
if (key === SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG) {
const nextConfig = this.safeJsonParse<MasterKeyConfig>(event.newValue);
if (nextConfig && !this.state.masterKeyConfig) {
// Master key was set up in another window - update our state
if (nextConfig) {
const currentConfig = this.state.masterKeyConfig as MasterKeyConfig | null;
const configChanged = !currentConfig
|| currentConfig.verificationHash !== nextConfig.verificationHash
|| currentConfig.salt !== nextConfig.salt
|| currentConfig.kdf !== nextConfig.kdf
|| currentConfig.kdfIterations !== nextConfig.kdfIterations;
if (!configChanged) return;
// Master key was set up or changed in another window. Lock this
// window so it cannot keep syncing with the stale in-memory password.
this.bumpSyncSecurityGeneration?.();
this.state.masterKeyConfig = nextConfig;
this.state.securityState = 'LOCKED';
this.state.unlockedKey = null;
this.masterPassword = null;
this.stopAutoSync();
this.notifyStateChange();
} else if (!nextConfig && this.state.masterKeyConfig) {
} else if (this.state.masterKeyConfig) {
// Master key was removed in another window
this.bumpSyncSecurityGeneration?.();
this.state.masterKeyConfig = null;
this.state.securityState = 'NO_KEY';
this.state.unlockedKey = null;
@@ -208,6 +229,7 @@ export function handleStorageEventImpl(this: any, event: StorageEvent): void {
localUpdatedAt?: number;
remoteVersion?: number;
remoteUpdatedAt?: number;
syncStrategy?: unknown;
}>(event.newValue) || {
autoSync: false,
interval: SYNC_CONSTANTS.DEFAULT_AUTO_SYNC_INTERVAL,
@@ -215,6 +237,7 @@ export function handleStorageEventImpl(this: any, event: StorageEvent): void {
localUpdatedAt: 0,
remoteVersion: 0,
remoteUpdatedAt: 0,
syncStrategy: DEFAULT_CLOUD_SYNC_STRATEGY,
};
this.state.autoSyncEnabled = Boolean(next.autoSync);
@@ -229,6 +252,7 @@ export function handleStorageEventImpl(this: any, event: StorageEvent): void {
this.state.localUpdatedAt = Number(next.localUpdatedAt ?? 0);
this.state.remoteVersion = Number(next.remoteVersion ?? 0);
this.state.remoteUpdatedAt = Number(next.remoteUpdatedAt ?? 0);
this.state.syncStrategy = normalizeCloudSyncStrategy(next.syncStrategy);
this.notifyStateChange();
return;
@@ -364,6 +388,7 @@ export async function setupMasterKeyImpl(this: any,password: string): Promise<vo
const config = await EncryptionService.createMasterKeyConfig(password);
this.bumpSyncSecurityGeneration?.();
this.state.masterKeyConfig = config;
this.state.securityState = 'LOCKED';
@@ -412,6 +437,7 @@ export function lockImpl(this: any): void {
}
// Clear sensitive data from memory
this.bumpSyncSecurityGeneration?.();
this.state.unlockedKey = null;
this.masterPassword = null;
this.state.securityState = 'LOCKED';
@@ -437,6 +463,7 @@ export async function changeMasterKeyImpl(this: any,oldPassword: string, newPass
return false;
}
this.bumpSyncSecurityGeneration?.();
this.state.masterKeyConfig = newConfig;
this.state.securityState = 'UNLOCKED';
this.masterPassword = newPassword;

View File

@@ -0,0 +1,580 @@
import test from "node:test";
import assert from "node:assert/strict";
import { EncryptionService } from "../EncryptionService.ts";
import { commitRemoteInspectionImpl } from "./authMethods.ts";
import { syncToProviderImpl, uploadToProviderImpl } from "./providerSyncMethods.ts";
import {
loadSyncSnapshotsImpl,
saveSyncBaseImpl,
syncAllProvidersImpl,
} from "./syncAllStorageMethods.ts";
import type { CloudProvider, SyncedFile, SyncPayload } from "../../../domain/sync.ts";
function payload(hostId: string): SyncPayload {
return payloadWithHosts([hostId]);
}
function payloadWithHosts(hostIds: string[]): SyncPayload {
return {
hosts: hostIds.map((hostId) => ({
id: hostId,
label: hostId,
hostname: `${hostId}.example.com`,
port: 22,
username: "root",
tags: [],
os: "linux",
})),
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
customGroups: [],
snippetPackages: [],
portForwardingRules: [],
groupConfigs: [],
settings: undefined,
syncedAt: 0,
};
}
function remoteFile(provider: CloudProvider, version: number, updatedAt: number): SyncedFile {
return {
meta: {
version,
updatedAt,
deviceId: `${provider}-device`,
deviceName: provider,
appVersion: "0.0.0",
iv: "",
salt: "",
algorithm: "AES-256-GCM",
kdf: "PBKDF2",
kdfIterations: 1,
},
payload: provider,
};
}
test("syncAllProviders uses the newest cloud payload without merging other remotes when cloud wins", async () => {
const originalDecryptPayload = EncryptionService.decryptPayload;
const originalEncryptPayload = EncryptionService.encryptPayload;
const githubRemote = remoteFile("github", 3, 300);
const googleRemote = remoteFile("google", 2, 200);
const githubPayload = payload("github-winner");
const localPayload = payload("local");
const uploaded: Array<{ provider: CloudProvider; payload: SyncPayload }> = [];
const committed: CloudProvider[] = [];
EncryptionService.decryptPayload = async (file: SyncedFile) => {
if (file === githubRemote) return githubPayload;
return payload("google-loser");
};
EncryptionService.encryptPayload = async (outgoing: SyncPayload) => ({
...remoteFile("github", 4, 400),
payload: JSON.stringify(outgoing),
});
try {
const manager = {
masterPassword: "pw",
adapters: new Map(),
state: {
securityState: "UNLOCKED",
providers: {
github: { enabled: true, connected: true, status: "connected" },
google: { enabled: true, connected: true, status: "connected" },
onedrive: { enabled: false, connected: false, status: "disconnected" },
webdav: { enabled: false, connected: false, status: "disconnected" },
s3: { enabled: false, connected: false, status: "disconnected" },
},
lastError: null,
syncState: "IDLE",
syncStrategy: "preferCloud",
localVersion: 1,
deviceId: "local-device",
deviceName: "Local",
},
getConnectedAdapter: async (provider: CloudProvider) => ({ provider }),
updateProviderStatus: () => {},
emit: () => {},
checkProviderConflict: async (provider: CloudProvider) => ({
conflict: true,
remoteFile: provider === "github" ? githubRemote : googleRemote,
}),
loadSyncBase: async () => payload("base"),
commitRemoteInspection: async (provider: CloudProvider) => {
committed.push(provider);
},
uploadToProvider: async (provider: CloudProvider, _adapter: unknown, _file: SyncedFile, outgoing: SyncPayload) => {
uploaded.push({ provider, payload: outgoing });
return { success: true, provider, action: "upload" as const, version: 4 };
},
exitBlockedState: () => {},
notifyStateChange: () => {},
};
const results = await syncAllProvidersImpl.call(manager, localPayload);
assert.equal(results.get("github")?.action, "download");
assert.equal(results.get("github")?.mergedPayload, githubPayload);
assert.equal(results.get("github")?.remoteFile, githubRemote);
assert.equal(uploaded.length, 1);
assert.equal(uploaded[0].provider, "google");
assert.equal(uploaded[0].payload.hosts[0]?.id, "github-winner");
assert.equal(uploaded[0].payload.syncMeta?.schemaVersion, 1);
assert.deepEqual(committed, []);
} finally {
EncryptionService.decryptPayload = originalDecryptPayload;
EncryptionService.encryptPayload = originalEncryptPayload;
}
});
test("syncToProvider uses the checked remote as metadata base when no stored base exists", async () => {
const originalDecryptPayload = EncryptionService.decryptPayload;
const originalEncryptPayload = EncryptionService.encryptPayload;
const checkedRemote = remoteFile("github", 3, 300);
const remotePayload = payloadWithHosts(["kept", "deleted-on-local"]);
const localPayload = payload("kept");
let uploadedPayload: SyncPayload | undefined;
EncryptionService.decryptPayload = async (file: SyncedFile) => {
assert.equal(file, checkedRemote);
return remotePayload;
};
EncryptionService.encryptPayload = async (outgoing: SyncPayload) => ({
...remoteFile("github", 4, 400),
payload: JSON.stringify(outgoing),
});
try {
const manager = {
masterPassword: "pw",
adapters: new Map(),
providerDecryptSeq: { github: 0 },
state: {
securityState: "UNLOCKED",
providers: {
github: { enabled: true, connected: true, status: "connected" },
},
lastError: null,
syncState: "IDLE",
syncStrategy: "smartMerge",
localVersion: 1,
deviceId: "local-device",
deviceName: "Local",
},
getConnectedAdapter: async () => ({ provider: "github" }),
updateProviderStatus: () => {},
emit: () => {},
checkProviderConflict: async () => ({ conflict: false, remoteFile: checkedRemote }),
loadSyncBase: async () => null,
uploadToProvider: async (provider: CloudProvider, _adapter: unknown, _file: SyncedFile, outgoing: SyncPayload) => {
uploadedPayload = outgoing;
return { success: true, provider, action: "upload" as const, version: 4 };
},
exitBlockedState: () => {},
};
const result = await syncToProviderImpl.call(manager, "github", localPayload);
assert.equal(result.success, true);
assert.deepEqual(uploadedPayload?.syncMeta?.deletions, [{
entityType: "hosts",
id: "deleted-on-local",
deletedAt: uploadedPayload?.syncMeta?.generatedAt,
deviceId: "local-device",
}]);
} finally {
EncryptionService.decryptPayload = originalDecryptPayload;
EncryptionService.encryptPayload = originalEncryptPayload;
}
});
test("syncToProvider aborts an upload when the master key changes after encryption", async () => {
const originalEncryptPayload = EncryptionService.encryptPayload;
const localPayload = payload("local");
let generation = 0;
let uploaded = false;
EncryptionService.encryptPayload = async (outgoing: SyncPayload) => {
generation += 1;
return {
...remoteFile("github", 2, 200),
payload: JSON.stringify(outgoing),
};
};
try {
const manager = {
masterPassword: "old-master-password",
adapters: new Map(),
state: {
securityState: "UNLOCKED",
providers: {
github: { enabled: true, connected: true, status: "connected" },
},
lastError: null,
syncState: "IDLE",
syncStrategy: "smartMerge",
localVersion: 1,
deviceId: "local-device",
deviceName: "Local",
},
getSyncSecurityGeneration: () => 0,
assertSyncSecurityGeneration: (expected: number) => {
if (generation !== expected) {
throw new Error("Sync cancelled because master key changed");
}
},
getConnectedAdapter: async () => ({ provider: "github" }),
updateProviderStatus: () => {},
emit: () => {},
checkProviderConflict: async () => ({ conflict: false }),
loadSyncBase: async () => null,
uploadToProvider: async (provider: CloudProvider) => {
uploaded = true;
return { success: true, provider, action: "upload" as const, version: 2 };
},
exitBlockedState: () => {},
addSyncHistoryEntry: () => {},
};
const result = await syncToProviderImpl.call(manager, "github", localPayload);
assert.equal(uploaded, false);
assert.equal(result.success, false);
assert.match(result.error ?? "", /master key changed/);
} finally {
EncryptionService.encryptPayload = originalEncryptPayload;
}
});
test("uploadToProvider skips local commits when the master key changes during upload", async () => {
let generation = 0;
let savedAnchor = false;
let savedBase = false;
let savedProvider = false;
const file = remoteFile("github", 2, 200);
const manager = {
providerDecryptSeq: { github: 0 },
state: {
providers: {
github: { enabled: true, connected: true, status: "syncing" },
},
lastError: null,
syncState: "SYNCING",
localVersion: 1,
localUpdatedAt: 100,
remoteVersion: 1,
remoteUpdatedAt: 100,
deviceName: "Local",
},
assertSyncSecurityGeneration: (expected: number) => {
if (generation !== expected) {
throw new Error("Sync cancelled because master key changed");
}
},
saveSyncConfig: () => {},
saveSyncBase: async () => {
savedBase = true;
},
saveSyncAnchor: async () => {
savedAnchor = true;
},
saveProviderConnection: async () => {
savedProvider = true;
},
notifyStateChange: () => {},
addSyncHistoryEntry: () => {},
updateProviderStatus: () => {},
emit: () => {},
};
const adapter = {
upload: async () => {
generation += 1;
return "resource-id";
},
};
const result = await uploadToProviderImpl.call(
manager,
"github",
adapter,
file,
payload("local"),
0,
);
assert.equal(result.success, false);
assert.match(result.error ?? "", /master key changed/);
assert.equal(savedBase, false);
assert.equal(savedAnchor, false);
assert.equal(savedProvider, false);
});
test("syncAllProviders uses the checked remote as metadata base when provider base is missing", async () => {
const originalDecryptPayload = EncryptionService.decryptPayload;
const originalEncryptPayload = EncryptionService.encryptPayload;
const checkedRemote = remoteFile("github", 3, 300);
const remotePayload = payloadWithHosts(["kept", "deleted-on-local"]);
const localPayload = payload("kept");
let uploadedPayload: SyncPayload | undefined;
EncryptionService.decryptPayload = async (file: SyncedFile) => {
assert.equal(file, checkedRemote);
return remotePayload;
};
EncryptionService.encryptPayload = async (outgoing: SyncPayload) => ({
...remoteFile("github", 4, 400),
payload: JSON.stringify(outgoing),
});
try {
const manager = {
masterPassword: "pw",
adapters: new Map(),
state: {
securityState: "UNLOCKED",
providers: {
github: { enabled: true, connected: true, status: "connected" },
google: { enabled: false, connected: false, status: "disconnected" },
onedrive: { enabled: false, connected: false, status: "disconnected" },
webdav: { enabled: false, connected: false, status: "disconnected" },
s3: { enabled: false, connected: false, status: "disconnected" },
},
lastError: null,
syncState: "IDLE",
syncStrategy: "smartMerge",
localVersion: 1,
deviceId: "local-device",
deviceName: "Local",
},
getConnectedAdapter: async () => ({ provider: "github" }),
updateProviderStatus: () => {},
emit: () => {},
checkProviderConflict: async () => ({ conflict: false, remoteFile: checkedRemote }),
loadSyncBase: async () => null,
uploadToProvider: async (provider: CloudProvider, _adapter: unknown, _file: SyncedFile, outgoing: SyncPayload) => {
uploadedPayload = outgoing;
return { success: true, provider, action: "upload" as const, version: 4 };
},
exitBlockedState: () => {},
notifyStateChange: () => {},
};
const results = await syncAllProvidersImpl.call(manager, localPayload);
assert.equal(results.get("github")?.success, true);
assert.deepEqual(uploadedPayload?.syncMeta?.deletions, [{
entityType: "hosts",
id: "deleted-on-local",
deletedAt: uploadedPayload?.syncMeta?.generatedAt,
deviceId: "local-device",
}]);
} finally {
EncryptionService.decryptPayload = originalDecryptPayload;
EncryptionService.encryptPayload = originalEncryptPayload;
}
});
test("commitRemoteInspection saves the comparison base before advancing the remote anchor", async () => {
const calls: string[] = [];
const file = remoteFile("github", 5, 500);
const incoming = payload("cloud");
const manager = {
providerDecryptSeq: { github: 0 },
state: {
providers: {
github: { resourceId: "old", lastSync: 0, lastSyncVersion: 0 },
},
localVersion: 0,
localUpdatedAt: 0,
remoteVersion: 0,
remoteUpdatedAt: 0,
},
getConnectedAdapter: async () => ({ resourceId: "remote-resource" }),
saveSyncConfig: () => calls.push("config"),
saveSyncBase: async () => calls.push("base"),
saveSyncAnchor: async () => calls.push("anchor"),
saveProviderConnection: async () => calls.push("connection"),
addSyncHistoryEntry: () => calls.push("history"),
notifyStateChange: () => calls.push("notify"),
};
await commitRemoteInspectionImpl.call(manager, "github", file, incoming, {
recordDownload: true,
});
assert.deepEqual(calls, ["base", "config", "anchor", "connection", "history", "notify"]);
});
test("commitRemoteInspection does not advance the remote anchor when saving the base fails", async () => {
const calls: string[] = [];
const manager = {
providerDecryptSeq: { github: 0 },
state: {
providers: {
github: { resourceId: "remote-resource", lastSync: 0, lastSyncVersion: 0 },
},
localVersion: 0,
localUpdatedAt: 0,
remoteVersion: 0,
remoteUpdatedAt: 0,
},
getConnectedAdapter: async () => ({ resourceId: "remote-resource" }),
saveSyncConfig: () => calls.push("config"),
saveSyncBase: async () => {
calls.push("base");
throw new Error("base failed");
},
saveSyncAnchor: async () => calls.push("anchor"),
saveProviderConnection: async () => calls.push("connection"),
addSyncHistoryEntry: () => calls.push("history"),
notifyStateChange: () => calls.push("notify"),
};
await assert.rejects(
() => commitRemoteInspectionImpl.call(manager, "github", remoteFile("github", 5, 500), payload("cloud")),
/base failed/,
);
assert.deepEqual(calls, ["base"]);
});
test("saveSyncBase reports storage failures so callers do not advance anchors", async () => {
const originalWarn = console.warn;
const manager = {
state: {
unlockedKey: {
derivedKey: await crypto.subtle.generateKey(
{ name: "AES-GCM", length: 256 },
true,
["encrypt", "decrypt"],
),
},
},
syncBaseKey: () => "sync-base",
saveToStorage: () => {
throw new Error("storage full");
},
};
console.warn = () => {};
try {
await assert.rejects(
() => saveSyncBaseImpl.call(manager, payload("cloud"), "github"),
/storage full/,
);
} finally {
console.warn = originalWarn;
}
});
test("saveSyncBase reports a missing local encryption key", async () => {
const manager = {
state: { unlockedKey: null },
syncBaseKey: () => "sync-base",
saveToStorage: () => {},
};
await assert.rejects(
() => saveSyncBaseImpl.call(manager, payload("cloud"), "github"),
/Sync base encryption key is unavailable/,
);
});
test("saveSyncBase keeps a bounded encrypted snapshot history before replacing the base", async () => {
const stored = new Map<string, string>();
const key = await crypto.subtle.generateKey(
{ name: "AES-GCM", length: 256 },
true,
["encrypt", "decrypt"],
);
const manager = {
state: { unlockedKey: { derivedKey: key } },
syncBaseKey: (provider?: CloudProvider) => `base-${provider ?? "default"}`,
syncSnapshotsKey: (provider?: CloudProvider) => `snapshots-${provider ?? "default"}`,
saveToStorage: (storageKey: string, value: string) => stored.set(storageKey, value),
loadFromStorage: (storageKey: string) => stored.get(storageKey),
};
await saveSyncBaseImpl.call(manager, payload("base-0"), "github");
for (let i = 1; i <= 7; i += 1) {
await saveSyncBaseImpl.call(manager, payload(`base-${i}`), "github");
}
const snapshots = await loadSyncSnapshotsImpl.call(manager, "github");
assert.equal(snapshots.length, 5);
assert.deepEqual(
snapshots.map((snapshot) => snapshot.payload.hosts[0]?.id),
["base-6", "base-5", "base-4", "base-3", "base-2"],
);
});
test("syncAllProviders builds provider-specific sync metadata from each provider base", async () => {
const originalEncryptPayload = EncryptionService.encryptPayload;
const uploaded: Array<{ provider: CloudProvider; payload: SyncPayload }> = [];
const baseByProvider = {
github: payload("shared"),
google: payload("deleted-on-local"),
} as Partial<Record<CloudProvider, SyncPayload>>;
const localPayload = payload("shared");
EncryptionService.encryptPayload = async (outgoing: SyncPayload) => ({
...remoteFile("github", 4, 400),
payload: JSON.stringify(outgoing),
});
try {
const manager = {
masterPassword: "pw",
adapters: new Map(),
state: {
securityState: "UNLOCKED",
providers: {
github: { enabled: true, connected: true, status: "connected" },
google: { enabled: true, connected: true, status: "connected" },
onedrive: { enabled: false, connected: false, status: "disconnected" },
webdav: { enabled: false, connected: false, status: "disconnected" },
s3: { enabled: false, connected: false, status: "disconnected" },
},
lastError: null,
syncState: "IDLE",
syncStrategy: "smartMerge",
localVersion: 1,
deviceId: "local-device",
deviceName: "Local",
},
getConnectedAdapter: async (provider: CloudProvider) => ({ provider }),
updateProviderStatus: () => {},
emit: () => {},
checkProviderConflict: async () => ({ conflict: false, remoteFile: null }),
loadSyncBase: async (provider: CloudProvider) => baseByProvider[provider] ?? null,
uploadToProvider: async (provider: CloudProvider, _adapter: unknown, _file: SyncedFile, outgoing: SyncPayload) => {
uploaded.push({ provider, payload: outgoing });
return { success: true, provider, action: "upload" as const, version: 4 };
},
exitBlockedState: () => {},
notifyStateChange: () => {},
};
await syncAllProvidersImpl.call(manager, localPayload);
assert.equal(uploaded.length, 2);
assert.deepEqual(uploaded.find((entry) => entry.provider === "github")?.payload.syncMeta?.deletions, []);
assert.deepEqual(uploaded.find((entry) => entry.provider === "google")?.payload.syncMeta?.deletions, [{
entityType: "hosts",
id: "deleted-on-local",
deletedAt: uploaded.find((entry) => entry.provider === "google")?.payload.syncMeta?.generatedAt,
deviceId: "local-device",
}]);
} finally {
EncryptionService.encryptPayload = originalEncryptPayload;
}
});

Some files were not shown because too many files have changed in this diff Show More