Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
368c31e48d | ||
|
|
0fa926de26 | ||
|
|
b9b7db2a4e | ||
|
|
e3f68e1a3f | ||
|
|
3c4746aea0 | ||
|
|
463dd4464f | ||
|
|
63b95bb68e | ||
|
|
ea41389842 | ||
|
|
429cb8d6e9 | ||
|
|
55faae767a | ||
|
|
94b8f298ae | ||
|
|
1ef3f9f979 | ||
|
|
e88313eb84 | ||
|
|
03cd9bc968 | ||
|
|
4d7c56e537 | ||
|
|
4769668ff9 | ||
|
|
8ca36a695b | ||
|
|
053a976d37 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -40,7 +40,6 @@ coverage
|
||||
|
||||
# Codex
|
||||
/.codex/
|
||||
/CLAUDE.md
|
||||
|
||||
# AI / Superpowers generated docs (local only)
|
||||
/docs/superpowers/
|
||||
|
||||
14
App.tsx
14
App.tsx
@@ -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
62
CLAUDE.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start dev server (runs lint first, then Vite + Electron concurrently)
|
||||
npm run dev
|
||||
|
||||
# Lint
|
||||
npm run lint
|
||||
npm run lint:fix
|
||||
|
||||
# Run all tests
|
||||
npm test
|
||||
|
||||
# Run a single test file
|
||||
node --test --import tsx path/to/file.test.ts
|
||||
|
||||
# Build renderer
|
||||
npm run build
|
||||
|
||||
# Package for current platform
|
||||
npm run pack
|
||||
|
||||
# Package for specific platforms
|
||||
npm run pack:mac
|
||||
npm run pack:win
|
||||
npm run pack:linux
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Netcatty is an Electron + React desktop app (SSH manager, terminal, SFTP browser). It has two runtimes:
|
||||
|
||||
### Electron Main Process (`electron/`)
|
||||
- **`main.cjs`** — entry point; wires crash logging, process error guards, and delegates to `main/registerBridges.cjs`
|
||||
- **`bridges/`** — one `.cjs` file per capability domain (sshBridge, sftpBridge, terminalBridge, portForwardingBridge, aiBridge, etc.). Each bridge exposes IPC handlers via `ipcMain`. Tests live alongside the bridge file (`*.test.cjs`).
|
||||
- **`preload.cjs`** — exposes a typed `window.electron` API to the renderer via `contextBridge`. Uses `preload/api.cjs` for the generated API surface.
|
||||
- **`cli/`** — `netcatty-tool-cli.cjs` is a separate internal binary for tool/MCP integration; treat as internal surface only.
|
||||
|
||||
### Renderer Process (React + Vite)
|
||||
Three-layer architecture (see `AGENTS.md` for full detail):
|
||||
|
||||
- **`domain/`** — pure TypeScript logic, no side effects. Models (`models.ts`), host helpers, workspace tree operations.
|
||||
- **`application/state/`** — React hooks that own state and persistence boundaries. Key hooks: `useVaultState` (hosts/keys/snippets), `useSessionState` (terminal sessions/workspace), `useSettingsState` (theme/config).
|
||||
- **`infrastructure/`** — external edges: `persistence/localStorageAdapter.ts` for storage, `services/` for network calls (Gemini AI, GitHub Gist sync), `config/` for defaults, storage keys, and terminal themes.
|
||||
- **`components/`** — presentation only. `App.tsx` wires hooks to components; no business logic in components.
|
||||
|
||||
### IPC Pattern
|
||||
UI calls `window.electron.*` (preload API) → IPC → bridge handler in main process. Never call `ipcRenderer` directly from components.
|
||||
|
||||
### Key Conventions
|
||||
- All storage reads/writes go through `localStorageAdapter`; storage keys are in `infrastructure/config/storageKeys.ts`.
|
||||
- Temporary files must use `tempDirBridge.getTempFilePath(fileName)` — never `os.tmpdir()` directly.
|
||||
- Aside panels (VaultView subpages) use the shared design system in `components/ui/aside-panel.tsx` — see `AGENTS.md` for usage patterns.
|
||||
- Renderer code is TypeScript/ESM; Electron main/bridges are CommonJS (`.cjs`).
|
||||
- Path alias `@/` resolves to the repo root (configured in `vite.config.ts` and `tsconfig.json`).
|
||||
70
application/AppHandlers.globalHotkeys.test.ts
Normal file
70
application/AppHandlers.globalHotkeys.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { handleGlobalHotkeyKeyDownImpl } from './app/AppHandlers.ts';
|
||||
import { matchesKeyBinding } from '../domain/models.ts';
|
||||
import { DEFAULT_KEY_BINDINGS } from '../domain/models/keyBindings.ts';
|
||||
|
||||
class FakeHTMLElement {
|
||||
tagName = 'TEXTAREA';
|
||||
isContentEditable = false;
|
||||
classList = {
|
||||
contains: (className: string) => className === 'xterm-helper-textarea',
|
||||
};
|
||||
|
||||
closest(selector: string): FakeHTMLElement | null {
|
||||
return selector.includes('xterm') ? this : null;
|
||||
}
|
||||
|
||||
hasAttribute(name: string): boolean {
|
||||
return name === 'data-session-id';
|
||||
}
|
||||
}
|
||||
|
||||
const previousHTMLElement = globalThis.HTMLElement;
|
||||
globalThis.HTMLElement = FakeHTMLElement as unknown as typeof HTMLElement;
|
||||
|
||||
test.after(() => {
|
||||
globalThis.HTMLElement = previousHTMLElement;
|
||||
});
|
||||
|
||||
test('global hotkey handler lets terminal font size shortcuts reach xterm', () => {
|
||||
const target = new FakeHTMLElement();
|
||||
const handledActions: string[] = [];
|
||||
let prevented = false;
|
||||
let stopped = false;
|
||||
const event = {
|
||||
key: '=',
|
||||
code: 'Equal',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
target,
|
||||
composedPath: () => [target],
|
||||
preventDefault: () => {
|
||||
prevented = true;
|
||||
},
|
||||
stopPropagation: () => {
|
||||
stopped = true;
|
||||
},
|
||||
} as unknown as KeyboardEvent;
|
||||
|
||||
handleGlobalHotkeyKeyDownImpl(
|
||||
() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: 'Ctrl + W',
|
||||
executeHotkeyAction: (action: string) => {
|
||||
handledActions.push(action);
|
||||
},
|
||||
hotkeyScheme: 'pc',
|
||||
keyBindings: DEFAULT_KEY_BINDINGS,
|
||||
matchesKeyBinding,
|
||||
}),
|
||||
event,
|
||||
);
|
||||
|
||||
assert.deepEqual(handledActions, []);
|
||||
assert.equal(prevented, false);
|
||||
assert.equal(stopped, false);
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
30
application/i18n/locales/cloudSyncStrategyLocales.test.ts
Normal file
30
application/i18n/locales/cloudSyncStrategyLocales.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import en from "../locales/en.ts";
|
||||
import ru from "../locales/ru.ts";
|
||||
import zhCN from "../locales/zh-CN.ts";
|
||||
|
||||
const strategyKeys = [
|
||||
"cloudSync.strategy.title",
|
||||
"cloudSync.strategy.desc",
|
||||
"cloudSync.strategy.smartMerge",
|
||||
"cloudSync.strategy.smartMergeDesc",
|
||||
"cloudSync.strategy.preferCloud",
|
||||
"cloudSync.strategy.preferCloudDesc",
|
||||
"cloudSync.strategy.preferLocal",
|
||||
"cloudSync.strategy.preferLocalDesc",
|
||||
] as const;
|
||||
|
||||
test("cloud sync strategy copy exists in every bundled locale", () => {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, zhCN })) {
|
||||
for (const key of strategyKeys) {
|
||||
assert.equal(
|
||||
typeof messages[key],
|
||||
"string",
|
||||
`${locale} is missing ${key}`,
|
||||
);
|
||||
assert.notEqual(messages[key], "", `${locale} has empty ${key}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -191,6 +191,7 @@ export const ruCoreMessages: Messages = {
|
||||
// Vault Sidebar
|
||||
'vault.sidebar.collapse': 'Свернуть боковую панель',
|
||||
'vault.sidebar.expand': 'Развернуть боковую панель',
|
||||
'vault.sidebar.resize': 'Изменить ширину боковой панели',
|
||||
|
||||
// Settings > Application
|
||||
'settings.application.checkUpdates': 'Проверить обновления',
|
||||
|
||||
@@ -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': 'Завершите авторизацию в браузере',
|
||||
|
||||
@@ -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': 'Выберите группу',
|
||||
|
||||
@@ -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': '保存主机以快速连接到你的服务器、虚拟机和容器。',
|
||||
|
||||
@@ -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': '连接串口',
|
||||
|
||||
@@ -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': '请在浏览器中完成授权',
|
||||
|
||||
4
application/state/snippetVariableValues.ts
Normal file
4
application/state/snippetVariableValues.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
readSnippetVariableValuesForSnippet,
|
||||
saveSnippetVariableValues,
|
||||
} from '../../infrastructure/persistence/snippetVariableValuesStorage';
|
||||
@@ -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
|
||||
|
||||
57
application/state/useCloudSync.masterKey.test.ts
Normal file
57
application/state/useCloudSync.masterKey.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { SYNC_STORAGE_KEYS } from "../../domain/sync.ts";
|
||||
import { EncryptionService } from "../../infrastructure/services/EncryptionService.ts";
|
||||
import { handleStorageEventImpl } from "../../infrastructure/services/cloudSync/stateAndSecurityMethods.ts";
|
||||
|
||||
test("master key replacement from another window locks the current window and clears the old password", async () => {
|
||||
const oldConfig = await EncryptionService.createMasterKeyConfig("old-master-password");
|
||||
const newConfig = await EncryptionService.createMasterKeyConfig("new-master-password");
|
||||
const fakeStorage = {};
|
||||
const originalWindow = globalThis.window;
|
||||
let notifyCount = 0;
|
||||
let stopAutoSyncCount = 0;
|
||||
let syncSecurityGenerationCount = 0;
|
||||
|
||||
(globalThis as typeof globalThis & { window?: unknown }).window = {
|
||||
localStorage: fakeStorage,
|
||||
};
|
||||
|
||||
const manager = {
|
||||
state: {
|
||||
masterKeyConfig: oldConfig,
|
||||
securityState: "UNLOCKED",
|
||||
unlockedKey: await EncryptionService.unlockMasterKey("old-master-password", oldConfig),
|
||||
},
|
||||
masterPassword: "old-master-password",
|
||||
safeJsonParse: (value: string | null) => (value ? JSON.parse(value) : null),
|
||||
stopAutoSync: () => {
|
||||
stopAutoSyncCount += 1;
|
||||
},
|
||||
bumpSyncSecurityGeneration: () => {
|
||||
syncSecurityGenerationCount += 1;
|
||||
},
|
||||
notifyStateChange: () => {
|
||||
notifyCount += 1;
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
handleStorageEventImpl.call(manager, {
|
||||
storageArea: fakeStorage,
|
||||
key: SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG,
|
||||
newValue: JSON.stringify(newConfig),
|
||||
} as StorageEvent);
|
||||
} finally {
|
||||
(globalThis as typeof globalThis & { window?: unknown }).window = originalWindow;
|
||||
}
|
||||
|
||||
assert.equal(manager.state.masterKeyConfig.verificationHash, newConfig.verificationHash);
|
||||
assert.equal(manager.state.securityState, "LOCKED");
|
||||
assert.equal(manager.state.unlockedKey, null);
|
||||
assert.equal(manager.masterPassword, null);
|
||||
assert.equal(stopAutoSyncCount, 1);
|
||||
assert.equal(syncSecurityGenerationCount, 1);
|
||||
assert.equal(notifyCount, 1);
|
||||
});
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
type ProviderConnection,
|
||||
type ConflictInfo,
|
||||
type ConflictResolution,
|
||||
type RemoteSyncPayload,
|
||||
type SyncedFile,
|
||||
type SyncPayload,
|
||||
type SyncResult,
|
||||
type SyncHistoryEntry,
|
||||
@@ -23,6 +25,8 @@ import {
|
||||
getSyncDotColor,
|
||||
isProviderReadyForSync,
|
||||
} from '../../domain/sync';
|
||||
import type { CloudSyncStrategy } from '../../domain/syncStrategy';
|
||||
import type { CloudSyncConflictAction } from '../../domain/syncStrategy';
|
||||
import {
|
||||
getCloudSyncManager,
|
||||
type SyncManagerState,
|
||||
@@ -48,6 +52,7 @@ export interface CloudSyncHook {
|
||||
deviceName: string;
|
||||
autoSyncEnabled: boolean;
|
||||
autoSyncInterval: number;
|
||||
syncStrategy: CloudSyncStrategy;
|
||||
localVersion: number;
|
||||
localUpdatedAt: number;
|
||||
remoteVersion: number;
|
||||
@@ -91,10 +96,11 @@ export interface CloudSyncHook {
|
||||
resetProviderStatus: (provider: CloudProvider) => void;
|
||||
|
||||
// Sync Actions
|
||||
syncNow: (payload: SyncPayload, opts?: { overrideShrink?: boolean }) => Promise<Map<CloudProvider, SyncResult>>;
|
||||
syncNow: (payload: SyncPayload, opts?: { overrideShrink?: boolean; conflictActionOverride?: CloudSyncConflictAction }) => Promise<Map<CloudProvider, SyncResult>>;
|
||||
syncToProvider: (provider: CloudProvider, payload: SyncPayload, opts?: { overrideShrink?: boolean }) => Promise<SyncResult>;
|
||||
downloadFromProvider: (provider: CloudProvider) => Promise<SyncPayload | null>;
|
||||
resolveConflict: (resolution: ConflictResolution) => Promise<SyncPayload | null>;
|
||||
downloadFromProvider: (provider: CloudProvider) => Promise<RemoteSyncPayload | null>;
|
||||
commitRemoteInspection: (provider: CloudProvider, remoteFile: SyncedFile, payload: SyncPayload, opts?: { recordDownload?: boolean }) => Promise<void>;
|
||||
resolveConflict: (resolution: ConflictResolution) => Promise<RemoteSyncPayload | null>;
|
||||
|
||||
// Gist Revision History
|
||||
getGistRevisionHistory: () => Promise<Array<{ version: string; date: Date }>>;
|
||||
@@ -113,6 +119,7 @@ export interface CloudSyncHook {
|
||||
// Settings
|
||||
setAutoSync: (enabled: boolean, intervalMinutes?: number) => void;
|
||||
setDeviceName: (name: string) => void;
|
||||
setSyncStrategy: (strategy: CloudSyncStrategy) => void;
|
||||
|
||||
// Local Data Reset
|
||||
resetLocalVersion: () => void;
|
||||
@@ -631,6 +638,10 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
const setDeviceName = useCallback((name: string) => {
|
||||
manager.setDeviceName(name);
|
||||
}, []);
|
||||
|
||||
const setSyncStrategy = useCallback((strategy: CloudSyncStrategy) => {
|
||||
manager.setSyncStrategy(strategy);
|
||||
}, []);
|
||||
|
||||
// ========== Utilities ==========
|
||||
|
||||
@@ -661,7 +672,7 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
throw new Error('Vault is locked');
|
||||
}, []);
|
||||
|
||||
const syncNowWithUnlock = useCallback(async (payload: SyncPayload, opts?: { overrideShrink?: boolean }) => {
|
||||
const syncNowWithUnlock = useCallback(async (payload: SyncPayload, opts?: { overrideShrink?: boolean; conflictActionOverride?: CloudSyncConflictAction }) => {
|
||||
await ensureUnlocked();
|
||||
return await manager.syncAllProviders(payload, opts);
|
||||
}, [ensureUnlocked]);
|
||||
@@ -676,6 +687,16 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
return await manager.downloadFromProvider(provider);
|
||||
}, [ensureUnlocked]);
|
||||
|
||||
const commitRemoteInspectionWithUnlock = useCallback(async (
|
||||
provider: CloudProvider,
|
||||
remoteFile: SyncedFile,
|
||||
payload: SyncPayload,
|
||||
opts: { recordDownload?: boolean } = {},
|
||||
) => {
|
||||
await ensureUnlocked();
|
||||
await manager.commitRemoteInspection(provider, remoteFile, payload, opts);
|
||||
}, [ensureUnlocked]);
|
||||
|
||||
const subscribeToEvents = useCallback(
|
||||
(callback: SyncEventCallback) => manager.subscribe(callback),
|
||||
[],
|
||||
@@ -703,6 +724,7 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
deviceName: state.deviceName,
|
||||
autoSyncEnabled: state.autoSyncEnabled,
|
||||
autoSyncInterval: state.autoSyncInterval,
|
||||
syncStrategy: state.syncStrategy,
|
||||
localVersion: state.localVersion,
|
||||
localUpdatedAt: state.localUpdatedAt,
|
||||
remoteVersion: state.remoteVersion,
|
||||
@@ -738,6 +760,7 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
syncNow: syncNowWithUnlock,
|
||||
syncToProvider: syncToProviderWithUnlock,
|
||||
downloadFromProvider: downloadFromProviderWithUnlock,
|
||||
commitRemoteInspection: commitRemoteInspectionWithUnlock,
|
||||
resolveConflict: resolveConflictWithUnlock,
|
||||
|
||||
// Gist Revision History (#679)
|
||||
@@ -747,6 +770,7 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
// Settings
|
||||
setAutoSync,
|
||||
setDeviceName,
|
||||
setSyncStrategy,
|
||||
|
||||
// Local Data Reset
|
||||
resetLocalVersion: () => manager.resetLocalVersion(),
|
||||
|
||||
@@ -49,5 +49,8 @@ export const getTerminalPassthroughActions = (): Set<string> => {
|
||||
'selectAll',
|
||||
'clearBuffer',
|
||||
'searchTerminal',
|
||||
'increaseTerminalFontSize',
|
||||
'decreaseTerminalFontSize',
|
||||
'resetTerminalFontSize',
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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")]));
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Globe,
|
||||
LayoutGrid,
|
||||
List as ListIcon,
|
||||
Search,
|
||||
Server,
|
||||
Shuffle,
|
||||
Zap,
|
||||
@@ -41,9 +40,14 @@ import {
|
||||
DialogTitle,
|
||||
} from "./ui/dialog";
|
||||
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
|
||||
import { Input } from "./ui/input";
|
||||
import { SortDropdown } from "./ui/sort-dropdown";
|
||||
import { toast } from "./ui/toast";
|
||||
import {
|
||||
VaultHeaderSearch,
|
||||
VaultPageHeader,
|
||||
vaultHeaderIconButtonClass,
|
||||
vaultHeaderSecondaryButtonClass,
|
||||
} from "./vault/VaultPageHeader";
|
||||
|
||||
// Import components and utilities from port-forwarding module
|
||||
import {
|
||||
@@ -586,13 +590,12 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
showWizard || showEditPanel || showNewForm ? "mr-[360px]" : "",
|
||||
)}
|
||||
>
|
||||
{/* Toolbar */}
|
||||
<div className="h-14 px-4 py-2 flex items-center gap-3 bg-secondary/80 supports-[backdrop-filter]:backdrop-blur-sm border-b border-border/50 relative z-20">
|
||||
<VaultPageHeader className="z-20">
|
||||
<Dropdown open={showNewMenu} onOpenChange={setShowNewMenu}>
|
||||
<DropdownTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="h-10 px-3 gap-2 bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
|
||||
className={vaultHeaderSecondaryButtonClass}
|
||||
>
|
||||
<Zap size={14} />
|
||||
{t("pf.action.newForwarding")}
|
||||
@@ -634,23 +637,17 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
</Dropdown>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={14}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
placeholder={t("common.searchPlaceholder")}
|
||||
className="h-10 pl-9 w-44 bg-secondary border-border/60 text-sm"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<VaultHeaderSearch
|
||||
placeholder={t("common.searchPlaceholder")}
|
||||
className="w-64"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* View mode toggle */}
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10">
|
||||
<Button variant="ghost" size="icon" className={vaultHeaderIconButtonClass}>
|
||||
{viewMode === "grid" ? (
|
||||
<LayoutGrid size={16} />
|
||||
) : (
|
||||
@@ -687,10 +684,10 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
<SortDropdown
|
||||
value={sortMode}
|
||||
onChange={setSortMode}
|
||||
className="h-10 w-10"
|
||||
className={vaultHeaderIconButtonClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</VaultPageHeader>
|
||||
|
||||
{/* Rules List */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
List as ListIcon,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Route,
|
||||
Settings2,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
@@ -48,6 +48,12 @@ import {
|
||||
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
|
||||
import { Input } from "./ui/input";
|
||||
import { toast } from "./ui/toast";
|
||||
import {
|
||||
VaultHeaderSearch,
|
||||
VaultPageHeader,
|
||||
vaultHeaderIconButtonClass,
|
||||
vaultHeaderSecondaryButtonClass,
|
||||
} from "./vault/VaultPageHeader";
|
||||
|
||||
interface ProxyProfilesManagerProps {
|
||||
proxyProfiles: ProxyProfile[];
|
||||
@@ -83,6 +89,23 @@ const getProfileUsageCount = (
|
||||
|
||||
type ProxyProfilesViewMode = "grid" | "list";
|
||||
|
||||
const proxyProtocolMeta = {
|
||||
http: {
|
||||
label: "HTTP",
|
||||
Icon: Globe,
|
||||
iconClassName: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
|
||||
},
|
||||
socks5: {
|
||||
label: "SOCKS5",
|
||||
Icon: Route,
|
||||
iconClassName: "bg-sky-500/10 text-sky-600 dark:text-sky-400",
|
||||
},
|
||||
} satisfies Record<ProxyConfig["type"], {
|
||||
label: string;
|
||||
Icon: React.ComponentType<{ size?: number; className?: string }>;
|
||||
iconClassName: string;
|
||||
}>;
|
||||
|
||||
interface ProxyProfileCardProps {
|
||||
profile: ProxyProfile;
|
||||
usageCount: number;
|
||||
@@ -106,7 +129,9 @@ const ProxyProfileCard: React.FC<ProxyProfileCardProps> = ({
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const usageLabel = t("proxyProfiles.usage", { count: usageCount });
|
||||
const accessibleLabel = `${profile.label}, ${profile.config.type.toUpperCase()}, ${profile.config.host}:${profile.config.port}, ${usageLabel}`;
|
||||
const protocol = proxyProtocolMeta[profile.config.type];
|
||||
const ProtocolIcon = protocol.Icon;
|
||||
const accessibleLabel = `${profile.label}, ${protocol.label}, ${profile.config.host}:${profile.config.port}, ${usageLabel}`;
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
@@ -124,19 +149,22 @@ const ProxyProfileCard: React.FC<ProxyProfileCardProps> = ({
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
<div className="h-11 w-11 rounded-xl bg-primary/15 text-primary flex items-center justify-center">
|
||||
<Globe size={18} />
|
||||
<div
|
||||
className={cn(
|
||||
"h-11 w-11 rounded-xl flex items-center justify-center",
|
||||
protocol.iconClassName,
|
||||
)}
|
||||
title={protocol.label}
|
||||
>
|
||||
<ProtocolIcon size={18} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="text-sm font-semibold truncate">{profile.label}</div>
|
||||
<Badge variant="secondary" className="text-[10px] shrink-0">
|
||||
{profile.config.type.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-[11px] font-mono text-muted-foreground truncate">
|
||||
{profile.config.host}:{profile.config.port} -{" "}
|
||||
{usageLabel}
|
||||
{protocol.label}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -289,34 +317,30 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
|
||||
return (
|
||||
<div className="h-full flex relative">
|
||||
<div className={cn("flex-1 flex flex-col min-h-0 transition-all duration-200", draft && "mr-[380px]")}>
|
||||
<header className="border-b border-border/50 bg-secondary/80 supports-[backdrop-filter]:backdrop-blur-sm shrink-0">
|
||||
<div className="h-14 px-4 py-2 flex items-center gap-3">
|
||||
<VaultPageHeader>
|
||||
<Button
|
||||
onClick={openCreate}
|
||||
variant="secondary"
|
||||
className="h-10 px-3 gap-2 bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
|
||||
className={vaultHeaderSecondaryButtonClass}
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("proxyProfiles.action.add")}
|
||||
</Button>
|
||||
<div className="ml-auto flex items-center gap-2 min-w-0 flex-shrink">
|
||||
<div className="relative flex-shrink min-w-[100px]">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label={t("proxyProfiles.search.placeholder")}
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t("proxyProfiles.search.placeholder")}
|
||||
className="h-10 pl-9 w-full bg-secondary border-border/60 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<VaultHeaderSearch
|
||||
aria-label={t("proxyProfiles.search.placeholder")}
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t("proxyProfiles.search.placeholder")}
|
||||
className="flex-shrink w-64"
|
||||
/>
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<Button
|
||||
aria-label={t("proxyProfiles.viewMode")}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10 flex-shrink-0"
|
||||
className={cn(vaultHeaderIconButtonClass, "flex-shrink-0")}
|
||||
>
|
||||
{proxyProfilesViewMode === "grid" ? (
|
||||
<LayoutGrid size={16} />
|
||||
@@ -344,8 +368,7 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</VaultPageHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="space-y-3 p-3">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
234
components/SnippetExecutionProvider.tsx
Normal file
234
components/SnippetExecutionProvider.tsx
Normal 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;
|
||||
}
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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'));
|
||||
|
||||
94
components/host-details/HostDetailsSection.tsx
Normal file
94
components/host-details/HostDetailsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -14,3 +14,9 @@ export type { ChainPanelProps } from './ChainPanel';
|
||||
|
||||
export { EnvVarsPanel } from './EnvVarsPanel';
|
||||
export type { EnvVarsPanelProps } from './EnvVarsPanel';
|
||||
|
||||
export {
|
||||
HostDetailsHelp,
|
||||
HostDetailsSection,
|
||||
HostDetailsSettingRow,
|
||||
} from './HostDetailsSection';
|
||||
|
||||
90
components/host/HostNotesEditor.tsx
Normal file
90
components/host/HostNotesEditor.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
50
components/host/HostNotesIndicator.tsx
Normal file
50
components/host/HostNotesIndicator.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
36
components/terminal/runtime/terminalDistroDetection.test.ts
Normal file
36
components/terminal/runtime/terminalDistroDetection.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
27
components/terminal/runtime/terminalFontZoom.test.ts
Normal file
27
components/terminal/runtime/terminalFontZoom.test.ts
Normal 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);
|
||||
});
|
||||
38
components/terminal/runtime/terminalFontZoom.ts
Normal file
38
components/terminal/runtime/terminalFontZoom.ts
Normal 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));
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
96
components/ui/code-textarea.tsx
Normal file
96
components/ui/code-textarea.tsx
Normal 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 };
|
||||
@@ -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}
|
||||
|
||||
78
components/vault/VaultPageHeader.tsx
Normal file
78
components/vault/VaultPageHeader.tsx
Normal 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";
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
24
domain/keyBindings.terminalFontSize.test.ts
Normal file
24
domain/keyBindings.terminalFontSize.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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' },
|
||||
|
||||
76
domain/snippetVariables.test.ts
Normal file
76
domain/snippetVariables.test.ts
Normal 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
117
domain/snippetVariables.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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" }]);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
253
domain/syncReliability.test.ts
Normal file
253
domain/syncReliability.test.ts
Normal 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
351
domain/syncReliability.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
56
domain/syncStrategy.test.ts
Normal file
56
domain/syncStrategy.test.ts
Normal 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
36
domain/syncStrategy.ts
Normal 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';
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
148
electron/bridges/privateKeyNormalizer.cjs
Normal file
148
electron/bridges/privateKeyNormalizer.cjs
Normal 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,
|
||||
};
|
||||
141
electron/bridges/privateKeyNormalizer.test.cjs
Normal file
141
electron/bridges/privateKeyNormalizer.test.cjs
Normal 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);
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
128
electron/bridges/sshAuthHelper.pkcs8.test.cjs
Normal file
128
electron/bridges/sshAuthHelper.pkcs8.test.cjs
Normal 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");
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"}');
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: [] },
|
||||
|
||||
@@ -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';
|
||||
|
||||
21
infrastructure/persistence/snippetVariableValuesStorage.ts
Normal file
21
infrastructure/persistence/snippetVariableValuesStorage.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
580
infrastructure/services/cloudSync/syncAllStorageMethods.test.ts
Normal file
580
infrastructure/services/cloudSync/syncAllStorageMethods.test.ts
Normal 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
Reference in New Issue
Block a user