mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-30 02:21:47 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
118c015bd5 | ||
|
|
88caf82e83 | ||
|
|
e3c45097c2 | ||
|
|
46de2023bd | ||
|
|
48f1a1cecf | ||
|
|
2bb52ce6ee | ||
|
|
c2c4b6ec77 | ||
|
|
3f35872638 | ||
|
|
bace30578c | ||
|
|
8aeb6ade6f | ||
|
|
b6bff4df6b |
No files matched your search
@@ -52,6 +52,9 @@ jobs:
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
- name: Test browser first-link lifecycle
|
||||
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox electron/hostBrowser/hostBrowser.firstLink.live.test.cjs
|
||||
|
||||
- name: Test terminal background rendering
|
||||
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox --use-angle=swiftshader --enable-unsafe-swiftshader scripts/terminal-background-rendering.live.test.cjs
|
||||
|
||||
@@ -71,6 +74,47 @@ jobs:
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
windows-fido:
|
||||
name: windows-fido
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Apply production dependency patches
|
||||
run: npx patch-package --error-on-fail
|
||||
|
||||
- name: Install user-mode OpenSSH and software authenticator toolchain
|
||||
uses: msys2/setup-msys2@v2
|
||||
with:
|
||||
msystem: MSYS
|
||||
update: true
|
||||
install: gcc openssl-devel openssh
|
||||
|
||||
- name: Build disposable software authenticator
|
||||
shell: msys2 {0}
|
||||
run: |
|
||||
gcc -shared -DHAVE_STDINT_H scripts/fixtures/fido/provider.c -lcrypto -o /usr/lib/netcatty-fido-test-provider.dll
|
||||
printf 'NETCATTY_WINDOWS_SSH_AGENT=%s\n' "$(cygpath -w /usr/bin/ssh-agent.exe)" >> "$GITHUB_ENV"
|
||||
printf 'NETCATTY_WINDOWS_SSH_KEYGEN=%s\n' "$(cygpath -w /usr/bin/ssh-keygen.exe)" >> "$GITHUB_ENV"
|
||||
cygpath -w /usr/bin >> "$GITHUB_PATH"
|
||||
ssh -V
|
||||
|
||||
- name: Exercise Windows pipes, PIN bridge, SK signatures and cancellation
|
||||
env:
|
||||
NETCATTY_FIDO_WINDOWS: "1"
|
||||
run: node --test scripts/fido-windows.live.test.cjs electron/bridges/fidoNativeAgent.test.cjs electron/bridges/fidoAskpass.winpath.test.cjs
|
||||
|
||||
windows-broadcast-ssh:
|
||||
name: windows-broadcast-ssh
|
||||
runs-on: windows-latest
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { MonitorHostKeyDialog } from './components/vault/MonitorHostKeyDialog';
|
||||
import { MonitorRuntimeHost } from './application/app/MonitorRuntimeHost';
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import { initializeFonts } from './application/state/fontStore';
|
||||
@@ -47,7 +49,9 @@ function SettingsI18nProvider({ children }: { children: React.ReactNode }) {
|
||||
function App() {
|
||||
return (
|
||||
<AppLocalStateProvider>
|
||||
<MonitorRuntimeHost />
|
||||
<AppSideEffects />
|
||||
<MonitorHostKeyDialog />
|
||||
<AppShell />
|
||||
</AppLocalStateProvider>
|
||||
);
|
||||
|
||||
@@ -500,6 +500,20 @@ export function handlePassphraseSkipImpl(getCtx: AppContextGetter, requestId: st
|
||||
}
|
||||
}
|
||||
|
||||
export function handleFidoPromptSubmitImpl(getCtx: AppContextGetter, requestId: string, response: string) {
|
||||
const { netcattyBridge, setFidoPromptQueue } = getCtx();
|
||||
const bridge = netcattyBridge.get();
|
||||
void bridge?.respondFidoPrompt?.(requestId, response, false);
|
||||
setFidoPromptQueue((prev: { requestId: string }[]) => prev.filter((r) => r.requestId !== requestId));
|
||||
}
|
||||
|
||||
export function handleFidoPromptCancelImpl(getCtx: AppContextGetter, requestId: string) {
|
||||
const { netcattyBridge, setFidoPromptQueue } = getCtx();
|
||||
const bridge = netcattyBridge.get();
|
||||
void bridge?.respondFidoPrompt?.(requestId, '', true);
|
||||
setFidoPromptQueue((prev: { requestId: string }[]) => prev.filter((r) => r.requestId !== requestId));
|
||||
}
|
||||
|
||||
export function createLocalTerminalWithCurrentShellImpl(getCtx: AppContextGetter) {
|
||||
const { classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, terminalSettings } = getCtx();
|
||||
{
|
||||
|
||||
@@ -86,6 +86,7 @@ import { toast } from '../../components/ui/toast';
|
||||
import { VaultSection } from '../../components/VaultView';
|
||||
import { KeyboardInteractiveRequest } from '../../components/KeyboardInteractiveModal';
|
||||
import { PassphraseRequest } from '../../components/PassphraseModal';
|
||||
import type { FidoPromptRequest } from '../../components/FidoPromptModal';
|
||||
import { classifyLocalShellType } from '../../lib/localShell';
|
||||
import { useDiscoveredShells, resolveShellSetting, ensureDiscoveredShells } from '../../lib/useDiscoveredShells';
|
||||
import { Host, HostProtocol, KnownHost, SerialConfig, Snippet, SSHKey, TerminalSession } from '../../types';
|
||||
@@ -94,7 +95,7 @@ import { isScriptSnippet } from '../../domain/snippetScript.ts';
|
||||
import { collectSnippetDeleteIds } from '../../domain/snippetSelection.ts';
|
||||
import { shouldOpenLocalTerminalOnStartup, resolveStartupLandingSetting } from '../../domain/startupLanding';
|
||||
import { useAppStartupEffects } from './useAppStartupEffects';
|
||||
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, duplicateSessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl, markForwardedNativeShortcutEvent } from './AppHandlers';
|
||||
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, handleFidoPromptSubmitImpl, handleFidoPromptCancelImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, duplicateSessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl, markForwardedNativeShortcutEvent } from './AppHandlers';
|
||||
|
||||
type OpenSessionInNewWindowPayload = {
|
||||
title?: string;
|
||||
@@ -139,6 +140,8 @@ export function AppSideEffects() {
|
||||
const [keyboardInteractiveQueue, setKeyboardInteractiveQueue] = useState<KeyboardInteractiveRequest[]>([]);
|
||||
// Passphrase request queue for encrypted SSH keys
|
||||
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
|
||||
// FIDO2 PIN / touch prompt queue (OpenSSH sk-*)
|
||||
const [fidoPromptQueue, setFidoPromptQueue] = useState<FidoPromptRequest[]>([]);
|
||||
const [deleteHostConfirm, setDeleteHostConfirm] = useState<{ hostId: string; name: string } | null>(null);
|
||||
const [pendingNewWindowSession, setPendingNewWindowSession] = useState<OpenSessionInNewWindowPayload | null>(null);
|
||||
const [pendingTrayPanelConnectHostIds, setPendingTrayPanelConnectHostIds] = useState<string[]>([]);
|
||||
@@ -836,6 +839,48 @@ export function AppSideEffects() {
|
||||
// Handle passphrase skip (skip this key, continue with others)
|
||||
const handlePassphraseSkip = useCallback((requestId: string) => { return handlePassphraseSkipImpl(() => ({ netcattyBridge, requestId, setPassphraseQueue }), requestId); }, []);
|
||||
|
||||
// FIDO2 PIN / touch prompts from main-process askpass / sk-helper
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onFidoPromptRequest) return;
|
||||
const unsubscribe = bridge.onFidoPromptRequest((request) => {
|
||||
console.log('[App] FIDO prompt request:', request);
|
||||
setFidoPromptQueue((prev) => [...prev, {
|
||||
...request,
|
||||
kind: request.kind === 'touch' || request.kind === 'confirm' || request.kind === 'recovery' ? request.kind : 'pin',
|
||||
}]);
|
||||
});
|
||||
return () => { unsubscribe?.(); };
|
||||
}, []);
|
||||
|
||||
const handleFidoPromptSubmit = useCallback((requestId: string, response: string) => {
|
||||
if (response === 'fallback') toast.info(t('fido.recovery.passwordFallback'));
|
||||
return handleFidoPromptSubmitImpl(() => ({ netcattyBridge, requestId, setFidoPromptQueue }), requestId, response);
|
||||
}, [t]);
|
||||
|
||||
const handleFidoPromptCancel = useCallback((requestId: string) => {
|
||||
return handleFidoPromptCancelImpl(() => ({ netcattyBridge, requestId, setFidoPromptQueue }), requestId);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onFidoPromptTimeout) return;
|
||||
const unsubscribe = bridge.onFidoPromptTimeout((event) => {
|
||||
setFidoPromptQueue((prev) => prev.filter((r) => r.requestId !== event.requestId));
|
||||
toast.error(t('fido.prompt.timeout'));
|
||||
});
|
||||
return () => { unsubscribe?.(); };
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onFidoPromptCancelled) return;
|
||||
const unsubscribe = bridge.onFidoPromptCancelled((event) => {
|
||||
setFidoPromptQueue((prev) => prev.filter((r) => r.requestId !== event.requestId));
|
||||
});
|
||||
return () => { unsubscribe?.(); };
|
||||
}, []);
|
||||
|
||||
// Handle passphrase timeout (request expired on backend)
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -1968,6 +2013,8 @@ export function AppSideEffects() {
|
||||
handlePassphraseCancel,
|
||||
handlePassphraseSkip,
|
||||
handlePassphraseSubmit,
|
||||
handleFidoPromptCancel,
|
||||
handleFidoPromptSubmit,
|
||||
handleProtocolSelect,
|
||||
handleRequestCloseEditorTabRef,
|
||||
resolveEmptyVaultConflict,
|
||||
@@ -1991,6 +2038,7 @@ export function AppSideEffects() {
|
||||
portForwardingRules,
|
||||
keyboardInteractiveQueue,
|
||||
passphraseQueue,
|
||||
fidoPromptQueue,
|
||||
deleteHostConfirm,
|
||||
vaultFocusRequest,
|
||||
openNoteRequest,
|
||||
@@ -2039,6 +2087,8 @@ export function AppSideEffects() {
|
||||
handlePassphraseCancel,
|
||||
handlePassphraseSkip,
|
||||
handlePassphraseSubmit,
|
||||
handleFidoPromptCancel,
|
||||
handleFidoPromptSubmit,
|
||||
handleProtocolSelect,
|
||||
resolveEmptyVaultConflict,
|
||||
handleCancelDeleteHost,
|
||||
@@ -2054,6 +2104,7 @@ export function AppSideEffects() {
|
||||
portForwardingRules,
|
||||
keyboardInteractiveQueue,
|
||||
passphraseQueue,
|
||||
fidoPromptQueue,
|
||||
deleteHostConfirm,
|
||||
vaultFocusRequest,
|
||||
openNoteRequest,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { QuickScriptEditorDialog } from '../../components/scripts/QuickScriptEdi
|
||||
import { AddToWorkspaceDialog } from '../../components/workspace/AddToWorkspaceDialog';
|
||||
import { KeyboardInteractiveModal } from '../../components/KeyboardInteractiveModal';
|
||||
import { PassphraseModal } from '../../components/PassphraseModal';
|
||||
import { FidoPromptModal } from '../../components/FidoPromptModal';
|
||||
import { UnsavedChangesProvider, promptUnsavedChanges } from '../../components/editor/UnsavedChangesDialog';
|
||||
import { SnippetExecutionProvider } from '../../components/SnippetExecutionProvider';
|
||||
import { Button } from '../../components/ui/button';
|
||||
@@ -268,11 +269,11 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
followAppTerminalTheme,
|
||||
groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDefaultTerminalThemeChange, handleDeleteHost,
|
||||
handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
|
||||
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect,
|
||||
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleFidoPromptSubmit, handleFidoPromptCancel, handleProtocolSelect,
|
||||
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleUpdateHostFromTerminal,
|
||||
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isGlobalBroadcastEnabled, onToggleGlobalBroadcast, canUseGlobalBroadcast, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
||||
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
|
||||
passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
|
||||
passphraseQueue, fidoPromptQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
|
||||
resolveEmptyVaultConflict, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
||||
sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
|
||||
setNavigateToSection, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
|
||||
@@ -1037,6 +1038,13 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onSkip={handlePassphraseSkip}
|
||||
/>
|
||||
|
||||
{/* FIDO2 PIN / touch presence (OpenSSH sk-*) */}
|
||||
<FidoPromptModal
|
||||
request={fidoPromptQueue?.[0] || null}
|
||||
onSubmit={handleFidoPromptSubmit}
|
||||
onCancel={handleFidoPromptCancel}
|
||||
/>
|
||||
|
||||
{/* Empty vault vs cloud data confirmation dialog (#679).
|
||||
This dialog intentionally cannot be dismissed — the user MUST
|
||||
choose "Restore" or "Keep Empty" before the sync flow can
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { useMonitorRuntime } from '../state/useMonitorRuntime';
|
||||
|
||||
/** Keep monitoring state and subscriptions outside the app coordinator. */
|
||||
export function MonitorRuntimeHost() {
|
||||
useMonitorRuntime();
|
||||
return null;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { Host, PortForwardingRule } from '../../domain/models';
|
||||
import type { VaultSection } from '../../components/VaultView';
|
||||
import type { KeyboardInteractiveRequest } from '../../components/KeyboardInteractiveModal';
|
||||
import type { PassphraseRequest } from '../../components/PassphraseModal';
|
||||
import type { FidoPromptRequest } from '../../components/FidoPromptModal';
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
@@ -31,6 +32,7 @@ export type AppLocalUiSnapshot = {
|
||||
portForwardingRules: readonly PortForwardingRule[];
|
||||
keyboardInteractiveQueue: readonly KeyboardInteractiveRequest[];
|
||||
passphraseQueue: readonly PassphraseRequest[];
|
||||
fidoPromptQueue: readonly FidoPromptRequest[];
|
||||
deleteHostConfirm: { hostId: string; name: string } | null;
|
||||
vaultFocusRequest: unknown;
|
||||
openNoteRequest: unknown;
|
||||
@@ -49,6 +51,7 @@ export const EMPTY_APP_LOCAL_UI: AppLocalUiSnapshot = Object.freeze({
|
||||
portForwardingRules: Object.freeze([]) as readonly PortForwardingRule[],
|
||||
keyboardInteractiveQueue: Object.freeze([]) as readonly KeyboardInteractiveRequest[],
|
||||
passphraseQueue: Object.freeze([]) as readonly PassphraseRequest[],
|
||||
fidoPromptQueue: Object.freeze([]) as readonly FidoPromptRequest[],
|
||||
deleteHostConfirm: null,
|
||||
vaultFocusRequest: null,
|
||||
openNoteRequest: null,
|
||||
@@ -81,6 +84,7 @@ class AppLocalUiStore {
|
||||
&& prev.portForwardingRules === next.portForwardingRules
|
||||
&& prev.keyboardInteractiveQueue === next.keyboardInteractiveQueue
|
||||
&& prev.passphraseQueue === next.passphraseQueue
|
||||
&& prev.fidoPromptQueue === next.fidoPromptQueue
|
||||
&& prev.deleteHostConfirm === next.deleteHostConfirm
|
||||
&& prev.vaultFocusRequest === next.vaultFocusRequest
|
||||
&& prev.openNoteRequest === next.openNoteRequest
|
||||
|
||||
@@ -58,12 +58,15 @@ export function DialogsHost() {
|
||||
handlePassphraseCancel: handlers.handlePassphraseCancel,
|
||||
handlePassphraseSkip: handlers.handlePassphraseSkip,
|
||||
handlePassphraseSubmit: handlers.handlePassphraseSubmit,
|
||||
handleFidoPromptCancel: handlers.handleFidoPromptCancel,
|
||||
handleFidoPromptSubmit: handlers.handleFidoPromptSubmit,
|
||||
handleProtocolSelect: handlers.handleProtocolSelect,
|
||||
handleRequestCloseEditorTabRef: handlers.handleRequestCloseEditorTabRef,
|
||||
isCreateWorkspaceOpen: local.isCreateWorkspaceOpen,
|
||||
isQuickSwitcherOpen: local.isQuickSwitcherOpen,
|
||||
keyboardInteractiveQueue: local.keyboardInteractiveQueue,
|
||||
passphraseQueue: local.passphraseQueue,
|
||||
fidoPromptQueue: local.fidoPromptQueue,
|
||||
protocolSelectHost: local.protocolSelectHost,
|
||||
quickResults,
|
||||
quickSearch: local.quickSearch,
|
||||
@@ -83,6 +86,7 @@ export function DialogsHost() {
|
||||
local.isQuickSwitcherOpen,
|
||||
local.keyboardInteractiveQueue,
|
||||
local.passphraseQueue,
|
||||
local.fidoPromptQueue,
|
||||
local.protocolSelectHost,
|
||||
local.quickSearch,
|
||||
quickResults,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import vm from 'node:vm';
|
||||
import React, { useState } from 'react';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import ts from 'typescript';
|
||||
|
||||
function loadComponent(path: string, name: string, bindings: Record<string, unknown>) {
|
||||
const source = readFileSync(new URL(path, import.meta.url), 'utf8');
|
||||
const ast = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||
const declaration = ast.statements.find((node) => ts.isFunctionDeclaration(node) && node.name?.text === name);
|
||||
assert.ok(declaration, `${name} must exist`);
|
||||
const code = ts.transpile(declaration.getText(ast).replace(/^export /, ''), {
|
||||
target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.React,
|
||||
});
|
||||
return vm.runInNewContext(`${code}; ${name}`, { React, ...bindings }) as React.ComponentType;
|
||||
}
|
||||
|
||||
test('monitor controls update their runtime without rendering unrelated app siblings', async () => {
|
||||
const env = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previous = env.IS_REACT_ACT_ENVIRONMENT;
|
||||
env.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let updateMonitor: (() => void) | undefined;
|
||||
let runtimeRenders = 0;
|
||||
let sideEffectRenders = 0;
|
||||
let shellRenders = 0;
|
||||
const useMonitorRuntime = () => {
|
||||
const [, setVersion] = useState(0);
|
||||
updateMonitor = () => setVersion((version) => version + 1);
|
||||
runtimeRenders += 1;
|
||||
};
|
||||
const MonitorRuntimeHost = loadComponent('./MonitorRuntimeHost.tsx', 'MonitorRuntimeHost', { useMonitorRuntime });
|
||||
const App = loadComponent('../../App.tsx', 'App', {
|
||||
useMonitorRuntime,
|
||||
MonitorRuntimeHost,
|
||||
AppLocalStateProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
AppSideEffects: () => { sideEffectRenders += 1; return null; },
|
||||
MonitorHostKeyDialog: () => null,
|
||||
AppShell: React.memo(() => { shellRenders += 1; return null; }),
|
||||
});
|
||||
let renderer: ReturnType<typeof create> | undefined;
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(App)); });
|
||||
const initialRuntimeRenders = runtimeRenders;
|
||||
const initialSideEffectRenders = sideEffectRenders;
|
||||
const initialShellRenders = shellRenders;
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
await act(async () => updateMonitor!());
|
||||
}
|
||||
assert.equal(runtimeRenders - initialRuntimeRenders, 10, 'monitor updates must still run');
|
||||
assert.equal(sideEffectRenders - initialSideEffectRenders, 0, 'monitor-only state must not render AppSideEffects');
|
||||
assert.equal(shellRenders - initialShellRenders, 0, 'monitor controls must leave the shell stable');
|
||||
} finally {
|
||||
await act(async () => renderer?.unmount());
|
||||
env.IS_REACT_ACT_ENVIRONMENT = previous;
|
||||
}
|
||||
});
|
||||
@@ -542,7 +542,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.log('[App] Keyboard-interactive request received:', request);
|
||||
console.log('[App] Keyboard-interactive request received:', { sessionId: request.sessionId, scope: request.scope, promptCount: request.prompts?.length });
|
||||
// Add to queue instead of replacing - supports multiple concurrent sessions
|
||||
setKeyboardInteractiveQueue(prev => [...prev, {
|
||||
requestId: request.requestId,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { installBrowserLockFixture } from "./testSupport/browserLockFixture";
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { SyncPayload } from '../domain/sync.ts';
|
||||
import type { CloudSyncManager } from '../infrastructure/services/CloudSyncManager.ts';
|
||||
|
||||
installBrowserLockFixture();
|
||||
|
||||
const NOW = 1_700_000_000_000;
|
||||
const localStorageValues = new Map<string, string>();
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from '../domain/models';
|
||||
import { netcattyBridge } from '../infrastructure/services/netcattyBridge';
|
||||
import { startPortForward } from '../infrastructure/services/portForwardingService';
|
||||
import { requestHostBrowserPanel } from './state/hostBrowserPanelRequests';
|
||||
import { isValidHslToken } from './state/settingsStateDefaults';
|
||||
|
||||
interface OpenHostBrowserOptions {
|
||||
host: Host;
|
||||
sessionId?: string;
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
knownHosts?: KnownHost[];
|
||||
terminalSettings?: Pick<TerminalSettings, 'verifyHostKeys' | 'keepaliveInterval' | 'keepaliveCountMax'>;
|
||||
}
|
||||
|
||||
const pendingOpens = new Map<string, Promise<void>>();
|
||||
const browserThemeTokens = ['background', 'foreground', 'secondary', 'muted', 'muted-foreground', 'border', 'primary', 'primary-foreground'] as const;
|
||||
|
||||
export function currentBrowserThemeTokens(element: Element = document.documentElement): Record<string, string> {
|
||||
const style = getComputedStyle(element);
|
||||
return Object.fromEntries(browserThemeTokens.flatMap((token) => {
|
||||
const value = style.getPropertyValue(`--${token}`).trim();
|
||||
return isValidHslToken(value) ? [[token, value]] : [];
|
||||
}));
|
||||
}
|
||||
|
||||
export async function openHostBrowser(options: OpenHostBrowserOptions): Promise<void> {
|
||||
requestHostBrowserPanel({ host: options.host, sessionId: options.sessionId });
|
||||
}
|
||||
|
||||
export async function openHostBrowserPanelUrl(browserId: string, url: string): Promise<void> {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.openHostBrowserUrl) throw new Error('The host browser is unavailable in this build');
|
||||
await bridge.openHostBrowserUrl({ browserId, url });
|
||||
}
|
||||
|
||||
export function mountHostBrowser(options: OpenHostBrowserOptions & { browserId: string }): Promise<void> {
|
||||
const { host, hosts, keys, identities, knownHosts, terminalSettings } = options;
|
||||
const existing = pendingOpens.get(options.browserId);
|
||||
if (existing) return existing;
|
||||
const opening = (async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.setHostBrowserPanelLayout || !bridge.openHostBrowser || !bridge.startPortForward) {
|
||||
throw new Error('The host browser is unavailable in this build');
|
||||
}
|
||||
const local = host.protocol === 'local';
|
||||
const result = local ? { success: true, connectionOptions: undefined } : await startPortForward({
|
||||
id: `host-browser-${crypto.randomUUID()}`,
|
||||
label: `Browser: ${host.label || host.hostname}`,
|
||||
type: 'dynamic',
|
||||
hostId: host.id,
|
||||
localPort: 0,
|
||||
bindAddress: '127.0.0.1',
|
||||
status: 'inactive',
|
||||
createdAt: Date.now(),
|
||||
}, host, hosts, keys, identities, () => {}, false, terminalSettings,
|
||||
knownHosts, { dedicatedTransport: true, prepareOnly: true });
|
||||
if (!result.success || (!local && !result.connectionOptions)) {
|
||||
throw new Error(result.error || 'Could not prepare the browser connection');
|
||||
}
|
||||
await bridge.openHostBrowser({
|
||||
browserId: options.browserId,
|
||||
embedded: true,
|
||||
local,
|
||||
hostId: host.id,
|
||||
label: host.label || host.hostname,
|
||||
theme: document.documentElement.classList.contains('dark') ? 'dark' : 'light',
|
||||
themeTokens: currentBrowserThemeTokens(),
|
||||
connectionOptions: result.connectionOptions,
|
||||
});
|
||||
})();
|
||||
pendingOpens.set(options.browserId, opening);
|
||||
void opening.finally(() => {
|
||||
if (pendingOpens.get(options.browserId) === opening) pendingOpens.delete(options.browserId);
|
||||
}).catch(() => {});
|
||||
return opening;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { BrowserBookmark } from '../domain/browserBookmark';
|
||||
import { netcattyBridge } from '../infrastructure/services/netcattyBridge';
|
||||
|
||||
let cachedBookmarks: BrowserBookmark[] | undefined;
|
||||
|
||||
export function getCachedHostBrowserBookmarks(): BrowserBookmark[] | undefined {
|
||||
return cachedBookmarks?.map((item) => ({ ...item }));
|
||||
}
|
||||
|
||||
export async function collectHostBrowserBookmarks(): Promise<BrowserBookmark[] | undefined> {
|
||||
const reader = netcattyBridge.get()?.getHostBrowserBookmarks;
|
||||
if (!reader) return cachedBookmarks;
|
||||
const items = await reader();
|
||||
cachedBookmarks = items;
|
||||
return getCachedHostBrowserBookmarks();
|
||||
}
|
||||
|
||||
export async function applyHostBrowserBookmarks(items: BrowserBookmark[]): Promise<void> {
|
||||
const writer = netcattyBridge.get()?.applyHostBrowserBookmarks;
|
||||
if (!writer) return;
|
||||
await writer(items);
|
||||
cachedBookmarks = items;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { enMonitorMessages } from './en/monitor';
|
||||
import type { Messages } from './types';
|
||||
import { enCoreMessages } from './en/core';
|
||||
import { enVaultMessages } from './en/vault';
|
||||
@@ -9,6 +10,7 @@ import { enScriptsMessages } from './en/scripts';
|
||||
export type { Messages } from './types';
|
||||
|
||||
const en: Messages = {
|
||||
...enMonitorMessages,
|
||||
...enCoreMessages,
|
||||
...enVaultMessages,
|
||||
...enTerminalMessages,
|
||||
|
||||
@@ -767,6 +767,11 @@ Highlight the focused split pane:
|
||||
'settings.terminal.behavior.smoothScrolling': 'Smooth scrolling',
|
||||
'settings.terminal.behavior.smoothScrolling.desc':
|
||||
'Animate terminal viewport scrolling instead of jumping instantly',
|
||||
'settings.terminal.sidePanel.pane.browser': 'Browser',
|
||||
'settings.terminal.behavior.linkBrowser': 'Open terminal links in',
|
||||
'settings.terminal.behavior.linkBrowser.desc': 'SSH links use the connected host; local terminal links use this computer.',
|
||||
'settings.terminal.behavior.linkBrowser.netcatty': 'Netcatty browser (default)',
|
||||
'settings.terminal.behavior.linkBrowser.system': 'System default browser',
|
||||
'settings.terminal.behavior.linkModifier': 'Link modifier key',
|
||||
'settings.terminal.behavior.linkModifier.desc': 'Hold this key to click on links in terminal',
|
||||
'settings.terminal.behavior.linkModifier.none': 'None (click directly)',
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
export const enMonitorMessages = {
|
||||
"monitor.actions": "Panel actions",
|
||||
"monitor.unavailable": "Monitoring is unavailable. Try starting again when the connection is restored.",
|
||||
"monitor.username": "Username",
|
||||
"monitor.password": "Password (this run only)",
|
||||
"monitor.status.limit":
|
||||
"Monitoring is limited to 256 unique hosts at a time.",
|
||||
"sync.entityType.monitorPanels": "monitor panels",
|
||||
"monitor.title": "Monitoring",
|
||||
"monitor.create": "New panel",
|
||||
"monitor.createFirst": "Create your first panel",
|
||||
"monitor.empty": "Your hosts, at a glance",
|
||||
"monitor.emptyHint":
|
||||
"Choose saved hosts from any group. Start monitoring when you are ready.",
|
||||
"monitor.choosePanel": "Choose a panel",
|
||||
"monitor.edit": "Edit panel",
|
||||
"monitor.copy": "Duplicate panel",
|
||||
"monitor.copyName": "{name} copy",
|
||||
"monitor.delete": "Delete panel",
|
||||
"monitor.deleteHint":
|
||||
"Delete “{name}”? Its monitoring will stop. Saved hosts and other panels are kept.",
|
||||
"monitor.start": "Start monitoring",
|
||||
"monitor.stop": "Stop",
|
||||
"monitor.stopAll": "Stop all",
|
||||
"monitor.list": "List",
|
||||
"monitor.charts": "Charts",
|
||||
"monitor.search": "Search name, address or group",
|
||||
"monitor.interval": "Update interval",
|
||||
"monitor.range": "Trend range",
|
||||
"monitor.seconds": "Every {count}s",
|
||||
"monitor.minutes": "Last {count} min",
|
||||
"monitor.sort": "Sort hosts",
|
||||
"monitor.sort.manual": "Saved order",
|
||||
"monitor.sort.name": "Name",
|
||||
"monitor.selectedCount": "Selected hosts: {count}",
|
||||
"monitor.noMatches": "No matching hosts",
|
||||
"monitor.host": "Host",
|
||||
"monitor.hosts": "Hosts",
|
||||
"monitor.status": "Status",
|
||||
"monitor.cpu": "CPU",
|
||||
"monitor.cores": "Cores: {count}",
|
||||
"monitor.memory": "Memory",
|
||||
"monitor.diskCapacity": "{used} used / {total} total",
|
||||
"monitor.diskCapacityUnknown": "Capacity unavailable",
|
||||
"monitor.rootDisk": "Root partition",
|
||||
"monitor.network": "Network",
|
||||
"monitor.download": "Download",
|
||||
"monitor.upload": "Upload",
|
||||
"monitor.updated": "Updated",
|
||||
"monitor.updatedAt": "Updated {time}",
|
||||
"monitor.noSamples": "Waiting for the first reading",
|
||||
"monitor.cpuTrend": "CPU trend",
|
||||
"monitor.memoryTrend": "Memory trend",
|
||||
"monitor.downloadTrend": "Download trend",
|
||||
"monitor.uploadTrend": "Upload trend",
|
||||
"monitor.memoryOnlyHint":
|
||||
"Readings stay in memory for up to 30 minutes and disappear when the app closes. Gaps mean no reading. Shared connections use the fastest active update interval.",
|
||||
"monitor.name": "Panel name",
|
||||
"monitor.description": "Description (optional)",
|
||||
"monitor.chooseHosts": "Choose hosts",
|
||||
"monitor.selectionHint":
|
||||
"Choose across groups. Unsupported connections are shown with a clear status when started.",
|
||||
"monitor.defaultView": "Default view",
|
||||
"monitor.saveHint":
|
||||
"Saving keeps configuration only and does not connect. Copies start stopped too.",
|
||||
"monitor.nameRequired": "Enter a panel name.",
|
||||
"monitor.hostRequired": "Select at least one host.",
|
||||
"monitor.duplicateName": "A panel already uses this name.",
|
||||
"monitor.saveFailed": "Could not save the panel. Please try again.",
|
||||
"monitor.deleted":
|
||||
"This panel was deleted in another window. Close the editor and create a new panel.",
|
||||
"monitor.moveUp": "Move host up",
|
||||
"monitor.removeHost": "Remove from panel",
|
||||
"monitor.missingHost": "Deleted host",
|
||||
"monitor.observed": "Observed for {count} min this run",
|
||||
"monitor.disks": "Partitions",
|
||||
"monitor.system": "System information",
|
||||
"monitor.loginHint":
|
||||
"Check the host credentials or connection settings, then retry. Identity and sign-in prompts are handled one host at a time.",
|
||||
"monitor.retry": "Retry connection",
|
||||
"monitor.unsupportedHint":
|
||||
"Monitoring needs Linux or macOS over SSH with support for additional command channels.",
|
||||
"monitor.openTerminal": "Open terminal",
|
||||
"monitor.confirmHost": "Confirm host identity",
|
||||
"monitor.status.stopped": "Not started",
|
||||
"monitor.status.queued": "Queued",
|
||||
"monitor.status.connecting": "Connecting",
|
||||
"monitor.status.live": "Up to date",
|
||||
"monitor.status.login": "Sign-in needed",
|
||||
"monitor.status.error": "Collection failed",
|
||||
"monitor.status.unsupported": "Unavailable",
|
||||
"monitor.status.disconnected": "Disconnected",
|
||||
"monitor.status.stale": "Out of date",
|
||||
"monitor.status.missing": "Host deleted",
|
||||
};
|
||||
@@ -36,8 +36,6 @@ export const enScriptsMessages = {
|
||||
'scripts.sidePanel.library': 'Library',
|
||||
'scripts.sidePanel.running': 'Running',
|
||||
'scripts.sidePanel.newScript': 'New script',
|
||||
'scripts.sidePanel.viewList': 'List view',
|
||||
'scripts.sidePanel.viewStacked': 'Stacked view',
|
||||
'scripts.running.empty': 'No scripts are running in this session.',
|
||||
'scripts.running.unnamed': 'Untitled script',
|
||||
'scripts.running.status.running': 'Running',
|
||||
|
||||
@@ -20,6 +20,9 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.networkDevice.tip.enabled': 'Network Device Mode enabled for {host}',
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': 'Open SFTP',
|
||||
'terminal.browser.title': 'Browser',
|
||||
'terminal.browser.sshOnly': 'Focus an SSH terminal to open its browser.',
|
||||
'terminal.browser.open': 'Open Browser',
|
||||
'terminal.toolbar.availableAfterConnect': 'Available after connect',
|
||||
'terminal.toolbar.sendYmodem': 'Send with YMODEM',
|
||||
'terminal.toolbar.receiveYmodem': 'Receive with YMODEM',
|
||||
@@ -436,6 +439,12 @@ export const enTerminalMessages: Messages = {
|
||||
'cloudSync.history.download': 'Download',
|
||||
'cloudSync.history.resolved': 'Resolved',
|
||||
'cloudSync.history.error': 'Error',
|
||||
'cloudSync.localBackups.clearBusy': 'Finish restoring or recovering the vault before clearing backups.',
|
||||
'cloudSync.localBackups.clear': 'Clear all backups',
|
||||
'cloudSync.localBackups.clearConfirmTitle': 'Clear local backup history?',
|
||||
'cloudSync.localBackups.clearConfirmDesc': 'Permanently delete all local vault backups on this device. Your current hosts, saved credentials and cloud copies will remain. This does not prepare the device for handover. New backups will still be created during normal use.',
|
||||
'cloudSync.localBackups.clearSuccess': 'Local backup history cleared.',
|
||||
'cloudSync.localBackups.clearFailed': 'Could not clear all local backups. Please retry.',
|
||||
'cloudSync.localBackups.title': 'Local Backup History',
|
||||
'cloudSync.localBackups.desc': 'Netcatty keeps local restore points before app version changes and before vault restores.',
|
||||
'cloudSync.localBackups.retentionTitle': 'Backup Retention',
|
||||
@@ -531,6 +540,7 @@ export const enTerminalMessages: Messages = {
|
||||
'cloudSync.conflict.entity.customGroups': 'Groups',
|
||||
'cloudSync.conflict.entity.snippetPackages': 'Snippet packages',
|
||||
'cloudSync.conflict.entity.portForwardingRules': 'Port forwarding',
|
||||
'cloudSync.conflict.entity.browserBookmarks': 'Browser bookmarks',
|
||||
'cloudSync.conflict.entity.groupConfigs': 'Group settings',
|
||||
'cloudSync.conflict.entity.settings': 'Settings',
|
||||
'cloudSync.conflict.keepLocal': 'Overwrite cloud (keep local)',
|
||||
@@ -627,6 +637,43 @@ export const enTerminalMessages: Messages = {
|
||||
'keychain.field.publicKey': 'Public key',
|
||||
'keychain.field.certificatePlaceholder': 'Certificate content (optional)',
|
||||
'keychain.generate.keyType': 'Key type',
|
||||
'keychain.generate.standardKeys': 'Standard keys',
|
||||
'keychain.generate.securityKeys': 'Security keys · FIDO2',
|
||||
'keychain.generate.residentDesc': 'Allow credentials to be recovered from this security key.',
|
||||
'keychain.generate.verifyRequiredDesc': 'Ask for the device PIN before signing.',
|
||||
'keychain.generate.generating': 'Generating…',
|
||||
'fido.prompt.pinTouchDesc': 'Enter your PIN, then touch the key to confirm.',
|
||||
'fido.prompt.details': 'Request details',
|
||||
'fido.prompt.showPin': 'Show PIN',
|
||||
'fido.prompt.hidePin': 'Hide PIN',
|
||||
'fido.prompt.verifying': 'Verifying…',
|
||||
'keychain.generate.fidoHint': 'Connect a FIDO2 security key. You may be asked for its PIN or a touch.',
|
||||
'fido.prompt.pinTitle': 'Security key PIN',
|
||||
'fido.prompt.touchTitle': 'Touch your security key',
|
||||
'fido.prompt.pinDesc': 'Enter your security key PIN to continue.',
|
||||
'fido.prompt.touchDesc': 'Touch your security key to confirm it is you.',
|
||||
'fido.prompt.pinLabel': 'PIN',
|
||||
'fido.prompt.touchWaiting': 'Waiting for your key…',
|
||||
'fido.prompt.touchAutomatic': 'Continues automatically after your touch',
|
||||
"fido.recovery.device-unavailable.title": "Connect the matching security key",
|
||||
"fido.recovery.device-unavailable.desc": "Connect the device containing this key's credential, then retry.",
|
||||
"fido.recovery.timeout.title": "Security key confirmation timed out",
|
||||
"fido.recovery.timeout.desc": "This device operation has stopped. Check that your key is connected, then retry and follow its touch or verification prompt.",
|
||||
"fido.recovery.pin-failed.title": "Security key PIN was not accepted",
|
||||
"fido.recovery.pin-failed.desc": "Check the FIDO2 PIN for this device before retrying. Repeated incorrect entries can lock the device.",
|
||||
"fido.recovery.failed.title": "Security key verification did not complete",
|
||||
"fido.recovery.failed.desc": "Check that the matching device is connected and supports this key. Retry, or cancel to check the device and OpenSSH configuration.",
|
||||
"fido.recovery.retry": "Retry verification",
|
||||
"fido.recovery.usePassword": "Use configured password",
|
||||
"fido.recovery.passwordFallback": "Trying the password configured for this host",
|
||||
"fido.prompt.cancelled": "Security key authentication cancelled.",
|
||||
'fido.prompt.touchDone': 'I touched it',
|
||||
'fido.prompt.timeout': 'FIDO prompt timed out. Try connecting again.',
|
||||
'fido.error.opensshMissing': 'OpenSSH with FIDO/libfido2 is required. On macOS: brew install openssh libfido2.',
|
||||
'fido.error.agentUnavailable': 'Could not start a FIDO-capable SSH agent.',
|
||||
'keychain.generate.resident': 'Store credentials on the key',
|
||||
'keychain.generate.verifyRequired': 'Verify PIN each time',
|
||||
'keychain.action.loadResident': 'Load resident keys from security key',
|
||||
'keychain.generate.keySize': 'Key size',
|
||||
'keychain.generate.labelPlaceholder': 'Key label',
|
||||
'keychain.generate.passphrasePlaceholder': 'Passphrase (optional)',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { esMonitorMessages } from './es/monitor';
|
||||
import type { Messages } from './types';
|
||||
import { esCoreMessages } from './es/core';
|
||||
import { esVaultMessages } from './es/vault';
|
||||
@@ -9,6 +10,7 @@ import { esScriptsMessages } from './es/scripts';
|
||||
export type { Messages } from './types';
|
||||
|
||||
const es: Messages = {
|
||||
...esMonitorMessages,
|
||||
...esCoreMessages,
|
||||
...esVaultMessages,
|
||||
...esTerminalMessages,
|
||||
|
||||
@@ -682,6 +682,11 @@ Resalta el panel dividido enfocado:
|
||||
'settings.terminal.behavior.smoothScrolling': 'Desplazamiento suave',
|
||||
'settings.terminal.behavior.smoothScrolling.desc':
|
||||
'Anima el desplazamiento de la vista de la terminal en lugar de saltar instantáneamente',
|
||||
'settings.terminal.sidePanel.pane.browser': 'Navegador',
|
||||
'settings.terminal.behavior.linkBrowser': 'Abrir enlaces de terminal en',
|
||||
'settings.terminal.behavior.linkBrowser.desc': 'Los enlaces SSH usan el host conectado; los enlaces locales usan este equipo.',
|
||||
'settings.terminal.behavior.linkBrowser.netcatty': 'Navegador Netcatty (predeterminado)',
|
||||
'settings.terminal.behavior.linkBrowser.system': 'Navegador del sistema',
|
||||
'settings.terminal.behavior.linkModifier': 'Tecla modificadora de enlaces',
|
||||
'settings.terminal.behavior.linkModifier.desc': 'Mantén presionada esta tecla para hacer clic en enlaces de la terminal',
|
||||
'settings.terminal.behavior.linkModifier.none': 'Ninguna (clic directo)',
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
export const esMonitorMessages = {
|
||||
"monitor.actions": "Acciones del panel",
|
||||
"monitor.unavailable": "La monitorización no está disponible. Iníciala de nuevo cuando se restablezca la conexión.",
|
||||
"monitor.username": "Usuario",
|
||||
"monitor.password": "Contraseña (solo esta ejecución)",
|
||||
"monitor.status.limit":
|
||||
"Se pueden monitorizar hasta 256 equipos únicos a la vez.",
|
||||
"sync.entityType.monitorPanels": "paneles de monitorización",
|
||||
"monitor.title": "Monitorización",
|
||||
"monitor.create": "Nuevo panel",
|
||||
"monitor.createFirst": "Crear el primer panel",
|
||||
"monitor.empty": "Tus equipos de un vistazo",
|
||||
"monitor.emptyHint":
|
||||
"Elige equipos guardados de cualquier grupo e inicia cuando quieras.",
|
||||
"monitor.choosePanel": "Elegir panel",
|
||||
"monitor.edit": "Editar panel",
|
||||
"monitor.copy": "Duplicar panel",
|
||||
"monitor.copyName": "Copia de {name}",
|
||||
"monitor.delete": "Eliminar panel",
|
||||
"monitor.deleteHint":
|
||||
"¿Eliminar «{name}»? Se detendrá su monitorización. Se conservarán los equipos y otros paneles.",
|
||||
"monitor.start": "Iniciar monitorización",
|
||||
"monitor.stop": "Detener",
|
||||
"monitor.stopAll": "Detener todo",
|
||||
"monitor.list": "Lista",
|
||||
"monitor.charts": "Gráficos",
|
||||
"monitor.search": "Buscar nombre, dirección o grupo",
|
||||
"monitor.interval": "Intervalo de actualización",
|
||||
"monitor.range": "Rango de tendencias",
|
||||
"monitor.seconds": "Cada {count} s",
|
||||
"monitor.minutes": "Últimos {count} min",
|
||||
"monitor.sort": "Ordenar equipos",
|
||||
"monitor.sort.manual": "Orden guardado",
|
||||
"monitor.sort.name": "Nombre",
|
||||
"monitor.selectedCount": "Equipos seleccionados: {count}",
|
||||
"monitor.noMatches": "No hay equipos coincidentes",
|
||||
"monitor.host": "Equipo",
|
||||
"monitor.hosts": "Equipos",
|
||||
"monitor.status": "Estado",
|
||||
"monitor.cpu": "CPU",
|
||||
"monitor.cores": "Núcleos: {count}",
|
||||
"monitor.memory": "Memoria",
|
||||
"monitor.diskCapacity": "{used} usados / {total} en total",
|
||||
"monitor.diskCapacityUnknown": "Capacidad no disponible",
|
||||
"monitor.rootDisk": "Partición raíz",
|
||||
"monitor.network": "Red",
|
||||
"monitor.download": "Descarga",
|
||||
"monitor.upload": "Subida",
|
||||
"monitor.updated": "Actualizado",
|
||||
"monitor.updatedAt": "Actualizado: {time}",
|
||||
"monitor.noSamples": "Esperando la primera lectura",
|
||||
"monitor.cpuTrend": "Tendencia de CPU",
|
||||
"monitor.memoryTrend": "Tendencia de memoria",
|
||||
"monitor.downloadTrend": "Tendencia de descarga",
|
||||
"monitor.uploadTrend": "Tendencia de subida",
|
||||
"monitor.memoryOnlyHint":
|
||||
"Las lecturas se conservan en memoria hasta 30 minutos y desaparecen al cerrar. Los huecos indican datos ausentes. Las conexiones compartidas usan el intervalo activo más rápido.",
|
||||
"monitor.name": "Nombre del panel",
|
||||
"monitor.description": "Descripción (opcional)",
|
||||
"monitor.chooseHosts": "Elegir equipos",
|
||||
"monitor.selectionHint":
|
||||
"Selecciona entre grupos. Las conexiones no compatibles se indicarán al iniciar.",
|
||||
"monitor.defaultView": "Vista predeterminada",
|
||||
"monitor.saveHint":
|
||||
"Guardar conserva la configuración sin conectar. Las copias también empiezan detenidas.",
|
||||
"monitor.nameRequired": "Escribe un nombre para el panel.",
|
||||
"monitor.hostRequired": "Selecciona al menos un equipo.",
|
||||
"monitor.duplicateName": "Ya existe un panel con ese nombre.",
|
||||
"monitor.saveFailed": "No se pudo guardar el panel. Inténtalo de nuevo.",
|
||||
"monitor.deleted":
|
||||
"El panel se eliminó en otra ventana. Cierra el editor y crea uno nuevo.",
|
||||
"monitor.moveUp": "Subir equipo",
|
||||
"monitor.removeHost": "Quitar del panel",
|
||||
"monitor.missingHost": "Equipo eliminado",
|
||||
"monitor.observed": "Observado durante {count} min",
|
||||
"monitor.disks": "Particiones",
|
||||
"monitor.system": "Información del sistema",
|
||||
"monitor.loginHint":
|
||||
"Comprueba las credenciales o la conexión y reintenta. Las solicitudes se procesan equipo por equipo.",
|
||||
"monitor.retry": "Reintentar conexión",
|
||||
"monitor.unsupportedHint":
|
||||
"Se necesita Linux o macOS por SSH con canales de comandos adicionales.",
|
||||
"monitor.openTerminal": "Abrir terminal",
|
||||
"monitor.confirmHost": "Confirmar identidad",
|
||||
"monitor.status.stopped": "Sin iniciar",
|
||||
"monitor.status.queued": "En cola",
|
||||
"monitor.status.connecting": "Conectando",
|
||||
"monitor.status.live": "Actualizado",
|
||||
"monitor.status.login": "Requiere acceso",
|
||||
"monitor.status.error": "Error de lectura",
|
||||
"monitor.status.unsupported": "No disponible",
|
||||
"monitor.status.disconnected": "Desconectado",
|
||||
"monitor.status.stale": "Sin actualizar",
|
||||
"monitor.status.missing": "Equipo eliminado",
|
||||
};
|
||||
@@ -36,8 +36,6 @@ export const esScriptsMessages = {
|
||||
'scripts.sidePanel.library': 'Biblioteca',
|
||||
'scripts.sidePanel.running': 'En ejecución',
|
||||
'scripts.sidePanel.newScript': 'Nuevo script',
|
||||
'scripts.sidePanel.viewList': 'Vista de lista',
|
||||
'scripts.sidePanel.viewStacked': 'Vista apilada',
|
||||
'scripts.running.empty': 'No hay scripts en ejecución en esta sesión.',
|
||||
'scripts.running.unnamed': 'Script sin título',
|
||||
'scripts.running.status.running': 'En ejecución',
|
||||
|
||||
@@ -20,6 +20,9 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.networkDevice.tip.enabled': 'Modo de Dispositivo de Red activado para {host}',
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': 'Abrir SFTP',
|
||||
'terminal.browser.title': 'Navegador',
|
||||
'terminal.browser.sshOnly': 'Selecciona una terminal SSH para abrir su navegador.',
|
||||
'terminal.browser.open': 'Abrir navegador',
|
||||
'terminal.toolbar.availableAfterConnect': 'Disponible después de conectar',
|
||||
'terminal.toolbar.sendYmodem': 'Enviar con YMODEM',
|
||||
'terminal.toolbar.receiveYmodem': 'Recibir con YMODEM',
|
||||
@@ -435,6 +438,12 @@ export const esTerminalMessages: Messages = {
|
||||
'cloudSync.history.download': 'Descarga',
|
||||
'cloudSync.history.resolved': 'Resuelto',
|
||||
'cloudSync.history.error': 'Error',
|
||||
'cloudSync.localBackups.clearBusy': 'Finaliza la restauración o recuperación de la bóveda antes de eliminar las copias.',
|
||||
'cloudSync.localBackups.clear': 'Eliminar todas las copias',
|
||||
'cloudSync.localBackups.clearConfirmTitle': '¿Eliminar el historial de copias locales?',
|
||||
'cloudSync.localBackups.clearConfirmDesc': 'Elimina permanentemente todas las copias locales de la bóveda en este dispositivo. Los hosts actuales, las credenciales guardadas y las copias en la nube se conservan. Esto no prepara el dispositivo para entregarlo a otra persona. Se seguirán creando nuevas copias durante el uso normal.',
|
||||
'cloudSync.localBackups.clearSuccess': 'Historial de copias locales eliminado.',
|
||||
'cloudSync.localBackups.clearFailed': 'No se pudieron eliminar todas las copias locales. Inténtalo de nuevo.',
|
||||
'cloudSync.localBackups.title': 'Historial de copias de seguridad locales',
|
||||
'cloudSync.localBackups.desc': 'Netcatty conserva puntos de restauración locales antes de los cambios de versión de la app y antes de las restauraciones de la bóveda.',
|
||||
'cloudSync.localBackups.retentionTitle': 'Retención de copias de seguridad',
|
||||
@@ -530,6 +539,7 @@ export const esTerminalMessages: Messages = {
|
||||
'cloudSync.conflict.entity.customGroups': 'Grupos',
|
||||
'cloudSync.conflict.entity.snippetPackages': 'Paquetes de snippets',
|
||||
'cloudSync.conflict.entity.portForwardingRules': 'Reenvío de puertos',
|
||||
'cloudSync.conflict.entity.browserBookmarks': 'Marcadores del navegador',
|
||||
'cloudSync.conflict.entity.groupConfigs': 'Configuración de grupos',
|
||||
'cloudSync.conflict.entity.settings': 'Configuración',
|
||||
'cloudSync.conflict.keepLocal': 'Sobrescribir la nube (conservar local)',
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { enMonitorMessages } from "./en/monitor";
|
||||
import { esMonitorMessages } from "./es/monitor";
|
||||
import { ruMonitorMessages } from "./ru/monitor";
|
||||
import { zhCNMonitorMessages } from "./zh-CN/monitor";
|
||||
import { zhTWMonitorMessages } from "./zh-TW/monitor";
|
||||
|
||||
test("monitoring UI literal message keys exist, including editor-only controls", () => {
|
||||
for (const name of ["MonitorPanels", "MonitorNavButton", "MonitorDiskUsage"]) {
|
||||
const source = readFileSync(new URL(`../../../components/vault/${name}.tsx`, import.meta.url), "utf8");
|
||||
const keys = [...source.matchAll(/\bt\("(monitor\.[^"]+)"/g)].map((match) => match[1]);
|
||||
for (const key of keys)
|
||||
assert.ok(Object.hasOwn(enMonitorMessages, key), `${name}: ${key}`);
|
||||
}
|
||||
});
|
||||
for (const [locale, messages] of Object.entries({
|
||||
es: esMonitorMessages,
|
||||
ru: ruMonitorMessages,
|
||||
"zh-CN": zhCNMonitorMessages,
|
||||
"zh-TW": zhTWMonitorMessages,
|
||||
})) {
|
||||
test(`monitoring has translated actions, states and matching placeholders in ${locale}`, () => {
|
||||
assert.deepEqual(
|
||||
Object.keys(messages).sort(),
|
||||
Object.keys(enMonitorMessages).sort(),
|
||||
);
|
||||
for (const key of Object.keys(enMonitorMessages)) {
|
||||
const english = enMonitorMessages[key as keyof typeof enMonitorMessages];
|
||||
const translated = messages[key as keyof typeof messages];
|
||||
assert.ok(translated.trim(), key);
|
||||
assert.deepEqual(
|
||||
(translated.match(/\{\w+\}/g) ?? []).sort(),
|
||||
(english.match(/\{\w+\}/g) ?? []).sort(),
|
||||
key,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ruMonitorMessages } from './ru/monitor';
|
||||
import type { Messages } from './types';
|
||||
import { ruCoreMessages } from './ru/core';
|
||||
import { ruVaultMessages } from './ru/vault';
|
||||
@@ -9,6 +10,7 @@ import { ruScriptsMessages } from './ru/scripts';
|
||||
export type { Messages } from './types';
|
||||
|
||||
const ru: Messages = {
|
||||
...ruMonitorMessages,
|
||||
...ruCoreMessages,
|
||||
...ruVaultMessages,
|
||||
...ruTerminalMessages,
|
||||
|
||||
@@ -718,6 +718,11 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.behavior.smoothScrolling': 'Плавная прокрутка',
|
||||
'settings.terminal.behavior.smoothScrolling.desc':
|
||||
'Анимировать прокрутку области терминала вместо мгновенного перехода',
|
||||
'settings.terminal.sidePanel.pane.browser': 'Браузер',
|
||||
'settings.terminal.behavior.linkBrowser': 'Открывать ссылки терминала в',
|
||||
'settings.terminal.behavior.linkBrowser.desc': 'SSH-ссылки используют подключённый хост; локальные ссылки — этот компьютер.',
|
||||
'settings.terminal.behavior.linkBrowser.netcatty': 'Браузер Netcatty (по умолчанию)',
|
||||
'settings.terminal.behavior.linkBrowser.system': 'Системный браузер',
|
||||
'settings.terminal.behavior.linkModifier': 'Клавиша-модификатор для ссылок',
|
||||
'settings.terminal.behavior.linkModifier.desc': 'Удерживайте эту клавишу, чтобы нажимать на ссылки в терминале',
|
||||
'settings.terminal.behavior.linkModifier.none': 'Нет (нажимать напрямую)',
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
export const ruMonitorMessages = {
|
||||
"monitor.actions": "Действия панели",
|
||||
"monitor.unavailable": "Мониторинг недоступен. Запустите его снова после восстановления связи.",
|
||||
"monitor.username": "Пользователь",
|
||||
"monitor.password": "Пароль (только для этого запуска)",
|
||||
"monitor.status.limit": "Одновременно можно наблюдать не более 256 серверов.",
|
||||
"sync.entityType.monitorPanels": "панели мониторинга",
|
||||
"monitor.title": "Мониторинг",
|
||||
"monitor.create": "Новая панель",
|
||||
"monitor.createFirst": "Создать первую панель",
|
||||
"monitor.empty": "Все серверы перед глазами",
|
||||
"monitor.emptyHint":
|
||||
"Выберите сохранённые серверы из любых групп и запустите мониторинг.",
|
||||
"monitor.choosePanel": "Выбрать панель",
|
||||
"monitor.edit": "Изменить панель",
|
||||
"monitor.copy": "Дублировать панель",
|
||||
"monitor.copyName": "Копия {name}",
|
||||
"monitor.delete": "Удалить панель",
|
||||
"monitor.deleteHint":
|
||||
"Удалить «{name}»? Мониторинг этой панели остановится. Серверы и другие панели сохранятся.",
|
||||
"monitor.start": "Начать мониторинг",
|
||||
"monitor.stop": "Остановить",
|
||||
"monitor.stopAll": "Остановить всё",
|
||||
"monitor.list": "Список",
|
||||
"monitor.charts": "Графики",
|
||||
"monitor.search": "Имя, адрес или группа",
|
||||
"monitor.interval": "Интервал обновления",
|
||||
"monitor.range": "Период графика",
|
||||
"monitor.seconds": "Каждые {count} с",
|
||||
"monitor.minutes": "Последние {count} мин",
|
||||
"monitor.sort": "Сортировка серверов",
|
||||
"monitor.sort.manual": "Сохранённый порядок",
|
||||
"monitor.sort.name": "Имя",
|
||||
"monitor.selectedCount": "Выбрано серверов: {count}",
|
||||
"monitor.noMatches": "Нет подходящих серверов",
|
||||
"monitor.host": "Сервер",
|
||||
"monitor.hosts": "Серверы",
|
||||
"monitor.status": "Состояние",
|
||||
"monitor.cpu": "ЦП",
|
||||
"monitor.cores": "Ядер: {count}",
|
||||
"monitor.memory": "Память",
|
||||
"monitor.diskCapacity": "Использовано {used} / всего {total}",
|
||||
"monitor.diskCapacityUnknown": "Данные об объёме недоступны",
|
||||
"monitor.rootDisk": "Корневой раздел",
|
||||
"monitor.network": "Сеть",
|
||||
"monitor.download": "Загрузка",
|
||||
"monitor.upload": "Отправка",
|
||||
"monitor.updated": "Обновлено",
|
||||
"monitor.updatedAt": "Обновлено: {time}",
|
||||
"monitor.noSamples": "Ожидание первого измерения",
|
||||
"monitor.cpuTrend": "График ЦП",
|
||||
"monitor.memoryTrend": "График памяти",
|
||||
"monitor.downloadTrend": "График приёма",
|
||||
"monitor.uploadTrend": "График передачи",
|
||||
"monitor.memoryOnlyHint":
|
||||
"Измерения хранятся в памяти до 30 минут и удаляются при выходе. Пробелы означают отсутствие данных. Общие соединения используют наименьший активный интервал.",
|
||||
"monitor.name": "Название панели",
|
||||
"monitor.description": "Описание (необязательно)",
|
||||
"monitor.chooseHosts": "Выбрать серверы",
|
||||
"monitor.selectionHint":
|
||||
"Можно выбирать из разных групп. Неподдерживаемые подключения будут отмечены при запуске.",
|
||||
"monitor.defaultView": "Вид по умолчанию",
|
||||
"monitor.saveHint":
|
||||
"Сохранение не подключает серверы. Копии также создаются остановленными.",
|
||||
"monitor.nameRequired": "Введите название панели.",
|
||||
"monitor.hostRequired": "Выберите хотя бы один сервер.",
|
||||
"monitor.duplicateName": "Панель с таким названием уже существует.",
|
||||
"monitor.saveFailed": "Не удалось сохранить панель. Повторите попытку.",
|
||||
"monitor.deleted":
|
||||
"Панель удалена в другом окне. Закройте редактор и создайте новую.",
|
||||
"monitor.moveUp": "Переместить выше",
|
||||
"monitor.removeHost": "Убрать из панели",
|
||||
"monitor.missingHost": "Удалённый сервер",
|
||||
"monitor.observed": "Наблюдение: {count} мин",
|
||||
"monitor.disks": "Разделы дисков",
|
||||
"monitor.system": "Информация о системе",
|
||||
"monitor.loginHint":
|
||||
"Проверьте данные входа и настройки подключения. Подтверждения обрабатываются по одному серверу.",
|
||||
"monitor.retry": "Повторить подключение",
|
||||
"monitor.unsupportedHint":
|
||||
"Для мониторинга нужен Linux или macOS по SSH с дополнительными каналами команд.",
|
||||
"monitor.openTerminal": "Открыть терминал",
|
||||
"monitor.confirmHost": "Подтвердить сервер",
|
||||
"monitor.status.stopped": "Не запущено",
|
||||
"monitor.status.queued": "В очереди",
|
||||
"monitor.status.connecting": "Подключение",
|
||||
"monitor.status.live": "Актуально",
|
||||
"monitor.status.login": "Нужен вход",
|
||||
"monitor.status.error": "Ошибка сбора",
|
||||
"monitor.status.unsupported": "Недоступно",
|
||||
"monitor.status.disconnected": "Отключено",
|
||||
"monitor.status.stale": "Устарело",
|
||||
"monitor.status.missing": "Сервер удалён",
|
||||
};
|
||||
@@ -36,8 +36,6 @@ export const ruScriptsMessages = {
|
||||
'scripts.sidePanel.library': 'Библиотека',
|
||||
'scripts.sidePanel.running': 'Выполняются',
|
||||
'scripts.sidePanel.newScript': 'Новый скрипт',
|
||||
'scripts.sidePanel.viewList': 'Список',
|
||||
'scripts.sidePanel.viewStacked': 'Плитка',
|
||||
'scripts.running.empty': 'В этой сессии не выполняются скрипты.',
|
||||
'scripts.running.unnamed': 'Скрипт без названия',
|
||||
'scripts.running.status.running': 'Выполняется',
|
||||
|
||||
@@ -41,6 +41,9 @@ export const ruTerminalMessages: Messages = {
|
||||
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': 'Открыть SFTP',
|
||||
'terminal.browser.title': 'Браузер',
|
||||
'terminal.browser.sshOnly': 'Выберите SSH-терминал, чтобы открыть его браузер.',
|
||||
'terminal.browser.open': 'Открыть браузер',
|
||||
'terminal.toolbar.availableAfterConnect': 'Доступно после подключения',
|
||||
'terminal.toolbar.sendYmodem': 'Отправить через YMODEM',
|
||||
'terminal.toolbar.receiveYmodem': 'Получить через YMODEM',
|
||||
@@ -454,6 +457,12 @@ export const ruTerminalMessages: Messages = {
|
||||
'cloudSync.history.download': 'Скачивание',
|
||||
'cloudSync.history.resolved': 'Разрешено',
|
||||
'cloudSync.history.error': 'Ошибка',
|
||||
'cloudSync.localBackups.clearBusy': 'Завершите восстановление хранилища перед удалением резервных копий.',
|
||||
'cloudSync.localBackups.clear': 'Удалить все копии',
|
||||
'cloudSync.localBackups.clearConfirmTitle': 'Удалить историю локальных копий?',
|
||||
'cloudSync.localBackups.clearConfirmDesc': 'Все локальные резервные копии хранилища на этом устройстве будут удалены безвозвратно. Текущие хосты, сохранённые учётные данные и облачные копии останутся. Это не очистка устройства для передачи другому человеку. При обычном использовании новые копии будут создаваться по-прежнему.',
|
||||
'cloudSync.localBackups.clearSuccess': 'История локальных копий удалена.',
|
||||
'cloudSync.localBackups.clearFailed': 'Не удалось удалить все локальные копии. Повторите попытку.',
|
||||
'cloudSync.localBackups.title': 'История локальных резервных копий',
|
||||
'cloudSync.localBackups.desc': 'Netcatty сохраняет локальные точки восстановления перед сменой версии приложения и перед восстановлением хранилища.',
|
||||
'cloudSync.localBackups.retentionTitle': 'Хранение резервных копий',
|
||||
@@ -549,6 +558,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'cloudSync.conflict.entity.customGroups': 'Группы',
|
||||
'cloudSync.conflict.entity.snippetPackages': 'Пакеты сниппетов',
|
||||
'cloudSync.conflict.entity.portForwardingRules': 'Проброс портов',
|
||||
'cloudSync.conflict.entity.browserBookmarks': 'Закладки браузера',
|
||||
'cloudSync.conflict.entity.groupConfigs': 'Настройки групп',
|
||||
'cloudSync.conflict.entity.settings': 'Настройки',
|
||||
'cloudSync.conflict.keepLocal': 'Перезаписать облако (сохранить локальную)',
|
||||
@@ -642,6 +652,43 @@ export const ruTerminalMessages: Messages = {
|
||||
'keychain.field.publicKey': 'Публичный ключ',
|
||||
'keychain.field.certificatePlaceholder': 'Содержимое сертификата (необязательно)',
|
||||
'keychain.generate.keyType': 'Тип ключа',
|
||||
'keychain.generate.standardKeys': 'Обычные ключи',
|
||||
'keychain.generate.securityKeys': 'Ключи безопасности · FIDO2',
|
||||
'keychain.generate.residentDesc': 'Позволить восстановить учётные данные с ключа безопасности.',
|
||||
'keychain.generate.verifyRequiredDesc': 'Запрашивать PIN устройства перед подписью.',
|
||||
'keychain.generate.generating': 'Создание…',
|
||||
'fido.prompt.pinTouchDesc': 'Введите PIN, затем коснитесь ключа для подтверждения.',
|
||||
'fido.prompt.details': 'Подробности запроса',
|
||||
'fido.prompt.showPin': 'Показать PIN',
|
||||
'fido.prompt.hidePin': 'Скрыть PIN',
|
||||
'fido.prompt.verifying': 'Проверка…',
|
||||
'keychain.generate.fidoHint': 'Подключите ключ FIDO2. При создании может потребоваться PIN или касание.',
|
||||
'fido.prompt.pinTitle': 'PIN ключа безопасности',
|
||||
'fido.prompt.touchTitle': 'Коснитесь ключа безопасности',
|
||||
'fido.prompt.pinDesc': 'Введите PIN ключа безопасности, чтобы продолжить.',
|
||||
'fido.prompt.touchDesc': 'Коснитесь ключа безопасности, чтобы подтвердить свою личность.',
|
||||
'fido.prompt.pinLabel': 'PIN',
|
||||
'fido.prompt.touchWaiting': 'Ожидание ответа ключа…',
|
||||
'fido.prompt.touchAutomatic': 'Продолжим автоматически после касания',
|
||||
"fido.recovery.device-unavailable.title": "Подключите нужный ключ безопасности",
|
||||
"fido.recovery.device-unavailable.desc": "Подключите устройство с учётными данными этого ключа и повторите попытку.",
|
||||
"fido.recovery.timeout.title": "Время подтверждения ключа истекло",
|
||||
"fido.recovery.timeout.desc": "Операция устройства остановлена. Проверьте подключение ключа, повторите попытку и подтвердите её на устройстве.",
|
||||
"fido.recovery.pin-failed.title": "PIN ключа безопасности не принят",
|
||||
"fido.recovery.pin-failed.desc": "Проверьте PIN FIDO2 этого устройства. Повторные ошибки могут заблокировать устройство.",
|
||||
"fido.recovery.failed.title": "Проверка ключа не завершена",
|
||||
"fido.recovery.failed.desc": "Проверьте подключение нужного устройства и поддержку ключа. Повторите попытку или отмените её и проверьте настройки OpenSSH.",
|
||||
"fido.recovery.retry": "Повторить проверку",
|
||||
"fido.recovery.usePassword": "Использовать сохранённый пароль",
|
||||
"fido.recovery.passwordFallback": "Вход с паролем, настроенным для этого хоста",
|
||||
"fido.prompt.cancelled": "Проверка ключа безопасности отменена.",
|
||||
'fido.prompt.touchDone': 'Я коснулся',
|
||||
'fido.prompt.timeout': 'Время ожидания FIDO истекло. Попробуйте снова.',
|
||||
'fido.error.opensshMissing': 'Нужен OpenSSH с FIDO/libfido2. macOS: brew install openssh libfido2.',
|
||||
'fido.error.agentUnavailable': 'Не удалось запустить SSH agent с FIDO.',
|
||||
'keychain.generate.resident': 'Хранить учётные данные на ключе',
|
||||
'keychain.generate.verifyRequired': 'Проверять PIN каждый раз',
|
||||
'keychain.action.loadResident': 'Загрузить resident-ключи с устройства',
|
||||
'keychain.generate.keySize': 'Размер ключа',
|
||||
'keychain.generate.labelPlaceholder': 'Метка ключа',
|
||||
'keychain.generate.passphrasePlaceholder': 'Парольная фраза (необязательно)',
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import en from './en.ts';
|
||||
import ru from './ru.ts';
|
||||
import es from './es.ts';
|
||||
import zhCN from './zh-CN.ts';
|
||||
import zhTW from './zh-TW.ts';
|
||||
|
||||
test('scripts side panel view-toggle copy exists in every locale', () => {
|
||||
const KEYS = ['scripts.sidePanel.viewList', 'scripts.sidePanel.viewStacked'];
|
||||
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
|
||||
const missing = KEYS.filter((key) => !(messages as Record<string, string>)[key]);
|
||||
assert.deepEqual(missing, [], `${locale} is missing scripts view-toggle copy`);
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { zhCNMonitorMessages } from './zh-CN/monitor';
|
||||
import type { Messages } from './types';
|
||||
import { zhCNCoreMessages } from './zh-CN/core';
|
||||
import { zhCNVaultMessages } from './zh-CN/vault';
|
||||
@@ -9,6 +10,7 @@ import { zhCNScriptsMessages } from './zh-CN/scripts';
|
||||
export type { Messages } from './types';
|
||||
|
||||
const zhCN: Messages = {
|
||||
...zhCNMonitorMessages,
|
||||
...zhCNCoreMessages,
|
||||
...zhCNVaultMessages,
|
||||
...zhCNTerminalMessages,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
export const zhCNMonitorMessages = {
|
||||
"monitor.actions": "面板操作",
|
||||
"monitor.unavailable": "监控暂不可用,连接恢复后请重新开始。",
|
||||
"monitor.username": "用户名",
|
||||
"monitor.password": "密码(仅本次使用)",
|
||||
"monitor.status.limit": "同时最多监控 256 台不同主机。",
|
||||
"sync.entityType.monitorPanels": "监控面板",
|
||||
"monitor.title": "监控",
|
||||
"monitor.create": "新建面板",
|
||||
"monitor.createFirst": "创建第一个监控面板",
|
||||
"monitor.empty": "一眼查看多台主机",
|
||||
"monitor.emptyHint": "跨分组选择已有主机,准备好后再开始监控。",
|
||||
"monitor.choosePanel": "选择面板",
|
||||
"monitor.edit": "编辑面板",
|
||||
"monitor.copy": "复制面板",
|
||||
"monitor.copyName": "{name} 副本",
|
||||
"monitor.delete": "删除面板",
|
||||
"monitor.deleteHint":
|
||||
"删除“{name}”?该面板将停止监控,已有主机和其他面板会保留。",
|
||||
"monitor.start": "开始监控",
|
||||
"monitor.stop": "停止",
|
||||
"monitor.stopAll": "全部停止",
|
||||
"monitor.list": "列表",
|
||||
"monitor.charts": "图表",
|
||||
"monitor.search": "搜索名称、地址或分组",
|
||||
"monitor.interval": "更新频率",
|
||||
"monitor.range": "趋势范围",
|
||||
"monitor.seconds": "每 {count} 秒",
|
||||
"monitor.minutes": "最近 {count} 分钟",
|
||||
"monitor.sort": "主机排序",
|
||||
"monitor.sort.manual": "保存的顺序",
|
||||
"monitor.sort.name": "名称",
|
||||
"monitor.selectedCount": "已选 {count} 台主机",
|
||||
"monitor.noMatches": "没有符合条件的主机",
|
||||
"monitor.host": "主机",
|
||||
"monitor.hosts": "主机",
|
||||
"monitor.status": "状态",
|
||||
"monitor.cpu": "CPU",
|
||||
"monitor.cores": "{count} 核",
|
||||
"monitor.memory": "内存",
|
||||
"monitor.diskCapacity": "已用 {used} / 总容量 {total}",
|
||||
"monitor.diskCapacityUnknown": "暂无容量读数",
|
||||
"monitor.rootDisk": "根分区",
|
||||
"monitor.network": "网络",
|
||||
"monitor.download": "下载",
|
||||
"monitor.upload": "上传",
|
||||
"monitor.updated": "更新时间",
|
||||
"monitor.updatedAt": "更新于 {time}",
|
||||
"monitor.noSamples": "等待第一次读数",
|
||||
"monitor.cpuTrend": "CPU 趋势",
|
||||
"monitor.memoryTrend": "内存趋势",
|
||||
"monitor.downloadTrend": "下载趋势",
|
||||
"monitor.uploadTrend": "上传趋势",
|
||||
"monitor.memoryOnlyHint":
|
||||
"读数只保留在内存中,最多 30 分钟,退出后清空。空白表示没有读数。共享连接按当前最快的更新需求采集。",
|
||||
"monitor.name": "面板名称",
|
||||
"monitor.description": "说明(可选)",
|
||||
"monitor.chooseHosts": "选择主机",
|
||||
"monitor.selectionHint": "可跨分组选择。开始后会明确标出不支持的连接。",
|
||||
"monitor.defaultView": "默认展示",
|
||||
"monitor.saveHint": "保存仅保留配置,不会连接。复制的面板也不会自动开始。",
|
||||
"monitor.nameRequired": "请输入面板名称。",
|
||||
"monitor.hostRequired": "请至少选择一台主机。",
|
||||
"monitor.duplicateName": "已有同名面板,请换个名称。",
|
||||
"monitor.saveFailed": "面板未能保存,请重试。",
|
||||
"monitor.deleted": "该面板已在其他窗口删除。请关闭编辑后新建面板。",
|
||||
"monitor.moveUp": "主机上移",
|
||||
"monitor.removeHost": "从面板移除",
|
||||
"monitor.missingHost": "已删除的主机",
|
||||
"monitor.observed": "本次已观察 {count} 分钟",
|
||||
"monitor.disks": "磁盘分区",
|
||||
"monitor.system": "系统信息",
|
||||
"monitor.loginHint":
|
||||
"请检查主机的登录信息或连接设置后重试。身份确认和登录提示会逐台处理。",
|
||||
"monitor.retry": "重试连接",
|
||||
"monitor.unsupportedHint":
|
||||
"监控需要支持额外命令通道的 Linux 或 macOS SSH 连接。",
|
||||
"monitor.openTerminal": "打开终端",
|
||||
"monitor.confirmHost": "确认主机身份",
|
||||
"monitor.status.stopped": "未开始",
|
||||
"monitor.status.queued": "排队中",
|
||||
"monitor.status.connecting": "连接中",
|
||||
"monitor.status.live": "已更新",
|
||||
"monitor.status.login": "需要登录",
|
||||
"monitor.status.error": "采集失败",
|
||||
"monitor.status.unsupported": "不支持",
|
||||
"monitor.status.disconnected": "已断开",
|
||||
"monitor.status.stale": "读数过期",
|
||||
"monitor.status.missing": "主机已删除",
|
||||
};
|
||||
@@ -36,8 +36,6 @@ export const zhCNScriptsMessages = {
|
||||
'scripts.sidePanel.library': '脚本库',
|
||||
'scripts.sidePanel.running': '运行中',
|
||||
'scripts.sidePanel.newScript': '新建脚本',
|
||||
'scripts.sidePanel.viewList': '列表视图',
|
||||
'scripts.sidePanel.viewStacked': '堆叠视图',
|
||||
'scripts.running.empty': '当前会话没有正在运行的脚本。',
|
||||
'scripts.running.unnamed': '未命名脚本',
|
||||
'scripts.running.status.running': '运行中',
|
||||
|
||||
@@ -403,6 +403,11 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.behavior.scrollOnPaste.desc': '粘贴文本时将终端滚动到底部',
|
||||
'settings.terminal.behavior.smoothScrolling': '平滑滚动',
|
||||
'settings.terminal.behavior.smoothScrolling.desc': '滚动终端视口时使用平滑动画',
|
||||
'settings.terminal.sidePanel.pane.browser': '浏览器',
|
||||
'settings.terminal.behavior.linkBrowser': '终端链接打开方式',
|
||||
'settings.terminal.behavior.linkBrowser.desc': 'SSH 终端通过对应主机访问,本地终端通过本机访问。',
|
||||
'settings.terminal.behavior.linkBrowser.netcatty': 'Netcatty 内置浏览器(默认)',
|
||||
'settings.terminal.behavior.linkBrowser.system': '系统默认浏览器',
|
||||
'settings.terminal.behavior.linkModifier': '链接修饰键',
|
||||
'settings.terminal.behavior.linkModifier.desc': '按住此键再点击终端中的链接',
|
||||
'settings.terminal.behavior.linkModifier.none': '无(直接点击)',
|
||||
@@ -711,13 +716,50 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'keychain.export.scriptRequired': '脚本 *',
|
||||
'keychain.export.exportToHost': '导出到主机',
|
||||
'keychain.export.failedGeneric': '导出失败:{message}',
|
||||
'keychain.field.label': 'Label',
|
||||
'keychain.field.labelRequired': 'Label *',
|
||||
'keychain.field.label': '名称',
|
||||
'keychain.field.labelRequired': '名称 *',
|
||||
'keychain.field.labelPlaceholder': '密钥 Label',
|
||||
'keychain.field.privateKeyRequired': '私钥 *',
|
||||
'keychain.field.publicKey': '公钥',
|
||||
'keychain.field.certificatePlaceholder': '证书内容(可选)',
|
||||
'keychain.generate.keyType': '密钥类型',
|
||||
'keychain.generate.standardKeys': '标准密钥',
|
||||
'keychain.generate.securityKeys': '安全密钥 · FIDO2',
|
||||
'keychain.generate.residentDesc': '允许从安全密钥中找回凭据。',
|
||||
'keychain.generate.verifyRequiredDesc': '签名前验证设备 PIN,增加一层保护。',
|
||||
'keychain.generate.generating': '正在生成…',
|
||||
'fido.prompt.pinTouchDesc': '输入 PIN 后,触摸安全密钥确认身份。',
|
||||
'fido.prompt.details': '查看请求详情',
|
||||
'fido.prompt.showPin': '显示 PIN',
|
||||
'fido.prompt.hidePin': '隐藏 PIN',
|
||||
'fido.prompt.verifying': '正在验证…',
|
||||
'keychain.generate.fidoHint': '请连接 FIDO2 安全密钥,生成时按提示输入 PIN 或触摸设备。',
|
||||
'fido.prompt.pinTitle': '安全密钥 PIN',
|
||||
'fido.prompt.touchTitle': '请触摸安全密钥',
|
||||
'fido.prompt.pinDesc': '输入安全密钥的 PIN 以继续。',
|
||||
'fido.prompt.touchDesc': '轻触安全密钥,确认是你本人在操作。',
|
||||
'fido.prompt.pinLabel': 'PIN',
|
||||
'fido.prompt.touchWaiting': '等待安全密钥响应…',
|
||||
'fido.prompt.touchAutomatic': '触摸后将自动继续',
|
||||
"fido.recovery.device-unavailable.title": "请连接对应的安全密钥",
|
||||
"fido.recovery.device-unavailable.desc": "请连接保存了此密钥凭据的设备,然后重试。",
|
||||
"fido.recovery.timeout.title": "等待安全密钥确认超时",
|
||||
"fido.recovery.timeout.desc": "本次设备操作已停止。请确认设备仍已连接,重试后按提示触摸或完成设备验证。",
|
||||
"fido.recovery.pin-failed.title": "安全密钥 PIN 未通过验证",
|
||||
"fido.recovery.pin-failed.desc": "请先检查此设备的 FIDO2 PIN,再重试。连续输错 PIN 可能导致设备锁定。",
|
||||
"fido.recovery.failed.title": "安全密钥验证未完成",
|
||||
"fido.recovery.failed.desc": "请确认对应设备已连接,并支持当前密钥。你可以重试,或取消后检查设备和 OpenSSH 配置。",
|
||||
"fido.recovery.retry": "重试验证",
|
||||
"fido.recovery.usePassword": "改用已配置的密码",
|
||||
"fido.recovery.passwordFallback": "正在使用此主机已配置的密码登录",
|
||||
"fido.prompt.cancelled": "已取消安全密钥验证。",
|
||||
'fido.prompt.touchDone': '已触摸',
|
||||
'fido.prompt.timeout': 'FIDO 提示已超时,请重新连接。',
|
||||
'fido.error.opensshMissing': '需要带 FIDO/libfido2 的 OpenSSH。macOS:brew install openssh libfido2。',
|
||||
'fido.error.agentUnavailable': '无法启动支持 FIDO 的 SSH agent。',
|
||||
'keychain.generate.resident': '在设备上保存凭据',
|
||||
'keychain.generate.verifyRequired': '每次验证 PIN',
|
||||
'keychain.action.loadResident': '从安全密钥加载驻留密钥',
|
||||
'keychain.generate.keySize': '密钥长度',
|
||||
'keychain.generate.labelPlaceholder': '密钥 Label',
|
||||
'keychain.generate.passphrasePlaceholder': 'Passphrase(可选)',
|
||||
|
||||
@@ -468,6 +468,9 @@ export const zhCNVaultMessages: Messages = {
|
||||
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': '打开 SFTP',
|
||||
'terminal.browser.title': '浏览器',
|
||||
'terminal.browser.sshOnly': '请聚焦 SSH 终端以打开浏览器。',
|
||||
'terminal.browser.open': '打开浏览器',
|
||||
'terminal.toolbar.availableAfterConnect': '连接后可用',
|
||||
'terminal.toolbar.sendYmodem': 'YMODEM 发送',
|
||||
'terminal.toolbar.receiveYmodem': 'YMODEM 接收',
|
||||
@@ -839,6 +842,12 @@ export const zhCNVaultMessages: Messages = {
|
||||
'cloudSync.history.download': '下载',
|
||||
'cloudSync.history.resolved': '已解决',
|
||||
'cloudSync.history.error': '错误',
|
||||
'cloudSync.localBackups.clearBusy': '请先完成资料恢复或处理中断的恢复,再清空备份。',
|
||||
'cloudSync.localBackups.clear': '清空全部备份',
|
||||
'cloudSync.localBackups.clearConfirmTitle': '清空本地备份历史?',
|
||||
'cloudSync.localBackups.clearConfirmDesc': '将永久删除此设备上全部主机库的历史备份。当前主机、保存的凭据和云端副本仍会保留,这不代表设备已完成交接清理。以后正常使用时仍会生成新备份。',
|
||||
'cloudSync.localBackups.clearSuccess': '已清空本地备份历史。',
|
||||
'cloudSync.localBackups.clearFailed': '未能清空全部本地备份,请重试。',
|
||||
'cloudSync.localBackups.title': '本地备份历史',
|
||||
'cloudSync.localBackups.desc': 'Netcatty 会在版本变化前,以及恢复主机库前,自动留下一份本地恢复点。',
|
||||
'cloudSync.localBackups.retentionTitle': '备份保留数量',
|
||||
@@ -933,6 +942,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'cloudSync.conflict.entity.customGroups': '分组',
|
||||
'cloudSync.conflict.entity.snippetPackages': '片段包',
|
||||
'cloudSync.conflict.entity.portForwardingRules': '端口转发',
|
||||
'cloudSync.conflict.entity.browserBookmarks': '浏览器书签',
|
||||
'cloudSync.conflict.entity.groupConfigs': '分组设置',
|
||||
'cloudSync.conflict.entity.settings': '设置',
|
||||
'cloudSync.conflict.keepLocal': '覆盖云端(保留本地)',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { zhTWMonitorMessages } from './zh-TW/monitor';
|
||||
import type { Messages } from './types';
|
||||
import { zhTWCoreMessages } from './zh-TW/core';
|
||||
import { zhTWVaultMessages } from './zh-TW/vault';
|
||||
@@ -9,6 +10,7 @@ import { zhTWScriptsMessages } from './zh-TW/scripts';
|
||||
export type { Messages } from './types';
|
||||
|
||||
const zhTW: Messages = {
|
||||
...zhTWMonitorMessages,
|
||||
...zhTWCoreMessages,
|
||||
...zhTWVaultMessages,
|
||||
...zhTWTerminalMessages,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
export const zhTWMonitorMessages = {
|
||||
"monitor.actions": "面板操作",
|
||||
"monitor.unavailable": "監控暫時無法使用,連線恢復後請重新開始。",
|
||||
"monitor.username": "使用者名稱",
|
||||
"monitor.password": "密碼(僅本次使用)",
|
||||
"monitor.status.limit": "同時最多監控 256 台不同主機。",
|
||||
"sync.entityType.monitorPanels": "監控面板",
|
||||
"monitor.title": "監控",
|
||||
"monitor.create": "新增面板",
|
||||
"monitor.createFirst": "建立第一個監控面板",
|
||||
"monitor.empty": "一眼查看多台主機",
|
||||
"monitor.emptyHint": "跨群組選擇現有主機,準備好後再開始監控。",
|
||||
"monitor.choosePanel": "選擇面板",
|
||||
"monitor.edit": "編輯面板",
|
||||
"monitor.copy": "複製面板",
|
||||
"monitor.copyName": "{name} 副本",
|
||||
"monitor.delete": "刪除面板",
|
||||
"monitor.deleteHint":
|
||||
"刪除「{name}」?此面板將停止監控,現有主機和其他面板會保留。",
|
||||
"monitor.start": "開始監控",
|
||||
"monitor.stop": "停止",
|
||||
"monitor.stopAll": "全部停止",
|
||||
"monitor.list": "列表",
|
||||
"monitor.charts": "圖表",
|
||||
"monitor.search": "搜尋名稱、位址或群組",
|
||||
"monitor.interval": "更新頻率",
|
||||
"monitor.range": "趨勢範圍",
|
||||
"monitor.seconds": "每 {count} 秒",
|
||||
"monitor.minutes": "最近 {count} 分鐘",
|
||||
"monitor.sort": "主機排序",
|
||||
"monitor.sort.manual": "儲存的順序",
|
||||
"monitor.sort.name": "名稱",
|
||||
"monitor.selectedCount": "已選 {count} 台主機",
|
||||
"monitor.noMatches": "沒有符合條件的主機",
|
||||
"monitor.host": "主機",
|
||||
"monitor.hosts": "主機",
|
||||
"monitor.status": "狀態",
|
||||
"monitor.cpu": "CPU",
|
||||
"monitor.cores": "{count} 核心",
|
||||
"monitor.memory": "記憶體",
|
||||
"monitor.diskCapacity": "已用 {used} / 總容量 {total}",
|
||||
"monitor.diskCapacityUnknown": "暫無容量讀數",
|
||||
"monitor.rootDisk": "根分割區",
|
||||
"monitor.network": "網路",
|
||||
"monitor.download": "下載",
|
||||
"monitor.upload": "上傳",
|
||||
"monitor.updated": "更新時間",
|
||||
"monitor.updatedAt": "更新於 {time}",
|
||||
"monitor.noSamples": "等待第一次讀數",
|
||||
"monitor.cpuTrend": "CPU 趨勢",
|
||||
"monitor.memoryTrend": "記憶體趨勢",
|
||||
"monitor.downloadTrend": "下載趨勢",
|
||||
"monitor.uploadTrend": "上傳趨勢",
|
||||
"monitor.memoryOnlyHint":
|
||||
"讀數只保留在記憶體中,最多 30 分鐘,結束後清空。空白表示沒有讀數。共用連線依目前最快的更新需求採集。",
|
||||
"monitor.name": "面板名稱",
|
||||
"monitor.description": "說明(選填)",
|
||||
"monitor.chooseHosts": "選擇主機",
|
||||
"monitor.selectionHint": "可跨群組選擇。開始後會明確標示不支援的連線。",
|
||||
"monitor.defaultView": "預設檢視",
|
||||
"monitor.saveHint": "儲存僅保留設定,不會連線。複製的面板也不會自動開始。",
|
||||
"monitor.nameRequired": "請輸入面板名稱。",
|
||||
"monitor.hostRequired": "請至少選擇一台主機。",
|
||||
"monitor.duplicateName": "已有同名面板,請換個名稱。",
|
||||
"monitor.saveFailed": "面板無法儲存,請重試。",
|
||||
"monitor.deleted": "此面板已在其他視窗刪除。請關閉編輯後新增面板。",
|
||||
"monitor.moveUp": "主機上移",
|
||||
"monitor.removeHost": "從面板移除",
|
||||
"monitor.missingHost": "已刪除的主機",
|
||||
"monitor.observed": "本次已觀察 {count} 分鐘",
|
||||
"monitor.disks": "磁碟分割區",
|
||||
"monitor.system": "系統資訊",
|
||||
"monitor.loginHint":
|
||||
"請檢查主機的登入資訊或連線設定後重試。身分確認與登入提示會逐台處理。",
|
||||
"monitor.retry": "重試連線",
|
||||
"monitor.unsupportedHint":
|
||||
"監控需要支援額外命令通道的 Linux 或 macOS SSH 連線。",
|
||||
"monitor.openTerminal": "開啟終端機",
|
||||
"monitor.confirmHost": "確認主機身分",
|
||||
"monitor.status.stopped": "未開始",
|
||||
"monitor.status.queued": "排程中",
|
||||
"monitor.status.connecting": "連線中",
|
||||
"monitor.status.live": "已更新",
|
||||
"monitor.status.login": "需要登入",
|
||||
"monitor.status.error": "採集失敗",
|
||||
"monitor.status.unsupported": "不支援",
|
||||
"monitor.status.disconnected": "已中斷",
|
||||
"monitor.status.stale": "讀數過期",
|
||||
"monitor.status.missing": "主機已刪除",
|
||||
};
|
||||
@@ -36,8 +36,6 @@ export const zhTWScriptsMessages = {
|
||||
'scripts.sidePanel.library': '指令碼庫',
|
||||
'scripts.sidePanel.running': '執行中',
|
||||
'scripts.sidePanel.newScript': '新增指令碼',
|
||||
'scripts.sidePanel.viewList': '列表檢視',
|
||||
'scripts.sidePanel.viewStacked': '堆疊檢視',
|
||||
'scripts.running.empty': '目前工作階段沒有正在執行的指令碼。',
|
||||
'scripts.running.unnamed': '未命名指令碼',
|
||||
'scripts.running.status.running': '執行中',
|
||||
|
||||
@@ -403,6 +403,11 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.behavior.scrollOnPaste.desc': '貼上文字時將終端捲動到底部',
|
||||
'settings.terminal.behavior.smoothScrolling': '平滑捲動',
|
||||
'settings.terminal.behavior.smoothScrolling.desc': '捲動終端可視區域時使用平滑動畫',
|
||||
'settings.terminal.sidePanel.pane.browser': '瀏覽器',
|
||||
'settings.terminal.behavior.linkBrowser': '終端連結開啟方式',
|
||||
'settings.terminal.behavior.linkBrowser.desc': 'SSH 終端透過對應主機存取,本機終端透過本機存取。',
|
||||
'settings.terminal.behavior.linkBrowser.netcatty': 'Netcatty 內建瀏覽器(預設)',
|
||||
'settings.terminal.behavior.linkBrowser.system': '系統預設瀏覽器',
|
||||
'settings.terminal.behavior.linkModifier': '連結修飾鍵',
|
||||
'settings.terminal.behavior.linkModifier.desc': '按住此鍵再點選終端中的連結',
|
||||
'settings.terminal.behavior.linkModifier.none': '無(直接點選)',
|
||||
@@ -711,13 +716,50 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'keychain.export.scriptRequired': '指令碼 *',
|
||||
'keychain.export.exportToHost': '匯出到主機',
|
||||
'keychain.export.failedGeneric': '匯出失敗:{message}',
|
||||
'keychain.field.label': 'Label',
|
||||
'keychain.field.labelRequired': 'Label *',
|
||||
'keychain.field.label': '名稱',
|
||||
'keychain.field.labelRequired': '名稱 *',
|
||||
'keychain.field.labelPlaceholder': '金鑰 Label',
|
||||
'keychain.field.privateKeyRequired': '私鑰 *',
|
||||
'keychain.field.publicKey': '公鑰',
|
||||
'keychain.field.certificatePlaceholder': '憑證內容(可選)',
|
||||
'keychain.generate.keyType': '金鑰型別',
|
||||
'keychain.generate.standardKeys': '標準金鑰',
|
||||
'keychain.generate.securityKeys': '安全金鑰 · FIDO2',
|
||||
'keychain.generate.residentDesc': '允許從安全金鑰中找回憑證。',
|
||||
'keychain.generate.verifyRequiredDesc': '簽章前驗證裝置 PIN,多一層保護。',
|
||||
'keychain.generate.generating': '正在產生…',
|
||||
'fido.prompt.pinTouchDesc': '輸入 PIN 後,觸碰安全金鑰確認身分。',
|
||||
'fido.prompt.details': '檢視請求詳情',
|
||||
'fido.prompt.showPin': '顯示 PIN',
|
||||
'fido.prompt.hidePin': '隱藏 PIN',
|
||||
'fido.prompt.verifying': '正在驗證…',
|
||||
'keychain.generate.fidoHint': '請連接 FIDO2 安全金鑰,產生時依提示輸入 PIN 或觸碰裝置。',
|
||||
'fido.prompt.pinTitle': '安全金鑰 PIN',
|
||||
'fido.prompt.touchTitle': '請觸摸安全金鑰',
|
||||
'fido.prompt.pinDesc': '輸入安全金鑰的 PIN 以繼續。',
|
||||
'fido.prompt.touchDesc': '輕觸安全金鑰,確認是你本人操作。',
|
||||
'fido.prompt.pinLabel': 'PIN',
|
||||
'fido.prompt.touchWaiting': '等待安全金鑰回應…',
|
||||
'fido.prompt.touchAutomatic': '觸摸後將自動繼續',
|
||||
"fido.recovery.device-unavailable.title": "請連接對應的安全金鑰",
|
||||
"fido.recovery.device-unavailable.desc": "請連接儲存了此金鑰憑證的裝置,然後重試。",
|
||||
"fido.recovery.timeout.title": "等待安全金鑰確認逾時",
|
||||
"fido.recovery.timeout.desc": "本次裝置操作已停止。請確認裝置仍已連接,重試後依提示觸摸或完成裝置驗證。",
|
||||
"fido.recovery.pin-failed.title": "安全金鑰 PIN 未通過驗證",
|
||||
"fido.recovery.pin-failed.desc": "請先檢查此裝置的 FIDO2 PIN,再重試。連續輸錯 PIN 可能導致裝置鎖定。",
|
||||
"fido.recovery.failed.title": "安全金鑰驗證未完成",
|
||||
"fido.recovery.failed.desc": "請確認對應裝置已連接,並支援目前的金鑰。你可以重試,或取消後檢查裝置和 OpenSSH 設定。",
|
||||
"fido.recovery.retry": "重試驗證",
|
||||
"fido.recovery.usePassword": "改用已設定的密碼",
|
||||
"fido.recovery.passwordFallback": "正在使用此主機已設定的密碼登入",
|
||||
"fido.prompt.cancelled": "已取消安全金鑰驗證。",
|
||||
'fido.prompt.touchDone': '已觸摸',
|
||||
'fido.prompt.timeout': 'FIDO 提示已逾時,請重新連線。',
|
||||
'fido.error.opensshMissing': '需要支援 FIDO/libfido2 的 OpenSSH。macOS:brew install openssh libfido2。',
|
||||
'fido.error.agentUnavailable': '無法啟動支援 FIDO 的 SSH agent。',
|
||||
'keychain.generate.resident': '在裝置上儲存憑證',
|
||||
'keychain.generate.verifyRequired': '每次驗證 PIN',
|
||||
'keychain.action.loadResident': '從安全金鑰載入駐留金鑰',
|
||||
'keychain.generate.keySize': '金鑰長度',
|
||||
'keychain.generate.labelPlaceholder': '金鑰 Label',
|
||||
'keychain.generate.passphrasePlaceholder': 'Passphrase(可選)',
|
||||
|
||||
@@ -468,6 +468,9 @@ export const zhTWVaultMessages: Messages = {
|
||||
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': '開啟 SFTP',
|
||||
'terminal.browser.title': '瀏覽器',
|
||||
'terminal.browser.sshOnly': '請聚焦 SSH 終端以開啟瀏覽器。',
|
||||
'terminal.browser.open': '開啟瀏覽器',
|
||||
'terminal.toolbar.availableAfterConnect': '連線後可用',
|
||||
'terminal.toolbar.sendYmodem': 'YMODEM 傳送',
|
||||
'terminal.toolbar.receiveYmodem': 'YMODEM 接收',
|
||||
@@ -839,6 +842,12 @@ export const zhTWVaultMessages: Messages = {
|
||||
'cloudSync.history.download': '下載',
|
||||
'cloudSync.history.resolved': '已解決',
|
||||
'cloudSync.history.error': '錯誤',
|
||||
'cloudSync.localBackups.clearBusy': '請先完成資料還原或處理中斷的還原,再清空備份。',
|
||||
'cloudSync.localBackups.clear': '清空全部備份',
|
||||
'cloudSync.localBackups.clearConfirmTitle': '清空本機備份歷史?',
|
||||
'cloudSync.localBackups.clearConfirmDesc': '將永久刪除此裝置上全部主機庫的歷史備份。目前的主機、儲存的憑證和雲端副本仍會保留,這不代表裝置已完成交接清理。日後正常使用時仍會產生新備份。',
|
||||
'cloudSync.localBackups.clearSuccess': '已清空本機備份歷史。',
|
||||
'cloudSync.localBackups.clearFailed': '未能清空全部本機備份,請重試。',
|
||||
'cloudSync.localBackups.title': '本機備份歷史',
|
||||
'cloudSync.localBackups.desc': 'Netcatty 會在版本變化前,以及恢復主機庫前,自動留下一份本機恢復點。',
|
||||
'cloudSync.localBackups.retentionTitle': '備份保留數量',
|
||||
@@ -933,6 +942,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'cloudSync.conflict.entity.customGroups': '群組',
|
||||
'cloudSync.conflict.entity.snippetPackages': '片段包',
|
||||
'cloudSync.conflict.entity.portForwardingRules': '埠轉發',
|
||||
'cloudSync.conflict.entity.browserBookmarks': '瀏覽器書籤',
|
||||
'cloudSync.conflict.entity.groupConfigs': '群組設定',
|
||||
'cloudSync.conflict.entity.settings': '設定',
|
||||
'cloudSync.conflict.keepLocal': '覆蓋雲端(保留本機)',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import test, { type TestContext } from 'node:test';
|
||||
|
||||
import type { SyncPayload } from '../domain/sync.ts';
|
||||
|
||||
@@ -132,3 +132,89 @@ for (const localRestore of [false, true]) {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function installBackupLocks(t: TestContext) {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
|
||||
let readers = 0;
|
||||
let writer: Promise<void> | null = null;
|
||||
const locks = {
|
||||
async request(_name: string, options: { mode: string; ifAvailable?: boolean }, callback: (lock: object | null) => Promise<unknown>) {
|
||||
if (options.mode === 'shared') {
|
||||
if (writer) await writer;
|
||||
readers += 1;
|
||||
try { return await callback({}); } finally { readers -= 1; }
|
||||
}
|
||||
assert.equal(options.ifAvailable, true, 'cleanup must refuse rather than queue behind a restore');
|
||||
if (readers || writer) return callback(null);
|
||||
let release!: () => void;
|
||||
writer = new Promise<void>((resolve) => { release = resolve; });
|
||||
try { return await callback({}); } finally { writer = null; release(); }
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { locks } });
|
||||
t.after(() => {
|
||||
if (previous) Object.defineProperty(globalThis, 'navigator', previous);
|
||||
else Reflect.deleteProperty(globalThis, 'navigator');
|
||||
});
|
||||
}
|
||||
|
||||
function installClearBridge(t: TestContext, clearVaultBackups: () => Promise<unknown>) {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: { netcatty: { clearVaultBackups } } });
|
||||
t.after(() => {
|
||||
if (previous) Object.defineProperty(globalThis, 'window', previous);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
});
|
||||
}
|
||||
|
||||
test('cleanup refuses while another restore is applying, including nested restore helpers', async (t) => {
|
||||
installBackupLocks(t);
|
||||
const { clearLocalVaultBackups, withRestoreBarrier, LocalVaultBackupBusyError } = await import('./localVaultBackups.ts');
|
||||
let clears = 0;
|
||||
installClearBridge(t, async () => { clears += 1; });
|
||||
let finish!: () => void;
|
||||
let entered!: () => void;
|
||||
const active = new Promise<void>((resolve) => { entered = resolve; });
|
||||
const gate = new Promise<void>((resolve) => { finish = resolve; });
|
||||
const restoring = withRestoreBarrier(() => withRestoreBarrier(async () => { entered(); await gate; }));
|
||||
await active;
|
||||
try {
|
||||
await assert.rejects(clearLocalVaultBackups(), LocalVaultBackupBusyError);
|
||||
assert.equal(clears, 0);
|
||||
} finally {
|
||||
finish();
|
||||
await restoring;
|
||||
}
|
||||
await clearLocalVaultBackups();
|
||||
assert.equal(clears, 1);
|
||||
});
|
||||
|
||||
test('restore waits for an in-progress cleanup before reading or applying data', async (t) => {
|
||||
installBackupLocks(t);
|
||||
const { clearLocalVaultBackups, withRestoreBarrier } = await import('./localVaultBackups.ts');
|
||||
let finish!: () => void;
|
||||
let entered!: () => void;
|
||||
const active = new Promise<void>((resolve) => { entered = resolve; });
|
||||
const gate = new Promise<void>((resolve) => { finish = resolve; });
|
||||
installClearBridge(t, async () => { entered(); await gate; });
|
||||
const clearing = clearLocalVaultBackups();
|
||||
await active;
|
||||
let applied = false;
|
||||
const restoring = withRestoreBarrier(async () => { applied = true; });
|
||||
await Promise.resolve();
|
||||
assert.equal(applied, false);
|
||||
finish();
|
||||
await Promise.all([clearing, restoring]);
|
||||
assert.equal(applied, true);
|
||||
});
|
||||
|
||||
test('cleanup retains recovery backups after an interrupted protected apply', async (t) => {
|
||||
installBackupLocks(t);
|
||||
const { clearLocalVaultBackups, LocalVaultBackupBusyError } = await import('./localVaultBackups.ts');
|
||||
const { STORAGE_KEY_VAULT_APPLY_IN_PROGRESS } = await import('../infrastructure/config/storageKeys.ts');
|
||||
let clears = 0;
|
||||
installClearBridge(t, async () => { clears += 1; });
|
||||
localStorageValues.set(STORAGE_KEY_VAULT_APPLY_IN_PROGRESS, JSON.stringify({ startedAt: Date.now(), protectiveBackupId: 'recovery' }));
|
||||
await assert.rejects(clearLocalVaultBackups(), LocalVaultBackupBusyError);
|
||||
assert.equal(clears, 0);
|
||||
});
|
||||
@@ -10,6 +10,15 @@ import { getCloudSyncManager } from '../infrastructure/services/CloudSyncManager
|
||||
import { netcattyBridge } from '../infrastructure/services/netcattyBridge';
|
||||
import { hasMeaningfulSyncData } from './syncPayload';
|
||||
|
||||
const BACKUP_APPLY_LOCK = 'netcatty:vault-backup-apply';
|
||||
|
||||
export class LocalVaultBackupBusyError extends Error {
|
||||
constructor() {
|
||||
super('Finish restoring or recovering the vault before clearing backups.');
|
||||
this.name = 'LocalVaultBackupBusyError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot the current sync data version (the integer that increments
|
||||
* on each successful cloud sync). Returns undefined when the value is
|
||||
@@ -86,6 +95,16 @@ export async function trimLocalVaultBackups(maxCount = getLocalVaultBackupMaxCou
|
||||
await bridge?.trimVaultBackups?.({ maxCount });
|
||||
}
|
||||
|
||||
export async function clearLocalVaultBackups(): Promise<void> {
|
||||
const bridge = netcattyBridge.get();
|
||||
const locks = typeof navigator === 'undefined' ? undefined : navigator.locks;
|
||||
if (!bridge?.clearVaultBackups || !locks) throw new Error('Local backup cleanup is unavailable.');
|
||||
await locks.request(BACKUP_APPLY_LOCK, { mode: 'exclusive', ifAvailable: true }, async (lock) => {
|
||||
if (!lock || readInterruptedVaultApply()) throw new LocalVaultBackupBusyError();
|
||||
await bridge.clearVaultBackups!();
|
||||
});
|
||||
}
|
||||
|
||||
export async function getLocalVaultBackupCapabilities(): Promise<{
|
||||
encryptionAvailable: boolean;
|
||||
}> {
|
||||
@@ -265,6 +284,15 @@ export async function withRestoreBarrier<T>(
|
||||
task: () => Promise<T>,
|
||||
holdMs: number = RESTORE_BARRIER_HOLD_MS,
|
||||
): Promise<T> {
|
||||
const locks = typeof navigator === 'undefined' ? undefined : navigator.locks;
|
||||
// Shared locks allow existing nested restore helpers. Cleanup never queues
|
||||
// an exclusive lock, so a nested helper cannot wait behind its own cleanup.
|
||||
return locks
|
||||
? locks.request(BACKUP_APPLY_LOCK, { mode: 'shared' }, () => runWithRestoreBarrier(task, holdMs))
|
||||
: runWithRestoreBarrier(task, holdMs);
|
||||
}
|
||||
|
||||
async function runWithRestoreBarrier<T>(task: () => Promise<T>, holdMs: number): Promise<T> {
|
||||
const writeDeadline = () => {
|
||||
try {
|
||||
localStorageAdapter.writeNumber(
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { MonitorPanel } from "../domain/monitorPanels";
|
||||
const store = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => store.set(k, v),
|
||||
removeItem: (k: string) => store.delete(k),
|
||||
},
|
||||
});
|
||||
const {
|
||||
buildCloudSyncPayload,
|
||||
buildLocalVaultPayload,
|
||||
applySyncPayload,
|
||||
applyLocalVaultPayload,
|
||||
} = await import("./syncPayload");
|
||||
const {
|
||||
writeMonitorPanels,
|
||||
readMonitorPanels,
|
||||
saveMonitorPanel,
|
||||
deleteMonitorPanel,
|
||||
} = await import("./state/monitorPanelsStorage");
|
||||
const panel: MonitorPanel = {
|
||||
id: "p",
|
||||
name: "Fleet",
|
||||
description: "",
|
||||
hostIds: ["h1", "h2"],
|
||||
view: "charts",
|
||||
interval: 10,
|
||||
range: 15,
|
||||
sort: "manual",
|
||||
};
|
||||
const vault = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
knownHosts: [],
|
||||
};
|
||||
const opts = { collectPluginSidecars: () => undefined };
|
||||
const importers = { importVaultData: () => {} };
|
||||
test("real builders and importers round-trip panel configuration through cloud and backups without runtime state", async () => {
|
||||
store.clear();
|
||||
writeMonitorPanels([
|
||||
{
|
||||
...panel,
|
||||
stats: { cpu: 42 },
|
||||
running: true,
|
||||
points: [1],
|
||||
error: "never-save",
|
||||
},
|
||||
]);
|
||||
const cloud = await buildCloudSyncPayload(vault, [], opts);
|
||||
const backup = buildLocalVaultPayload(vault);
|
||||
assert.deepEqual(cloud.monitorPanels, [panel]);
|
||||
assert.deepEqual(backup.monitorPanels, [panel]);
|
||||
assert.ok(!JSON.stringify(cloud).includes("never-save"));
|
||||
writeMonitorPanels([]);
|
||||
await applySyncPayload(cloud, importers);
|
||||
assert.deepEqual(readMonitorPanels(), [panel]);
|
||||
delete backup.aiSessions;
|
||||
writeMonitorPanels([]);
|
||||
await applyLocalVaultPayload(backup, importers);
|
||||
assert.deepEqual(readMonitorPanels(), [panel]);
|
||||
const renamed = {
|
||||
...panel,
|
||||
name: "Changed",
|
||||
hostIds: ["h2"],
|
||||
view: "list" as const,
|
||||
};
|
||||
await applySyncPayload({ ...cloud, monitorPanels: [renamed] }, importers);
|
||||
assert.deepEqual(readMonitorPanels(), [renamed]);
|
||||
const legacy = { ...cloud };
|
||||
delete legacy.monitorPanels;
|
||||
await applySyncPayload(legacy, importers);
|
||||
assert.deepEqual(readMonitorPanels(), [renamed]);
|
||||
await applySyncPayload({ ...cloud, monitorPanels: [] }, importers);
|
||||
assert.deepEqual(readMonitorPanels(), []);
|
||||
});
|
||||
test("serialized window mutations rebase stale fields and do not resurrect a deletion", async () => {
|
||||
store.clear();
|
||||
writeMonitorPanels([panel]);
|
||||
await Promise.all([
|
||||
saveMonitorPanel(panel, { ...panel, name: "Rename" }),
|
||||
saveMonitorPanel(panel, { ...panel, hostIds: ["h3"], view: "list" }),
|
||||
]);
|
||||
assert.deepEqual(readMonitorPanels(), [
|
||||
{ ...panel, name: "Rename", hostIds: ["h3"], view: "list" },
|
||||
]);
|
||||
await deleteMonitorPanel("p");
|
||||
await assert.rejects(
|
||||
saveMonitorPanel(panel, { ...panel, name: "Stale" }),
|
||||
/deleted/,
|
||||
);
|
||||
assert.deepEqual(readMonitorPanels(), []);
|
||||
});
|
||||
|
||||
test("cloud apply preserves panel edits committed after preparation", async () => {
|
||||
store.clear();
|
||||
writeMonitorPanels([panel]);
|
||||
const { prepareSyncPayloadApply } = await import("./syncPayload");
|
||||
const prepared = await prepareSyncPayloadApply(
|
||||
{
|
||||
hosts: [],
|
||||
keys: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
monitorPanels: [{ ...panel, name: "From cloud" }],
|
||||
},
|
||||
importers,
|
||||
);
|
||||
await saveMonitorPanel(panel, { ...panel, hostIds: ["h3"], view: "list" });
|
||||
await prepared();
|
||||
assert.deepEqual(readMonitorPanels(), [
|
||||
{ ...panel, name: "From cloud", hostIds: ["h3"], view: "list" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("cloud and backup imports preserve both independently created same-name panels", async () => {
|
||||
store.clear();
|
||||
const original = { ...panel, id: "a", name: "Fleet" };
|
||||
const other = { ...panel, id: "b", name: "FLEET" };
|
||||
writeMonitorPanels([original]);
|
||||
await applySyncPayload(
|
||||
{
|
||||
hosts: [],
|
||||
keys: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
monitorPanels: [other, original],
|
||||
},
|
||||
importers,
|
||||
);
|
||||
assert.deepEqual(
|
||||
readMonitorPanels().map((p) => [p.id, p.name]),
|
||||
[
|
||||
["a", "Fleet"],
|
||||
["b", "FLEET (2)"],
|
||||
],
|
||||
);
|
||||
const backup = buildLocalVaultPayload(vault);
|
||||
delete backup.aiSessions;
|
||||
writeMonitorPanels([]);
|
||||
await applyLocalVaultPayload(backup, importers);
|
||||
assert.equal(readMonitorPanels().length, 2);
|
||||
assert.equal(
|
||||
new Set(readMonitorPanels().map((p) => p.name.toLowerCase())).size,
|
||||
2,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { requestHostBrowserPanel, subscribeHostBrowserPanelRequests, type HostBrowserPanelRequest } from './hostBrowserPanelRequests';
|
||||
|
||||
test('detached terminal requests reach the main window instead of being dropped', async () => {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const forwarded: HostBrowserPanelRequest[] = [];
|
||||
const delivered: HostBrowserPanelRequest[] = [];
|
||||
let nativeListener: ((request: HostBrowserPanelRequest) => void) | undefined;
|
||||
let removed = false;
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: { netcatty: {
|
||||
requestHostBrowserPanel: async (request: HostBrowserPanelRequest) => { forwarded.push(request); return true; },
|
||||
onHostBrowserPanelRequest: (listener: typeof nativeListener) => {
|
||||
nativeListener = listener;
|
||||
return () => { removed = true; nativeListener = undefined; };
|
||||
},
|
||||
} } });
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
try {
|
||||
const request = { host: { id: 'host', hostname: 'host', label: 'Host', username: 'test' }, url: 'http://localhost:3000/' } as HostBrowserPanelRequest;
|
||||
requestHostBrowserPanel(request);
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(forwarded, [request]);
|
||||
unsubscribe = subscribeHostBrowserPanelRequests((value) => delivered.push(value));
|
||||
assert.ok(nativeListener);
|
||||
nativeListener(request);
|
||||
assert.deepEqual(delivered, [request]);
|
||||
assert.equal(forwarded.length, 1, 'main-window consumption must not forward the request again');
|
||||
unsubscribe(); unsubscribe = undefined;
|
||||
assert.equal(removed, true);
|
||||
} finally {
|
||||
unsubscribe?.();
|
||||
if (previous) Object.defineProperty(globalThis, 'window', previous);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Host } from '../../domain/models';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export type HostBrowserPanelRequest = { host: Host; sessionId?: string; url?: string };
|
||||
type Listener = (request: HostBrowserPanelRequest) => void;
|
||||
const listeners = new Set<Listener>();
|
||||
let pending: HostBrowserPanelRequest | null = null;
|
||||
let unsubscribeNative: (() => void) | undefined;
|
||||
|
||||
export function requestHostBrowserPanel(request: HostBrowserPanelRequest): void {
|
||||
if (!listeners.size) {
|
||||
const forward = netcattyBridge.get()?.requestHostBrowserPanel;
|
||||
if (forward) {
|
||||
void forward(request).catch(() => { pending = request; });
|
||||
} else pending = request;
|
||||
return;
|
||||
}
|
||||
for (const listener of listeners) listener(request);
|
||||
}
|
||||
|
||||
export function subscribeHostBrowserPanelRequests(listener: Listener): () => void {
|
||||
listeners.add(listener);
|
||||
if (!unsubscribeNative) unsubscribeNative = netcattyBridge.get()?.onHostBrowserPanelRequest?.((request) => {
|
||||
for (const current of listeners) current(request);
|
||||
});
|
||||
if (pending) {
|
||||
const request = pending;
|
||||
pending = null;
|
||||
listener(request);
|
||||
}
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (!listeners.size) { unsubscribeNative?.(); unsubscribeNative = undefined; }
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { mergeSyncPayloads } from "../../domain/syncMerge";
|
||||
import { useSyncExternalStore } from "react";
|
||||
import {
|
||||
normalizeMonitorPanels,
|
||||
rebaseMonitorPanel,
|
||||
type MonitorPanel,
|
||||
} from "../../domain/monitorPanels";
|
||||
import { STORAGE_KEY_MONITOR_PANELS } from "../../infrastructure/config/storageKeys";
|
||||
import {
|
||||
localStorageAdapter,
|
||||
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
|
||||
} from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import { withVaultImportLock } from "./vaultManagedImportLock";
|
||||
|
||||
export function readMonitorPanels(): MonitorPanel[] {
|
||||
return normalizeMonitorPanels(
|
||||
localStorageAdapter.read(STORAGE_KEY_MONITOR_PANELS),
|
||||
);
|
||||
}
|
||||
export function writeMonitorPanels(value: unknown): void {
|
||||
if (
|
||||
!localStorageAdapter.write(
|
||||
STORAGE_KEY_MONITOR_PANELS,
|
||||
normalizeMonitorPanels(value),
|
||||
)
|
||||
)
|
||||
throw new Error("monitor.saveFailed");
|
||||
}
|
||||
export async function saveMonitorPanel(
|
||||
base: MonitorPanel | null,
|
||||
next: MonitorPanel,
|
||||
): Promise<void> {
|
||||
return withVaultImportLock("vault", async () =>
|
||||
writeMonitorPanels(rebaseMonitorPanel(readMonitorPanels(), base, next)),
|
||||
);
|
||||
}
|
||||
export async function deleteMonitorPanel(id: string): Promise<void> {
|
||||
return withVaultImportLock("vault", async () =>
|
||||
writeMonitorPanels(readMonitorPanels().filter((p) => p.id !== id)),
|
||||
);
|
||||
}
|
||||
/** Rebase a prepared cloud snapshot against edits committed while it was applying. */
|
||||
export async function applyMonitorPanels(
|
||||
value: unknown,
|
||||
base: MonitorPanel[],
|
||||
replace = false,
|
||||
): Promise<void> {
|
||||
return withVaultImportLock("vault", async () => {
|
||||
const remote = normalizeMonitorPanels(value);
|
||||
if (replace) {
|
||||
writeMonitorPanels(remote);
|
||||
return;
|
||||
}
|
||||
const payload = (panels: MonitorPanel[]) => ({
|
||||
hosts: [],
|
||||
keys: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 0,
|
||||
monitorPanels: panels,
|
||||
});
|
||||
writeMonitorPanels(
|
||||
mergeSyncPayloads(
|
||||
payload(base),
|
||||
payload(readMonitorPanels()),
|
||||
payload(remote),
|
||||
).payload.monitorPanels ?? [],
|
||||
);
|
||||
});
|
||||
}
|
||||
let cachedRaw: string | null | undefined;
|
||||
let cached: MonitorPanel[] = [];
|
||||
function getSnapshot() {
|
||||
const raw = localStorageAdapter.readString(STORAGE_KEY_MONITOR_PANELS);
|
||||
if (raw !== cachedRaw) {
|
||||
cachedRaw = raw;
|
||||
cached = readMonitorPanels();
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
function subscribe(listener: () => void) {
|
||||
const handler = (event: Event) => {
|
||||
const key =
|
||||
event instanceof StorageEvent
|
||||
? event.key
|
||||
: (event as CustomEvent<{ key: string }>).detail?.key;
|
||||
if (!key || key === STORAGE_KEY_MONITOR_PANELS) listener();
|
||||
};
|
||||
window.addEventListener("storage", handler);
|
||||
window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handler);
|
||||
return () => {
|
||||
window.removeEventListener("storage", handler);
|
||||
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handler);
|
||||
};
|
||||
}
|
||||
export function useMonitorPanels(): MonitorPanel[] {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { mergeMonitorSnapshotHistory, type MonitorSnapshot, type MonitorHistoryCursor } from "../../domain/monitorRuntime";
|
||||
const empty: MonitorSnapshot = {
|
||||
historyMode: "replace",
|
||||
running: [],
|
||||
count: 0,
|
||||
hostCount: 0,
|
||||
epoch: 0,
|
||||
runtimeId: "",
|
||||
takenAt: 0,
|
||||
activeSessions: [],
|
||||
startedAt: {},
|
||||
hosts: {},
|
||||
};
|
||||
let snapshot = empty;
|
||||
const listeners = new Set<() => void>();
|
||||
export function publishMonitorSnapshot(next: MonitorSnapshot) {
|
||||
snapshot = mergeMonitorSnapshotHistory(snapshot, next);
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
export function getMonitorHistoryCursor(): MonitorHistoryCursor {
|
||||
return {
|
||||
runtimeId: snapshot.runtimeId,
|
||||
epoch: snapshot.epoch,
|
||||
after: Object.fromEntries(Object.entries(snapshot.hosts).map(([id, host]) => [id, { at: host.points.at(-1)?.at ?? 0, statsAt: host.at, historyId: host.historyId }])),
|
||||
};
|
||||
}
|
||||
export function publishMonitorUnavailable() {
|
||||
publishMonitorSnapshot({ ...empty, unavailable: true });
|
||||
}
|
||||
export function useMonitorSnapshot() {
|
||||
return useSyncExternalStore(
|
||||
(l) => {
|
||||
listeners.add(l);
|
||||
return () => {
|
||||
listeners.delete(l);
|
||||
};
|
||||
},
|
||||
() => snapshot,
|
||||
() => empty,
|
||||
);
|
||||
}
|
||||
export const monitorActions: {
|
||||
start: (id: string) => void;
|
||||
stop: (id: string) => void;
|
||||
stopAll: () => void;
|
||||
retry: (
|
||||
id: string,
|
||||
credentials?: { username: string; password: string },
|
||||
) => void;
|
||||
} = {
|
||||
start: () => {},
|
||||
stop: () => {},
|
||||
stopAll: () => {},
|
||||
retry: () => {},
|
||||
};
|
||||
@@ -7,6 +7,55 @@ import { PREFLIGHT_STAT_CONCURRENCY, PREFLIGHT_STAT_MAX_FILES, useSftpTransfers
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
import type { SftpPane } from "./types";
|
||||
|
||||
for (const direction of ["download", "upload"] as const) {
|
||||
test(`failed ${direction} connection replaces Waiting with the actual error (#3698)`, async () => {
|
||||
let streamStarts = 0;
|
||||
const restore = installGlobals({
|
||||
statSftp: async () => ({ type: "file", size: 3626, lastModified: 1758000001000 }),
|
||||
statLocal: async () => null,
|
||||
startStreamTransfer: async () => { streamStarts += 1; return {}; },
|
||||
});
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const ownerId = `failed-${direction}-connection-3698`;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId,
|
||||
getActivePane: (side) => makePane(direction === "download" ? side : side === "left" ? "right" : "left"),
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map([["remote-conn", "sftp-remote"]]) },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
acquireTransferSession: async () => { throw new Error("SFTP channel open timed out after 10000ms"); },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
const results = await ops!.startTransfer(
|
||||
[{ name: "aops_dns_view_demo.sh", isDirectory: false }], "left", "right",
|
||||
);
|
||||
assert.equal(results[0]?.status, "failed");
|
||||
});
|
||||
const task = sftpTransferCenterStore.getOwnerTasks(ownerId)[0];
|
||||
assert.equal(task?.status, "failed");
|
||||
assert.equal(task?.error, "SFTP channel open timed out after 10000ms");
|
||||
assert.ok(task?.endTime);
|
||||
assert.equal(streamStarts, 0);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
type StartOptions = {
|
||||
transferId: string;
|
||||
sourcePath: string;
|
||||
|
||||
@@ -721,7 +721,6 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const t0 = performance.now();
|
||||
logger.debug(`[SFTP:perf] processTransfer START — file=${task.fileName} isDir=${task.isDirectory}`);
|
||||
|
||||
@@ -1137,7 +1136,6 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
}
|
||||
return "failed";
|
||||
}
|
||||
} finally {
|
||||
// Walk registry cleanup is owned by transferRuntime.runWalk.
|
||||
// Drop ephemeral pool holds opened when browse was soft-closed.
|
||||
|
||||
@@ -5,6 +5,8 @@ import vm from 'node:vm';
|
||||
import React, { useCallback, useRef, useSyncExternalStore } from 'react';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import ts from 'typescript';
|
||||
import { resolveSystemSidebarSession } from '../../domain/systemManager/resolveSystemSession';
|
||||
import { collectSessionIds } from '../../domain/workspace';
|
||||
import { getSidePanelLiveSnapshot, subscribeSidePanelLiveSnapshot, sidePanelLiveStore, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, type SidePanelLiveSnapshot } from './sidePanelLiveStore';
|
||||
|
||||
// Exercise the production hook without importing the unrelated panel UI modules.
|
||||
@@ -20,6 +22,60 @@ const useSnapshot = vm.runInNewContext(`${code}; useSidePanelLiveSnapshotForTab`
|
||||
|
||||
const env = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
|
||||
test('workspace browser follows focus without replacing the earlier terminal context', async () => {
|
||||
const previous = env.IS_REACT_ACT_ENVIRONMENT;
|
||||
env.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const browserSlot = ast.statements.find((node) => ts.isFunctionDeclaration(node) && node.name?.text === 'SidePanelBrowserSlot');
|
||||
assert.ok(browserSlot);
|
||||
const slotCode = ts.transpile(browserSlot.getText(ast), { target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.React });
|
||||
const Slot = vm.runInNewContext(`${slotCode}; SidePanelBrowserSlot`, {
|
||||
React, useRef, useSidePanelLiveSnapshotForTab: useSnapshot, resolveSystemSidebarSession, collectSessionIds,
|
||||
sidePanelHiddenPanelClassName: (hidden: boolean) => hidden ? 'hidden' : 'visible',
|
||||
HostBrowserSidePanel: (props: object) => React.createElement('div', { ...props, 'data-browser-context': true }),
|
||||
}) as React.ComponentType<{ tabId: string; ctx: object; isVisible: boolean }>;
|
||||
const sessionA = { id: 'session-a', hostId: 'host-a', protocol: 'ssh' };
|
||||
const sessionB = { id: 'session-b', hostId: 'host-b', protocol: 'ssh' };
|
||||
const workspace = { id: 'workspace', focusedSessionId: sessionA.id, root: {
|
||||
id: 'split', type: 'split', direction: 'vertical', sizes: [0.5, 0.5], children: [
|
||||
{ id: 'a', type: 'pane', sessionId: sessionA.id }, { id: 'b', type: 'pane', sessionId: sessionB.id },
|
||||
],
|
||||
} } as NonNullable<SidePanelLiveSnapshot['activeWorkspace']>;
|
||||
const ctx = { sessions: [sessionA, sessionB], workspaceById: new Map([['workspace', workspace]]),
|
||||
sessionHostsMap: new Map([['session-a', { id: 'host-a', hostname: 'a' }], ['session-b', { id: 'host-b', hostname: 'b' }]]),
|
||||
sftpAuthHostBySessionId: new Map(), effectiveHosts: [], keys: [], identities: [], knownHosts: [], terminalSettings: {}, t: (key: string) => key };
|
||||
const publish = (session: typeof sessionA) => sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT,
|
||||
activeWorkspace: workspace, focusedSessionId: session.id,
|
||||
activeTerminalSessionForSystem: session as SidePanelLiveSnapshot['activeTerminalSessionForSystem'] });
|
||||
let renderer: ReturnType<typeof create> | undefined;
|
||||
try {
|
||||
publish(sessionA);
|
||||
await act(async () => { renderer = create(React.createElement(Slot, { tabId: 'workspace', ctx, isVisible: true })); });
|
||||
const contexts = () => renderer!.root.findAll((node) => node.type === 'div' && node.props['data-browser-context'] === true);
|
||||
const first = contexts()[0];
|
||||
assert.equal(first.props.host.id, 'host-a');
|
||||
await act(async () => publish(sessionB));
|
||||
assert.equal(contexts().length, 2);
|
||||
assert.equal(contexts()[0], first, 'the first terminal keeps the same browser mount');
|
||||
assert.equal(first.props.isVisible, false);
|
||||
assert.equal(contexts()[1].props.host.id, 'host-b');
|
||||
assert.equal(contexts()[1].props.isVisible, true);
|
||||
await act(async () => publish(sessionA));
|
||||
assert.equal(first.props.isVisible, true);
|
||||
assert.equal(contexts()[1].props.isVisible, false);
|
||||
await act(async () => renderer!.update(React.createElement(Slot, { tabId: 'workspace', ctx, isVisible: false })));
|
||||
assert.ok(contexts().every((context) => !context.props.isVisible), 'hidden tabs hide every native browser context');
|
||||
ctx.workspaceById.set('workspace', { ...workspace, focusedSessionId: sessionB.id,
|
||||
root: { id: 'b', type: 'pane', sessionId: sessionB.id } });
|
||||
await act(async () => renderer!.update(React.createElement(Slot, { tabId: 'workspace', ctx, isVisible: false })));
|
||||
assert.equal(contexts().length, 1, 'a detached session must leave the old workspace even while it still exists globally');
|
||||
assert.equal(contexts()[0].props.host.id, 'host-b', 'the remaining workspace browser stays mounted');
|
||||
} finally {
|
||||
await act(async () => renderer?.unmount());
|
||||
sidePanelLiveStore.update(SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT);
|
||||
env.IS_REACT_ACT_ENVIRONMENT = previous;
|
||||
}
|
||||
});
|
||||
|
||||
test('returning to a tab never exposes the previous tab snapshot before its publisher catches up', async () => {
|
||||
const previous = env.IS_REACT_ACT_ENVIRONMENT;
|
||||
env.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
@@ -19,6 +19,7 @@ export type TerminalSidePanelTabId = SidePanelTool;
|
||||
|
||||
export const TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER: TerminalSidePanelTabId[] = [
|
||||
'sftp',
|
||||
'browser',
|
||||
'scripts',
|
||||
'history',
|
||||
'theme',
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { installBrowserLockFixture } from "../testSupport/browserLockFixture";
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import type { MonitorPanel } from "../../domain/monitorPanels";
|
||||
import type { CloudProvider, SyncResult, SyncPayload } from "../../domain/sync";
|
||||
|
||||
installBrowserLockFixture();
|
||||
|
||||
const store = new Map<string, string>();
|
||||
const storage = {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
store.delete(key);
|
||||
},
|
||||
};
|
||||
const events = new EventTarget();
|
||||
for (const key of [
|
||||
"addEventListener",
|
||||
"removeEventListener",
|
||||
"dispatchEvent",
|
||||
] as const) {
|
||||
Object.defineProperty(globalThis, key, {
|
||||
configurable: true,
|
||||
value: events[key].bind(events),
|
||||
});
|
||||
}
|
||||
const windowFixture = Object.assign(events, {
|
||||
localStorage: storage,
|
||||
CustomEvent,
|
||||
setInterval,
|
||||
clearInterval,
|
||||
});
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: storage,
|
||||
});
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: windowFixture,
|
||||
});
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
configurable: true,
|
||||
value: Object.assign(new EventTarget(), { visibilityState: "hidden" }),
|
||||
});
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const { useAutoSync } = await import("./useAutoSync");
|
||||
const { getCloudSyncManager } =
|
||||
await import("../../infrastructure/services/CloudSyncManager");
|
||||
const { saveMonitorPanel, deleteMonitorPanel, writeMonitorPanels } =
|
||||
await import("./monitorPanelsStorage");
|
||||
|
||||
test("normal manual and debounced auto-sync upload panel creation, edits and deletion", async () => {
|
||||
const manager = getCloudSyncManager();
|
||||
const snapshot = {
|
||||
...manager.getState(),
|
||||
securityState: "UNLOCKED" as const,
|
||||
syncState: "IDLE" as const,
|
||||
autoSyncEnabled: true,
|
||||
providers: {
|
||||
...manager.getState().providers,
|
||||
webdav: { provider: "webdav" as const, status: "connected" as const },
|
||||
},
|
||||
};
|
||||
const uploads: SyncPayload[] = [];
|
||||
const originals = {
|
||||
getState: manager.getState,
|
||||
syncAllProviders: manager.syncAllProviders,
|
||||
loadSyncBase: manager.loadSyncBase,
|
||||
inspectProviderRemote: manager.inspectProviderRemote,
|
||||
setPendingLocalSync: manager.setPendingLocalSync,
|
||||
setAvailablePluginSyncProviders: manager.setAvailablePluginSyncProviders,
|
||||
};
|
||||
manager.getState = () => snapshot;
|
||||
manager.loadSyncBase = async () => null;
|
||||
manager.inspectProviderRemote = async () => ({
|
||||
remoteFile: null,
|
||||
payload: null,
|
||||
remoteChanged: false,
|
||||
});
|
||||
manager.syncAllProviders = async (payload) => {
|
||||
uploads.push(structuredClone(payload));
|
||||
return new Map<CloudProvider, SyncResult>([
|
||||
["webdav", { provider: "webdav", success: true, action: "upload" }],
|
||||
]);
|
||||
};
|
||||
manager.setPendingLocalSync = () => {};
|
||||
manager.setAvailablePluginSyncProviders = () => {};
|
||||
const panel: MonitorPanel = {
|
||||
id: "p",
|
||||
name: "Fleet",
|
||||
description: "",
|
||||
hostIds: ["h1", "h2"],
|
||||
view: "charts",
|
||||
interval: 10,
|
||||
range: 15,
|
||||
sort: "manual",
|
||||
};
|
||||
let api: ReturnType<typeof useAutoSync> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const apply = async () => {};
|
||||
const Probe = () => {
|
||||
api = useAutoSync({
|
||||
hosts: [],
|
||||
keys: [],
|
||||
snippets: [],
|
||||
customGroups: ["fixture"],
|
||||
startupReady: true,
|
||||
onApplyPayload: apply,
|
||||
onApplyConvergentPayload: apply,
|
||||
});
|
||||
return null;
|
||||
};
|
||||
const waitForUpload = async (count: number) => {
|
||||
// Flush the adapter's batched event, then observe the actual debounced
|
||||
// upload. Slow CI must not be mistaken for an incomplete collection.
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
if (uploads.length === count) {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 3500));
|
||||
});
|
||||
} else {
|
||||
const deadline = Date.now() + 10000;
|
||||
while (uploads.length < count && Date.now() < deadline)
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
});
|
||||
}
|
||||
assert.equal(
|
||||
uploads.length,
|
||||
count,
|
||||
"a panel-only change must alter the auto-sync hash and upload",
|
||||
);
|
||||
};
|
||||
try {
|
||||
writeMonitorPanels([
|
||||
{ ...panel, stats: { cpu: 42 }, points: [1], running: true },
|
||||
]);
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
});
|
||||
await act(async () => {
|
||||
assert.equal(await api!.syncNow({ trigger: "manual" }), true);
|
||||
});
|
||||
assert.deepEqual(uploads[0].monitorPanels, [panel]);
|
||||
const renamed = { ...panel, name: "Renamed" };
|
||||
await act(async () => {
|
||||
await saveMonitorPanel(panel, renamed);
|
||||
});
|
||||
await waitForUpload(2);
|
||||
assert.deepEqual(uploads[1].monitorPanels, [renamed]);
|
||||
const edited = {
|
||||
...renamed,
|
||||
hostIds: ["h2"],
|
||||
view: "list" as const,
|
||||
interval: 30 as const,
|
||||
};
|
||||
await act(async () => {
|
||||
await saveMonitorPanel(renamed, edited);
|
||||
});
|
||||
await waitForUpload(3);
|
||||
assert.deepEqual(uploads[2].monitorPanels, [edited]);
|
||||
await act(async () => {
|
||||
await deleteMonitorPanel(panel.id);
|
||||
});
|
||||
await waitForUpload(4);
|
||||
assert.deepEqual(uploads[3].monitorPanels, []);
|
||||
await act(async () => {
|
||||
await saveMonitorPanel(null, panel);
|
||||
});
|
||||
await waitForUpload(5);
|
||||
assert.deepEqual(uploads[4].monitorPanels, [panel]);
|
||||
assert.ok(
|
||||
uploads.every((p) => !JSON.stringify(p.monitorPanels).includes("stats")),
|
||||
);
|
||||
await act(async () => {
|
||||
await saveMonitorPanel(panel, panel);
|
||||
});
|
||||
await waitForUpload(5);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
Object.assign(manager, originals);
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readMonitorPanels } from './monitorPanelsStorage';
|
||||
import { STORAGE_KEY_MONITOR_PANELS } from '../../infrastructure/config/storageKeys';
|
||||
/**
|
||||
* useAutoSync - Auto-sync Hook for Cloud Sync
|
||||
*
|
||||
@@ -55,6 +57,7 @@ import {
|
||||
} from './autoSyncRemoteSchedule';
|
||||
import { resolveAutoSyncHashDecision } from './autoSyncHashDecision';
|
||||
import { getNotesSnapshot, subscribeNotes } from './notesStore';
|
||||
import { collectHostBrowserBookmarks } from '../hostBrowserBookmarks';
|
||||
|
||||
/** Prefer dedicated SYNC_PREFERENCES; fall back to legacy SYNC_CONFIG fields. */
|
||||
function isPersistedAutoSyncEnabled(fallback: boolean): boolean {
|
||||
@@ -150,6 +153,7 @@ const isRestoreInProgress = (): boolean => {
|
||||
|
||||
const getSyncPayloadDataHash = (payload: SyncPayload): string => {
|
||||
return JSON.stringify({
|
||||
monitorPanels: payload.monitorPanels,
|
||||
hosts: sanitizeHostsForSync(payload.hosts),
|
||||
keys: payload.keys,
|
||||
identities: payload.identities,
|
||||
@@ -160,6 +164,7 @@ const getSyncPayloadDataHash = (payload: SyncPayload): string => {
|
||||
notes: payload.notes,
|
||||
noteGroups: payload.noteGroups,
|
||||
portForwardingRules: sanitizePortForwardingRulesForSync(payload.portForwardingRules),
|
||||
browserBookmarks: payload.browserBookmarks,
|
||||
groupConfigs: payload.groupConfigs,
|
||||
settings: payload.settings,
|
||||
pluginSidecars: payload.pluginSidecars,
|
||||
@@ -224,6 +229,13 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
|
||||
// Listen for SFTP bookmark changes to trigger auto-sync
|
||||
const [bookmarksVersion, setBookmarksVersion] = useState(0);
|
||||
const [hostBrowserBookmarksVersion, setHostBrowserBookmarksVersion] = useState(0);
|
||||
useEffect(() => {
|
||||
const unsubscribe = netcattyBridge.get()?.onHostBrowserBookmarksChanged?.(() => {
|
||||
setHostBrowserBookmarksVersion((version) => version + 1);
|
||||
});
|
||||
return () => { unsubscribe?.(); };
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const handler = () => setBookmarksVersion((v) => v + 1);
|
||||
window.addEventListener('sftp-bookmarks-changed', handler);
|
||||
@@ -252,7 +264,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const chatChanged = key === STORAGE_KEY_AI_SESSIONS
|
||||
&& localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled === true;
|
||||
if (!SYNCABLE_SETTING_STORAGE_KEY_SET.has(key)
|
||||
&& key !== STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS && !chatChanged) return;
|
||||
&& key !== STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS && key !== STORAGE_KEY_MONITOR_PANELS && !chatChanged) return;
|
||||
setSyncableSettingsStorageVersion((v) => v + 1);
|
||||
};
|
||||
|
||||
@@ -274,6 +286,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
|
||||
const getSyncSnapshot = useCallback(() => {
|
||||
return {
|
||||
monitorPanels: readMonitorPanels(),
|
||||
hosts: sanitizeHostsForSync(config.hosts),
|
||||
keys: config.keys,
|
||||
identities: config.identities,
|
||||
@@ -309,9 +322,11 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const { withPluginSyncSidecars } = await import('../syncPayload');
|
||||
const { collectPluginSyncSidecarsFromHost } = await import('../pluginSyncSidecarBridge');
|
||||
const aiSessions = collectAiSessionsForSync();
|
||||
const browserBookmarks = await collectHostBrowserBookmarks();
|
||||
const base: SyncPayload = {
|
||||
...getSyncSnapshot(),
|
||||
...(aiSessions !== undefined ? { aiSessions } : {}),
|
||||
...(browserBookmarks !== undefined ? { browserBookmarks } : {}),
|
||||
settings: await collectCloudSyncableSettings(),
|
||||
syncedAt: Date.now(),
|
||||
};
|
||||
@@ -1125,6 +1140,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
config.settingsVersion,
|
||||
enabled,
|
||||
bookmarksVersion,
|
||||
hostBrowserBookmarksVersion,
|
||||
pluginSidecarsVersion,
|
||||
convergentSyncPaused,
|
||||
syncableSettingsStorageVersion,
|
||||
@@ -1142,6 +1158,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
config.settingsVersion,
|
||||
enabled,
|
||||
bookmarksVersion,
|
||||
hostBrowserBookmarksVersion,
|
||||
pluginSidecarsVersion,
|
||||
syncableSettingsStorageVersion,
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { currentBrowserThemeTokens, mountHostBrowser } from '../hostBrowser';
|
||||
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from '../../domain/models';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
interface BrowserPanelOptions {
|
||||
browserId: string;
|
||||
host: Host;
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
knownHosts: KnownHost[];
|
||||
terminalSettings: TerminalSettings;
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
export function useHostBrowserPanel(options: BrowserPanelOptions) {
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
const optionsRef = useRef(options);
|
||||
optionsRef.current = options;
|
||||
const opened = useRef(false);
|
||||
const mountedBrowserId = useRef<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const { browserId, isVisible } = options;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
let frame = 0;
|
||||
let previous = '';
|
||||
const update = () => {
|
||||
const element = elementRef.current;
|
||||
const rect = element?.getBoundingClientRect();
|
||||
// Native page views must leave the screen with their owning pane and
|
||||
// must not cover a renderer dialog or a toolbar's open menu.
|
||||
const covered = !!document.querySelector('[role="dialog"], [role="alertdialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper]');
|
||||
const visible = isVisible && !!element?.isConnected && !!rect
|
||||
&& rect.width > 0 && rect.height > 0 && !element?.closest('[inert]') && !covered;
|
||||
const bounds = rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : undefined;
|
||||
const theme = element && getComputedStyle(element).colorScheme === 'light' ? 'light' : 'dark';
|
||||
const themeTokens = currentBrowserThemeTokens(element ?? undefined);
|
||||
const next = JSON.stringify({ browserId, visible, bounds, theme, themeTokens });
|
||||
if (next !== previous) {
|
||||
previous = next;
|
||||
void bridge?.setHostBrowserPanelLayout?.({ browserId, visible, bounds, theme, themeTokens }).catch(() => {});
|
||||
}
|
||||
if (isVisible) frame = requestAnimationFrame(update);
|
||||
};
|
||||
update();
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
void bridge?.setHostBrowserPanelLayout?.({ browserId, visible: false }).catch(() => {});
|
||||
};
|
||||
}, [browserId, isVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || opened.current) return;
|
||||
opened.current = true;
|
||||
setError(null);
|
||||
let cancelled = false;
|
||||
void mountHostBrowser({ ...optionsRef.current, browserId }).catch((reason: unknown) => {
|
||||
opened.current = false;
|
||||
if (cancelled) return;
|
||||
setError(reason instanceof Error ? reason.message : String(reason));
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [attempt, browserId, isVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedBrowserId.current = browserId;
|
||||
return () => {
|
||||
opened.current = false;
|
||||
mountedBrowserId.current = null;
|
||||
// StrictMode replays setup immediately. Do not destroy its pending URL
|
||||
// or native browser unless the owning panel actually remains unmounted.
|
||||
queueMicrotask(() => {
|
||||
if (mountedBrowserId.current === browserId) return;
|
||||
void netcattyBridge.get()?.closeHostBrowserPanel?.(browserId).catch(() => {});
|
||||
});
|
||||
};
|
||||
}, [browserId]);
|
||||
|
||||
return { elementRef, error, retry: () => setAttempt((value) => value + 1) };
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
type LocalVaultBackupPreview,
|
||||
clearLocalVaultBackups,
|
||||
getLocalVaultBackupCapabilities,
|
||||
getLocalVaultBackupMaxCount,
|
||||
listLocalVaultBackups,
|
||||
@@ -20,13 +21,15 @@ export function useLocalVaultBackups() {
|
||||
// a destructive action that might later be disabled.
|
||||
const [encryptionAvailable, setEncryptionAvailable] = useState<boolean | null>(null);
|
||||
|
||||
const refreshSequence = useRef(0);
|
||||
const refreshBackups = useCallback(async () => {
|
||||
const sequence = ++refreshSequence.current;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const next = await listLocalVaultBackups();
|
||||
setBackups(next);
|
||||
if (sequence === refreshSequence.current) setBackups(next);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (sequence === refreshSequence.current) setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -76,6 +79,14 @@ export function useLocalVaultBackups() {
|
||||
return sanitized;
|
||||
}, [refreshBackups]);
|
||||
|
||||
const clearBackups = useCallback(async () => {
|
||||
try {
|
||||
await clearLocalVaultBackups();
|
||||
} finally {
|
||||
await refreshBackups();
|
||||
}
|
||||
}, [refreshBackups]);
|
||||
|
||||
const openBackupDirectory = useCallback(async () => {
|
||||
await openLocalVaultBackupDir();
|
||||
}, []);
|
||||
@@ -86,6 +97,7 @@ export function useLocalVaultBackups() {
|
||||
maxBackups,
|
||||
encryptionAvailable,
|
||||
refreshBackups,
|
||||
clearBackups,
|
||||
readBackup: readLocalVaultBackup,
|
||||
setMaxBackups: updateMaxBackups,
|
||||
openBackupDirectory,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useAppVaultRuntime } from "./appRuntimeBridge";
|
||||
import { useMonitorSnapshot } from "./monitorRuntimeStore";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
import {
|
||||
createKnownHostFromHostKeyInfo,
|
||||
upsertKnownHost,
|
||||
} from "../../domain/knownHosts";
|
||||
import { toHostKeyInfo } from "../../domain/hostKey";
|
||||
type Request = Parameters<
|
||||
Parameters<NonNullable<NetcattyBridge["onHostKeyVerification"]>>[0]
|
||||
>[0];
|
||||
export function useMonitorHostKeyVerification() {
|
||||
const { knownHosts, updateKnownHosts } = useAppVaultRuntime();
|
||||
const [request, setRequest] = useState<Request | null>(null);
|
||||
const snapshot = useMonitorSnapshot();
|
||||
const receivedAt = useRef(0);
|
||||
useEffect(
|
||||
() =>
|
||||
netcattyBridge.get()?.onHostKeyVerification?.((r) => {
|
||||
if (r.sessionId?.startsWith("monitor-")) {
|
||||
receivedAt.current = Date.now();
|
||||
setRequest(r);
|
||||
}
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const respond = (accept: boolean, save = false) => {
|
||||
if (!request) return;
|
||||
if (accept && save)
|
||||
updateKnownHosts(
|
||||
upsertKnownHost(
|
||||
knownHosts,
|
||||
createKnownHostFromHostKeyInfo(toHostKeyInfo(request)),
|
||||
),
|
||||
);
|
||||
void netcattyBridge
|
||||
.get()
|
||||
?.respondHostKeyVerification?.(request.requestId, accept, save);
|
||||
setRequest(null);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (
|
||||
request &&
|
||||
snapshot.takenAt >= receivedAt.current &&
|
||||
(!snapshot.running.length ||
|
||||
!snapshot.activeSessions.includes(request.sessionId ?? ""))
|
||||
) {
|
||||
void netcattyBridge
|
||||
.get()
|
||||
?.respondHostKeyVerification?.(request.requestId, false, false);
|
||||
setRequest(null);
|
||||
}
|
||||
}, [
|
||||
request,
|
||||
snapshot.running.length,
|
||||
snapshot.takenAt,
|
||||
snapshot.activeSessions,
|
||||
]);
|
||||
return { request, respond };
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { hostRestrictsExtraSshChannels } from "../../domain/host";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { buildHostConnectionTestPlan } from "../../domain/hostConnectionTest";
|
||||
import { resolveEffectiveTerminalHost } from "../../domain/terminalHostResolution";
|
||||
import { getEffectiveKnownHosts } from "../../infrastructure/syncHelpers";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
import {
|
||||
useAppLockChrome,
|
||||
useAppSettingsRuntime,
|
||||
useAppVaultRuntime,
|
||||
} from "./appRuntimeBridge";
|
||||
import { useMonitorPanels } from "./monitorPanelsStorage";
|
||||
import {
|
||||
monitorActions,
|
||||
getMonitorHistoryCursor,
|
||||
publishMonitorSnapshot,
|
||||
publishMonitorUnavailable,
|
||||
} from "./monitorRuntimeStore";
|
||||
import type { MonitorPanelDemand } from "../../domain/monitorRuntime";
|
||||
|
||||
/** Root lifetime, independent of Vault navigation. No runtime state is persisted. */
|
||||
export function useMonitorRuntime() {
|
||||
const panels = useMonitorPanels();
|
||||
const {
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
groupConfigs,
|
||||
proxyProfiles,
|
||||
knownHosts,
|
||||
isInitialized,
|
||||
} = useAppVaultRuntime();
|
||||
const { terminalSettings } = useAppSettingsRuntime();
|
||||
const { locked } = useAppLockChrome();
|
||||
const [running, setRunning] = useState(() => new Map<string, string>());
|
||||
const [retryVersion, setRetryVersion] = useState(0);
|
||||
const epoch = useRef<number | null>(null);
|
||||
const runtimeId = useRef<string | null>(null);
|
||||
const credentials = useRef(
|
||||
new Map<string, { username: string; password: string }>(),
|
||||
);
|
||||
useEffect(() => {
|
||||
monitorActions.start = (id) =>
|
||||
setRunning((prev) => new Map(prev).set(id, crypto.randomUUID()));
|
||||
monitorActions.stop = (id) =>
|
||||
setRunning((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
monitorActions.stopAll = () => {
|
||||
credentials.current.clear();
|
||||
setRunning(new Map());
|
||||
void netcattyBridge.get()?.monitorStopAll?.();
|
||||
};
|
||||
monitorActions.retry = (id, override) => {
|
||||
if (override) credentials.current.set(id, override);
|
||||
void netcattyBridge
|
||||
.get()
|
||||
?.monitorRetry?.(id)
|
||||
.then(() => setRetryVersion((v) => v + 1));
|
||||
};
|
||||
const offline = () => monitorActions.stopAll();
|
||||
window.addEventListener("offline", offline);
|
||||
return () => {
|
||||
window.removeEventListener("offline", offline);
|
||||
void netcattyBridge.get()?.monitorUpdate?.([]);
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (locked || !isInitialized) {
|
||||
credentials.current.clear();
|
||||
setRunning(new Map());
|
||||
}
|
||||
}, [locked, isInitialized]);
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.monitorUpdate) {
|
||||
publishMonitorUnavailable();
|
||||
return;
|
||||
}
|
||||
const demand: MonitorPanelDemand[] =
|
||||
locked || !isInitialized
|
||||
? []
|
||||
: panels
|
||||
.filter((p) => running.has(p.id))
|
||||
.map((panel) => ({
|
||||
id: panel.id,
|
||||
runId: running.get(panel.id)!,
|
||||
interval: panel.interval,
|
||||
hosts: panel.hostIds.map((id) => {
|
||||
const host = hosts.find((h) => h.id === id);
|
||||
if (
|
||||
!host ||
|
||||
(host.protocol && host.protocol !== "ssh") ||
|
||||
hostRestrictsExtraSshChannels(host)
|
||||
)
|
||||
return { id };
|
||||
const effective = resolveEffectiveTerminalHost({
|
||||
host,
|
||||
groupConfigs,
|
||||
proxyProfiles,
|
||||
});
|
||||
const plan = buildHostConnectionTestPlan({
|
||||
host,
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
groupConfigs,
|
||||
proxyProfiles,
|
||||
knownHosts: getEffectiveKnownHosts(knownHosts) ?? [],
|
||||
terminalSettings,
|
||||
sessionId: `monitor-${id}`,
|
||||
bootEpoch: 0,
|
||||
authOverride: credentials.current.has(id)
|
||||
? { ...credentials.current.get(id), authMethod: "password" }
|
||||
: null,
|
||||
});
|
||||
if (!plan.ok) return { id, status: "error" as const };
|
||||
if (plan.needsCredentialReentry)
|
||||
return { id, status: "login" as const };
|
||||
return {
|
||||
id,
|
||||
options: {
|
||||
...plan.options,
|
||||
singleChannelSsh: hostRestrictsExtraSshChannels(effective),
|
||||
sshAuthReadyTimeoutMs: 120000,
|
||||
agentForwarding: effective.agentForwarding,
|
||||
},
|
||||
};
|
||||
}),
|
||||
}));
|
||||
const activeHostIds = new Set(
|
||||
demand.flatMap((p) => p.hosts.map((h) => h.id)),
|
||||
);
|
||||
for (const id of credentials.current.keys())
|
||||
if (!activeHostIds.has(id)) credentials.current.delete(id);
|
||||
void bridge
|
||||
.monitorUpdate(demand)
|
||||
.then(publishMonitorSnapshot)
|
||||
.catch(() => {
|
||||
credentials.current.clear();
|
||||
setRunning((prev) => (prev.size ? new Map() : prev));
|
||||
publishMonitorUnavailable();
|
||||
});
|
||||
}, [
|
||||
panels,
|
||||
running,
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
groupConfigs,
|
||||
proxyProfiles,
|
||||
knownHosts,
|
||||
terminalSettings,
|
||||
locked,
|
||||
isInitialized,
|
||||
retryVersion,
|
||||
]);
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
let disposed = false;
|
||||
let pending = false;
|
||||
const poll = async () => {
|
||||
if (pending || !bridge?.monitorSnapshot) return;
|
||||
pending = true;
|
||||
try {
|
||||
const snapshot = await bridge.monitorSnapshot(getMonitorHistoryCursor());
|
||||
if (disposed) return;
|
||||
if (
|
||||
(epoch.current !== null && epoch.current !== snapshot.epoch) ||
|
||||
(runtimeId.current !== null &&
|
||||
runtimeId.current !== snapshot.runtimeId)
|
||||
) {
|
||||
credentials.current.clear();
|
||||
setRunning(new Map());
|
||||
}
|
||||
epoch.current = snapshot.epoch;
|
||||
runtimeId.current = snapshot.runtimeId;
|
||||
publishMonitorSnapshot(snapshot);
|
||||
} catch {
|
||||
credentials.current.clear();
|
||||
setRunning((prev) => (prev.size ? new Map() : prev));
|
||||
publishMonitorUnavailable();
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
const timer = setInterval(() => {
|
||||
void poll();
|
||||
}, 1000);
|
||||
return () => {
|
||||
disposed = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import {
|
||||
parseScriptsViewMode,
|
||||
SCRIPTS_VIEW_MODE_STORAGE_KEY,
|
||||
} from "./useScriptsViewMode.ts";
|
||||
|
||||
test("scripts view mode parser only accepts 'stacked'", () => {
|
||||
assert.equal(parseScriptsViewMode("stacked"), "stacked");
|
||||
assert.equal(parseScriptsViewMode("list"), "list");
|
||||
assert.equal(parseScriptsViewMode("grid"), "list");
|
||||
assert.equal(parseScriptsViewMode(null), "list");
|
||||
assert.equal(parseScriptsViewMode(""), "list");
|
||||
});
|
||||
|
||||
test("scripts view mode hook persists the chosen mode", () => {
|
||||
const source = readFileSync(new URL("./useScriptsViewMode.ts", import.meta.url), "utf8");
|
||||
assert.match(source, /useStoredString/);
|
||||
// The shared hook owns persistence and same-/cross-window subscriptions.
|
||||
assert.match(
|
||||
source,
|
||||
/useStoredString\(SCRIPTS_VIEW_MODE_STORAGE_KEY, "list", isScriptsViewMode\)/,
|
||||
);
|
||||
assert.equal(SCRIPTS_VIEW_MODE_STORAGE_KEY.startsWith("netcatty:"), true);
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import { STORAGE_KEY_SCRIPTS_SIDE_PANEL_VIEW } from "../../infrastructure/config/storageKeys";
|
||||
import { useStoredString } from "./useStoredString";
|
||||
|
||||
/** View modes for the scripts side panel library:
|
||||
* 'list' (one snippet per row, tree of packages) and 'stacked' (compact
|
||||
* wrap-around chips). */
|
||||
export type ScriptsViewMode = "list" | "stacked";
|
||||
|
||||
export const SCRIPTS_VIEW_MODE_STORAGE_KEY = STORAGE_KEY_SCRIPTS_SIDE_PANEL_VIEW;
|
||||
|
||||
/** Pure resolver so callers and tests share one validation rule. */
|
||||
export const parseScriptsViewMode = (value: string | null): ScriptsViewMode =>
|
||||
value === "stacked" ? "stacked" : "list";
|
||||
|
||||
const isScriptsViewMode = (value: string | null): value is ScriptsViewMode =>
|
||||
value === "list" || value === "stacked";
|
||||
|
||||
export const useScriptsViewMode = () =>
|
||||
useStoredString(SCRIPTS_VIEW_MODE_STORAGE_KEY, "list", isScriptsViewMode);
|
||||
@@ -1,59 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import { SCRIPTS_VIEW_MODE_STORAGE_KEY, useScriptsViewMode } from "./useScriptsViewMode.ts";
|
||||
|
||||
test("script view changes follow native storage events from another window", async (t) => {
|
||||
const windowA = new EventTarget();
|
||||
const windowB = new EventTarget();
|
||||
let activeWindow = windowA;
|
||||
const data = new Map<string, string>();
|
||||
const values = {
|
||||
localStorage: { getItem: (key: string) => data.get(key) ?? null, setItem: (key: string, value: string) => { data.set(key, value); } },
|
||||
addEventListener: (type: string, listener: EventListener) => activeWindow.addEventListener(type, listener),
|
||||
removeEventListener: (type: string, listener: EventListener) => activeWindow.removeEventListener(type, listener),
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
};
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, value });
|
||||
}
|
||||
let a!: ReturnType<typeof useScriptsViewMode>;
|
||||
let b!: ReturnType<typeof useScriptsViewMode>;
|
||||
function FirstWindow() { a = useScriptsViewMode(); return null; }
|
||||
function SecondWindow() { b = useScriptsViewMode(); return null; }
|
||||
let first!: ReactTestRenderer;
|
||||
let second!: ReactTestRenderer;
|
||||
t.after(async () => {
|
||||
activeWindow = windowA; await act(async () => first?.unmount());
|
||||
activeWindow = windowB; await act(async () => second?.unmount());
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
});
|
||||
activeWindow = windowA; await act(async () => { first = create(React.createElement(FirstWindow)); });
|
||||
activeWindow = windowB; await act(async () => { second = create(React.createElement(SecondWindow)); });
|
||||
assert.equal(a[0], "list"); assert.equal(b[0], "list");
|
||||
// BrowserWindow A writes shared storage. Electron emits the native event
|
||||
// in B, not an adapter CustomEvent from A's independent JS environment.
|
||||
activeWindow = windowA; await act(async () => a[1]("stacked"));
|
||||
assert.equal(data.get(SCRIPTS_VIEW_MODE_STORAGE_KEY), "stacked");
|
||||
assert.equal(b[0], "list");
|
||||
const notify = async (target: EventTarget, key = SCRIPTS_VIEW_MODE_STORAGE_KEY) => {
|
||||
const event = new Event("storage");
|
||||
Object.defineProperty(event, "key", { value: key });
|
||||
await act(async () => { target.dispatchEvent(event); });
|
||||
};
|
||||
activeWindow = windowB; await notify(windowB);
|
||||
assert.equal(b[0], "stacked");
|
||||
await act(async () => b[1]("list"));
|
||||
assert.equal(a[0], "stacked");
|
||||
activeWindow = windowA; await notify(windowA);
|
||||
assert.equal(a[0], "list");
|
||||
data.set(SCRIPTS_VIEW_MODE_STORAGE_KEY, "stacked");
|
||||
await notify(windowA, "unrelated");
|
||||
assert.equal(a[0], "list", "unrelated preferences must not trigger an update");
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readMonitorPanels, writeMonitorPanels } from './monitorPanelsStorage';
|
||||
import type { MonitorPanel } from '../../domain/monitorPanels';
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import {
|
||||
hostsEqualForIdentityReuse,
|
||||
@@ -124,6 +126,7 @@ import {
|
||||
import { commitVaultGroupMutationPersistence } from "./vaultGroupMutationPersistence";
|
||||
|
||||
type ExportableVaultData = {
|
||||
monitorPanels?: MonitorPanel[];
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities?: Identity[];
|
||||
@@ -1188,6 +1191,7 @@ export const useVaultState = () => {
|
||||
}, []);
|
||||
|
||||
const clearVaultData = useCallback(() => {
|
||||
void withVaultImportLock('vault', async () => writeMonitorPanels([]));
|
||||
updateHosts([]);
|
||||
updateKeys([]);
|
||||
updateIdentities([]);
|
||||
@@ -1972,6 +1976,7 @@ export const useVaultState = () => {
|
||||
|
||||
const exportData = useCallback(
|
||||
(): ExportableVaultData => ({
|
||||
monitorPanels: readMonitorPanels(),
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
@@ -1990,6 +1995,7 @@ export const useVaultState = () => {
|
||||
const importData = useCallback(
|
||||
(payload: Partial<ExportableVaultData>): Promise<void> => {
|
||||
const encryptedWrites: Promise<void>[] = [];
|
||||
if (payload.monitorPanels !== undefined) encryptedWrites.push(withVaultImportLock('vault', async () => writeMonitorPanels(payload.monitorPanels)));
|
||||
if (payload.hosts) {
|
||||
// Cloud payloads no longer carry `lastConnectedAt` (#2629), but any
|
||||
// remote reconciliation replaces the whole local host array. Re-attach
|
||||
|
||||
@@ -86,6 +86,13 @@ test('saved default resolves per session with fresh ids and protocol-aware pruni
|
||||
const again = state.resolveDefaultLayoutForSession({ protocol: 'ssh' });
|
||||
assert.ok(again);
|
||||
assert.notEqual(again.layout.root.id, sshResolved.layout.root.id);
|
||||
await act(async () => {
|
||||
state?.saveWorkspaceLayoutAsDefault(createSidePanelLayout('browser', 'browser-pane'));
|
||||
});
|
||||
assert.equal(state.resolveDefaultLayoutForSession({ protocol: 'telnet' }), null);
|
||||
for (const protocol of ['ssh', 'mosh', 'local']) {
|
||||
assert.equal(state.resolveDefaultLayoutForSession({ protocol })?.focusedTool, 'browser');
|
||||
}
|
||||
} finally {
|
||||
if (previousLocalStorage) {
|
||||
Object.defineProperty(globalThis, 'localStorage', previousLocalStorage);
|
||||
|
||||
@@ -35,7 +35,7 @@ export type ResolvedWorkspaceLayoutPreset = {
|
||||
export function useWorkspaceLayoutPresetState() {
|
||||
/**
|
||||
* Resolve the saved default preset into a fresh layout for a newly
|
||||
* connected session. Panes the session cannot serve (SFTP on non-file
|
||||
* connected session. Panes the session cannot serve (SFTP/Browser on non-file
|
||||
* protocols) are pruned, and ids are rekeyed so several workspaces can
|
||||
* run the same preset in parallel. Returns null when there is no preset
|
||||
* or nothing survives availability filtering.
|
||||
@@ -48,7 +48,7 @@ export function useWorkspaceLayoutPresetState() {
|
||||
const proto = session.protocol ?? 'ssh';
|
||||
const usable = withoutUnavailableSidePanelTools(
|
||||
preset.layout,
|
||||
(tool) => tool !== 'sftp' || canServeSftp(proto),
|
||||
(tool) => (tool !== 'sftp' && tool !== 'browser') || canServeSftp(proto),
|
||||
);
|
||||
if (!usable) return null;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { installBrowserLockFixture } from "./testSupport/browserLockFixture";
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { AISession } from '../infrastructure/ai/types';
|
||||
@@ -8,6 +9,8 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { getAgentRuntime } from '../infrastructure/ai/harness/globalAgentRuntime';
|
||||
import { useAIChatStreaming, type UseAIChatStreamingReturn } from './state/useAIChatStreaming';
|
||||
|
||||
installBrowserLockFixture();
|
||||
|
||||
const storage = new Map<string, string>();
|
||||
const events = new EventTarget();
|
||||
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { installBrowserLockFixture } from "./testSupport/browserLockFixture";
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
@@ -38,6 +39,8 @@ function installLocalStorage(): LocalStorageMock {
|
||||
return localStorage;
|
||||
}
|
||||
|
||||
installBrowserLockFixture();
|
||||
|
||||
const localStorage = installLocalStorage();
|
||||
const {
|
||||
applyLocalVaultPayload,
|
||||
@@ -1420,6 +1423,26 @@ test("hasCloudSyncEntityData ignores settings-only payloads for empty-vault reco
|
||||
);
|
||||
});
|
||||
|
||||
test("surviving browser bookmarks do not suppress empty-vault recovery", () => {
|
||||
const local: SyncPayload = {
|
||||
...vault([]), syncedAt: 1,
|
||||
browserBookmarks: [{
|
||||
id: "app", title: "App", url: "https://app.example.com/", scope: "all-hosts",
|
||||
hostId: null, icon: "A", color: "#247969", order: 0, createdAt: 1, updatedAt: 1,
|
||||
}],
|
||||
};
|
||||
assert.equal(hasMeaningfulCloudSyncData(local), true, "bookmarks must remain syncable");
|
||||
assert.equal(hasCloudSyncEntityData(local), false);
|
||||
const host = {
|
||||
id: "remote", label: "Remote", hostname: "remote.example.com", username: "test",
|
||||
port: 22, protocol: "ssh" as const, tags: [], os: "linux" as const,
|
||||
};
|
||||
for (const hosts of [[host], [host, { ...host, id: "second" }]]) {
|
||||
assert.equal(shouldPromptCloudVaultRecovery(local, { ...vault([]), hosts, syncedAt: 2 }), true);
|
||||
}
|
||||
assert.equal(shouldPromptCloudVaultRecovery({ ...vault([]), syncedAt: 1 }, local), false);
|
||||
});
|
||||
|
||||
test("shouldPromptCloudVaultRecovery ignores settings-only remote payloads", () => {
|
||||
const settingsOnlyPayload: SyncPayload = {
|
||||
hosts: [],
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readMonitorPanels, applyMonitorPanels } from './state/monitorPanelsStorage';
|
||||
import { normalizeMonitorPanels } from '../domain/monitorPanels';
|
||||
import { decryptProviderHeaders, encryptProviderHeaders } from '../infrastructure/ai/providerHeaderCredentials';
|
||||
/**
|
||||
* Sync Payload Builders - Single source of truth for constructing and applying
|
||||
@@ -59,6 +61,7 @@ import {
|
||||
readCommandBlocklistSetting,
|
||||
} from './state/commandBlocklistSettings';
|
||||
import { rehydrateGlobalSftpBookmarks } from './state/sftp/globalSftpBookmarks';
|
||||
import { applyHostBrowserBookmarks, collectHostBrowserBookmarks, getCachedHostBrowserBookmarks } from './hostBrowserBookmarks';
|
||||
import {
|
||||
clampTerminalFontSizeValue,
|
||||
nextTerminalFontSizeSyncVersion,
|
||||
@@ -213,11 +216,11 @@ export function hasMeaningfulCloudSyncData(payload: SyncPayload): boolean {
|
||||
|
||||
/**
|
||||
* Returns true only when the payload contains synced vault entities.
|
||||
* Settings and chat history are ignored: neither proves that the vault's
|
||||
* Settings, chat history, and browser bookmarks are ignored: none proves that the vault's
|
||||
* hosts and keys survived local data loss.
|
||||
*/
|
||||
export function hasCloudSyncEntityData(payload: SyncPayload): boolean {
|
||||
return hasSyncPayloadEntityData(payload, CLOUD_SYNC_PAYLOAD_ENTITY_KEYS.filter((key) => key !== 'aiSessions'));
|
||||
return hasSyncPayloadEntityData(payload, CLOUD_SYNC_PAYLOAD_ENTITY_KEYS.filter((key) => key !== 'aiSessions' && key !== 'browserBookmarks'));
|
||||
}
|
||||
|
||||
export function shouldPromptCloudVaultRecovery(
|
||||
@@ -1077,6 +1080,7 @@ function buildVaultSyncPayload(
|
||||
vault: SyncableVaultData,
|
||||
portForwardingRules?: PortForwardingRule[],
|
||||
): SyncPayload {
|
||||
const browserBookmarks = getCachedHostBrowserBookmarks();
|
||||
return {
|
||||
hosts: sanitizeHostsForSync(vault.hosts),
|
||||
keys: vault.keys,
|
||||
@@ -1088,7 +1092,9 @@ function buildVaultSyncPayload(
|
||||
notes: vault.notes,
|
||||
noteGroups: vault.noteGroups,
|
||||
groupConfigs: vault.groupConfigs,
|
||||
monitorPanels: readMonitorPanels(),
|
||||
portForwardingRules: sanitizePortForwardingRulesForSync(portForwardingRules),
|
||||
...(browserBookmarks !== undefined ? { browserBookmarks } : {}),
|
||||
settings: collectSyncableSettings(),
|
||||
syncedAt: Date.now(),
|
||||
};
|
||||
@@ -1163,6 +1169,7 @@ export async function buildCloudSyncPayload(
|
||||
},
|
||||
): Promise<SyncPayload> {
|
||||
const aiSessions = collectAiSessionsForSync();
|
||||
const browserBookmarks = await collectHostBrowserBookmarks();
|
||||
const base: SyncPayload = {
|
||||
hosts: sanitizeHostsForSync(vault.hosts),
|
||||
keys: vault.keys,
|
||||
@@ -1174,8 +1181,10 @@ export async function buildCloudSyncPayload(
|
||||
notes: vault.notes,
|
||||
noteGroups: vault.noteGroups,
|
||||
groupConfigs: vault.groupConfigs,
|
||||
monitorPanels: readMonitorPanels(),
|
||||
portForwardingRules: sanitizePortForwardingRulesForSync(portForwardingRules),
|
||||
...(aiSessions !== undefined ? { aiSessions } : {}),
|
||||
...(browserBookmarks !== undefined ? { browserBookmarks } : {}),
|
||||
settings: await collectCloudSyncableSettings(),
|
||||
syncedAt: Date.now(),
|
||||
};
|
||||
@@ -1226,6 +1235,8 @@ export async function buildLocalVaultPayloadAsync(
|
||||
portForwardingRules?: PortForwardingRule[],
|
||||
): Promise<SyncPayload> {
|
||||
const base = buildLocalVaultPayload(vault, portForwardingRules);
|
||||
const browserBookmarks = await collectHostBrowserBookmarks();
|
||||
if (browserBookmarks !== undefined) base.browserBookmarks = browserBookmarks;
|
||||
const {
|
||||
collectPluginSyncSidecarsFromHost,
|
||||
isPluginSidecarHostUnavailableError,
|
||||
@@ -1352,6 +1363,7 @@ async function preparePayloadApply(
|
||||
// so a previously poisoned cloud/backup snapshot can still restore host
|
||||
// shells and let the user re-enter secrets (#2702).
|
||||
const sanitizedPayload = stripSyncPayloadEncryptedCredentials(payload);
|
||||
const monitorPanelsAtPrepare = readMonitorPanels();
|
||||
// Prepare credentials before any vault or settings mutation. Encryption can
|
||||
// fail when local credential storage is unavailable; reuse the result below.
|
||||
const providers = sanitizedPayload.settings?.ai?.providers;
|
||||
@@ -1393,6 +1405,7 @@ async function preparePayloadApply(
|
||||
}
|
||||
|
||||
return () => Promise.resolve(importers.importVaultData(JSON.stringify(vaultImport))).then(async () => {
|
||||
if (sanitizedPayload.monitorPanels !== undefined) await applyMonitorPanels(normalizeMonitorPanels(sanitizedPayload.monitorPanels), monitorPanelsAtPrepare, options.includeLocalOnlyData);
|
||||
// Only import port-forwarding rules when the payload explicitly carries
|
||||
// them. Absent field = "payload was created before this feature existed",
|
||||
// so local rules are preserved. Explicitly present [] = "remote has no
|
||||
@@ -1400,6 +1413,9 @@ async function preparePayloadApply(
|
||||
if (sanitizedPayload.portForwardingRules !== undefined && importers.importPortForwardingRules) {
|
||||
importers.importPortForwardingRules(sanitizedPayload.portForwardingRules);
|
||||
}
|
||||
if (sanitizedPayload.browserBookmarks !== undefined) {
|
||||
await applyHostBrowserBookmarks(sanitizedPayload.browserBookmarks);
|
||||
}
|
||||
|
||||
// AI chat sessions merge append-only: a cloud snapshot may be missing
|
||||
// turns that only exist on this device, so union instead of replace.
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/** Web Locks fixture for tests with a simulated browser window. */
|
||||
export function installBrowserLockFixture(): void {
|
||||
const states = new Map<string, {
|
||||
readers: number;
|
||||
writer: boolean;
|
||||
pending: Array<{ mode: LockMode; grant: () => void }>;
|
||||
}>();
|
||||
const locks = {
|
||||
async request<T>(
|
||||
name: string,
|
||||
optionsOrTask: LockOptions | ((lock: Pick<Lock, "name" | "mode"> | null) => T | Promise<T>),
|
||||
callback?: (lock: Pick<Lock, "name" | "mode"> | null) => T | Promise<T>,
|
||||
): Promise<T> {
|
||||
const options = typeof optionsOrTask === "function" ? {} : optionsOrTask;
|
||||
const task = typeof optionsOrTask === "function" ? optionsOrTask : callback!;
|
||||
const mode = options.mode ?? "exclusive";
|
||||
const state = states.get(name) ?? { readers: 0, writer: false, pending: [] };
|
||||
states.set(name, state);
|
||||
if (options.ifAvailable && (state.writer || state.pending.length || (mode === "exclusive" && state.readers))) {
|
||||
return task(null);
|
||||
}
|
||||
const drain = () => {
|
||||
while (!state.writer && state.pending.length) {
|
||||
const next = state.pending[0];
|
||||
if (next.mode === "exclusive" && state.readers) return;
|
||||
state.pending.shift();
|
||||
if (next.mode === "exclusive") state.writer = true;
|
||||
else state.readers += 1;
|
||||
next.grant();
|
||||
}
|
||||
};
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
state.pending.push({ mode, grant: () => {
|
||||
void (async () => {
|
||||
try {
|
||||
return await task({ name, mode });
|
||||
} finally {
|
||||
if (mode === "exclusive") state.writer = false;
|
||||
else state.readers -= 1;
|
||||
drain();
|
||||
}
|
||||
})().then(resolve, reject);
|
||||
} });
|
||||
drain();
|
||||
});
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
value: { locks },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { I18nProvider } from '../application/i18n/I18nProvider';
|
||||
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment, runWithAct } from './test-support/renderReactDom';
|
||||
|
||||
test('backup cleanup confirms, preserves data on cancel, reports errors and refreshes after retry', async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const globals = {
|
||||
localStorage: dom.window.localStorage,
|
||||
MutationObserver: dom.window.MutationObserver,
|
||||
NodeFilter: dom.window.NodeFilter,
|
||||
HTMLInputElement: dom.window.HTMLInputElement,
|
||||
};
|
||||
const previous = new Map(Object.keys(globals).map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
|
||||
for (const [key, value] of Object.entries(globals)) Object.defineProperty(globalThis, key, { configurable: true, value });
|
||||
Object.defineProperty(dom.window.navigator, 'locks', { configurable: true, value: {
|
||||
request: async (_name: string, _options: unknown, callback: (lock: object) => Promise<void>) => callback({}),
|
||||
} });
|
||||
const { LocalBackupsPanel } = await import('./cloud-sync/CloudSyncLocalBackupsPanel');
|
||||
const { toast } = await import('./ui/toast');
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
const notices: string[] = [];
|
||||
const success = toast.success;
|
||||
const error = toast.error;
|
||||
toast.success = (message) => { notices.push(`success:${message}`); };
|
||||
toast.error = (message) => { notices.push(`error:${message}`); };
|
||||
let calls = 0;
|
||||
let fail = true;
|
||||
let hasBackup = true;
|
||||
let finish: (() => void) | undefined;
|
||||
let finishProbe: ((result: { encryptionAvailable: boolean }) => void) | undefined;
|
||||
Object.defineProperty(dom.window, 'netcatty', { configurable: true, value: {
|
||||
// Cleanup must remain available even when encrypted backups cannot be read.
|
||||
getVaultBackupCapabilities: () => new Promise((resolve) => { finishProbe = resolve; }),
|
||||
listVaultBackups: async () => hasBackup ? [{
|
||||
id: 'fixture', createdAt: 1, reason: 'before_restore', fingerprint: 'fixture',
|
||||
preview: { hostCount: 1, keyCount: 0, snippetCount: 0, noteCount: 0, identityCount: 0, portForwardingRuleCount: 0 },
|
||||
}] : [],
|
||||
clearVaultBackups: async () => {
|
||||
calls += 1;
|
||||
if (fail) throw new Error('access denied');
|
||||
await new Promise<void>((resolve) => { finish = resolve; });
|
||||
hasBackup = false;
|
||||
return { deletedCount: 1 };
|
||||
},
|
||||
} });
|
||||
const click = async (node: Element) => {
|
||||
await dispatchDomEvent(node, new dom.window.MouseEvent('click', { bubbles: true }));
|
||||
await flushEffects();
|
||||
};
|
||||
const button = (text: string, scope: ParentNode = dom.document) => {
|
||||
const node = [...scope.querySelectorAll('button')].find((el) => el.textContent === text);
|
||||
assert.ok(node, `button ${text}`);
|
||||
return node as HTMLButtonElement;
|
||||
};
|
||||
try {
|
||||
await renderer.render(<I18nProvider locale="en"><LocalBackupsPanel onApplyPayload={() => { throw new Error('must not restore'); }} /></I18nProvider>);
|
||||
await flushEffects();
|
||||
assert.doesNotMatch(dom.document.body.textContent ?? '', /Master key required/);
|
||||
assert.equal([...dom.document.querySelectorAll('button')].some((el) => el.textContent === 'Restore'), false);
|
||||
await runWithAct(async () => { finishProbe?.({ encryptionAvailable: false }); });
|
||||
await flushEffects();
|
||||
assert.doesNotMatch(dom.document.body.textContent ?? '', /Master key required/);
|
||||
assert.equal([...dom.document.querySelectorAll('button')].some((el) => el.textContent === 'Restore'), false);
|
||||
await click(button('Clear all backups'));
|
||||
let dialog = dom.document.querySelector('[role="dialog"]');
|
||||
assert.ok(dialog);
|
||||
assert.match(dialog.textContent ?? '', /current hosts, saved credentials and cloud copies will remain/);
|
||||
await click(button('Cancel', dialog));
|
||||
assert.equal(calls, 0);
|
||||
assert.equal(hasBackup, true);
|
||||
await click(button('Clear all backups'));
|
||||
dialog = dom.document.querySelector('[role="dialog"]');
|
||||
assert.ok(dialog);
|
||||
await click(button('Clear all backups', dialog));
|
||||
assert.equal(calls, 1);
|
||||
assert.match(notices[0], /^error:/);
|
||||
assert.equal(hasBackup, true);
|
||||
dialog = dom.document.querySelector('[role="dialog"]');
|
||||
assert.ok(dialog);
|
||||
fail = false;
|
||||
await click(button('Clear all backups', dialog));
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(button('Clear all backups', dialog).disabled, true);
|
||||
assert.equal(button('Cancel', dialog).disabled, true);
|
||||
assert.equal(notices.length, 1);
|
||||
await runWithAct(async () => { finish?.(); });
|
||||
await flushEffects();
|
||||
assert.equal(dom.document.querySelector('[role="dialog"]'), null);
|
||||
assert.match(dom.document.body.textContent ?? '', /No local backups yet/);
|
||||
assert.match(notices[1], /^success:/);
|
||||
} finally {
|
||||
toast.success = success;
|
||||
toast.error = error;
|
||||
await renderer.unmount();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
test("FidoPromptModal ships PIN and touch UI wiring", () => {
|
||||
const source = readFileSync(join(import.meta.dirname, "FidoPromptModal.tsx"), "utf8");
|
||||
assert.match(source, /kind === "touch"/);
|
||||
assert.match(source, /fido-pin-input/);
|
||||
assert.match(source, /fido\.prompt\.pinTitle/);
|
||||
assert.match(source, /fido\.prompt\.touchTitle/);
|
||||
assert.match(source, /onSubmit\(request\.requestId/);
|
||||
assert.match(source, /onCancel\(request\.requestId/);
|
||||
});
|
||||
|
||||
test("AppView mounts FidoPromptModal", () => {
|
||||
const source = readFileSync(
|
||||
join(import.meta.dirname, "../application/app/AppView.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(source, /FidoPromptModal/);
|
||||
assert.match(source, /fidoPromptQueue/);
|
||||
assert.match(source, /handleFidoPromptSubmit/);
|
||||
});
|
||||
|
||||
test("GenerateStandardPanel exposes FIDO options", () => {
|
||||
const source = readFileSync(
|
||||
join(import.meta.dirname, "keychain/GenerateStandardPanel.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(source, /ED25519-SK/);
|
||||
assert.match(source, /ECDSA-SK/);
|
||||
assert.match(source, /resident/);
|
||||
assert.match(source, /verifyRequired/);
|
||||
assert.match(source, /fidoHint/);
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
/** PIN entry and user-presence prompts from OpenSSH. */
|
||||
import { Clock3, Eye, EyeOff, Hand, KeyRound, Loader2, ShieldAlert, Usb } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useId, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { Button } from "./ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "./ui/dialog";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
|
||||
export type FidoPromptKind = "pin" | "touch" | "confirm" | "recovery";
|
||||
export interface FidoPromptRequest {
|
||||
requestId: string;
|
||||
kind: FidoPromptKind;
|
||||
message?: string;
|
||||
title?: string;
|
||||
keyName?: string;
|
||||
notificationOnly?: boolean;
|
||||
reason?: "device-unavailable" | "timeout" | "pin-failed" | "failed";
|
||||
allowPasswordFallback?: boolean;
|
||||
}
|
||||
interface FidoPromptModalProps {
|
||||
request: FidoPromptRequest | null;
|
||||
onSubmit: (requestId: string, response: string) => void;
|
||||
onCancel: (requestId: string) => void;
|
||||
}
|
||||
|
||||
export const FidoPromptModal: React.FC<FidoPromptModalProps> = ({ request, onSubmit, onCancel }) => {
|
||||
const { t } = useI18n();
|
||||
// Retain the content during Radix's closing animation, but clear the PIN.
|
||||
const [lastRequest, setLastRequest] = useState(request);
|
||||
const [pin, setPin] = useState("");
|
||||
const [showPin, setShowPin] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const descriptionId = useId();
|
||||
useEffect(() => { if (request) setLastRequest(request); }, [request]);
|
||||
useEffect(() => {
|
||||
setPin("");
|
||||
setShowPin(false);
|
||||
setIsSubmitting(false);
|
||||
}, [request?.requestId]);
|
||||
|
||||
const visibleRequest = request || lastRequest;
|
||||
const isTouch = visibleRequest?.kind === "touch" || visibleRequest?.kind === "confirm";
|
||||
const isRecovery = visibleRequest?.kind === "recovery";
|
||||
const recoveryReason = visibleRequest?.reason || "failed";
|
||||
const RecoveryIcon = recoveryReason === "device-unavailable" ? Usb : recoveryReason === "timeout" ? Clock3 : ShieldAlert;
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!request || request.notificationOnly || isSubmitting || (!isTouch && !isRecovery && !pin)) return;
|
||||
setIsSubmitting(true);
|
||||
onSubmit(request.requestId, isRecovery ? "retry" : isTouch ? "" : pin);
|
||||
}, [request, isSubmitting, isTouch, isRecovery, pin, onSubmit]);
|
||||
const handleCancel = useCallback(() => {
|
||||
if (request) onCancel(request.requestId);
|
||||
}, [request, onCancel]);
|
||||
|
||||
const message = visibleRequest?.message?.trim();
|
||||
const hasPinAndTouch = !isTouch && /user presence|touch|tap/i.test(message || "");
|
||||
const isErrorPrompt = /incorrect|invalid|wrong|failed|retries|attempts|错误|錯誤|剩余|剩餘/i.test(message || "");
|
||||
|
||||
return (
|
||||
<Dialog open={!!request} onOpenChange={open => !open && handleCancel()}>
|
||||
{visibleRequest && (
|
||||
<DialogContent
|
||||
hideCloseButton
|
||||
aria-describedby={descriptionId}
|
||||
overlayClassName="fido-prompt-overlay bg-black/20"
|
||||
className="fido-prompt-dialog z-[51] w-[calc(100%_-_2rem)] max-w-[400px] gap-0 overflow-hidden rounded-xl border-border/70 p-6 sm:rounded-xl"
|
||||
style={{ boxShadow: '0 20px 60px -20px rgb(0 0 0 / 0.28)' }}
|
||||
>
|
||||
<div key={visibleRequest.requestId} className="fido-prompt-stage">
|
||||
{isRecovery ? (
|
||||
<div className="pb-2 pt-2 text-center">
|
||||
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-primary/8 text-primary" aria-hidden="true">
|
||||
<RecoveryIcon size={29} strokeWidth={1.5} />
|
||||
</div>
|
||||
<DialogTitle className="text-base">{t(`fido.recovery.${recoveryReason}.title`)}</DialogTitle>
|
||||
{visibleRequest.keyName && <p className="mt-2 truncate text-sm font-medium">{visibleRequest.keyName}</p>}
|
||||
<DialogDescription id={descriptionId} className="mt-2 text-sm leading-relaxed">
|
||||
{t(`fido.recovery.${recoveryReason}.desc`)}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
) : isTouch ? (
|
||||
<div className="pb-5 pt-2 text-center">
|
||||
<div className="relative mx-auto mb-6 flex h-24 w-24 items-center justify-center" aria-hidden="true">
|
||||
<span className="fido-touch-ring absolute inset-0 rounded-full bg-primary/5" />
|
||||
<span className="fido-touch-ring fido-touch-ring-delayed absolute inset-0 rounded-full bg-primary/5" />
|
||||
<div className="fido-touch-symbol relative flex h-16 w-16 items-center justify-center rounded-full bg-primary/8 text-primary">
|
||||
<Hand size={30} strokeWidth={1.5} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogTitle className="text-base">{visibleRequest.title || t("fido.prompt.touchTitle")}</DialogTitle>
|
||||
{visibleRequest.keyName && <p className="mt-2 truncate text-sm font-medium">{visibleRequest.keyName}</p>}
|
||||
<DialogDescription id={descriptionId} className="mt-2 text-sm leading-relaxed">
|
||||
{t("fido.prompt.touchDesc", { keyName: visibleRequest.keyName || "FIDO2" })}
|
||||
</DialogDescription>
|
||||
<div role="status" className="mt-5 flex items-center justify-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="fido-status-dot h-1.5 w-1.5 rounded-full bg-primary" aria-hidden="true" />
|
||||
{t(visibleRequest.notificationOnly ? "fido.prompt.touchAutomatic" : "fido.prompt.touchWaiting")}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary/8 text-primary" aria-hidden="true">
|
||||
<KeyRound size={21} strokeWidth={1.7} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<DialogTitle className="text-base">{visibleRequest.title || t("fido.prompt.pinTitle")}</DialogTitle>
|
||||
{visibleRequest.keyName && <p className="mt-1 truncate text-xs font-medium">{visibleRequest.keyName}</p>}
|
||||
<DialogDescription id={descriptionId} className="mt-1 text-xs leading-relaxed">
|
||||
{t(hasPinAndTouch ? "fido.prompt.pinTouchDesc" : "fido.prompt.pinDesc", { keyName: visibleRequest.keyName || "FIDO2" })}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fido-pin-input" className="text-xs">{t("fido.prompt.pinLabel")}</Label>
|
||||
<div className="relative">
|
||||
<Input id="fido-pin-input" type={showPin ? "text" : "password"}
|
||||
value={pin} onChange={e => setPin(e.target.value)} disabled={isSubmitting}
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Enter") { e.preventDefault(); handleSubmit(); }
|
||||
}} autoFocus className="h-11 pr-11" autoComplete="off" spellCheck={false} />
|
||||
<Button type="button" variant="ghost" size="icon" disabled={isSubmitting}
|
||||
className="absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2 text-muted-foreground"
|
||||
aria-label={t(showPin ? "fido.prompt.hidePin" : "fido.prompt.showPin")}
|
||||
aria-pressed={showPin} onClick={() => setShowPin(value => !value)}>
|
||||
{showPin ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{message && (isErrorPrompt ? (
|
||||
<p role="alert" className="mt-3 break-words text-xs leading-relaxed text-destructive">{message}</p>
|
||||
) : (
|
||||
<details className="mt-3 text-xs text-muted-foreground">
|
||||
<summary className="w-fit cursor-pointer rounded-sm text-[11px] outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring">{t("fido.prompt.details")}</summary>
|
||||
<p className="mt-2 break-words leading-relaxed">{message}</p>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
<div className="-mx-6 -mb-6 mt-5 flex items-center justify-end gap-2 border-t border-border/50 bg-muted/15 px-6 py-3.5">
|
||||
<Button type="button" variant="ghost" className="h-9" onClick={handleCancel} disabled={isSubmitting}>{t(visibleRequest.notificationOnly ? "common.close" : "common.cancel")}</Button>
|
||||
{isRecovery && visibleRequest.allowPasswordFallback && <Button type="button" variant="outline" className="h-9" onClick={() => request && onSubmit(request.requestId, "fallback")}>{t("fido.recovery.usePassword")}</Button>}
|
||||
{!visibleRequest.notificationOnly && <Button type="button" className="h-9 min-w-20 gap-2" onClick={handleSubmit} disabled={isSubmitting || (!isTouch && !isRecovery && !pin)}>
|
||||
{isSubmitting && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />}
|
||||
{t(isSubmitting ? "fido.prompt.verifying" : isRecovery ? "fido.recovery.retry" : "common.continue")}
|
||||
</Button>}
|
||||
</div>
|
||||
</DialogContent>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
export default FidoPromptModal;
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import { useHostBrowserPanel } from '../application/state/useHostBrowserPanel';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
|
||||
export function HostBrowserSidePanel(props: Parameters<typeof useHostBrowserPanel>[0]) {
|
||||
const { elementRef, error, retry } = useHostBrowserPanel(props);
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div ref={elementRef} className="relative h-full min-h-0 w-full overflow-hidden" data-section="host-browser-panel" data-browser-host-id={props.host.id}>
|
||||
{error && <div className="p-4 text-sm text-muted-foreground">
|
||||
<p>{error}</p>
|
||||
<button type="button" className="mt-3 text-primary" onClick={retry}>{t('common.retry')}</button>
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
EyeOff,
|
||||
FileText,
|
||||
FolderPlus,
|
||||
PlugZap,
|
||||
Plus,
|
||||
Settings2,
|
||||
Tag,
|
||||
@@ -61,6 +62,7 @@ import { Combobox, ComboboxOption, MultiCombobox } from "./ui/combobox";
|
||||
import { Input } from "./ui/input";
|
||||
import { Switch } from "./ui/switch";
|
||||
import { toast } from "./ui/toast";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
import {
|
||||
ChainPanel,
|
||||
@@ -1015,16 +1017,39 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
initialData ? t("hostDetails.title.details") : t("hostDetails.title.new")
|
||||
}
|
||||
actions={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSaveHost}
|
||||
aria-label={t("hostDetails.saveAria")}
|
||||
>
|
||||
<Check size={16} />
|
||||
</Button>
|
||||
<>
|
||||
{!pluginProtocolActive && form.protocol !== "telnet" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleStartTest}
|
||||
disabled={!canSaveHost}
|
||||
aria-label={t("hostConnectionTest.title")}
|
||||
>
|
||||
<PlugZap size={16} />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{t("hostConnectionTest.title")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSaveHost}
|
||||
aria-label={t("hostDetails.saveAria")}
|
||||
>
|
||||
<Check size={16} />
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<AsidePanelContent>
|
||||
@@ -1346,19 +1371,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
</Button>
|
||||
)}
|
||||
</AsidePanelContent>
|
||||
<AsidePanelFooter className="flex flex-row gap-2">
|
||||
{!pluginProtocolActive && form.protocol !== "telnet" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-10 px-4"
|
||||
onClick={handleStartTest}
|
||||
disabled={!canSaveHost}
|
||||
>
|
||||
{t("hostConnectionTest.test")}
|
||||
</Button>
|
||||
)}
|
||||
<AsidePanelFooter>
|
||||
<Button
|
||||
className="flex-1 h-10"
|
||||
className="w-full h-10"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSaveHost}
|
||||
>
|
||||
|
||||
@@ -282,6 +282,7 @@ interface HostTreeViewProps {
|
||||
onExpandAll?: (paths: string[]) => void;
|
||||
onCollapseAll?: () => void;
|
||||
onConnect: (host: Host) => void;
|
||||
onOpenBrowser?: (host: Host) => void;
|
||||
onEditHost: (host: Host) => void;
|
||||
onDuplicateHost: (host: Host) => void;
|
||||
onDeleteHost: (host: Host) => void;
|
||||
@@ -322,6 +323,7 @@ interface TreeNodeProps {
|
||||
expandedPaths: Set<string>;
|
||||
onToggle: (path: string) => void;
|
||||
onConnect: (host: Host) => void;
|
||||
onOpenBrowser?: (host: Host) => void;
|
||||
onEditHost: (host: Host) => void;
|
||||
onDuplicateHost: (host: Host) => void;
|
||||
onDeleteHost: (host: Host) => void;
|
||||
@@ -368,6 +370,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
expandedPaths,
|
||||
onToggle,
|
||||
onConnect,
|
||||
onOpenBrowser,
|
||||
onEditHost,
|
||||
onDuplicateHost,
|
||||
onDeleteHost,
|
||||
@@ -584,6 +587,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
expandedPaths={expandedPaths}
|
||||
onToggle={onToggle}
|
||||
onConnect={onConnect}
|
||||
onOpenBrowser={onOpenBrowser}
|
||||
onEditHost={onEditHost}
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
@@ -627,6 +631,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
host={host}
|
||||
depth={depth + 1}
|
||||
onConnect={onConnect}
|
||||
onOpenBrowser={onOpenBrowser}
|
||||
onEditHost={onEditHost}
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
@@ -660,6 +665,7 @@ interface HostTreeItemProps {
|
||||
host: Host;
|
||||
depth: number;
|
||||
onConnect: (host: Host) => void;
|
||||
onOpenBrowser?: (host: Host) => void;
|
||||
onEditHost: (host: Host) => void;
|
||||
onDuplicateHost: (host: Host) => void;
|
||||
onDeleteHost: (host: Host) => void;
|
||||
@@ -706,6 +712,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
host,
|
||||
depth,
|
||||
onConnect,
|
||||
onOpenBrowser,
|
||||
onEditHost,
|
||||
onDuplicateHost,
|
||||
onDeleteHost,
|
||||
@@ -871,6 +878,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
<HostTreeHostContextMenuContent
|
||||
host={host}
|
||||
onConnect={onConnect}
|
||||
onOpenBrowser={onOpenBrowser}
|
||||
onEditHost={onEditHost}
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onCopyHostname={onCopyHostname}
|
||||
@@ -890,6 +898,7 @@ const HostTreeViewInner: React.FC<HostTreeViewProps> = ({
|
||||
onExpandAll: externalOnExpandAll,
|
||||
onCollapseAll: externalOnCollapseAll,
|
||||
onConnect,
|
||||
onOpenBrowser,
|
||||
onEditHost,
|
||||
onDuplicateHost,
|
||||
onDeleteHost,
|
||||
@@ -1199,6 +1208,7 @@ const HostTreeViewInner: React.FC<HostTreeViewProps> = ({
|
||||
expandedPaths={expandedPaths}
|
||||
onToggle={togglePath}
|
||||
onConnect={onConnect}
|
||||
onOpenBrowser={onOpenBrowser}
|
||||
onEditHost={onEditHost}
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
@@ -1240,6 +1250,7 @@ const HostTreeViewInner: React.FC<HostTreeViewProps> = ({
|
||||
host={item.host}
|
||||
depth={item.depth}
|
||||
onConnect={onConnect}
|
||||
onOpenBrowser={onOpenBrowser}
|
||||
onEditHost={onEditHost}
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
@@ -1289,6 +1300,7 @@ function hostTreeViewAreEqual(prev: HostTreeViewProps, next: HostTreeViewProps):
|
||||
&& prev.onExpandAll === next.onExpandAll
|
||||
&& prev.onCollapseAll === next.onCollapseAll
|
||||
&& prev.onConnect === next.onConnect
|
||||
&& prev.onOpenBrowser === next.onOpenBrowser
|
||||
&& prev.onEditHost === next.onEditHost
|
||||
&& prev.onDuplicateHost === next.onDuplicateHost
|
||||
&& prev.onDeleteHost === next.onDeleteHost
|
||||
|
||||
@@ -379,6 +379,12 @@ echo $3 >> "$FILE"`);
|
||||
type: keyType,
|
||||
bits: keySize,
|
||||
comment: `${draftKey.label.trim()}@netcatty`,
|
||||
resident: keyType === "ED25519-SK" || keyType === "ECDSA-SK"
|
||||
? !!(draftKey as { resident?: boolean }).resident
|
||||
: undefined,
|
||||
verifyRequired: keyType === "ED25519-SK" || keyType === "ECDSA-SK"
|
||||
? !!(draftKey as { verifyRequired?: boolean }).verifyRequired
|
||||
: undefined,
|
||||
});
|
||||
if (!result) {
|
||||
throw new Error(
|
||||
@@ -389,15 +395,17 @@ echo $3 >> "$FILE"`);
|
||||
throw new Error(result.error || t("keychain.error.generateKeyPairFailed"));
|
||||
}
|
||||
|
||||
const resolvedType = (result.keyType as KeyType | undefined) || keyType;
|
||||
const isFidoSk = resolvedType === "ED25519-SK" || resolvedType === "ECDSA-SK";
|
||||
const newKey: SSHKey = {
|
||||
id: crypto.randomUUID(),
|
||||
label: draftKey.label.trim(),
|
||||
type: keyType,
|
||||
keySize: keyType !== "ED25519" ? keySize : undefined,
|
||||
type: resolvedType,
|
||||
keySize: resolvedType === "ED25519" || isFidoSk ? undefined : keySize,
|
||||
privateKey: result.privateKey,
|
||||
publicKey: result.publicKey,
|
||||
passphrase: draftKey.passphrase,
|
||||
savePassphrase: draftKey.savePassphrase,
|
||||
passphrase: isFidoSk ? undefined : draftKey.passphrase,
|
||||
savePassphrase: isFidoSk ? undefined : draftKey.savePassphrase,
|
||||
source: "generated",
|
||||
category: "key",
|
||||
created: Date.now(),
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { createDomRenderer, dispatchDomEvent, installDomEnvironment, runWithAct } from "./test-support/renderReactDom.tsx";
|
||||
import type { Snippet } from "../types";
|
||||
|
||||
test("stacked library continues loading after running a script and returning", async (t) => {
|
||||
const env = installDomEnvironment();
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, "IntersectionObserver");
|
||||
const previousStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: env.window.localStorage });
|
||||
const observers: Array<{ target: Element | null; intersect: () => void }> = [];
|
||||
class Observer {
|
||||
target: Element | null = null;
|
||||
constructor(private callback: IntersectionObserverCallback) { observers.push(this); }
|
||||
observe(target: Element) { this.target = target; }
|
||||
disconnect() { this.target = null; }
|
||||
intersect = () => {
|
||||
if (this.target?.isConnected) {
|
||||
this.callback([{ target: this.target, isIntersecting: true } as IntersectionObserverEntry], this as unknown as IntersectionObserver);
|
||||
}
|
||||
};
|
||||
}
|
||||
Object.defineProperty(globalThis, "IntersectionObserver", { configurable: true, value: Observer });
|
||||
const { ScriptsSidePanel } = await import("./ScriptsSidePanel.tsx");
|
||||
const { I18nProvider } = await import("../application/i18n/I18nProvider.tsx");
|
||||
const { SCRIPTS_VIEW_MODE_STORAGE_KEY } = await import("../application/state/useScriptsViewMode.ts");
|
||||
env.window.localStorage.setItem(SCRIPTS_VIEW_MODE_STORAGE_KEY, "stacked");
|
||||
const renderer = await createDomRenderer(env.document);
|
||||
t.after(async () => {
|
||||
await renderer.unmount();
|
||||
if (previous) Object.defineProperty(globalThis, "IntersectionObserver", previous);
|
||||
else Reflect.deleteProperty(globalThis, "IntersectionObserver");
|
||||
if (previousStorage) Object.defineProperty(globalThis, "localStorage", previousStorage);
|
||||
else Reflect.deleteProperty(globalThis, "localStorage");
|
||||
env.cleanup();
|
||||
});
|
||||
const snippets: Snippet[] = Array.from({ length: 300 }, (_, i) => ({
|
||||
id: `script-${i}`, label: `Script ${String(i).padStart(3, "0")}`, command: "echo ok", kind: "script", package: "",
|
||||
}));
|
||||
const executed: string[] = [];
|
||||
await renderer.render(
|
||||
<I18nProvider locale="en"><ScriptsSidePanel snippets={snippets} packages={[]}
|
||||
onSnippetClick={() => assert.fail("scripts use the existing script execution path")}
|
||||
onRunScript={(snippet) => executed.push(snippet.id)} /></I18nProvider>,
|
||||
);
|
||||
const library = () => env.document.querySelector('[data-scripts-view="stacked"]');
|
||||
const chipCount = () => library()?.querySelectorAll("button").length;
|
||||
const button = (label: string) => {
|
||||
const result = Array.from(env.document.querySelectorAll("button")).find((element) => element.textContent === label);
|
||||
assert.ok(result, `missing button ${label}`);
|
||||
return result;
|
||||
};
|
||||
assert.equal(chipCount(), 120);
|
||||
const firstSentinel = observers.at(-1)?.target;
|
||||
assert.ok(firstSentinel?.isConnected);
|
||||
await dispatchDomEvent(button("Script 000"), new env.window.MouseEvent("click", { bubbles: true }));
|
||||
assert.deepEqual(executed, ["script-0"]);
|
||||
assert.equal(library(), null, "execution opens Running");
|
||||
assert.equal(firstSentinel.isConnected, false);
|
||||
await dispatchDomEvent(button("Library"), new env.window.MouseEvent("click", { bubbles: true }));
|
||||
assert.equal(chipCount(), 120);
|
||||
assert.ok(observers.at(-1)?.target?.isConnected, "the new sentinel must be observed");
|
||||
assert.notEqual(observers.at(-1)?.target, firstSentinel);
|
||||
await runWithAct(() => { observers.at(-1)?.intersect(); });
|
||||
assert.equal(chipCount(), 240);
|
||||
await dispatchDomEvent(button("Running"), new env.window.MouseEvent("click", { bubbles: true }));
|
||||
await dispatchDomEvent(button("Library"), new env.window.MouseEvent("click", { bubbles: true }));
|
||||
assert.equal(chipCount(), 240, "returning preserves the loaded window");
|
||||
await runWithAct(() => { observers.at(-1)?.intersect(); });
|
||||
assert.equal(chipCount(), 300);
|
||||
assert.ok(button("Script 299"));
|
||||
});
|
||||
@@ -174,31 +174,3 @@ test("scripts side panel does not start a drag from a non-primary pointer", () =
|
||||
assert.match(source, /isNonPrimaryPointer/);
|
||||
assert.match(source, /primaryOnlyDragHandlers/);
|
||||
});
|
||||
|
||||
test("scripts side panel offers a stacked view toggle that persists the choice", () => {
|
||||
assert.match(source, /useScriptsViewMode/);
|
||||
const toggle = source.match(/viewStacked[\s\S]*?\{viewMode === 'stacked' \? <List size=\{14\} \/> : <LayoutGrid size=\{14\} \/>\}/);
|
||||
assert.ok(toggle, "view toggle button should swap list / stacked icons");
|
||||
assert.match(source, /aria-pressed=\{viewMode === 'stacked'\}/);
|
||||
// The chosen mode persists via the shared hook so it survives app restarts.
|
||||
assert.match(source, /useScriptsViewMode\(\)/);
|
||||
assert.match(source, /setViewMode\(viewMode === 'stacked' \? 'list' : 'stacked'\)/);
|
||||
});
|
||||
|
||||
test("scripts side panel stacked view renders wrap-around chips and keeps run behavior", () => {
|
||||
const stacked = source.match(/viewMode === 'stacked' \? \([\s\S]*?\) : \(/);
|
||||
assert.ok(stacked, "stacked branch should exist in the content area");
|
||||
assert.match(stacked[0], /flex flex-wrap/);
|
||||
// Chips reuse the same click handler (multi-select / run / execute) as rows.
|
||||
assert.match(stacked[0], /onClick=\{\(\) => handleSnippetClick\(snippet\)\}/);
|
||||
// Packages stay visible as section headers with their counts.
|
||||
assert.match(stacked[0], /item\.row\.name/);
|
||||
assert.match(stacked[0], /item\.countLabel/);
|
||||
assert.match(source, /const SnippetChip = memo/);
|
||||
// The stacked chip keeps the same context menu contract (edit / delete / run).
|
||||
const chip = source.match(/const SnippetChip = memo[\s\S]*?SnippetChip\.displayName/);
|
||||
assert.ok(chip, "SnippetChip component should exist");
|
||||
assert.match(chip[0], /ContextMenuContent/);
|
||||
assert.match(chip[0], /onRunParallel/);
|
||||
assert.match(chip[0], /onEdit/);
|
||||
});
|
||||
@@ -15,8 +15,6 @@ import {
|
||||
Expand,
|
||||
FolderPlus,
|
||||
Layers,
|
||||
LayoutGrid,
|
||||
List,
|
||||
Minimize2,
|
||||
Package,
|
||||
Play,
|
||||
@@ -29,7 +27,6 @@ import {
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { getScriptRecordingSnapshot, subscribeScriptRecording } from '../application/state/scriptRecordingStore.ts';
|
||||
import { useScriptsViewMode } from '../application/state/useScriptsViewMode.ts';
|
||||
import { VaultDeleteConfirmDialog } from './vault/VaultDeleteConfirmDialog';
|
||||
import {
|
||||
collectSnippetPackageTreePaths,
|
||||
@@ -64,12 +61,6 @@ const toolbarIconButtonClass =
|
||||
'h-7 w-7 shrink-0 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors disabled:opacity-40 disabled:pointer-events-none';
|
||||
|
||||
const SCRIPT_ROW_HEIGHT = 34;
|
||||
// The stacked view renders wrapping chips that cannot use FixedSizeVirtualList,
|
||||
// so cap the initial render and grow lazily as the user scrolls to keep a huge
|
||||
// library (or a broad search) from mounting every chip at once.
|
||||
const STACKED_INITIAL_RENDER_COUNT = 120;
|
||||
const STACKED_RENDER_BATCH = 120;
|
||||
|
||||
const isRootPackagePath = (path: string): boolean => {
|
||||
const body = path.startsWith('/') ? path.slice(1) : path;
|
||||
return body.length > 0 && !body.includes('/');
|
||||
@@ -179,14 +170,10 @@ export function buildScriptsSidePanelRows({
|
||||
snippets,
|
||||
packages,
|
||||
expandedPaths,
|
||||
snippetsBeforeChildren = false,
|
||||
}: {
|
||||
snippets: Snippet[];
|
||||
packages: string[];
|
||||
expandedPaths: Set<string>;
|
||||
/** Emit each package's own snippets before its sub-packages so flat views
|
||||
* (stacked chips) keep every package's chips attached to its section. */
|
||||
snippetsBeforeChildren?: boolean;
|
||||
}): TreeRow[] {
|
||||
const normalizedPackages = new Set(collectScriptsSidePanelPackagePaths(packages, snippets));
|
||||
|
||||
@@ -263,13 +250,6 @@ export function buildScriptsSidePanelRows({
|
||||
});
|
||||
|
||||
if (!isExpanded) return;
|
||||
if (snippetsBeforeChildren) {
|
||||
// Flat stacked view: a package's chips must follow its own section
|
||||
// header, not trail after its nested children's chips.
|
||||
emitSnippets(localSnippets, pkg, depth);
|
||||
children.forEach((child) => walk(child, depth + 1));
|
||||
return;
|
||||
}
|
||||
children.forEach((child) => walk(child, depth + 1));
|
||||
emitSnippets(localSnippets, pkg, depth);
|
||||
};
|
||||
@@ -303,7 +283,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
const [search, setSearch] = useState('');
|
||||
const [searchExpanded, setSearchExpanded] = useState(false);
|
||||
const [subView, setSubView] = useState<'library' | 'running'>('library');
|
||||
const [viewMode, setViewMode] = useScriptsViewMode();
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
|
||||
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
|
||||
const [selectedSnippetIds, setSelectedSnippetIds] = useState<Set<string>>(new Set());
|
||||
@@ -314,7 +293,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
const [renamingPackagePath, setRenamingPackagePath] = useState('');
|
||||
const [newPackageName, setNewPackageName] = useState('');
|
||||
const [packageError, setPackageError] = useState('');
|
||||
const [stackedRenderLimit, setStackedRenderLimit] = useState(STACKED_INITIAL_RENDER_COUNT);
|
||||
const packageDialogRef = useRef<HTMLDivElement>(null);
|
||||
const packageNameInputRef = useRef<HTMLInputElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -509,11 +487,8 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
snippets,
|
||||
packages,
|
||||
expandedPaths,
|
||||
// Stacked chips wrap flat, so each package's chips must stay attached
|
||||
// to its own section header.
|
||||
snippetsBeforeChildren: viewMode === 'stacked',
|
||||
});
|
||||
}, [snippets, packages, expandedPaths, searchMatches, isVisible, viewMode]);
|
||||
}, [snippets, packages, expandedPaths, searchMatches, isVisible]);
|
||||
|
||||
type ScriptsListItem =
|
||||
| { key: string; kind: 'search'; snippet: Snippet }
|
||||
@@ -546,48 +521,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
});
|
||||
}, [rows, searchMatches, t, isVisible]);
|
||||
|
||||
const stackedSentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Restart the lazy window whenever the underlying result set changes
|
||||
// (new search, package list changes, snippet edits, view-mode switch) — but
|
||||
// NOT on package expand/collapse. Expanding/collapsing only reshapes the
|
||||
// same result set; resetting there would drop the user's lazy-loaded window
|
||||
// and remount the package they just toggled, forcing them to re-scroll.
|
||||
// Keyed by data identity, not by listItems reference, for that reason.
|
||||
// Reset during render — not in an effect — so the first render of a new
|
||||
// result set never slices with the previous, potentially very large window.
|
||||
const stackedResultSetKey = useMemo(() => {
|
||||
if (searchMatches !== null) {
|
||||
return `search:${searchMatches.map((snippet) => snippet.id).join('\u0000')}`;
|
||||
}
|
||||
return `tree:${viewMode}:${packages.join('\u0000')}:${snippets
|
||||
.map((snippet) => snippet.id)
|
||||
.join('\u0000')}`;
|
||||
}, [searchMatches, viewMode, packages, snippets]);
|
||||
|
||||
const [prevResultSetKey, setPrevResultSetKey] = useState(stackedResultSetKey);
|
||||
if (prevResultSetKey !== stackedResultSetKey) {
|
||||
setPrevResultSetKey(stackedResultSetKey);
|
||||
setStackedRenderLimit(STACKED_INITIAL_RENDER_COUNT);
|
||||
}
|
||||
|
||||
const hasMoreStackedItems = viewMode === 'stacked' && stackedRenderLimit < listItems.length;
|
||||
|
||||
useEffect(() => {
|
||||
if (subView !== 'library' || !hasMoreStackedItems) return;
|
||||
const sentinel = stackedSentinelRef.current;
|
||||
if (!sentinel) return;
|
||||
// Re-created per batch: observing always emits an initial intersection
|
||||
// callback, so short result sets that never scroll keep loading.
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
setStackedRenderLimit((limit) => limit + STACKED_RENDER_BATCH);
|
||||
}
|
||||
});
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMoreStackedItems, stackedRenderLimit, subView]);
|
||||
|
||||
const handleSnippetClick = useCallback(
|
||||
(snippet: Snippet) => {
|
||||
if (isMultiSelectMode) {
|
||||
@@ -1046,30 +979,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t('snippets.searchPlaceholder')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
toolbarIconButtonClass,
|
||||
viewMode === 'stacked' && 'bg-muted/70 text-foreground',
|
||||
)}
|
||||
disabled={!hasAnyContent}
|
||||
aria-label={viewMode === 'stacked'
|
||||
? t('scripts.sidePanel.viewList')
|
||||
: t('scripts.sidePanel.viewStacked')}
|
||||
aria-pressed={viewMode === 'stacked'}
|
||||
onClick={() => setViewMode(viewMode === 'stacked' ? 'list' : 'stacked')}
|
||||
>
|
||||
{viewMode === 'stacked' ? <List size={14} /> : <LayoutGrid size={14} />}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{viewMode === 'stacked'
|
||||
? t('scripts.sidePanel.viewList')
|
||||
: t('scripts.sidePanel.viewStacked')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
@@ -1217,107 +1126,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
<div className="px-3 py-4 text-xs text-muted-foreground italic text-center">
|
||||
{t('common.noResultsFound')}
|
||||
</div>
|
||||
) : viewMode === 'stacked' ? (
|
||||
<div
|
||||
className="h-full overflow-auto px-2 py-1.5"
|
||||
data-scripts-view="stacked"
|
||||
>
|
||||
<div className="flex flex-wrap items-center content-start gap-1.5">
|
||||
{listItems.slice(0, stackedRenderLimit).map((item) => {
|
||||
if (item.kind === 'package') {
|
||||
// Mirror the list view's PackageRow context menu so stacked
|
||||
// mode keeps rename/delete reachable without switching views.
|
||||
const canMutatePackages = Boolean(onPackagesChange && onSnippetsChange);
|
||||
const header = (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => togglePackage(item.row.path)}
|
||||
aria-expanded={item.row.isExpanded}
|
||||
className="w-full basis-full flex items-center gap-1.5 pt-1.5 pb-0.5 text-left rounded-sm hover:bg-accent/40 transition-colors"
|
||||
style={{ paddingLeft: 8 + Math.min(item.row.depth, 4) * 10 }}
|
||||
>
|
||||
<ChevronRight
|
||||
size={11}
|
||||
className={cn(
|
||||
'shrink-0 text-muted-foreground transition-transform',
|
||||
item.row.isExpanded && 'rotate-90',
|
||||
!item.row.hasChildren && 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
<Package size={11} className="shrink-0 text-primary/80" />
|
||||
<span className="min-w-0 truncate text-[10px] font-medium text-muted-foreground">
|
||||
{item.row.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground tabular-nums">
|
||||
{item.countLabel}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
if (!canMutatePackages) {
|
||||
return header;
|
||||
}
|
||||
return (
|
||||
<ContextMenu key={item.key}>
|
||||
<ContextMenuTrigger asChild>
|
||||
{header}
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onClick={() => openRenamePackageDialog(item.row.path)}
|
||||
>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {t('common.rename')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => requestDeletePackage(item.row.path)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
const snippet = item.kind === 'search' ? item.snippet : item.row.snippet;
|
||||
const isScript = isScriptSnippet(snippet);
|
||||
// Search results drop the package headers, so surface each
|
||||
// snippet's package on the chip itself to keep results
|
||||
// distinguishable (labels are not unique across packages).
|
||||
const chipSubtitle = item.kind === 'search'
|
||||
? (snippet.package || t('terminal.toolbar.library'))
|
||||
: undefined;
|
||||
return (
|
||||
<SnippetChip
|
||||
key={item.key}
|
||||
snippet={snippet}
|
||||
subtitle={chipSubtitle}
|
||||
selected={selectedSnippetIds.has(snippet.id)}
|
||||
multiSelect={isMultiSelectMode}
|
||||
onClick={() => handleSnippetClick(snippet)}
|
||||
onEdit={() => handleEditSnippet(snippet)}
|
||||
onDelete={() => handleDeleteSnippet(snippet.id)}
|
||||
onCopyCommand={() => handleCopySnippetCommand(snippet)}
|
||||
copyCommandLabel={t('scripts.actions.copyCommand')}
|
||||
onRunParallel={onRunScriptOnWorkspace
|
||||
? () => onRunScriptOnWorkspace(snippet, 'parallel')
|
||||
: undefined}
|
||||
onRunSequential={isScript && onRunScriptOnWorkspace
|
||||
? () => onRunScriptOnWorkspace(snippet, 'sequential')
|
||||
: undefined}
|
||||
runParallelLabel={isScript
|
||||
? t('scripts.actions.runParallel')
|
||||
: t('scripts.actions.runOnAllTabs')}
|
||||
runSequentialLabel={t('scripts.actions.runSequential')}
|
||||
editLabel={t('action.edit')}
|
||||
deleteLabel={t('action.delete')}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{hasMoreStackedItems ? (
|
||||
<div ref={stackedSentinelRef} className="h-px w-full" aria-hidden="true" />
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<FixedSizeVirtualList
|
||||
className="h-full"
|
||||
@@ -1842,117 +1650,5 @@ const SnippetRow = memo<SnippetRowProps>(({
|
||||
));
|
||||
SnippetRow.displayName = 'SnippetRow';
|
||||
|
||||
interface SnippetChipProps {
|
||||
snippet: Snippet;
|
||||
/** Optional package context shown on the chip (used by stacked search
|
||||
* results, where the package headers are dropped). */
|
||||
subtitle?: string;
|
||||
selected?: boolean;
|
||||
multiSelect?: boolean;
|
||||
onClick: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onRunParallel?: () => void;
|
||||
onRunSequential?: () => void;
|
||||
onCopyCommand?: () => void;
|
||||
copyCommandLabel?: string;
|
||||
runParallelLabel?: string;
|
||||
runSequentialLabel?: string;
|
||||
editLabel: string;
|
||||
deleteLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact chip for the stacked (wrap) view. Shares the same click → run /
|
||||
* execute and right-click context menu as the list rows; only the layout is
|
||||
* denser. Drag reordering stays list-view-only.
|
||||
*/
|
||||
const SnippetChip = memo<SnippetChipProps>(({
|
||||
snippet,
|
||||
subtitle,
|
||||
selected = false,
|
||||
multiSelect = false,
|
||||
onClick,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onRunParallel,
|
||||
onRunSequential,
|
||||
onCopyCommand,
|
||||
copyCommandLabel,
|
||||
runParallelLabel,
|
||||
runSequentialLabel,
|
||||
editLabel,
|
||||
deleteLabel,
|
||||
}) => (
|
||||
<ContextMenu>
|
||||
{/* Radix ContextMenuTrigger needs a real DOM element (Tooltip.Root renders
|
||||
none), so mirror SnippetRow and wrap the tooltip in a plain div. */}
|
||||
<ContextMenuTrigger asChild>
|
||||
<div className="min-w-0 max-w-full">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={multiSelect ? selected : undefined}
|
||||
className={cn(
|
||||
'h-6 max-w-full min-w-0 px-2 rounded-md border border-border/60 bg-muted/40 hover:bg-accent/60 text-[11px] flex items-center gap-1 transition-colors',
|
||||
selected && 'bg-primary/10 border-primary/40 hover:bg-primary/15',
|
||||
)}
|
||||
>
|
||||
{multiSelect ? (
|
||||
<CheckSquare
|
||||
size={11}
|
||||
className={cn('shrink-0', selected ? 'text-primary' : 'text-muted-foreground/70')}
|
||||
/>
|
||||
) : isScriptSnippet(snippet) ? (
|
||||
<Play size={11} className="shrink-0 text-primary" />
|
||||
) : (
|
||||
<Zap size={11} className="shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="min-w-0 truncate">{snippet.label}</span>
|
||||
{subtitle ? (
|
||||
<>
|
||||
<span aria-hidden="true" className="shrink-0 text-muted-foreground/50">·</span>
|
||||
<span className="min-w-0 truncate text-[10px] text-muted-foreground/80">
|
||||
{subtitle}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" align="start">
|
||||
<SnippetCommandTooltipContent label={snippet.label} command={snippet.command} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
{onRunParallel ? (
|
||||
<ContextMenuItem onClick={onRunParallel}>
|
||||
<Layers className="mr-2 h-4 w-4" /> {runParallelLabel}
|
||||
</ContextMenuItem>
|
||||
) : null}
|
||||
{onRunSequential ? (
|
||||
<ContextMenuItem onClick={onRunSequential}>
|
||||
<Layers className="mr-2 h-4 w-4" /> {runSequentialLabel}
|
||||
</ContextMenuItem>
|
||||
) : null}
|
||||
{onCopyCommand ? (
|
||||
<ContextMenuItem onClick={onCopyCommand}>
|
||||
<Copy className="mr-2 h-4 w-4" /> {copyCommandLabel}
|
||||
</ContextMenuItem>
|
||||
) : null}
|
||||
<ContextMenuItem onClick={onEdit}>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {editLabel}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem className="text-destructive" onClick={onDelete}>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {deleteLabel}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
));
|
||||
SnippetChip.displayName = 'SnippetChip';
|
||||
|
||||
export const ScriptsSidePanel = memo(ScriptsSidePanelInner);
|
||||
ScriptsSidePanel.displayName = 'ScriptsSidePanel';
|
||||
@@ -14,6 +14,7 @@ interface SelectHostPanelProps {
|
||||
onSelect: (host: Host) => void;
|
||||
onSelectionChange?: (selectedHostIds: string[]) => void;
|
||||
onBack: () => void;
|
||||
onClose?: () => void;
|
||||
onContinue?: () => void;
|
||||
onNewHost?: () => void;
|
||||
availableKeys?: SSHKey[];
|
||||
@@ -25,6 +26,7 @@ interface SelectHostPanelProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
className?: string;
|
||||
headerClassName?: string;
|
||||
width?: string;
|
||||
layout?: AsidePanelLayout;
|
||||
resizable?: boolean;
|
||||
@@ -42,6 +44,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
onSelect,
|
||||
onSelectionChange,
|
||||
onBack,
|
||||
onClose,
|
||||
onContinue,
|
||||
onNewHost,
|
||||
availableKeys = [],
|
||||
@@ -53,6 +56,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
className,
|
||||
headerClassName,
|
||||
width,
|
||||
layout = "overlay",
|
||||
resizable = false,
|
||||
@@ -78,8 +82,9 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
return (
|
||||
<AsidePanel
|
||||
open={true}
|
||||
onClose={onBack}
|
||||
onClose={onClose ?? onBack}
|
||||
title={panelTitle}
|
||||
headerClassName={headerClassName}
|
||||
subtitle={subtitle}
|
||||
showBackButton={true}
|
||||
onBack={onBack}
|
||||
|
||||
@@ -429,7 +429,7 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
|
||||
data-active={isActive ? 'true' : undefined}
|
||||
className={cn(
|
||||
'flex h-full min-h-0 items-center gap-2.5 overflow-hidden rounded-lg px-2.5 transition-colors',
|
||||
isActive ? 'bg-primary/10 ring-1 ring-primary/40' : 'hover:bg-muted/70',
|
||||
isActive ? 'bg-primary/10' : 'hover:bg-muted/70',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (navIndex >= 0) setActiveNavIndex(navIndex);
|
||||
@@ -487,9 +487,7 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
|
||||
aria-label={t('selectHost.toggleHost', { name: host.label })}
|
||||
className={cn(
|
||||
'flex h-full min-h-0 cursor-pointer items-center gap-2.5 overflow-hidden rounded-lg px-2.5 transition-colors',
|
||||
isSelected ? 'bg-muted' : isActive ? 'bg-primary/10' : 'hover:bg-muted/70',
|
||||
// Keep keyboard cursor visible even when the host is already selected.
|
||||
isActive && 'ring-1 ring-primary/40',
|
||||
isActive ? 'bg-primary/10' : isSelected ? 'bg-muted' : 'hover:bg-muted/70',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (navIndex >= 0) setActiveNavIndex(navIndex);
|
||||
|
||||
+20
-1
@@ -130,6 +130,7 @@ import {
|
||||
import { isVaultInitialized } from "@/application/state/vaultInitStore.ts";
|
||||
import { useVaultSnapshotField } from "@/application/state/vaultSnapshotStore.ts";
|
||||
import { netcattyBridge } from "@/infrastructure/services/netcattyBridge.ts";
|
||||
import { openHostBrowser } from "../application/hostBrowser";
|
||||
import { handleTerminalOscNotification } from "@/application/state/oscDesktopNotifications.ts";
|
||||
import { OscNotificationStreamScanner } from "@/domain/terminalOscNotifications.ts";
|
||||
import { ScriptExecutionOverlay } from "./terminal/ScriptExecutionOverlay";
|
||||
@@ -4187,6 +4188,22 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
window.dispatchEvent(new CustomEvent('netcatty:script:recording:start', { detail: { sessionId } }));
|
||||
}, [sessionId, t]);
|
||||
|
||||
const handleOpenHostBrowser = useCallback(async () => {
|
||||
try {
|
||||
await openHostBrowser({
|
||||
sessionId,
|
||||
host,
|
||||
hosts: [host, ...resolvedChainHosts],
|
||||
keys,
|
||||
identities,
|
||||
terminalSettings,
|
||||
knownHosts,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Could not open the host browser');
|
||||
}
|
||||
}, [sessionId, host, resolvedChainHosts, keys, identities, terminalSettings, knownHosts]);
|
||||
|
||||
const renderControls = useCallback((opts?: { showClose?: boolean; restorePaneLayout?: boolean }) => (
|
||||
<TerminalToolbar
|
||||
sessionId={sessionId}
|
||||
@@ -4199,6 +4216,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onSnippetClick={(snippet) => { void executeSnippet(snippet); }}
|
||||
onDeleteSnippets={onDeleteSnippets}
|
||||
onOpenSFTP={handleOpenSFTP}
|
||||
onOpenBrowser={(!host.protocol || ['ssh', 'mosh', 'local'].includes(host.protocol)) ? () => { void handleOpenHostBrowser(); } : undefined}
|
||||
onSendYmodem={isSerialConnection ? handleSendYmodem : undefined}
|
||||
onReceiveYmodem={isSerialConnection ? handleReceiveYmodem : undefined}
|
||||
onOpenScripts={onOpenScripts ?? (() => {})}
|
||||
@@ -4252,6 +4270,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
compactToolbar,
|
||||
executeSnippet,
|
||||
handleOpenSFTP,
|
||||
handleOpenHostBrowser,
|
||||
handleReceiveYmodem,
|
||||
handleSendYmodem,
|
||||
handleSetTerminalEncoding,
|
||||
@@ -4629,7 +4648,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, RefreshCcw, Sparkles, SquareArrowOutUpRight, Unplug, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost: !!onUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI: onAddSelectionToAI ? handleAddSelectionToAI : undefined, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect: (attachExistingSession || compactToolbar) ? undefined : handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, identities, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
|
||||
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, RefreshCcw, Sparkles, SquareArrowOutUpRight, Unplug, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost: !!onUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI: onAddSelectionToAI ? handleAddSelectionToAI : undefined, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect: (attachExistingSession || compactToolbar) ? undefined : handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOpenHostBrowser, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, identities, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
|
||||
<ScriptExecutionOverlay
|
||||
run={activeScriptRun}
|
||||
onPause={() => { void pauseScriptRun(activeScriptRun.runId); }}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { subscribeHostBrowserPanelRequests } from '../application/state/hostBrowserPanelRequests';
|
||||
import { openHostBrowserPanelUrl } from '../application/hostBrowser';
|
||||
import { captureTerminalBroadcastInput, isTerminalBroadcastInputCurrent, markTerminalBroadcastUserInput } from "./terminal/runtime/terminalPacedBroadcast";
|
||||
import { pruneComposeBarHistory } from '../application/state/composeBarHistoryStore';
|
||||
import { FolderTree, History, MessageSquare, PanelLeft, PanelRight, Palette, X, Zap } from 'lucide-react';
|
||||
@@ -488,6 +490,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
setThemeMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
|
||||
} else if (pane.tool === 'system') {
|
||||
setSystemMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
|
||||
} else if (pane.tool === 'browser') {
|
||||
setBrowserMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
|
||||
} else if (pane.tool === 'notes') {
|
||||
setNotesMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
|
||||
} else if (pane.tool === 'sftp') {
|
||||
@@ -659,6 +663,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
setSystemMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
|
||||
} else if (targetPanel === 'notes') {
|
||||
setNotesMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
|
||||
} else if (targetPanel === 'browser') {
|
||||
setBrowserMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
|
||||
}
|
||||
|
||||
setSidePanelOpenTabs(prev => {
|
||||
@@ -770,6 +776,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const [scriptsMountedTabIds, setScriptsMountedTabIds] = useState<string[]>([]);
|
||||
const [systemMountedTabIds, setSystemMountedTabIds] = useState<string[]>([]);
|
||||
const [themeMountedTabIds, setThemeMountedTabIds] = useState<string[]>([]);
|
||||
const [browserMountedTabIds, setBrowserMountedTabIds] = useState<string[]>([]);
|
||||
const [notesMountedTabIds, setNotesMountedTabIds] = useState<string[]>([]);
|
||||
const [sidePanelWidth, setSidePanelWidth, persistSidePanelWidth] = useStoredNumber(
|
||||
STORAGE_KEY_SIDE_PANEL_WIDTH,
|
||||
@@ -1009,6 +1016,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
setScriptsMountedTabIds((previous) => removeMountedSidePanelTabId(previous, tabId));
|
||||
setThemeMountedTabIds((previous) => removeMountedSidePanelTabId(previous, tabId));
|
||||
setSystemMountedTabIds((previous) => removeMountedSidePanelTabId(previous, tabId));
|
||||
setBrowserMountedTabIds((previous) => removeMountedSidePanelTabId(previous, tabId));
|
||||
setNotesMountedTabIds((previous) => removeMountedSidePanelTabId(previous, tabId));
|
||||
setNotesOpenNoteByTab((previous) => {
|
||||
const next = new Map(previous);
|
||||
@@ -1549,6 +1557,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
setSystemMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
|
||||
return;
|
||||
}
|
||||
if (panel === 'browser') {
|
||||
setBrowserMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
|
||||
return;
|
||||
}
|
||||
if (panel === 'notes') {
|
||||
setNotesMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
|
||||
}
|
||||
@@ -1888,6 +1900,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
handleCloseSidePanel();
|
||||
return;
|
||||
}
|
||||
if (closingPane?.tool === 'browser') {
|
||||
setBrowserMountedTabIds((previous) => removeMountedSidePanelTabId(previous, tabId));
|
||||
}
|
||||
if (shouldMarkSftpPaneClosed({
|
||||
closingPaneTool: closingPane?.tool,
|
||||
closesWholePanel: closesWholePanel,
|
||||
@@ -1974,6 +1989,39 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
handleSwitchSidePanelTab('system');
|
||||
}, [handleSwitchSidePanelTab]);
|
||||
|
||||
const handleOpenBrowser = useCallback(() => {
|
||||
handleSwitchSidePanelTab('browser');
|
||||
}, [handleSwitchSidePanelTab]);
|
||||
|
||||
useEffect(() => subscribeHostBrowserPanelRequests(({ host, sessionId, url }) => {
|
||||
const session = sessionsRef.current.find((candidate) => sessionId
|
||||
? candidate.id === sessionId : !candidate.hiddenFromTabs && candidate.hostId === host.id
|
||||
&& (!candidate.protocol || ['ssh', 'mosh', 'local'].includes(candidate.protocol)));
|
||||
let tabId: string | null = null;
|
||||
if (session) {
|
||||
tabId = session.workspaceId ?? session.id;
|
||||
if (session.workspaceId) onSetWorkspaceFocusedSessionRef.current?.(session.workspaceId, session.id);
|
||||
activeTabStore.setActiveTabId(tabId);
|
||||
} else {
|
||||
const opened = onConnectToHost(host);
|
||||
if (typeof opened === 'string') tabId = opened;
|
||||
}
|
||||
if (!tabId) return;
|
||||
if (url) {
|
||||
void openHostBrowserPanelUrl(`${tabId}:${session?.id ?? tabId}`, url)
|
||||
.catch((error: unknown) => toast.error(error instanceof Error ? error.message : String(error)));
|
||||
}
|
||||
setBrowserMountedTabIds((previous) => addMountedSidePanelTabId(previous, tabId));
|
||||
setSidePanelOpenTabs((previous) => new Map(previous).set(tabId, 'browser'));
|
||||
}), [onConnectToHost, setSidePanelOpenTabs]);
|
||||
|
||||
useEffect(() => {
|
||||
setBrowserMountedTabIds((previous) => {
|
||||
const next = previous.filter((tabId) => validAIScopeTargetIds.has(tabId));
|
||||
return next.length === previous.length ? previous : next;
|
||||
});
|
||||
}, [validAIScopeTargetIds]);
|
||||
|
||||
const handleOpenNotes = useCallback(() => {
|
||||
const tabId = activeTabIdRef.current;
|
||||
if (!tabId) return;
|
||||
@@ -2546,6 +2594,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
handleOpenAI,
|
||||
handleOpenSystem,
|
||||
handleOpenNotes,
|
||||
handleOpenBrowser,
|
||||
handleFocusSidePanelPane,
|
||||
handleMagnifySidePanelPane,
|
||||
handleRestoreMagnifiedPane,
|
||||
@@ -2612,6 +2661,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
mountedSftpTabIds,
|
||||
magnifiedPane,
|
||||
notesMountedTabIds,
|
||||
browserMountedTabIds,
|
||||
notesOpenNoteByTab,
|
||||
scriptsMountedTabIds,
|
||||
systemMountedTabIds,
|
||||
|
||||
@@ -170,6 +170,7 @@ const SnippetsManager = lazy(() => import("./SnippetsManager"));
|
||||
|
||||
export type VaultSection =
|
||||
| "hosts"
|
||||
| "monitor"
|
||||
| "keys"
|
||||
| "proxies"
|
||||
| "snippets"
|
||||
@@ -1615,6 +1616,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
NotebookText,
|
||||
NotesManager,
|
||||
onCommitPluginImporterData,
|
||||
onConnect,
|
||||
onConnectSerial,
|
||||
onCreateLocalTerminal,
|
||||
onDeleteHost,
|
||||
|
||||
@@ -575,7 +575,9 @@ const CONFLICT_ENTITY_LABEL_KEYS: Record<SyncChangeEntityKey, string> = {
|
||||
customGroups: 'cloudSync.conflict.entity.customGroups',
|
||||
snippetPackages: 'cloudSync.conflict.entity.snippetPackages',
|
||||
portForwardingRules: 'cloudSync.conflict.entity.portForwardingRules',
|
||||
browserBookmarks: 'cloudSync.conflict.entity.browserBookmarks',
|
||||
groupConfigs: 'cloudSync.conflict.entity.groupConfigs',
|
||||
monitorPanels: 'monitor.title',
|
||||
aiSessions: 'cloudSync.aiSessions.title',
|
||||
settings: 'cloudSync.conflict.entity.settings',
|
||||
};
|
||||
|
||||
@@ -14,9 +14,11 @@ import {
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { useLocalVaultBackups } from '../../application/state/useLocalVaultBackups';
|
||||
import {
|
||||
LocalVaultBackupBusyError,
|
||||
MAX_LOCAL_VAULT_BACKUP_MAX_COUNT,
|
||||
MIN_LOCAL_VAULT_BACKUP_MAX_COUNT,
|
||||
withRestoreBarrier,
|
||||
@@ -29,6 +31,7 @@ import { cn } from '../../lib/utils';
|
||||
import { Button } from '../ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
import { Input } from '../ui/input';
|
||||
import { ConfirmDialog } from '../ui/confirm-dialog';
|
||||
import { toast } from '../ui/toast';
|
||||
|
||||
// ============================================================================
|
||||
@@ -54,10 +57,13 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
maxBackups,
|
||||
encryptionAvailable,
|
||||
refreshBackups,
|
||||
clearBackups,
|
||||
readBackup,
|
||||
setMaxBackups,
|
||||
openBackupDirectory,
|
||||
} = useLocalVaultBackups();
|
||||
const [showClearDialog, setShowClearDialog] = useState(false);
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [maxBackupsInput, setMaxBackupsInput] = useState(String(maxBackups));
|
||||
const [isSavingMaxBackups, setIsSavingMaxBackups] = useState(false);
|
||||
const [restoringBackupId, setRestoringBackupId] = useState<string | null>(null);
|
||||
@@ -175,34 +181,34 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const restoreAllowed = restoreDisabledReason === null;
|
||||
// While encryptionAvailable is still `null` we're mid-probe — render the
|
||||
// restore button as disabled so the user never sees a path they can't
|
||||
// actually take (I1 surface). Once resolved, `false` hides the panel body
|
||||
// via the unavailable banner below.
|
||||
const encryptionResolved = encryptionAvailable !== null;
|
||||
const encryptionUsable = encryptionAvailable === true;
|
||||
const performClear = async () => {
|
||||
if (isClearing) return;
|
||||
setIsClearing(true);
|
||||
try {
|
||||
await clearBackups();
|
||||
setShowClearDialog(false);
|
||||
toast.success(t('cloudSync.localBackups.clearSuccess'));
|
||||
} catch (error) {
|
||||
toast.error(t(error instanceof LocalVaultBackupBusyError
|
||||
? 'cloudSync.localBackups.clearBusy'
|
||||
: 'cloudSync.localBackups.clearFailed'), t('sync.toast.errorTitle'));
|
||||
} finally {
|
||||
setIsClearing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// safeStorage probe finished and returned "not available" → disable the
|
||||
// panel entirely; the main process refuses to write in this state (I1).
|
||||
if (encryptionResolved && !encryptionUsable) {
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 space-y-2">
|
||||
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle size={16} />
|
||||
<span className="text-sm font-medium">
|
||||
{t('cloudSync.localBackups.unavailableTitle')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('cloudSync.localBackups.unavailableDesc')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const restoreAllowed = restoreDisabledReason === null && encryptionAvailable === true;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{encryptionAvailable === false && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 space-y-2">
|
||||
<div className="text-sm font-medium text-amber-600 dark:text-amber-400">
|
||||
{t('cloudSync.localBackups.unavailableTitle')}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{t('cloudSync.localBackups.unavailableDesc')}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="max-w-lg">
|
||||
@@ -224,7 +230,7 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void handleSaveMaxBackups()}
|
||||
disabled={isSavingMaxBackups}
|
||||
disabled={isSavingMaxBackups || isClearing || restoringBackupId !== null}
|
||||
className="gap-2"
|
||||
>
|
||||
{isSavingMaxBackups && <Loader2 size={14} className="animate-spin" />}
|
||||
@@ -235,7 +241,7 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!restoreAllowed && (
|
||||
{restoreDisabledReason !== null && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400 mb-1">
|
||||
<AlertTriangle size={14} />
|
||||
@@ -248,19 +254,19 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border bg-card p-4 space-y-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t('cloudSync.localBackups.title')}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{t('cloudSync.localBackups.desc')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void refreshBackups()}
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || isClearing}
|
||||
className="gap-1"
|
||||
>
|
||||
<RefreshCw size={14} className={cn(isLoading && 'animate-spin')} />
|
||||
@@ -275,6 +281,16 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
<FolderOpen size={14} />
|
||||
{t('settings.system.openFolder')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowClearDialog(true)}
|
||||
disabled={isLoading || isClearing || isSavingMaxBackups || restoringBackupId !== null}
|
||||
className="gap-1 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t('cloudSync.localBackups.clear')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -339,7 +355,7 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
// clicks here would interleave destructive
|
||||
// writes and the second run's sentinel-clear
|
||||
// could mask a still-partial first apply.
|
||||
disabled={restoringBackupId !== null}
|
||||
disabled={restoringBackupId !== null || isClearing}
|
||||
className="gap-2"
|
||||
>
|
||||
{restoringBackupId === backup.id ? (
|
||||
@@ -356,6 +372,17 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showClearDialog}
|
||||
title={t('cloudSync.localBackups.clearConfirmTitle')}
|
||||
message={t('cloudSync.localBackups.clearConfirmDesc')}
|
||||
confirmLabel={t('cloudSync.localBackups.clear')}
|
||||
busy={isClearing}
|
||||
destructive
|
||||
onOpenChange={(open) => { if (!isClearing) setShowClearDialog(open); }}
|
||||
onConfirm={() => void performClear()}
|
||||
/>
|
||||
|
||||
{/* Restore confirmation dialog (I2). Keeps the destructive action
|
||||
gated behind an explicit second click, mirroring the clear-local
|
||||
dialog elsewhere in this screen. */}
|
||||
@@ -416,7 +443,7 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPendingRestoreBackup(null)}
|
||||
disabled={restoringBackupId !== null}
|
||||
disabled={restoringBackupId !== null || isClearing}
|
||||
>
|
||||
{t('cloudSync.localBackups.restoreConfirmCancel')}
|
||||
</Button>
|
||||
@@ -428,7 +455,7 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
setPendingRestoreBackup(null);
|
||||
await performRestore(target.id);
|
||||
}}
|
||||
disabled={restoringBackupId !== null}
|
||||
disabled={restoringBackupId !== null || isClearing}
|
||||
className="gap-2"
|
||||
>
|
||||
{restoringBackupId !== null ? (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Copy, FileSymlink, Files, Folder, FolderOpen, Monitor, Pencil, Plus, Server, Settings2 } from 'lucide-react';
|
||||
import { Copy, FileSymlink, Files, Folder, FolderOpen, Globe2, Monitor, Pencil, Plus, Server, Settings2 } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
@@ -14,6 +14,7 @@ import { PluginContributionIcon } from '../plugins/PluginContributionIcon';
|
||||
|
||||
export interface HostTreeHostContextMenuHandlers {
|
||||
onConnect: (host: Host) => void;
|
||||
onOpenBrowser?: (host: Host) => void;
|
||||
onEditHost?: (host: Host) => void;
|
||||
onRenameHost?: (host: Host) => void;
|
||||
onDuplicateHost: (host: Host) => void;
|
||||
@@ -33,11 +34,18 @@ export const OpenDualPaneSftpMenuItem: React.FC<{ host: Host }> = ({ host }) =>
|
||||
);
|
||||
};
|
||||
|
||||
export const OpenHostBrowserMenuItem: React.FC<{ host: Host; onOpenBrowser?: (host: Host) => void }> = ({ host, onOpenBrowser }) => {
|
||||
const { t } = useI18n();
|
||||
if (!onOpenBrowser || (host.protocol && !['ssh', 'mosh', 'local'].includes(host.protocol))) return null;
|
||||
return <ContextMenuItem onClick={() => onOpenBrowser(host)}><Globe2 className="mr-2 h-4 w-4" /> {t('terminal.browser.open')}</ContextMenuItem>;
|
||||
};
|
||||
|
||||
export const HostTreeHostContextMenuContent: React.FC<
|
||||
HostTreeHostContextMenuHandlers & { host: Host }
|
||||
> = ({
|
||||
host,
|
||||
onConnect,
|
||||
onOpenBrowser,
|
||||
onEditHost,
|
||||
onRenameHost,
|
||||
onDuplicateHost,
|
||||
@@ -65,6 +73,7 @@ export const HostTreeHostContextMenuContent: React.FC<
|
||||
<Monitor className="mr-2 h-4 w-4" /> {t('vault.hosts.connect')}
|
||||
</ContextMenuItem>
|
||||
<OpenDualPaneSftpMenuItem host={safeHost} />
|
||||
<OpenHostBrowserMenuItem host={safeHost} onOpenBrowser={onOpenBrowser} />
|
||||
{onEditHost && (
|
||||
<ContextMenuItem onClick={() => onEditHost(host)}>
|
||||
<Settings2 className="mr-2 h-4 w-4" /> {t('terminal.layer.hostTree.editHost')}
|
||||
|
||||
@@ -34,13 +34,9 @@ test('SelectHostPanelContent virtualizes with listbox keyboard model', () => {
|
||||
assert.match(source, /navigableIndexByKey/);
|
||||
assert.match(source, /navigableIndexByKey\.get\(row\.key\)/);
|
||||
assert.doesNotMatch(source, /navigable\.findIndex/);
|
||||
// Selected hosts must still show the keyboard active ring.
|
||||
assert.match(source, /isSelected \? 'bg-muted' : isActive \? 'bg-primary\/10' : 'hover:bg-muted\/70'/);
|
||||
assert.match(source, /isActive && 'ring-1 ring-primary\/40'/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/isSelected \? 'bg-muted' : isActive \? 'bg-primary\/10 ring-1 ring-primary\/40'/,
|
||||
);
|
||||
// The keyboard cursor stays visible on selected hosts through background alone.
|
||||
assert.match(source, /isActive \? 'bg-primary\/10' : isSelected \? 'bg-muted' : 'hover:bg-muted\/70'/);
|
||||
assert.doesNotMatch(source, /ring-1 ring-primary\/40/);
|
||||
assert.doesNotMatch(source, /filteredHosts\.map\(\(host\) =>/);
|
||||
// One listbox tab stop — not per-host tabIndex under virtualization.
|
||||
assert.match(source, /role="listbox"[\s\S]*tabIndex=\{0\}/);
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
/**
|
||||
* Generate Key Panel - Standard SSH Key generation form
|
||||
*/
|
||||
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
/** Standard and hardware-backed SSH key generation. */
|
||||
import { Eye, EyeOff, Loader2, Usb } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { KeyType, SSHKey } from '../../types';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '../ui/select';
|
||||
import { Switch } from '../ui/switch';
|
||||
|
||||
interface GenerateStandardPanelProps {
|
||||
draftKey: Partial<SSHKey>;
|
||||
setDraftKey: (key: Partial<SSHKey>) => void;
|
||||
draftKey: Partial<SSHKey> & { resident?: boolean; verifyRequired?: boolean };
|
||||
setDraftKey: (key: Partial<SSHKey> & { resident?: boolean; verifyRequired?: boolean }) => void;
|
||||
showPassphrase: boolean;
|
||||
setShowPassphrase: (show: boolean) => void;
|
||||
isGenerating: boolean;
|
||||
@@ -21,119 +19,115 @@ interface GenerateStandardPanelProps {
|
||||
}
|
||||
|
||||
export const GenerateStandardPanel: React.FC<GenerateStandardPanelProps> = ({
|
||||
draftKey,
|
||||
setDraftKey,
|
||||
showPassphrase,
|
||||
setShowPassphrase,
|
||||
isGenerating,
|
||||
onGenerate,
|
||||
draftKey, setDraftKey, showPassphrase, setShowPassphrase, isGenerating, onGenerate,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isFido = draftKey.type === 'ED25519-SK' || draftKey.type === 'ECDSA-SK';
|
||||
const selectType = (value: string) => {
|
||||
const type = value as KeyType;
|
||||
setDraftKey({ ...draftKey, type, keySize: type === 'RSA' ? 4096 : type === 'ECDSA' ? 256 : undefined });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-5" data-section="keychain-generate-form">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('keychain.field.label')}</Label>
|
||||
<Input
|
||||
value={draftKey.label || ''}
|
||||
<Label htmlFor="generate-key-label">{t('keychain.field.label')}</Label>
|
||||
<Input id="generate-key-label" value={draftKey.label || ''}
|
||||
onChange={e => setDraftKey({ ...draftKey, label: e.target.value })}
|
||||
placeholder={t('keychain.generate.labelPlaceholder')}
|
||||
/>
|
||||
placeholder={t('keychain.generate.labelPlaceholder')} disabled={isGenerating} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('keychain.generate.keyType')}</Label>
|
||||
<div className="flex gap-2">
|
||||
{(['ED25519', 'ECDSA', 'RSA'] as KeyType[]).map((t) => (
|
||||
<Button
|
||||
key={t}
|
||||
variant={draftKey.type === t ? 'secondary' : 'ghost'}
|
||||
className={cn(
|
||||
"flex-1 h-10",
|
||||
draftKey.type === t && "bg-primary/15 text-primary"
|
||||
)}
|
||||
onClick={() => {
|
||||
// Set default keySize based on type
|
||||
const defaultSize = t === 'ED25519' ? undefined : (t === 'RSA' ? 4096 : 256);
|
||||
setDraftKey({ ...draftKey, type: t, keySize: defaultSize });
|
||||
}}
|
||||
>
|
||||
{t}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Label htmlFor="generate-key-type">{t('keychain.generate.keyType')}</Label>
|
||||
<Select value={draftKey.type || 'ED25519'} onValueChange={selectType} disabled={isGenerating}>
|
||||
<SelectTrigger id="generate-key-type"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>{t('keychain.generate.standardKeys')}</SelectLabel>
|
||||
{['ED25519', 'ECDSA', 'RSA'].map(type => <SelectItem key={type} value={type}>{type}</SelectItem>)}
|
||||
</SelectGroup>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>{t('keychain.generate.securityKeys')}</SelectLabel>
|
||||
<SelectItem value="ED25519-SK">ED25519-SK</SelectItem>
|
||||
<SelectItem value="ECDSA-SK">ECDSA-SK</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Key Size selector - only for RSA and ECDSA */}
|
||||
{(draftKey.type === 'RSA' || draftKey.type === 'ECDSA') && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('keychain.generate.keySize')}</Label>
|
||||
<div className="flex gap-2">
|
||||
{(draftKey.type === 'RSA'
|
||||
? [4096, 2048, 1024]
|
||||
: [256, 384, 521]
|
||||
).map((size) => (
|
||||
<Button
|
||||
key={size}
|
||||
variant={draftKey.keySize === size ? 'secondary' : 'ghost'}
|
||||
className={cn(
|
||||
"flex-1 h-10",
|
||||
draftKey.keySize === size && "bg-primary/15 text-primary"
|
||||
)}
|
||||
onClick={() => setDraftKey({ ...draftKey, keySize: size })}
|
||||
>
|
||||
{draftKey.type === 'RSA' ? `${size} bits` : `P-${size}`}
|
||||
<div className="space-y-2 fido-field-stage" key="key-size">
|
||||
<Label htmlFor="generate-key-size">{t('keychain.generate.keySize')}</Label>
|
||||
<Select value={String(draftKey.keySize || (draftKey.type === 'RSA' ? 4096 : 256))}
|
||||
onValueChange={value => setDraftKey({ ...draftKey, keySize: Number(value) })} disabled={isGenerating}>
|
||||
<SelectTrigger id="generate-key-size"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(draftKey.type === 'RSA' ? [4096, 2048, 1024] : [256, 384, 521]).map(size => (
|
||||
<SelectItem key={size} value={String(size)}>{draftKey.type === 'RSA' ? `${size} bits` : `P-${size}`}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isFido ? (
|
||||
<div key="hardware" className="fido-field-stage fido-key-options space-y-4">
|
||||
<div className="flex gap-2.5 rounded-lg bg-muted/40 px-3 py-3 text-xs leading-relaxed text-muted-foreground">
|
||||
<Usb size={15} className="mt-0.5 shrink-0" aria-hidden="true" />
|
||||
<p>{t('keychain.generate.fidoHint')}</p>
|
||||
</div>
|
||||
<div className="divide-y divide-border/50 border-y border-border/50">
|
||||
<div className="flex items-center justify-between gap-4 py-3.5">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<Label id="fido-resident-label" className="text-sm font-medium">{t('keychain.generate.resident')}</Label>
|
||||
<p id="fido-resident-description" className="text-xs leading-relaxed text-muted-foreground">{t('keychain.generate.residentDesc')}</p>
|
||||
</div>
|
||||
<Switch id="fido-resident" checked={!!draftKey.resident} disabled={isGenerating}
|
||||
aria-labelledby="fido-resident-label" aria-describedby="fido-resident-description"
|
||||
onCheckedChange={resident => setDraftKey({ ...draftKey, resident })} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 py-3.5">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<Label id="fido-verify-label" className="text-sm font-medium">{t('keychain.generate.verifyRequired')}</Label>
|
||||
<p id="fido-verify-description" className="text-xs leading-relaxed text-muted-foreground">{t('keychain.generate.verifyRequiredDesc')}</p>
|
||||
</div>
|
||||
<Switch id="fido-verify-required" checked={!!draftKey.verifyRequired} disabled={isGenerating}
|
||||
aria-labelledby="fido-verify-label" aria-describedby="fido-verify-description"
|
||||
onCheckedChange={verifyRequired => setDraftKey({ ...draftKey, verifyRequired })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div key="software" className="fido-field-stage space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="generate-key-passphrase">{t('terminal.auth.passphrase')}</Label>
|
||||
<div className="relative">
|
||||
<Input id="generate-key-passphrase" type={showPassphrase ? 'text' : 'password'}
|
||||
value={draftKey.passphrase || ''} disabled={isGenerating}
|
||||
onChange={e => setDraftKey({ ...draftKey, passphrase: e.target.value })}
|
||||
placeholder={t('keychain.generate.passphrasePlaceholder')} className="pr-10" />
|
||||
<Button type="button" variant="ghost" size="icon" disabled={isGenerating}
|
||||
aria-label={t(showPassphrase ? 'hostDetails.password.hide' : 'hostDetails.password.show')}
|
||||
className="absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2 text-muted-foreground"
|
||||
onClick={() => setShowPassphrase(!showPassphrase)}>
|
||||
{showPassphrase ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Label id="generate-save-passphrase">{t('keychain.generate.savePassphrase')}</Label>
|
||||
<Switch checked={!!draftKey.savePassphrase} disabled={isGenerating}
|
||||
aria-labelledby="generate-save-passphrase"
|
||||
onCheckedChange={savePassphrase => setDraftKey({ ...draftKey, savePassphrase })} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('terminal.auth.passphrase')}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassphrase ? 'text' : 'password'}
|
||||
value={draftKey.passphrase || ''}
|
||||
onChange={e => setDraftKey({ ...draftKey, passphrase: e.target.value })}
|
||||
placeholder={t('keychain.generate.passphrasePlaceholder')}
|
||||
className="pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8"
|
||||
onClick={() => setShowPassphrase(!showPassphrase)}
|
||||
>
|
||||
{showPassphrase ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="savePassphrase"
|
||||
checked={draftKey.savePassphrase || false}
|
||||
onChange={e => setDraftKey({ ...draftKey, savePassphrase: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
<Label htmlFor="savePassphrase" className="text-sm font-normal cursor-pointer">
|
||||
{t('keychain.generate.savePassphrase')}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full h-11"
|
||||
onClick={onGenerate}
|
||||
disabled={isGenerating || !draftKey.label?.trim()}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<div className="h-4 w-4 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin" />
|
||||
) : (
|
||||
t('keychain.generate.generateSave')
|
||||
)}
|
||||
<Button className="h-10 w-full gap-2" onClick={onGenerate} disabled={isGenerating || !draftKey.label?.trim()}>
|
||||
{isGenerating && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
|
||||
{t(isGenerating ? 'keychain.generate.generating' : 'keychain.generate.generateSave')}
|
||||
</Button>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { SK_ECDSA_NISTP256, SK_SSH_ED25519 } from "../../domain/fidoSsh.ts";
|
||||
import { detectKeyType, resolveImportedKeyType } from "./utils.ts";
|
||||
|
||||
/** Real OpenSSH shape: algorithm only after base64 decode. */
|
||||
function makeSkPrivatePem(algo: string): string {
|
||||
const body = Buffer.from(
|
||||
`openssh-key-v1\0\0\0\0\0none\0\0\0\0\0\0\0\0\0\x01${algo}`,
|
||||
).toString("base64");
|
||||
const pem = `-----BEGIN OPENSSH PRIVATE KEY-----\n${body}\n-----END OPENSSH PRIVATE KEY-----\n`;
|
||||
assert.equal(pem.includes("@openssh.com"), false);
|
||||
return pem;
|
||||
}
|
||||
|
||||
test("resolveImportedKeyType prefers FIDO detection over seeded ED25519 draft", () => {
|
||||
const pem = makeSkPrivatePem(SK_SSH_ED25519);
|
||||
// Simulate openImport seed + paste import (handleImport path).
|
||||
const seededDraftType = "ED25519" as const;
|
||||
const detected = resolveImportedKeyType({ privateKey: pem });
|
||||
assert.equal(detected, "ED25519-SK");
|
||||
// Product rule: material wins; never keep seeded ED25519 when SK is detected.
|
||||
const savedType = detected; // same as fixed handleImport
|
||||
assert.notEqual(savedType, seededDraftType);
|
||||
assert.equal(savedType, "ED25519-SK");
|
||||
});
|
||||
|
||||
test("resolveImportedKeyType distinguishes ECDSA-SK from base64-only PEM", () => {
|
||||
assert.equal(
|
||||
resolveImportedKeyType({ privateKey: makeSkPrivatePem(SK_ECDSA_NISTP256) }),
|
||||
"ECDSA-SK",
|
||||
);
|
||||
});
|
||||
|
||||
test("detectKeyType uses the same material path for file drop", () => {
|
||||
assert.equal(detectKeyType(makeSkPrivatePem(SK_SSH_ED25519)), "ED25519-SK");
|
||||
assert.equal(detectKeyType("-----BEGIN RSA PRIVATE KEY-----\nMII\n-----END RSA PRIVATE KEY-----"), "RSA");
|
||||
});
|
||||
|
||||
test("resolveImportedKeyType keeps soft keys", () => {
|
||||
assert.equal(
|
||||
resolveImportedKeyType({
|
||||
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nAAAA\n-----END OPENSSH PRIVATE KEY-----",
|
||||
}),
|
||||
"ED25519",
|
||||
);
|
||||
});
|
||||
|
||||
test("KeychainManager handleImport uses material type not seeded draft type", () => {
|
||||
const source = readFileSync(join(import.meta.dirname, "../KeychainManager.tsx"), "utf8");
|
||||
// Must resolve from material; must not keep draftKey.type first (openImport seeds ED25519).
|
||||
assert.match(source, /detectSshKeyType/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/type:\s*\(draftKey\.type as KeyType\)\s*\|\|\s*detectedType/,
|
||||
);
|
||||
assert.match(source, /type:\s*detected.type/);
|
||||
});
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { BadgeCheck, Key } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { detectFidoSshKeyType } from '../../domain/fidoSsh';
|
||||
import { logger } from '../../lib/logger';
|
||||
import { detectSshKeyType } from '../../domain/keyTypeDetect';
|
||||
import { KeyType, SSHKey } from '../../types';
|
||||
@@ -25,12 +26,25 @@ export const getKeyTypeDisplay = (key: SSHKey, isMac: boolean): string => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect key type from private key content
|
||||
* Resolve vault KeyType from imported key material.
|
||||
*
|
||||
* Always prefer material detection over the import form's seeded default
|
||||
* (`openImport` seeds `type: "ED25519"`). Real OpenSSH sk private PEMs only
|
||||
* expose sk-* algorithms after base64 decode — handled by detectFidoSshKeyType.
|
||||
*/
|
||||
export const detectKeyType = (privateKey: string): KeyType => {
|
||||
return detectSshKeyType(privateKey).type;
|
||||
export const resolveImportedKeyType = (args: {
|
||||
privateKey: string;
|
||||
publicKey?: string;
|
||||
}): KeyType => {
|
||||
return detectFidoSshKeyType(args) || detectSshKeyType(args.privateKey, args.publicKey).type;
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect key type from private key content (file drop / paste helpers).
|
||||
*/
|
||||
export const detectKeyType = (privateKey: string): KeyType =>
|
||||
resolveImportedKeyType({ privateKey });
|
||||
|
||||
/**
|
||||
* Copy text to clipboard
|
||||
*/
|
||||
|
||||
@@ -367,6 +367,22 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
|
||||
<Toggle checked={terminalSettings.smoothScrolling} onChange={(v) => updateTerminalSetting("smoothScrolling", v)} />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
anchorId="terminal-link-browser"
|
||||
label={t("settings.terminal.behavior.linkBrowser")}
|
||||
description={t("settings.terminal.behavior.linkBrowser.desc")}
|
||||
>
|
||||
<Select
|
||||
value={terminalSettings.linkBrowser ?? 'netcatty'}
|
||||
options={[
|
||||
{ value: 'netcatty', label: t('settings.terminal.behavior.linkBrowser.netcatty') },
|
||||
{ value: 'system', label: t('settings.terminal.behavior.linkBrowser.system') },
|
||||
]}
|
||||
onChange={(v) => updateTerminalSetting('linkBrowser', v as 'netcatty' | 'system')}
|
||||
className="w-48"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.behavior.linkModifier")}
|
||||
description={t("settings.terminal.behavior.linkModifier.desc")}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ClipboardPaste,
|
||||
Copy,
|
||||
Download,
|
||||
Globe2,
|
||||
Pencil,
|
||||
RefreshCcw,
|
||||
Sparkles,
|
||||
@@ -65,6 +66,7 @@ export interface TerminalContextMenuProps {
|
||||
isReconnectable?: boolean;
|
||||
onReconnect?: () => void;
|
||||
onClose?: () => void;
|
||||
onOpenBrowser?: () => void;
|
||||
onSelectWord?: () => void;
|
||||
onAddSelectionToAI?: () => void;
|
||||
onRename?: () => void;
|
||||
@@ -224,6 +226,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
isReconnectable,
|
||||
onReconnect,
|
||||
onClose,
|
||||
onOpenBrowser,
|
||||
onSelectWord,
|
||||
onAddSelectionToAI,
|
||||
onRename,
|
||||
@@ -410,6 +413,14 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
{t('terminal.menu.selectAll')}
|
||||
<ContextMenuShortcut>{selectAllShortcut}</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
{onOpenBrowser && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={onOpenBrowser}>
|
||||
<Globe2 size={14} className="mr-2" /> {t('terminal.browser.open')}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showReconnectAction && (
|
||||
<>
|
||||
|
||||
@@ -68,6 +68,21 @@ test("keeps SFTP visible before the terminal overflow menu for SSH sessions", ()
|
||||
assert.ok(sftpIndex < moreIndex);
|
||||
});
|
||||
|
||||
test("browser action remains available when an SSH terminal is disconnected", () => {
|
||||
const disconnectedSsh = renderToolbar(sshHost, "disconnected", { onOpenBrowser: () => {} });
|
||||
const serial = renderToolbar(serialHost, "connected", { onOpenBrowser: () => {} });
|
||||
assert.match(disconnectedSsh, /aria-label="Open Browser"/);
|
||||
assert.doesNotMatch(disconnectedSsh, /aria-label="Open Browser"[^>]*disabled/);
|
||||
assert.doesNotMatch(serial, /aria-label="Open Browser"/);
|
||||
});
|
||||
|
||||
test("browser action is available for supported local and mosh terminals", () => {
|
||||
for (const protocol of ['local', 'mosh'] as const) {
|
||||
const markup = renderToolbar({ ...sshHost, protocol }, "connected", { onOpenBrowser: () => {} });
|
||||
assert.match(markup, /aria-label="Open Browser"/);
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps Scripts visible before the terminal overflow menu", () => {
|
||||
const markup = renderToolbar(sshHost);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
FileText,
|
||||
FolderInput,
|
||||
FolderSync,
|
||||
Globe2,
|
||||
Highlighter,
|
||||
History,
|
||||
Languages,
|
||||
@@ -44,6 +45,7 @@ import { isPluginHostProtocol } from '../../domain/pluginConnection';
|
||||
export const TERMINAL_TOOLBAR_ITEM_IDS = [
|
||||
'highlight',
|
||||
'sftp',
|
||||
'browser',
|
||||
'ymodemSend',
|
||||
'ymodemReceive',
|
||||
'compose',
|
||||
@@ -65,6 +67,7 @@ export const TERMINAL_TOOLBAR_LAYOUT_DEFAULTS: ToolbarItemLayoutDefaults = {
|
||||
placement: {
|
||||
highlight: 'show',
|
||||
sftp: 'show',
|
||||
browser: 'show',
|
||||
ymodemSend: 'show',
|
||||
ymodemReceive: 'show',
|
||||
compose: 'show',
|
||||
@@ -94,6 +97,7 @@ export interface TerminalToolbarProps {
|
||||
/** Popup vault mutation path — compact toolbar has no AppSideEffects listener. */
|
||||
onDeleteSnippets?: (ids: ReadonlySet<string>) => void;
|
||||
onOpenSFTP: () => void;
|
||||
onOpenBrowser?: () => void;
|
||||
onSendYmodem?: () => void;
|
||||
onReceiveYmodem?: () => void;
|
||||
onOpenScripts: () => void;
|
||||
@@ -133,6 +137,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
onSnippetClick,
|
||||
onDeleteSnippets,
|
||||
onOpenSFTP,
|
||||
onOpenBrowser,
|
||||
onSendYmodem,
|
||||
onReceiveYmodem,
|
||||
onOpenScripts,
|
||||
@@ -238,6 +243,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
'terminalSettings',
|
||||
];
|
||||
if (!hidesSftp) ids.push('sftp');
|
||||
if (onOpenBrowser && (!host?.protocol || ['ssh', 'mosh', 'local'].includes(host.protocol))) ids.push('browser');
|
||||
if (isSerialTerminal) {
|
||||
ids.push('ymodemSend', 'ymodemReceive');
|
||||
}
|
||||
@@ -253,6 +259,8 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
historySupported,
|
||||
isSerialTerminal,
|
||||
onConfigureOsc7,
|
||||
onOpenBrowser,
|
||||
host?.protocol,
|
||||
onSetTerminalEncoding,
|
||||
onStartRecording,
|
||||
showLogButton,
|
||||
@@ -262,6 +270,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
(): Record<TerminalToolbarItemId, string> => ({
|
||||
highlight: t('terminal.toolbar.hostHighlight.title'),
|
||||
sftp: t('terminal.toolbar.openSftp'),
|
||||
browser: t('terminal.browser.open'),
|
||||
ymodemSend: t('terminal.toolbar.sendYmodem'),
|
||||
ymodemReceive: t('terminal.toolbar.receiveYmodem'),
|
||||
compose: t('terminal.toolbar.composeBar'),
|
||||
@@ -283,6 +292,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
(): Record<TerminalToolbarItemId, React.ReactNode> => ({
|
||||
highlight: <Highlighter size={14} />,
|
||||
sftp: <FolderInput size={14} />,
|
||||
browser: <Globe2 size={14} />,
|
||||
ymodemSend: <Upload size={14} />,
|
||||
ymodemReceive: <Download size={14} />,
|
||||
compose: <TextCursorInput size={14} />,
|
||||
@@ -316,7 +326,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
);
|
||||
|
||||
const { shown, collapsed } = useMemo(() => {
|
||||
const available = new Set(availableIds);
|
||||
const available = new Set<string>(availableIds);
|
||||
const shownIds: string[] = [];
|
||||
const collapsedIds: string[] = [];
|
||||
for (const id of toolbarLayout.layout.order) {
|
||||
@@ -519,6 +529,18 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
case 'browser':
|
||||
return (
|
||||
<Tooltip key={id}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="secondary" size="icon" className={buttonBase}
|
||||
aria-label={t('terminal.browser.open')} onClick={onOpenBrowser}>
|
||||
<Globe2 size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t('terminal.browser.open')}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
case 'ymodemSend':
|
||||
return (
|
||||
<Tooltip key={id}>
|
||||
@@ -818,6 +840,15 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
</button>
|
||||
</PopoverClose>
|
||||
);
|
||||
case 'browser':
|
||||
return (
|
||||
<PopoverClose asChild key={id}>
|
||||
<button type="button" className={menuItemClass} onClick={onOpenBrowser}>
|
||||
<Globe2 size={12} className="shrink-0" />
|
||||
<span className="flex-1 text-left truncate">{t('terminal.browser.open')}</span>
|
||||
</button>
|
||||
</PopoverClose>
|
||||
);
|
||||
case 'ymodemSend':
|
||||
return (
|
||||
<PopoverClose asChild key={id}>
|
||||
|
||||
@@ -586,6 +586,7 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
|
||||
showContextMenuOverFullscreenApps={terminalSettings?.showContextMenuOverFullscreenApps}
|
||||
onSaveScreen={terminalContextActions.onSaveScreen}
|
||||
onCopy={terminalContextActions.onCopy}
|
||||
onOpenBrowser={(!host?.protocol || ['ssh', 'mosh', 'local'].includes(host.protocol)) ? () => { void ctx.handleOpenHostBrowser(); } : undefined}
|
||||
onPaste={terminalContextActions.onPaste}
|
||||
onUploadClipboardImage={status === "connected" ? terminalContextActions.onUploadClipboardImage : undefined}
|
||||
onPasteSelection={terminalContextActions.onPasteSelection}
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
sanitizeCredentialValue,
|
||||
} from "../../../domain/credentials";
|
||||
import { resolveBridgeSshAgentAuth, resolveHostAuth } from "../../../domain/sshAuth";
|
||||
import { requiresFidoSshAgentAuth } from "../../../domain/fidoSsh";
|
||||
import {
|
||||
hostRestrictsExtraSshChannels,
|
||||
resolveHostKeepalive,
|
||||
@@ -111,6 +112,32 @@ const createPluginConnectionRequestId = (): string => {
|
||||
return `plugin-connection-${randomId}`.slice(0, 128);
|
||||
};
|
||||
|
||||
/**
|
||||
* Private key material for the bridge.
|
||||
* Soft keys: PEM/OpenSSH private key for ssh2.
|
||||
* FIDO2 SK: still send the OpenSSH key *handle* so the main process can
|
||||
* ssh-add it into the system agent (signing stays on the hardware).
|
||||
* Reference keys: path-only (no inline material).
|
||||
*/
|
||||
const resolveBridgePrivateKeyMaterial = (
|
||||
key: SSHKey | undefined | null,
|
||||
options?: { forceAgent?: boolean },
|
||||
): string | undefined => {
|
||||
if (!key || key.source === "reference") return undefined;
|
||||
// Soft-key agent mode still omits inline privateKey when the agent will sign.
|
||||
if (
|
||||
options?.forceAgent
|
||||
&& !requiresFidoSshAgentAuth({
|
||||
type: key.type,
|
||||
publicKey: key.publicKey,
|
||||
privateKey: key.privateKey,
|
||||
})
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return sanitizeCredentialValue(key.privateKey) || undefined;
|
||||
};
|
||||
|
||||
const formatPluginDiagnosticLines = (
|
||||
diagnostics: ReadonlyArray<ProviderValidationIssue> | undefined,
|
||||
): string[] => (diagnostics ?? [])
|
||||
@@ -451,7 +478,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
authMethod: jumpAuth.authMethod,
|
||||
requiresMfa: !!jumpHost.requiresMfa,
|
||||
password: jumpPassword,
|
||||
privateKey: jumpKey?.source === 'reference' ? undefined : jumpPrivateKey,
|
||||
privateKey: resolveBridgePrivateKeyMaterial(jumpKey, {
|
||||
forceAgent: jumpAgentAuth.useSshAgent === true && !jumpKey?.certificate,
|
||||
}),
|
||||
certificate: jumpKey?.certificate,
|
||||
passphrase: jumpPassphrase,
|
||||
publicKey: jumpKey?.publicKey,
|
||||
@@ -679,10 +708,11 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
sftpReuseOptions: sftpReuseOptions?.password || sftpReuseOptions?.username !== effectiveUsername
|
||||
? undefined
|
||||
: sftpReuseOptions,
|
||||
privateKey: attempt.key?.source === 'reference' ? undefined : (sanitizeCredentialValue(attempt.key?.privateKey) || undefined),
|
||||
privateKey: resolveBridgePrivateKeyMaterial(attempt.key),
|
||||
certificate: attempt.key?.certificate,
|
||||
publicKey: attempt.key?.publicKey,
|
||||
keyId: attempt.key?.id,
|
||||
keyLabel: attempt.key?.label,
|
||||
keySource: attempt.key?.source,
|
||||
passphrase: attempt.key
|
||||
? (effectivePassphrase || sanitizeCredentialValue(attempt.key.passphrase))
|
||||
@@ -855,7 +885,10 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
ctx.setIsConnectionAwaitingUserInput?.(false);
|
||||
ctx.setIsConnectionPastTcpDial?.(false);
|
||||
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const rawMessage = err instanceof Error ? err.message : String(err);
|
||||
const message = rawMessage.includes("Security key authentication cancelled")
|
||||
? tr("fido.prompt.cancelled", "Security key authentication cancelled.")
|
||||
: rawMessage;
|
||||
const authError = isAuthError(err);
|
||||
|
||||
if (isJumpHostAuthError(err, message)) {
|
||||
@@ -1269,7 +1302,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
authMethod,
|
||||
requiresMfa: !!ctx.host.requiresMfa,
|
||||
password: effectivePassword,
|
||||
privateKey: (usesSystemAgent && !key?.certificate) || key?.source === 'reference' ? undefined : (sanitizeCredentialValue(key?.privateKey) || undefined),
|
||||
privateKey: resolveBridgePrivateKeyMaterial(key, {
|
||||
forceAgent: usesSystemAgent && !key?.certificate,
|
||||
}),
|
||||
certificate: key?.certificate,
|
||||
keyId: key?.id,
|
||||
passphrase: key && (!usesSystemAgent || Boolean(key.certificate))
|
||||
@@ -1563,7 +1598,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
username: jumpAuth.username || "root",
|
||||
authMethod: jumpAuth.authMethod,
|
||||
password: jumpPassword,
|
||||
privateKey: (jumpAgentAuth.useSshAgent && !jumpKey?.certificate) || jumpKey?.source === 'reference' ? undefined : jumpPrivateKey,
|
||||
privateKey: resolveBridgePrivateKeyMaterial(jumpKey, {
|
||||
forceAgent: jumpAgentAuth.useSshAgent === true && !jumpKey?.certificate,
|
||||
}),
|
||||
certificate: jumpKey?.certificate,
|
||||
passphrase: jumpAgentAuth.useSshAgent && !jumpKey?.certificate ? undefined : jumpPassphrase,
|
||||
keyId: jumpAuth.keyId,
|
||||
@@ -1602,7 +1639,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
hostId: ctx.host.id,
|
||||
username: resolvedAuth.username || "root",
|
||||
password: effectivePassword,
|
||||
privateKey: (usesSystemAgent && !key?.certificate) || key?.source === 'reference' ? undefined : (sanitizeCredentialValue(key?.privateKey) || undefined),
|
||||
privateKey: resolveBridgePrivateKeyMaterial(key, {
|
||||
forceAgent: usesSystemAgent && !key?.certificate,
|
||||
}),
|
||||
certificate: key?.certificate,
|
||||
keyId: key?.id,
|
||||
passphrase: key && (!usesSystemAgent || Boolean(key.certificate))
|
||||
|
||||
@@ -198,6 +198,7 @@ import {
|
||||
} from "./terminalUserPaste";
|
||||
import { pasteTextWithMultilineConfirm } from "../terminalClipboardPaste";
|
||||
import { requestMultilinePasteConfirm } from "../../../application/state/multilinePasteConfirmStore";
|
||||
import { requestHostBrowserPanel } from "../../../application/state/hostBrowserPanelRequests";
|
||||
import {
|
||||
consumeOsc133CommandCompletion,
|
||||
type PromptLineBreakState,
|
||||
@@ -576,6 +577,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
};
|
||||
const terminalLinkHandler = createTerminalLinkHandler({
|
||||
canActivate: canActivateTerminalLink,
|
||||
useInternal: () => ctx.terminalSettingsRef.current?.linkBrowser !== 'system',
|
||||
openInternal: ['ssh', 'mosh', 'local'].includes(ctx.host.protocol ?? 'ssh')
|
||||
? (url) => requestHostBrowserPanel({ host: ctx.host, sessionId: ctx.sessionId, url })
|
||||
: undefined,
|
||||
openExternalAvailable: ctx.terminalBackend.openExternalAvailable,
|
||||
openExternal: ctx.terminalBackend.openExternal,
|
||||
confirmOscLink: (uri) => window.confirm(
|
||||
|
||||
@@ -11,6 +11,42 @@ const click = {
|
||||
shiftKey: false,
|
||||
} as MouseEvent;
|
||||
|
||||
test("terminal links default to the internal browser and apply settings immediately", async () => {
|
||||
const internal: string[] = [];
|
||||
const external: string[] = [];
|
||||
let systemBrowser = false;
|
||||
const handler = createTerminalLinkHandler({
|
||||
canActivate: () => true,
|
||||
openExternalAvailable: () => true,
|
||||
openExternal: async (url) => { external.push(url); },
|
||||
openInternal: (url) => { internal.push(url); },
|
||||
useInternal: () => !systemBrowser,
|
||||
confirmOscLink: () => true,
|
||||
});
|
||||
await handler.open('http://localhost:3000/');
|
||||
systemBrowser = true;
|
||||
await handler.open('https://example.com/');
|
||||
assert.deepEqual(internal, ['http://localhost:3000/']);
|
||||
assert.deepEqual(external, ['https://example.com/']);
|
||||
});
|
||||
|
||||
test("internal browser failures never fall back to the local system browser", async () => {
|
||||
const errors: unknown[] = [];
|
||||
let external = 0;
|
||||
const handler = createTerminalLinkHandler({
|
||||
canActivate: () => true,
|
||||
openExternalAvailable: () => true,
|
||||
openExternal: async () => { external += 1; },
|
||||
openInternal: () => { throw new Error('Disconnected'); },
|
||||
confirmOscLink: () => true,
|
||||
onError: (error) => errors.push(error),
|
||||
warn: () => {},
|
||||
});
|
||||
await handler.open('http://localhost:3000/');
|
||||
assert.equal(external, 0);
|
||||
assert.equal(errors.length, 1);
|
||||
});
|
||||
|
||||
test("OSC 8 links use the Netcatty external browser bridge", async () => {
|
||||
const opened: string[] = [];
|
||||
const confirmed: string[] = [];
|
||||
|
||||
@@ -7,6 +7,8 @@ export type TerminalLinkHandlerOptions = {
|
||||
canActivate: (event: TerminalLinkClickEvent) => boolean;
|
||||
openExternalAvailable: () => boolean;
|
||||
openExternal: (uri: string) => Promise<void>;
|
||||
openInternal?: (uri: string) => Promise<void> | void;
|
||||
useInternal?: () => boolean;
|
||||
confirmOscLink: (uri: string) => boolean;
|
||||
openWindow?: (uri: string) => unknown;
|
||||
onError?: (error: unknown) => void;
|
||||
@@ -31,6 +33,10 @@ export function createTerminalLinkHandler(
|
||||
}
|
||||
|
||||
try {
|
||||
if (options.openInternal && (options.useInternal?.() ?? true)) {
|
||||
await options.openInternal(uri);
|
||||
return;
|
||||
}
|
||||
if (options.openExternalAvailable()) {
|
||||
await options.openExternal(uri);
|
||||
return;
|
||||
|
||||
@@ -84,6 +84,7 @@ test('side panel tab order falls back to the default order', () => {
|
||||
assert.deepEqual(normalizeTerminalSidePanelTabOrder(['scripts', 'bad-tab']), [
|
||||
'scripts',
|
||||
'sftp',
|
||||
'browser',
|
||||
'history',
|
||||
'theme',
|
||||
'system',
|
||||
@@ -93,7 +94,7 @@ test('side panel tab order falls back to the default order', () => {
|
||||
});
|
||||
|
||||
test('side panel tab order accepts a stored permutation', () => {
|
||||
const stored = ['scripts', 'sftp', 'history', 'theme', 'system', 'notes', 'ai'];
|
||||
const stored = ['scripts', 'sftp', 'browser', 'history', 'theme', 'system', 'notes', 'ai'];
|
||||
|
||||
assert.deepEqual(normalizeTerminalSidePanelTabOrder(stored), stored);
|
||||
});
|
||||
@@ -105,7 +106,7 @@ test('side panel tab order moves the dragged tab before the target tab', () => {
|
||||
'notes',
|
||||
'scripts',
|
||||
),
|
||||
['sftp', 'notes', 'scripts', 'history', 'theme', 'system', 'ai'],
|
||||
['sftp', 'browser', 'notes', 'scripts', 'history', 'theme', 'system', 'ai'],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -117,7 +118,7 @@ test('side panel tab order can move the dragged tab after the target tab', () =>
|
||||
'ai',
|
||||
'after',
|
||||
),
|
||||
['sftp', 'history', 'theme', 'system', 'notes', 'ai', 'scripts'],
|
||||
['sftp', 'browser', 'history', 'theme', 'system', 'notes', 'ai', 'scripts'],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -130,7 +131,7 @@ test('narrow side panels keep the active tool visible and move extra tools into
|
||||
});
|
||||
|
||||
assert.deepEqual(fitted.shown, ['sftp', 'ai']);
|
||||
assert.deepEqual(fitted.collapsed, ['scripts', 'history', 'theme', 'system', 'notes']);
|
||||
assert.deepEqual(fitted.collapsed, ['browser', 'scripts', 'history', 'theme', 'system', 'notes']);
|
||||
});
|
||||
|
||||
test('notes side panel forwards repeated open-note requests', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { Activity, FolderTree, History, Maximize2, MessageSquare, Minimize2, NotebookText, Palette, PanelsTopLeft, Play, Save, SplitSquareHorizontal, SplitSquareVertical, X } from 'lucide-react';
|
||||
import { Activity, FolderTree, Globe2, History, Maximize2, MessageSquare, Minimize2, NotebookText, Palette, PanelsTopLeft, Play, Save, SplitSquareHorizontal, SplitSquareVertical, X } from 'lucide-react';
|
||||
import {
|
||||
buildSidePanelChromeThemeFromTerminalTheme,
|
||||
buildTerminalSidePanelCssVars,
|
||||
@@ -555,6 +555,7 @@ export function getTerminalSidePanelShellWidth({
|
||||
function hasMountedSidePanelContent(ctx: SidePanelContext): boolean {
|
||||
const {
|
||||
mountedAiTabIds,
|
||||
browserMountedTabIds = [],
|
||||
mountedSftpTabIds,
|
||||
notesMountedTabIds,
|
||||
scriptsMountedTabIds,
|
||||
@@ -569,7 +570,8 @@ function hasMountedSidePanelContent(ctx: SidePanelContext): boolean {
|
||||
&& Array.from((sidePanelOpenTabs as Map<string, SidePanelTab>).values()).includes('notes');
|
||||
|
||||
return !(
|
||||
mountedSftpTabIds.length === 0
|
||||
browserMountedTabIds.length === 0
|
||||
&& mountedSftpTabIds.length === 0
|
||||
&& mountedAiTabIds.length === 0
|
||||
&& notesMountedTabIds.length === 0
|
||||
&& scriptsMountedTabIds.length === 0
|
||||
@@ -628,6 +630,7 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
handleOpenAI,
|
||||
handleOpenHistory,
|
||||
handleOpenNotes,
|
||||
handleOpenBrowser,
|
||||
handleOpenScripts,
|
||||
handleOpenSystem,
|
||||
handleOpenTheme,
|
||||
@@ -1036,6 +1039,7 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
|
||||
const sidePanelTabItems = useMemo<SidePanelTabItem[]>(() => [
|
||||
{ id: 'sftp' as const, label: t('terminal.layer.sftp'), icon: <FolderTree size={15} />, onClick: handleToggleSftpFromBar },
|
||||
{ id: 'browser' as const, label: t('terminal.browser.title'), icon: <Globe2 size={15} />, onClick: handleOpenBrowser },
|
||||
{ id: 'scripts' as const, label: t('terminal.layer.scripts'), icon: <Play size={15} />, onClick: handleOpenScripts },
|
||||
{ id: 'history' as const, label: t('terminal.layer.history'), icon: <History size={15} />, onClick: handleOpenHistory },
|
||||
{ id: 'theme' as const, label: t('terminal.layer.theme'), icon: <Palette size={15} />, onClick: handleOpenTheme },
|
||||
@@ -1046,6 +1050,7 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
handleOpenAI,
|
||||
handleOpenHistory,
|
||||
handleOpenNotes,
|
||||
handleOpenBrowser,
|
||||
handleOpenScripts,
|
||||
handleOpenSystem,
|
||||
handleOpenTheme,
|
||||
@@ -1297,6 +1302,7 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
draggable
|
||||
aria-label={item.label}
|
||||
data-tab-id={item.id}
|
||||
data-tab-type="sidepanel"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
|
||||
Loaded 100 of 235 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user