mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-03 04:22:38 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c849bf6a73 | ||
|
|
0d9c4a6e02 | ||
|
|
acd5700284 | ||
|
|
05b33b5521 | ||
|
|
786e5b48ef | ||
|
|
6414c9e177 |
No files matched your search
@@ -176,10 +176,9 @@ jobs:
|
||||
pack_script: pack:mac
|
||||
- name: windows
|
||||
os: windows-latest
|
||||
# Official Windows releases are x64-only for now. Bundled mosh/et
|
||||
# are win32-x64, and win.target must not hard-code arm64 or the
|
||||
# x64 job still emits broken win-arm64 / universal NSIS packages
|
||||
# (#2570). Use pack:win-x64 until a dedicated arm64 job exists.
|
||||
# The mosh binary workflow currently produces win32-x64 only.
|
||||
# Keep official packages aligned with bundled-mosh coverage
|
||||
# until Cygwin arm64 is stable enough to build win32-arm64.
|
||||
pack_script: pack:win-x64
|
||||
env:
|
||||
MOSH_BIN_RELEASE: ${{ needs.resolve-mosh.outputs.mosh_bin_release }}
|
||||
|
||||
@@ -196,16 +196,9 @@ jobs:
|
||||
}
|
||||
|
||||
if (event === 'issues') {
|
||||
const issue = context.payload.issue;
|
||||
const decision = auto.decideIssuesEventRoute({
|
||||
action: context.payload.action,
|
||||
labels: issue?.labels || [],
|
||||
actorLogin: context.payload.sender?.login,
|
||||
botLogins: process.env.ISSUE_BOT_LOGINS,
|
||||
});
|
||||
return set(decision.kind, {
|
||||
issue_number: String(issue.number),
|
||||
reason: decision.reason,
|
||||
return set('issue_classify', {
|
||||
issue_number: String(context.payload.issue.number),
|
||||
reason: `issues:${context.payload.action}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -271,7 +264,6 @@ jobs:
|
||||
hasOpenBotPull: Boolean(pull),
|
||||
hasOpenRelatedPull: Boolean(relatedPull),
|
||||
body: comment.body,
|
||||
labels,
|
||||
});
|
||||
if (refined.kind === 'issue_classify') {
|
||||
return set('issue_classify', {
|
||||
@@ -357,41 +349,6 @@ jobs:
|
||||
|
||||
return set('skip', { reason: `unhandled ${event}` });
|
||||
|
||||
ready_for_human_handoff:
|
||||
name: Hand reopened issue to maintainers
|
||||
needs: route
|
||||
if: needs.route.outputs.kind == 'ready_for_human_handoff'
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: cursor-source-issue-${{ needs.route.outputs.issue_number || github.run_id }}
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout helpers
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Apply ready-for-human handoff
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
ISSUE_NUMBER: ${{ needs.route.outputs.issue_number }}
|
||||
TRUSTED_COMMENT_AUTHORS: ${{ vars.AUTOMATION_OWN_ACTORS || 'binaricat,netcatty-bot,github-actions[bot]' }}
|
||||
with:
|
||||
github-token: ${{ secrets.TRIAGE_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const auto = require(`${process.env.GITHUB_WORKSPACE}/scripts/cursor-automation.cjs`);
|
||||
await auto.applyReadyForHumanHandoff({
|
||||
github,
|
||||
context,
|
||||
issueNumber: process.env.ISSUE_NUMBER,
|
||||
dedupeMarker: auto.REOPEN_HANDOFF_MARKER,
|
||||
trustedCommentAuthors: process.env.TRUSTED_COMMENT_AUTHORS,
|
||||
});
|
||||
|
||||
cleanup_source_issue:
|
||||
name: Clean source issue state
|
||||
needs: route
|
||||
@@ -2136,7 +2093,6 @@ jobs:
|
||||
".cursor-runtime/followup-pending-snapshots.json",
|
||||
".cursor-runtime/followup-verify-result.json",
|
||||
".cursor-runtime/followup-test-comparison.json",
|
||||
".cursor-runtime/followup-base-tests.log",
|
||||
".cursor-runtime/followup-candidate-lint.log",
|
||||
".cursor-runtime/followup-candidate-tests.log",
|
||||
".cursor-runtime/followup-candidate-build.log",
|
||||
@@ -2158,7 +2114,6 @@ jobs:
|
||||
.cursor-runtime/followup-pending-snapshots.json
|
||||
.cursor-runtime/followup-verify-result.json
|
||||
.cursor-runtime/followup-test-comparison.json
|
||||
.cursor-runtime/followup-base-tests.log
|
||||
.cursor-runtime/followup-candidate-lint.log
|
||||
.cursor-runtime/followup-candidate-tests.log
|
||||
.cursor-runtime/followup-candidate-build.log
|
||||
@@ -3424,7 +3379,6 @@ jobs:
|
||||
".cursor-runtime/implement-pr-body.md",
|
||||
".cursor-runtime/verify-result.json",
|
||||
".cursor-runtime/test-comparison.json",
|
||||
".cursor-runtime/base-tests.log",
|
||||
".cursor-runtime/candidate-lint.log",
|
||||
".cursor-runtime/candidate-tests.log",
|
||||
".cursor-runtime/candidate-build.log",
|
||||
@@ -3446,7 +3400,6 @@ jobs:
|
||||
.cursor-runtime/implement-pr-body.out.md
|
||||
.cursor-runtime/verify-result.json
|
||||
.cursor-runtime/test-comparison.json
|
||||
.cursor-runtime/base-tests.log
|
||||
.cursor-runtime/candidate-lint.log
|
||||
.cursor-runtime/candidate-tests.log
|
||||
.cursor-runtime/candidate-build.log
|
||||
@@ -3456,14 +3409,6 @@ jobs:
|
||||
if: steps.patch.outputs.artifact_ready == 'true' && steps.verify.outputs.passed != 'true'
|
||||
run: |
|
||||
echo "Candidate patch was preserved, but candidate-specific verification failed." >&2
|
||||
if [[ -f .cursor-runtime/test-comparison.json ]]; then
|
||||
echo "Test comparison:" >&2
|
||||
cat .cursor-runtime/test-comparison.json >&2 || true
|
||||
fi
|
||||
if [[ -f .cursor-runtime/verify-result.json ]]; then
|
||||
echo "Verify result:" >&2
|
||||
cat .cursor-runtime/verify-result.json >&2 || true
|
||||
fi
|
||||
exit 1
|
||||
|
||||
- name: Mark needs human on implement failure
|
||||
@@ -4867,7 +4812,6 @@ jobs:
|
||||
".cursor-runtime/fix.patch",
|
||||
".cursor-runtime/fix-verify-result.json",
|
||||
".cursor-runtime/fix-test-comparison.json",
|
||||
".cursor-runtime/fix-base-tests.log",
|
||||
".cursor-runtime/fix-candidate-lint.log",
|
||||
".cursor-runtime/fix-candidate-tests.log",
|
||||
".cursor-runtime/fix-candidate-build.log",
|
||||
@@ -4887,7 +4831,6 @@ jobs:
|
||||
.cursor-runtime/fix.patch
|
||||
.cursor-runtime/fix-verify-result.json
|
||||
.cursor-runtime/fix-test-comparison.json
|
||||
.cursor-runtime/fix-base-tests.log
|
||||
.cursor-runtime/fix-candidate-lint.log
|
||||
.cursor-runtime/fix-candidate-tests.log
|
||||
.cursor-runtime/fix-candidate-build.log
|
||||
@@ -4897,14 +4840,6 @@ jobs:
|
||||
if: steps.codex.outputs.action == 'fix' && steps.fixpatch.outputs.artifact_ready == 'true' && steps.fixverify.outputs.passed != 'true'
|
||||
run: |
|
||||
echo "Codex fix patch was preserved, but candidate-specific verification failed." >&2
|
||||
if [[ -f .cursor-runtime/fix-test-comparison.json ]]; then
|
||||
echo "Test comparison:" >&2
|
||||
cat .cursor-runtime/fix-test-comparison.json >&2 || true
|
||||
fi
|
||||
if [[ -f .cursor-runtime/fix-verify-result.json ]]; then
|
||||
echo "Verify result:" >&2
|
||||
cat .cursor-runtime/fix-verify-result.json >&2 || true
|
||||
fi
|
||||
exit 1
|
||||
|
||||
- name: Mark needs human when no fix produced
|
||||
@@ -5361,7 +5296,8 @@ jobs:
|
||||
},
|
||||
);
|
||||
const forceRetry =
|
||||
process.env.GITHUB_EVENT_NAME === 'workflow_dispatch'; if (
|
||||
process.env.GITHUB_EVENT_NAME === 'workflow_dispatch';
|
||||
if (
|
||||
!forceRetry &&
|
||||
auto.shouldSkipExternalCodexRerequest({
|
||||
existingComments: comments,
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
rememberKeyPassphrase,
|
||||
shouldUpdateReferenceKeyPassphrase,
|
||||
} from './application/defaultKeyPassphrases';
|
||||
import { isTerminalBootEpochCurrent } from './domain/terminalBootEpoch';
|
||||
import { initializeFonts } from './application/state/fontStore';
|
||||
import { initializeUIFonts } from './application/state/uiFontStore';
|
||||
import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
|
||||
@@ -35,7 +34,7 @@ import { buildTelnetDeepLinkConnectionHost, buildTelnetDeepLinkEphemeralHostFrom
|
||||
import { buildJmsDeepLinkEphemeralHost, isSupportedJmsProtocol, parseJmsDeepLink } from './domain/jmsDeepLink';
|
||||
import { applyEphemeralHostsUpdate, splitHostsUpdateByEphemeral } from './domain/ephemeralHosts';
|
||||
import { resolveHostAuth } from './domain/sshAuth';
|
||||
import { isEncryptedCredentialPlaceholder, stripSyncPayloadEncryptedCredentials } from './domain/credentials';
|
||||
import { isEncryptedCredentialPlaceholder } from './domain/credentials';
|
||||
import {
|
||||
mergeTerminalHostUpdate,
|
||||
TERMINAL_THEME_AUTO,
|
||||
@@ -291,11 +290,15 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
draggingSessionId,
|
||||
setDraggingSessionId,
|
||||
sessionRenameTarget,
|
||||
sessionRenameValue,
|
||||
setSessionRenameValue,
|
||||
startSessionRename,
|
||||
renameSessionInline,
|
||||
submitSessionRename,
|
||||
resetSessionRename,
|
||||
workspaceRenameTarget,
|
||||
workspaceRenameValue,
|
||||
setWorkspaceRenameValue,
|
||||
startWorkspaceRename,
|
||||
submitWorkspaceRename,
|
||||
resetWorkspaceRename,
|
||||
@@ -661,8 +664,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
applyProtectedSyncPayload({
|
||||
buildPreApplyPayload: () => buildCurrentSyncPayload(),
|
||||
applyPayload: async () => {
|
||||
const portable = stripSyncPayloadEncryptedCredentials(payload);
|
||||
await applySyncPayload(portable, {
|
||||
await applySyncPayload(payload, {
|
||||
importVaultData: importDataFromString,
|
||||
importPortForwardingRules,
|
||||
onSettingsApplied: settings.rehydrateAllFromStorage,
|
||||
@@ -824,28 +826,12 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const unsubscribe = bridge.onPassphraseRequest(async (request) => {
|
||||
console.log('[App] Passphrase request received:', request);
|
||||
|
||||
const isRequestCurrent = () => (
|
||||
typeof request.sessionId !== "string"
|
||||
|| isTerminalBootEpochCurrent(request.sessionId, request.bootEpoch)
|
||||
);
|
||||
|
||||
// Disconnect/reconnect may leave a late passphrase prompt for a
|
||||
// superseded boot; reject epoch-tagged requests that are no longer current.
|
||||
if (!isRequestCurrent()) {
|
||||
void bridge.respondPassphrase?.(request.requestId, "", true);
|
||||
return;
|
||||
}
|
||||
|
||||
// If the bridge already tried a passphrase and it was wrong, skip auto-respond
|
||||
if (!request.passphraseInvalid) {
|
||||
// Check if a reference key exists for this path — use its passphrase
|
||||
const currentKeys = keysRef.current;
|
||||
const refKey = currentKeys.find((k: SSHKey) => k.source === 'reference' && k.filePath === request.keyPath);
|
||||
if (refKey?.passphrase && refKey.savePassphrase !== false && !isEncryptedCredentialPlaceholder(refKey.passphrase)) {
|
||||
if (!isRequestCurrent()) {
|
||||
void bridge.respondPassphrase?.(request.requestId, "", true);
|
||||
return;
|
||||
}
|
||||
console.log('[App] Auto-responding with reference key passphrase for:', request.keyPath);
|
||||
void bridge.respondPassphrase?.(request.requestId, refKey.passphrase, false);
|
||||
return;
|
||||
@@ -853,10 +839,6 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
// Fallback: try old storage for passphrase
|
||||
const saved = await loadDefaultKeyPassphrase(request.keyPath);
|
||||
if (!isRequestCurrent()) {
|
||||
void bridge.respondPassphrase?.(request.requestId, "", true);
|
||||
return;
|
||||
}
|
||||
if (saved) {
|
||||
console.log('[App] Auto-responding with saved passphrase for:', request.keyPath);
|
||||
// Migrate to reference key if one exists
|
||||
@@ -874,29 +856,18 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
} catch (err) {
|
||||
console.warn('[App] Failed to migrate passphrase to reference key:', err);
|
||||
}
|
||||
if (!isRequestCurrent()) {
|
||||
void bridge.respondPassphrase?.(request.requestId, "", true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
void bridge.respondPassphrase?.(request.requestId, saved, false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isRequestCurrent()) {
|
||||
void bridge.respondPassphrase?.(request.requestId, "", true);
|
||||
return;
|
||||
}
|
||||
|
||||
// No saved passphrase or it was invalid, show modal
|
||||
setPassphraseQueue(prev => [...prev, {
|
||||
requestId: request.requestId,
|
||||
keyPath: request.keyPath,
|
||||
keyName: request.keyName,
|
||||
hostname: request.hostname,
|
||||
sessionId: request.sessionId,
|
||||
bootEpoch: request.bootEpoch,
|
||||
}]);
|
||||
});
|
||||
|
||||
@@ -905,31 +876,6 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
};
|
||||
}, [updateKeys]);
|
||||
|
||||
// Drop queued passphrase prompts when a terminal disconnects mid-boot.
|
||||
useEffect(() => {
|
||||
const onDisconnected = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ sessionId?: string }>).detail;
|
||||
const disconnectedSessionId = detail?.sessionId;
|
||||
if (!disconnectedSessionId) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
setPassphraseQueue((prev) => {
|
||||
const keep: typeof prev = [];
|
||||
for (const item of prev) {
|
||||
if (item.sessionId === disconnectedSessionId) {
|
||||
void bridge?.respondPassphrase?.(item.requestId, "", true);
|
||||
continue;
|
||||
}
|
||||
keep.push(item);
|
||||
}
|
||||
return keep;
|
||||
});
|
||||
};
|
||||
window.addEventListener("netcatty:terminal-session-disconnected", onDisconnected);
|
||||
return () => {
|
||||
window.removeEventListener("netcatty:terminal-session-disconnected", onDisconnected);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle passphrase submit
|
||||
const handlePassphraseSubmit = useCallback(async (requestId: string, passphrase: string, remember: boolean) => { return handlePassphraseSubmitImpl(() => ({ keysRef, netcattyBridge, passphrase, passphraseQueue, remember, rememberKeyPassphrase, requestId, setPassphraseQueue, updateKeys }), requestId, passphrase, remember); }, [passphraseQueue, updateKeys]);
|
||||
|
||||
@@ -1941,7 +1887,10 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
resetWorkspaceRename,
|
||||
resolvedTheme,
|
||||
sessionRenameTarget,
|
||||
sessionRenameValue,
|
||||
setActiveTabId,
|
||||
setSessionRenameValue,
|
||||
setWorkspaceRenameValue,
|
||||
settings,
|
||||
startSessionRename,
|
||||
renameSessionInline,
|
||||
@@ -1953,7 +1902,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
toggleConnectionLogSaved,
|
||||
updateConnectionLog,
|
||||
workspaceRenameTarget,
|
||||
}), [accentMode, clearUnsavedConnectionLogs, closeLogView, customAccent, deleteConnectionLog, handleEndSessionDrag, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handleSyncNowManual, isMacClient, logViews, openLogView, orderedTabsWithEditors, orphanSessionsForShell, reorderWorkTabs, resetSessionRename, resetWorkspaceRename, resolvedTheme, sessionRenameTarget, setActiveTabId, settings, startSessionRename, renameSessionInline, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, themeById, toggleConnectionLogSaved, updateConnectionLog, workspaceRenameTarget]);
|
||||
workspaceRenameValue,
|
||||
}), [accentMode, clearUnsavedConnectionLogs, closeLogView, customAccent, deleteConnectionLog, handleEndSessionDrag, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handleSyncNowManual, isMacClient, logViews, openLogView, orderedTabsWithEditors, orphanSessionsForShell, reorderWorkTabs, resetSessionRename, resetWorkspaceRename, resolvedTheme, sessionRenameTarget, sessionRenameValue, setActiveTabId, setSessionRenameValue, setWorkspaceRenameValue, settings, startSessionRename, renameSessionInline, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, themeById, toggleConnectionLogSaved, updateConnectionLog, workspaceRenameTarget, workspaceRenameValue]);
|
||||
|
||||
const appDialogsDomain = useMemo(() => ({
|
||||
addToWorkspaceDialog,
|
||||
|
||||
@@ -53,8 +53,3 @@ test('shared host tree forwards work-surface host management callbacks', () => {
|
||||
assert.match(hostTreeLayerSource, /onNewHost=\{onNewHost\}/);
|
||||
assert.match(hostTreeLayerSource, /onEditHost=\{onEditHost\}/);
|
||||
});
|
||||
|
||||
test('shared host tree layer is memoized with a custom areEqual', () => {
|
||||
assert.match(hostTreeLayerSource, /memo\(AppHostTreeLayerInner,\s*appHostTreeLayerAreEqual\)/);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { useActiveTabId } from '../state/activeTabStore';
|
||||
import type { EditorTabChrome } from '../state/editorTabStore';
|
||||
@@ -47,33 +47,7 @@ export function getAppHostTreeLayerStyle(surfaceVisible: boolean): React.CSSProp
|
||||
};
|
||||
}
|
||||
|
||||
function appHostTreeLayerAreEqual(
|
||||
prev: AppHostTreeLayerProps,
|
||||
next: AppHostTreeLayerProps,
|
||||
): boolean {
|
||||
return prev.enabled === next.enabled
|
||||
&& prev.hosts === next.hosts
|
||||
&& prev.customGroups === next.customGroups
|
||||
&& prev.groupConfigs === next.groupConfigs
|
||||
&& prev.sessions === next.sessions
|
||||
&& prev.workspaces === next.workspaces
|
||||
&& prev.editorTabs === next.editorTabs
|
||||
&& prev.logViews === next.logViews
|
||||
&& prev.orderedTabs === next.orderedTabs
|
||||
&& prev.accentMode === next.accentMode
|
||||
&& prev.currentTerminalTheme === next.currentTerminalTheme
|
||||
&& prev.customAccent === next.customAccent
|
||||
&& prev.followAppTerminalTheme === next.followAppTerminalTheme
|
||||
&& prev.hostById === next.hostById
|
||||
&& prev.themeById === next.themeById
|
||||
&& prev.resolveSessionAppearance === next.resolveSessionAppearance
|
||||
&& prev.onConnect === next.onConnect
|
||||
&& prev.onNewHost === next.onNewHost
|
||||
&& prev.onEditHost === next.onEditHost
|
||||
&& prev.onCreateLocalTerminal === next.onCreateLocalTerminal;
|
||||
}
|
||||
|
||||
const AppHostTreeLayerInner: React.FC<AppHostTreeLayerProps> = ({
|
||||
export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
|
||||
enabled,
|
||||
hosts,
|
||||
customGroups,
|
||||
@@ -180,6 +154,3 @@ const AppHostTreeLayerInner: React.FC<AppHostTreeLayerProps> = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AppHostTreeLayer = memo(AppHostTreeLayerInner, appHostTreeLayerAreEqual);
|
||||
AppHostTreeLayer.displayName = 'AppHostTreeLayer';
|
||||
+112
-159
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo } from 'react';
|
||||
import { AlertTriangle, Download, Trash2 } from 'lucide-react';
|
||||
import { activeTabStore, toEditorTabId, useIsEditorTabActive } from '../state/activeTabStore';
|
||||
import { editorTabStore } from '../state/editorTabStore';
|
||||
@@ -12,7 +12,7 @@ import { QuickScriptEditorDialog } from '../../components/scripts/QuickScriptEdi
|
||||
import { AddToWorkspaceDialog } from '../../components/workspace/AddToWorkspaceDialog';
|
||||
import { KeyboardInteractiveModal } from '../../components/KeyboardInteractiveModal';
|
||||
import { PassphraseModal } from '../../components/PassphraseModal';
|
||||
import { UnsavedChangesProvider, promptUnsavedChanges } from '../../components/editor/UnsavedChangesDialog';
|
||||
import { UnsavedChangesProvider } from '../../components/editor/UnsavedChangesDialog';
|
||||
import { SnippetExecutionProvider } from '../../components/SnippetExecutionProvider';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../../components/ui/dialog';
|
||||
@@ -66,68 +66,6 @@ const TextEditorTabFallback = ({ tabId }: { tabId: string }) => {
|
||||
);
|
||||
};
|
||||
|
||||
/** Local draft so keystrokes do not rebuild App chrome domain every character. */
|
||||
function RenameDraftDialog({
|
||||
open,
|
||||
inputId,
|
||||
initialName,
|
||||
title,
|
||||
nameLabel,
|
||||
placeholder,
|
||||
cancelLabel,
|
||||
saveLabel,
|
||||
onCancel,
|
||||
onSave,
|
||||
}: {
|
||||
open: boolean;
|
||||
inputId: string;
|
||||
initialName: string;
|
||||
title: string;
|
||||
nameLabel: string;
|
||||
placeholder: string;
|
||||
cancelLabel: string;
|
||||
saveLabel: string;
|
||||
onCancel: () => void;
|
||||
onSave: (name: string) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(initialName);
|
||||
useEffect(() => {
|
||||
if (open) setDraft(initialName);
|
||||
}, [open, initialName]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) onCancel();
|
||||
}}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 py-2">
|
||||
<Label htmlFor={inputId}>{nameLabel}</Label>
|
||||
<Input
|
||||
id={inputId}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
if (!draft.trim()) return;
|
||||
onSave(draft);
|
||||
}}
|
||||
autoFocus
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onCancel}>{cancelLabel}</Button>
|
||||
<Button onClick={() => onSave(draft)} disabled={!draft.trim()}>{saveLabel}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export type AppViewProps = {
|
||||
domains: AppViewDomains;
|
||||
};
|
||||
@@ -189,14 +127,14 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
|
||||
passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
|
||||
resolveEmptyVaultConflict, resolvedTheme, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
||||
sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
|
||||
setNavigateToSection, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
|
||||
setWorkspaceFocusedSession, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
|
||||
sessionRenameValue, sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
|
||||
setNavigateToSection, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
|
||||
setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
|
||||
sftpShowHiddenFiles, sftpUseCompressedUpload, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
|
||||
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById,
|
||||
toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog,
|
||||
readPersistedHosts, readPersistedManagedSources, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources,
|
||||
updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaces,
|
||||
updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaceRenameValue, workspaces,
|
||||
VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper,
|
||||
} = ctx;
|
||||
|
||||
@@ -273,70 +211,6 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
pluginViewTabStore.close(tabId);
|
||||
}, [orderedTabsWithEditors]);
|
||||
|
||||
const orderedTabsWithEditorsRef = useRef(orderedTabsWithEditors);
|
||||
orderedTabsWithEditorsRef.current = orderedTabsWithEditors;
|
||||
|
||||
// Stable for TopTabs memo: read ordered tabs via ref; prompt via module singleton.
|
||||
const handleRequestCloseEditorTab = useCallback(async (id: string): Promise<boolean> => {
|
||||
const tab = editorTabStore.getTab(id);
|
||||
if (!tab) return false;
|
||||
|
||||
const closeEditorAndActivateNeighbor = () => {
|
||||
const closingTabId = toEditorTabId(id);
|
||||
const list = orderedTabsWithEditorsRef.current;
|
||||
const idx = list.indexOf(closingTabId);
|
||||
releaseEditorTabSaveCoordinator(id);
|
||||
editorTabStore.close(id);
|
||||
if (activeTabStore.getActiveTabId() !== closingTabId) return;
|
||||
const next = list[idx - 1] ?? list[idx + 1] ?? 'vault';
|
||||
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
|
||||
};
|
||||
|
||||
const dirty = tab.content !== tab.baselineContent;
|
||||
if (!dirty) {
|
||||
closeEditorAndActivateNeighbor();
|
||||
return true;
|
||||
}
|
||||
const choice = await promptUnsavedChanges(tab.fileName);
|
||||
if (choice === 'cancel') return false;
|
||||
if (choice === 'discard') {
|
||||
closeEditorAndActivateNeighbor();
|
||||
return true;
|
||||
}
|
||||
if (choice === 'save') {
|
||||
const ok = await saveEditorTab(id);
|
||||
if (!ok) {
|
||||
const msg = editorTabStore.getTab(id)?.saveError ?? 'Save failed';
|
||||
toast.error(msg, 'SFTP');
|
||||
return false;
|
||||
}
|
||||
const latest = editorTabStore.getTab(id);
|
||||
if (!latest || latest.content !== latest.baselineContent) return false;
|
||||
closeEditorAndActivateNeighbor();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
// Keep the hotkey ref current during render so Cmd/Ctrl+W never sees the
|
||||
// App.tsx stub `() => false` between commit and useEffect.
|
||||
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
|
||||
|
||||
const handleSaveSessionRename = useCallback((name: string) => {
|
||||
if (!sessionRenameTarget) return;
|
||||
if (!name.trim()) return;
|
||||
submitSessionRename(sessionRenameTarget.id, name);
|
||||
resetSessionRename();
|
||||
}, [resetSessionRename, sessionRenameTarget, submitSessionRename]);
|
||||
|
||||
const handleSaveWorkspaceRename = useCallback((name: string) => {
|
||||
if (!workspaceRenameTarget) return;
|
||||
if (!name.trim()) return;
|
||||
submitWorkspaceRename(workspaceRenameTarget.id, name);
|
||||
resetWorkspaceRename();
|
||||
}, [resetWorkspaceRename, submitWorkspaceRename, workspaceRenameTarget]);
|
||||
|
||||
useEffect(() => {
|
||||
const catalog = buildPluginSettingScopeCatalog({
|
||||
hosts,
|
||||
@@ -350,7 +224,54 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
return (
|
||||
<SnippetExecutionProvider>
|
||||
<UnsavedChangesProvider>
|
||||
{() => (
|
||||
{({ prompt }) => {
|
||||
// Helper: close an editor tab and activate the neighbor (left-preference), or vault.
|
||||
const closeEditorAndActivateNeighbor = (id: string) => {
|
||||
const closingTabId = toEditorTabId(id);
|
||||
const list = orderedTabsWithEditors;
|
||||
const idx = list.indexOf(closingTabId);
|
||||
releaseEditorTabSaveCoordinator(id);
|
||||
editorTabStore.close(id);
|
||||
if (activeTabStore.getActiveTabId() !== closingTabId) return;
|
||||
const next = list[idx - 1] ?? list[idx + 1] ?? 'vault';
|
||||
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
|
||||
};
|
||||
|
||||
// Real dirty-confirm close handler.
|
||||
const handleRequestCloseEditorTab = async (id: string): Promise<boolean> => {
|
||||
const tab = editorTabStore.getTab(id);
|
||||
if (!tab) return false;
|
||||
const dirty = tab.content !== tab.baselineContent;
|
||||
if (!dirty) {
|
||||
closeEditorAndActivateNeighbor(id);
|
||||
return true;
|
||||
}
|
||||
const choice = await prompt(tab.fileName);
|
||||
if (choice === 'cancel') return false;
|
||||
if (choice === 'discard') {
|
||||
closeEditorAndActivateNeighbor(id);
|
||||
return true;
|
||||
}
|
||||
if (choice === 'save') {
|
||||
const ok = await saveEditorTab(id);
|
||||
if (!ok) {
|
||||
const msg = editorTabStore.getTab(id)?.saveError ?? 'Save failed';
|
||||
toast.error(msg, 'SFTP');
|
||||
return false;
|
||||
}
|
||||
const latest = editorTabStore.getTab(id);
|
||||
if (!latest || latest.content !== latest.baselineContent) return false;
|
||||
closeEditorAndActivateNeighbor(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Expose to the hotkey dispatcher (Cmd/Ctrl+W).
|
||||
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" data-terminal-appearance-root onContextMenu={handleRootContextMenu}>
|
||||
<TopTabs
|
||||
theme={resolvedTheme}
|
||||
@@ -543,6 +464,8 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
identities={identities}
|
||||
snippets={snippets}
|
||||
snippetPackages={snippetPackages}
|
||||
notes={notes}
|
||||
noteGroups={noteGroups}
|
||||
sessions={sessions}
|
||||
workspaces={workspaces}
|
||||
knownHosts={effectiveKnownHosts}
|
||||
@@ -606,6 +529,8 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
updateHosts={updateTerminalHosts}
|
||||
updateSnippets={updateSnippets}
|
||||
updateSnippetPackages={updateSnippetPackages}
|
||||
updateNotes={updateNotes}
|
||||
updateNoteGroups={updateNoteGroups}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
||||
sftpAutoSync={sftpAutoSync}
|
||||
@@ -790,32 +715,59 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
</LazyLoadBoundary>
|
||||
)}
|
||||
|
||||
<RenameDraftDialog
|
||||
open={!!sessionRenameTarget}
|
||||
inputId="session-name"
|
||||
initialName={sessionRenameTarget ? (sessionRenameTarget.customName || sessionRenameTarget.hostLabel) : ''}
|
||||
title={t('dialog.renameSession.title')}
|
||||
nameLabel={t('field.name')}
|
||||
placeholder={t('placeholder.sessionName')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
saveLabel={t('common.save')}
|
||||
onCancel={resetSessionRename}
|
||||
onSave={handleSaveSessionRename}
|
||||
/>
|
||||
|
||||
<RenameDraftDialog
|
||||
open={!!workspaceRenameTarget}
|
||||
inputId="workspace-name"
|
||||
initialName={workspaceRenameTarget ? workspaceRenameTarget.title : ''}
|
||||
title={t('dialog.renameWorkspace.title')}
|
||||
nameLabel={t('field.name')}
|
||||
placeholder={t('placeholder.workspaceName')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
saveLabel={t('common.save')}
|
||||
onCancel={resetWorkspaceRename}
|
||||
onSave={handleSaveWorkspaceRename}
|
||||
/>
|
||||
<Dialog open={!!sessionRenameTarget} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
resetSessionRename();
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('dialog.renameSession.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 py-2">
|
||||
<Label htmlFor="session-name">{t('field.name')}</Label>
|
||||
<Input
|
||||
id="session-name"
|
||||
value={sessionRenameValue}
|
||||
onChange={(e) => setSessionRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') submitSessionRename(); }}
|
||||
autoFocus
|
||||
placeholder={t('placeholder.sessionName')}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={resetSessionRename}>{t('common.cancel')}</Button>
|
||||
<Button onClick={submitSessionRename} disabled={!sessionRenameValue.trim()}>{t('common.save')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={!!workspaceRenameTarget} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
resetWorkspaceRename();
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('dialog.renameWorkspace.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 py-2">
|
||||
<Label htmlFor="workspace-name">{t('field.name')}</Label>
|
||||
<Input
|
||||
id="workspace-name"
|
||||
value={workspaceRenameValue}
|
||||
onChange={(e) => setWorkspaceRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') submitWorkspaceRename(); }}
|
||||
autoFocus
|
||||
placeholder={t('placeholder.workspaceName')}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={resetWorkspaceRename}>{t('common.cancel')}</Button>
|
||||
<Button onClick={submitWorkspaceRename} disabled={!workspaceRenameValue.trim()}>{t('common.save')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{isCreateWorkspaceOpen && (
|
||||
<LazyLoadBoundary name="Create workspace" resetKey="create-workspace">
|
||||
@@ -919,7 +871,8 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
}}
|
||||
</UnsavedChangesProvider>
|
||||
</SnippetExecutionProvider>
|
||||
);
|
||||
|
||||
@@ -5,10 +5,6 @@ import {
|
||||
removeKeyboardInteractiveRequest,
|
||||
shouldQueueKeyboardInteractiveRequest,
|
||||
} from "./useAppStartupEffects.ts";
|
||||
import {
|
||||
clearTerminalBootEpoch,
|
||||
setTerminalBootEpoch,
|
||||
} from "../../domain/terminalBootEpoch.ts";
|
||||
|
||||
const sessions = [{ id: "terminal-1" }, { id: "terminal-2" }];
|
||||
|
||||
@@ -23,42 +19,6 @@ test("terminal-scoped keyboard-interactive requests are limited to owned session
|
||||
);
|
||||
});
|
||||
|
||||
test("disconnected terminal sessions do not queue keyboard-interactive prompts", () => {
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest(
|
||||
{ scope: "terminal", sessionId: "terminal-1" },
|
||||
[{ id: "terminal-1", status: "disconnected" }],
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest(
|
||||
{ scope: "terminal", sessionId: "terminal-1" },
|
||||
[{ id: "terminal-1", status: "connecting" }],
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("superseded terminal boot epochs do not queue keyboard-interactive prompts", () => {
|
||||
setTerminalBootEpoch("terminal-1", 3);
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest(
|
||||
{ scope: "terminal", sessionId: "terminal-1", bootEpoch: 1 },
|
||||
[{ id: "terminal-1", status: "connecting" }],
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest(
|
||||
{ scope: "terminal", sessionId: "terminal-1", bootEpoch: 3 },
|
||||
[{ id: "terminal-1", status: "connecting" }],
|
||||
),
|
||||
true,
|
||||
);
|
||||
clearTerminalBootEpoch("terminal-1");
|
||||
});
|
||||
|
||||
test("external keyboard-interactive requests are not filtered by terminal session ids", () => {
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest({ scope: "external", sessionId: "sftp-conn-1" }, sessions),
|
||||
|
||||
@@ -9,12 +9,8 @@ import { sftpTransferCenterStore } from '../state/sftpTransferCenterStore';
|
||||
import { resumeTransferWithDedicatedSession } from '../state/sftp/dedicatedTransferResume';
|
||||
import { getSftpTransferResourceKeys, globalSftpTransferScheduler } from '../state/sftp/globalTransferScheduler';
|
||||
import { hasNewSourceFingerprint } from '../state/sftp/transferProgressMetadata';
|
||||
import {
|
||||
STORAGE_KEY_AUTO_UPDATE_ENABLED,
|
||||
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from '../../infrastructure/config/storageKeys';
|
||||
import type { TransferTask } from '../../domain/models';
|
||||
import { isTerminalBootEpochCurrent } from '../../domain/terminalBootEpoch';
|
||||
import {
|
||||
canApplyDedicatedResumeProgress,
|
||||
createDedicatedResumeChildUpdateBatcher,
|
||||
@@ -28,10 +24,8 @@ type KeyboardInteractiveRequestLike = {
|
||||
scope?: KeyboardInteractiveScope;
|
||||
sessionId?: string;
|
||||
hostId?: string;
|
||||
requestId?: string;
|
||||
bootEpoch?: number;
|
||||
};
|
||||
type SessionIdLike = { id: string; hostId?: string; hostname?: string; status?: string };
|
||||
type SessionIdLike = { id: string; hostId?: string; hostname?: string };
|
||||
type KeyboardInteractiveQueueItem = { requestId: string };
|
||||
|
||||
export function shouldQueueKeyboardInteractiveRequest(
|
||||
@@ -40,14 +34,7 @@ export function shouldQueueKeyboardInteractiveRequest(
|
||||
): boolean {
|
||||
if (request.scope !== "terminal") return true;
|
||||
if (!request.sessionId) return false;
|
||||
const session = sessions.find((entry) => entry.id === request.sessionId);
|
||||
if (!session) return false;
|
||||
// Status-bar disconnect keeps the tab; do not queue MFA for aborted panes.
|
||||
if (session.status === "disconnected") return false;
|
||||
// After disconnect → reconnect the tab is connecting again; reject MFA from
|
||||
// a superseded SSH start that still shares this sessionId.
|
||||
if (!isTerminalBootEpochCurrent(request.sessionId, request.bootEpoch)) return false;
|
||||
return true;
|
||||
return sessions.some((session) => session.id === request.sessionId);
|
||||
}
|
||||
|
||||
export function removeKeyboardInteractiveRequest<T extends KeyboardInteractiveQueueItem>(
|
||||
@@ -221,7 +208,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
// Skip "update available" toast if auto-download has already started or completed
|
||||
if (updateState.autoDownloadStatus !== 'idle') return;
|
||||
// Don't show automatic notification when auto-update is disabled
|
||||
if (localStorageAdapter.readString(STORAGE_KEY_AUTO_UPDATE_ENABLED) === 'false') return;
|
||||
if (localStorageAdapter.readString('netcatty_auto_update_enabled_v1') === 'false') return;
|
||||
if (updateState.hasUpdate && updateState.latestRelease) {
|
||||
const version = updateState.latestRelease.version;
|
||||
toast.info(
|
||||
@@ -422,9 +409,6 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
targetType: task.direction === "download" ? "local" : "sftp",
|
||||
sourceSftpId: task.direction === "download" ? sftpId : undefined,
|
||||
targetSftpId: task.direction === "upload" ? sftpId : undefined,
|
||||
// Keep host-scoped path gates across session reopen (Codex P1).
|
||||
sourceHostId: task.sourceHostId,
|
||||
targetHostId: task.targetHostId,
|
||||
totalBytes: task.totalBytes,
|
||||
resumable: task.resumable !== false,
|
||||
checkpointBytes,
|
||||
@@ -436,30 +420,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
});
|
||||
},
|
||||
);
|
||||
// Same-id retry stole ownership; wait for the live owner's terminal
|
||||
// status instead of treating this invoke as completed (Codex P2).
|
||||
if (result?.superseded === true) {
|
||||
// Wait for live owner terminal status only (no fixed deadline).
|
||||
for (;;) {
|
||||
const latest = sftpTransferCenterStore.getSnapshot().tasks.find((candidate) => candidate.id === task.id);
|
||||
const status = latest?.status;
|
||||
if (status === "completed" || status === "cancelled" || status === "failed") {
|
||||
if (status === "failed") {
|
||||
throw new Error(latest?.error || "Transfer failed");
|
||||
}
|
||||
if (status === "cancelled") {
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "cancelled",
|
||||
transferId: task.id,
|
||||
endedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
// completed: events already applied; cancelled handled above.
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
} else if (result?.cancelled || result?.error === "Transfer cancelled") {
|
||||
if (result?.cancelled || result?.error === "Transfer cancelled") {
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({ type: "cancelled", transferId: task.id, endedAt: Date.now() });
|
||||
} else if (result?.error) {
|
||||
throw new Error(result.error);
|
||||
@@ -532,12 +493,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
if (!bridge?.onKeyboardInteractive) return;
|
||||
|
||||
const unsubscribe = bridge.onKeyboardInteractive((request) => {
|
||||
if (!shouldQueueKeyboardInteractiveRequest(request, sessionsRef.current)) {
|
||||
if (request.scope === "terminal" && request.requestId) {
|
||||
void bridge.respondKeyboardInteractive?.(request.requestId, [], true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!shouldQueueKeyboardInteractiveRequest(request, sessionsRef.current)) return;
|
||||
console.log('[App] Keyboard-interactive request received:', request);
|
||||
// Add to queue instead of replacing - supports multiple concurrent sessions
|
||||
setKeyboardInteractiveQueue(prev => [...prev, {
|
||||
@@ -555,23 +511,10 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
const unsubscribeCancelled = bridge.onKeyboardInteractiveCancelled?.((event) => {
|
||||
setKeyboardInteractiveQueue(prev => removeKeyboardInteractiveRequest(prev, event.requestId));
|
||||
});
|
||||
const onTerminalDisconnected = (event: Event) => {
|
||||
const sessionId = (event as CustomEvent<{ sessionId?: string }>).detail?.sessionId;
|
||||
if (!sessionId) return;
|
||||
setKeyboardInteractiveQueue((prev) => {
|
||||
const doomed = prev.filter((request) => request.sessionId === sessionId);
|
||||
for (const request of doomed) {
|
||||
void bridge.respondKeyboardInteractive?.(request.requestId, [], true);
|
||||
}
|
||||
return prev.filter((request) => request.sessionId !== sessionId);
|
||||
});
|
||||
};
|
||||
window.addEventListener("netcatty:terminal-session-disconnected", onTerminalDisconnected);
|
||||
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
unsubscribeCancelled?.();
|
||||
window.removeEventListener("netcatty:terminal-session-disconnected", onTerminalDisconnected);
|
||||
};
|
||||
}, [enabled, setKeyboardInteractiveQueue]);
|
||||
|
||||
|
||||
@@ -404,14 +404,6 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.reject': 'Reject',
|
||||
'ai.chat.toolLabel': 'Tool',
|
||||
'ai.chat.targetLabel': 'Target',
|
||||
'ai.chat.rawCommand': 'Command',
|
||||
'ai.chat.copyCommand': 'Copy',
|
||||
'ai.chat.commandCopied': 'Copied',
|
||||
'ai.chat.approvalSession': 'Session',
|
||||
'ai.chat.approvalShell': 'Shell',
|
||||
'ai.chat.approvalCwd': 'Cwd',
|
||||
'ai.chat.approvalReason': 'Reason',
|
||||
'ai.chat.approvalInvocation': 'Invocation',
|
||||
'ai.chat.permissionRequired': 'Permission Required',
|
||||
'ai.chat.permissionDescription': 'The AI agent wants to execute a tool call that requires your approval.',
|
||||
'ai.chat.commandBlocked': 'This command is blocked by your security policy and cannot be executed.',
|
||||
@@ -451,8 +443,6 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.newChat': 'New Chat',
|
||||
'ai.chat.allSessions': 'All Sessions',
|
||||
'ai.chat.loadEarlierMessages': 'Load earlier messages ({n} more)',
|
||||
'ai.chat.jumpNav': 'Jump to message',
|
||||
'ai.chat.jumpUntitled': '(empty message)',
|
||||
'ai.chat.usedTools': 'Tools used: {n}',
|
||||
'ai.chat.loadMoreSessions': 'Load more sessions ({n} more)',
|
||||
'ai.chat.noSessions': 'No previous sessions',
|
||||
|
||||
@@ -197,9 +197,6 @@ export const enCoreMessages: Messages = {
|
||||
'settings.sessionLogs.timestamps': 'Add timestamps',
|
||||
'settings.sessionLogs.timestampsDesc': 'Prefix each line in plain text and HTML logs with the local time.',
|
||||
'settings.sessionLogs.hint': 'Session logs capture all terminal output for troubleshooting and auditing purposes.',
|
||||
'settings.sessionLogs.clearAll': 'Clear all logs',
|
||||
'settings.sessionLogs.clearAllDesc': 'Delete all saved session log files and host subdirectories from the save directory.',
|
||||
'settings.sessionLogs.clearConfirm': 'This will permanently delete all session log files in the save directory. This cannot be undone. Continue?',
|
||||
|
||||
// Settings > SSH Debug Logs
|
||||
'settings.sshDebugLogs.title': 'SSH Debug Logs',
|
||||
|
||||
@@ -10,7 +10,6 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.tabs.processes': 'Processes',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.tabs.gpu': 'GPU',
|
||||
'systemManager.popup.loading': 'Opening terminal…',
|
||||
'systemManager.popup.startupFailed': 'The startup command did not complete successfully. Check that the target is still available and try again.',
|
||||
|
||||
@@ -24,7 +23,6 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDockerStats': 'Failed to load container stats',
|
||||
'systemManager.errors.loadDockerImages': 'Failed to load images',
|
||||
'systemManager.errors.loadOverview': 'Failed to load system overview',
|
||||
'systemManager.errors.loadGpu': 'Failed to load GPU / NPU stats',
|
||||
'systemManager.errors.sshChannelUnavailable': 'The server refused to open a new execution channel. Try again later, or reconnect this host.',
|
||||
|
||||
'systemManager.overview.empty': 'No system overview data yet.',
|
||||
@@ -189,23 +187,6 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.start': 'Start',
|
||||
'systemManager.docker.stop': 'Stop',
|
||||
|
||||
'systemManager.gpu.unavailable': 'No NVIDIA GPU or Ascend NPU tools detected on this host.',
|
||||
'systemManager.gpu.loading': 'Loading accelerator stats…',
|
||||
'systemManager.gpu.empty': 'Accelerator tools are present, but no devices were reported.',
|
||||
'systemManager.gpu.meta': '{{devices}} device(s) · {{processes}} process(es)',
|
||||
'systemManager.gpu.devices': 'Devices',
|
||||
'systemManager.gpu.processes': 'Compute processes',
|
||||
'systemManager.gpu.noProcesses': 'No compute processes reported.',
|
||||
'systemManager.gpu.vendor.nvidia': 'NVIDIA',
|
||||
'systemManager.gpu.vendor.ascend': 'Ascend',
|
||||
'systemManager.gpu.util': 'Util',
|
||||
'systemManager.gpu.memory': 'VRAM',
|
||||
'systemManager.gpu.hbm': 'HBM',
|
||||
'systemManager.gpu.temperature': 'Temperature',
|
||||
'systemManager.gpu.power': 'Power',
|
||||
'systemManager.gpu.fan': 'Fan {{value}}%',
|
||||
'systemManager.gpu.driver': 'Driver {{version}}',
|
||||
|
||||
'systemManager.inspect.status': 'Status',
|
||||
'systemManager.inspect.image': 'Image',
|
||||
'systemManager.inspect.created': 'Created',
|
||||
|
||||
@@ -82,10 +82,6 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.statusbar.copyHostname.tooltip': 'Copy host address ({hostname})',
|
||||
'terminal.statusbar.copyHostname.toast': 'Copied host address: {hostname}',
|
||||
'terminal.statusbar.copyHostname.error': 'Failed to copy host address to clipboard',
|
||||
'terminal.statusbar.disconnect.label': 'Disconnect',
|
||||
'terminal.statusbar.disconnect.tooltip': 'Disconnect this session without closing the tab',
|
||||
'terminal.statusbar.reconnect.label': 'Reconnect',
|
||||
'terminal.statusbar.reconnect.tooltip': 'Reconnect this session',
|
||||
'terminal.serverStats.cpu': 'CPU Usage',
|
||||
'terminal.serverStats.cpuCores': 'CPU Core Usage',
|
||||
'terminal.serverStats.memory': 'Memory Usage',
|
||||
|
||||
@@ -113,6 +113,8 @@ export const enVaultMessages: Messages = {
|
||||
'knownHosts.empty.title': 'No Known Hosts',
|
||||
'knownHosts.empty.desc':
|
||||
"Known hosts are SSH servers you've connected to before. Import from your system's known_hosts file to get started.",
|
||||
'knownHosts.results.showingLimited':
|
||||
'Showing {shown} of {total} hosts. Use search to find specific hosts.',
|
||||
'knownHosts.toast.scanUnavailable': 'System scan is unavailable on this platform.',
|
||||
'knownHosts.toast.scanNoFile': 'No system known_hosts file found.',
|
||||
'knownHosts.toast.scanNoEntries': 'No usable entries found in known_hosts.',
|
||||
@@ -266,8 +268,6 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.transfers.calculatingTotal': 'Calculating total size...',
|
||||
'sftp.transfers.filesCount': '{count} files',
|
||||
'sftp.transfers.filesProgress': '{current}/{total} files',
|
||||
// Progressive folder walk: total is "found so far", not a fixed grand total.
|
||||
'sftp.transfers.filesDiscoveredProgress': '{completed} done · {discovered} found',
|
||||
'sftp.transfers.expandChildren': 'Show files',
|
||||
'sftp.transfers.collapseChildren': 'Hide files',
|
||||
'sftp.transfers.expandChildList': 'Show detail',
|
||||
@@ -448,6 +448,13 @@ export const enVaultMessages: Messages = {
|
||||
'settings.tab.sftpFileAssociations': 'SFTP',
|
||||
'settings.sftp.transferConcurrency': 'Transfer Concurrency',
|
||||
'settings.sftp.transferConcurrency.desc': 'Maximum number of files transferred in parallel for the same endpoint, including folder contents and separately selected files. Higher values may improve speed but can overwhelm some servers.',
|
||||
'settings.sftp.transferPoolIdleTtl': 'Transfer connection keep-alive',
|
||||
'settings.sftp.transferPoolIdleTtl.desc': 'How long dedicated transfer SFTP connections stay warm after idle. Longer values reduce cold-start delay before the next upload or download. Busy transfers are never closed.',
|
||||
'settings.sftp.transferPoolIdleTtl.1m': '1 minute',
|
||||
'settings.sftp.transferPoolIdleTtl.5m': '5 minutes',
|
||||
'settings.sftp.transferPoolIdleTtl.15m': '15 minutes',
|
||||
'settings.sftp.transferPoolIdleTtl.30m': '30 minutes',
|
||||
'settings.sftp.transferPoolIdleTtl.never': 'Until app quit',
|
||||
'settings.ssh.transportIdleTtl': 'SSH connection keep-alive',
|
||||
'settings.ssh.transportIdleTtl.desc': 'How long a shared SSH connection stays warm after the last terminal, SFTP browse, bulk transfer, or port-forward lease is released. While warm, reconnecting to the same host can skip re-authentication (including MFA). Active sessions and transfers are never closed.',
|
||||
'settings.ssh.transportIdleTtl.1m': '1 minute',
|
||||
@@ -553,9 +560,6 @@ export const enVaultMessages: Messages = {
|
||||
'settings.sftp.compressedUpload.enable': 'Enable folder compression',
|
||||
'settings.sftp.compressedUpload.enableDesc': 'Automatically compress folders using tar before transfer. Requires tar support on the server. Falls back to regular transfer if not available.',
|
||||
|
||||
'settings.sftp.skipUnchanged.enable': 'Skip unchanged files',
|
||||
'settings.sftp.skipUnchanged.enableDesc': 'Skip files whose size and modification time already match the destination (rsync-style). Disabled when replacing a folder into a staging path.',
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': 'Search hosts or tabs',
|
||||
'qs.jumpTo': 'Jump To',
|
||||
@@ -904,8 +908,30 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.plugin.unavailable': 'The configured provider is unavailable. Configuration is preserved until the plugin is restored.',
|
||||
'hostDetails.plugin.useSsh': 'Use SSH instead',
|
||||
|
||||
// Host form (legacy keys still referenced by TerminalAuthDialog)
|
||||
// Host form (legacy modal)
|
||||
'hostForm.title.edit': 'Edit Host',
|
||||
'hostForm.title.new': 'New Host',
|
||||
'hostForm.desc.edit': 'Update connection details for this host',
|
||||
'hostForm.desc.new': 'Create a new SSH host entry',
|
||||
'hostForm.field.label': 'Label',
|
||||
'hostForm.placeholder.label': 'My Production Server',
|
||||
'hostForm.field.hostname': 'Hostname / IP',
|
||||
'hostForm.placeholder.hostname': '192.168.1.1',
|
||||
'hostForm.field.port': 'Port',
|
||||
'hostForm.field.username': 'Username',
|
||||
'hostForm.field.osType': 'OS Type',
|
||||
'hostForm.placeholder.selectOs': 'Select OS',
|
||||
'hostForm.field.group': 'Group',
|
||||
'hostForm.placeholder.group': 'e.g. AWS, DigitalOcean',
|
||||
'hostForm.field.tags': 'Tags',
|
||||
'hostForm.placeholder.addTag': 'Add a tag...',
|
||||
'hostForm.auth.method': 'Authentication Method',
|
||||
'hostForm.auth.password': 'Password',
|
||||
'hostForm.auth.sshKey': 'SSH Key',
|
||||
'hostForm.auth.selectKey': 'Select an SSH Key',
|
||||
'hostForm.auth.noKeys': 'No keys available',
|
||||
'hostForm.auth.noKeysHint': 'No SSH keys found in Keychain. Please create one first.',
|
||||
'hostForm.saveHost': 'Save Host',
|
||||
|
||||
// Connection logs
|
||||
'logs.table.date': 'Date',
|
||||
|
||||
@@ -356,14 +356,6 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.reject': 'Отклонить',
|
||||
'ai.chat.toolLabel': 'Инструмент',
|
||||
'ai.chat.targetLabel': 'Цель',
|
||||
'ai.chat.rawCommand': 'Команда',
|
||||
'ai.chat.copyCommand': 'Копировать',
|
||||
'ai.chat.commandCopied': 'Скопировано',
|
||||
'ai.chat.approvalSession': 'Сессия',
|
||||
'ai.chat.approvalShell': 'Shell',
|
||||
'ai.chat.approvalCwd': 'Каталог',
|
||||
'ai.chat.approvalReason': 'Причина',
|
||||
'ai.chat.approvalInvocation': 'Вызов',
|
||||
'ai.chat.permissionRequired': 'Требуется разрешение',
|
||||
'ai.chat.permissionDescription': 'AI-агент хочет выполнить вызов инструмента, для которого требуется ваше одобрение.',
|
||||
'ai.chat.commandBlocked': 'Эта команда заблокирована вашей политикой безопасности и не может быть выполнена.',
|
||||
@@ -403,8 +395,6 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.newChat': 'Новый чат',
|
||||
'ai.chat.allSessions': 'Все сессии',
|
||||
'ai.chat.loadEarlierMessages': 'Загрузить более ранние сообщения (ещё {n})',
|
||||
'ai.chat.jumpNav': 'Перейти к сообщению',
|
||||
'ai.chat.jumpUntitled': '(пустое сообщение)',
|
||||
'ai.chat.usedTools': 'Использовано инструментов: {n}',
|
||||
'ai.chat.loadMoreSessions': 'Загрузить больше сессий (ещё {n})',
|
||||
'ai.chat.noSessions': 'Предыдущих сессий нет',
|
||||
|
||||
@@ -185,9 +185,6 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.sessionLogs.timestamps': 'Добавлять метки времени',
|
||||
'settings.sessionLogs.timestampsDesc': 'Добавлять локальное время в начало каждой строки в текстовых и HTML-журналах.',
|
||||
'settings.sessionLogs.hint': 'Журналы сессий сохраняют весь вывод терминала для диагностики и аудита.',
|
||||
'settings.sessionLogs.clearAll': 'Очистить все журналы',
|
||||
'settings.sessionLogs.clearAllDesc': 'Удалить все сохранённые файлы журналов сессий и вложенные папки хостов из папки сохранения.',
|
||||
'settings.sessionLogs.clearConfirm': 'Это приведёт к безвозвратному удалению всех файлов журналов сессий в папке сохранения. Это действие нельзя отменить. Продолжить?',
|
||||
|
||||
// Settings > SSH Debug Logs
|
||||
'settings.sshDebugLogs.title': 'Отладочные журналы SSH',
|
||||
|
||||
@@ -10,7 +10,6 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.tabs.processes': 'Процессы',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.tabs.gpu': 'GPU',
|
||||
'systemManager.popup.loading': 'Открытие терминала…',
|
||||
'systemManager.popup.startupFailed': 'Команда запуска не была выполнена успешно. Проверьте, что цель доступна, и повторите попытку.',
|
||||
|
||||
@@ -24,7 +23,6 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDockerStats': 'Не удалось загрузить статистику контейнеров',
|
||||
'systemManager.errors.loadDockerImages': 'Не удалось загрузить образы',
|
||||
'systemManager.errors.loadOverview': 'Не удалось загрузить обзор системы',
|
||||
'systemManager.errors.loadGpu': 'Не удалось загрузить статистику GPU / NPU',
|
||||
'systemManager.errors.sshChannelUnavailable': 'Сервер отказался открыть новый канал выполнения. Повторите попытку позже или переподключите этот хост.',
|
||||
|
||||
'systemManager.overview.empty': 'Данных обзора пока нет.',
|
||||
@@ -189,23 +187,6 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.start': 'Запустить',
|
||||
'systemManager.docker.stop': 'Остановить',
|
||||
|
||||
'systemManager.gpu.unavailable': 'На этом хосте не обнаружены инструменты NVIDIA GPU или Ascend NPU.',
|
||||
'systemManager.gpu.loading': 'Загрузка статистики ускорителей…',
|
||||
'systemManager.gpu.empty': 'Инструменты ускорителей найдены, но устройства не сообщены.',
|
||||
'systemManager.gpu.meta': '{{devices}} устройств · {{processes}} процессов',
|
||||
'systemManager.gpu.devices': 'Устройства',
|
||||
'systemManager.gpu.processes': 'Вычислительные процессы',
|
||||
'systemManager.gpu.noProcesses': 'Вычислительные процессы не сообщены.',
|
||||
'systemManager.gpu.vendor.nvidia': 'NVIDIA',
|
||||
'systemManager.gpu.vendor.ascend': 'Ascend',
|
||||
'systemManager.gpu.util': 'Загрузка',
|
||||
'systemManager.gpu.memory': 'VRAM',
|
||||
'systemManager.gpu.hbm': 'HBM',
|
||||
'systemManager.gpu.temperature': 'Температура',
|
||||
'systemManager.gpu.power': 'Питание',
|
||||
'systemManager.gpu.fan': 'Вентилятор {{value}}%',
|
||||
'systemManager.gpu.driver': 'Драйвер {{version}}',
|
||||
|
||||
'systemManager.inspect.status': 'Статус',
|
||||
'systemManager.inspect.image': 'Образ',
|
||||
'systemManager.inspect.created': 'Создан',
|
||||
|
||||
@@ -103,10 +103,6 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.statusbar.copyHostname.tooltip': 'Копировать адрес хоста ({hostname})',
|
||||
'terminal.statusbar.copyHostname.toast': 'Адрес хоста скопирован: {hostname}',
|
||||
'terminal.statusbar.copyHostname.error': 'Не удалось скопировать адрес хоста в буфер обмена',
|
||||
'terminal.statusbar.disconnect.label': 'Отключить',
|
||||
'terminal.statusbar.disconnect.tooltip': 'Отключить сессию, не закрывая вкладку',
|
||||
'terminal.statusbar.reconnect.label': 'Переподключиться',
|
||||
'terminal.statusbar.reconnect.tooltip': 'Переподключить эту сессию',
|
||||
'terminal.serverStats.cpu': 'Использование CPU',
|
||||
'terminal.serverStats.cpuCores': 'Использование ядер CPU',
|
||||
'terminal.serverStats.memory': 'Использование памяти',
|
||||
|
||||
@@ -150,6 +150,8 @@ export const ruVaultMessages: Messages = {
|
||||
'knownHosts.empty.title': 'Нет известных хостов',
|
||||
'knownHosts.empty.desc':
|
||||
'Известные хосты — это SSH-серверы, к которым вы подключались раньше. Импортируйте системный файл known_hosts, чтобы начать.',
|
||||
'knownHosts.results.showingLimited':
|
||||
'Показано {shown} из {total} хостов. Используйте поиск, чтобы найти нужные хосты.',
|
||||
'knownHosts.toast.scanUnavailable': 'Сканирование системы недоступно на этой платформе.',
|
||||
'knownHosts.toast.scanNoFile': 'Системный файл known_hosts не найден.',
|
||||
'knownHosts.toast.scanNoEntries': 'В known_hosts не найдено пригодных записей.',
|
||||
@@ -303,7 +305,6 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.transfers.calculatingTotal': 'Вычисление общего размера...',
|
||||
'sftp.transfers.filesCount': '{count} файл(ов)',
|
||||
'sftp.transfers.filesProgress': '{current}/{total} файл(ов)',
|
||||
'sftp.transfers.filesDiscoveredProgress': '{completed} готово · {discovered} найдено',
|
||||
'sftp.transfers.expandChildren': 'Показать файлы',
|
||||
'sftp.transfers.collapseChildren': 'Скрыть файлы',
|
||||
'sftp.transfers.expandChildList': 'Показать детали',
|
||||
@@ -484,6 +485,13 @@ export const ruVaultMessages: Messages = {
|
||||
'settings.tab.sftpFileAssociations': 'SFTP',
|
||||
'settings.sftp.transferConcurrency': 'Параллелизм передачи',
|
||||
'settings.sftp.transferConcurrency.desc': 'Максимальное число файлов, одновременно передаваемых для одной цели, включая содержимое папок и отдельно выбранные файлы. Большие значения могут ускорить передачу, но перегрузить некоторые серверы.',
|
||||
'settings.sftp.transferPoolIdleTtl': 'Удержание transfer-соединений',
|
||||
'settings.sftp.transferPoolIdleTtl.desc': 'Как долго простаивающие SFTP-соединения пула передачи остаются открытыми. Большее время снижает задержку перед следующей передачей. Активные передачи не закрываются.',
|
||||
'settings.sftp.transferPoolIdleTtl.1m': '1 минута',
|
||||
'settings.sftp.transferPoolIdleTtl.5m': '5 минут',
|
||||
'settings.sftp.transferPoolIdleTtl.15m': '15 минут',
|
||||
'settings.sftp.transferPoolIdleTtl.30m': '30 минут',
|
||||
'settings.sftp.transferPoolIdleTtl.never': 'До выхода из приложения',
|
||||
'settings.ssh.transportIdleTtl': 'Удержание SSH-соединений',
|
||||
'settings.ssh.transportIdleTtl.desc': 'Как долго общее SSH-соединение остаётся открытым после освобождения последнего lease терминала, SFTP-браузера, пакетной передачи или проброса портов. Пока оно тёплое, повторное подключение к тому же хосту может обойти повторную аутентификацию (включая MFA). Активные сессии и передачи не закрываются.',
|
||||
'settings.ssh.transportIdleTtl.1m': '1 минута',
|
||||
@@ -589,9 +597,6 @@ export const ruVaultMessages: Messages = {
|
||||
'settings.sftp.compressedUpload.enable': 'Включить сжатие папок',
|
||||
'settings.sftp.compressedUpload.enableDesc': 'Автоматически сжимать папки с помощью tar перед передачей. Требует поддержки tar на сервере. Если она недоступна, будет использована обычная передача.',
|
||||
|
||||
'settings.sftp.skipUnchanged.enable': 'Пропускать неизменённые файлы',
|
||||
'settings.sftp.skipUnchanged.enableDesc': 'Пропускать файлы, у которых размер и время изменения уже совпадают с назначением (в стиле rsync). Отключается при замене папки во временный путь.',
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': 'Поиск хостов или вкладок',
|
||||
'qs.jumpTo': 'Перейти к',
|
||||
@@ -936,8 +941,30 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.plugin.unavailable': 'Настроенный провайдер недоступен. Конфигурация сохранится до восстановления плагина.',
|
||||
'hostDetails.plugin.useSsh': 'Использовать SSH',
|
||||
|
||||
// Host form (legacy keys still referenced by TerminalAuthDialog)
|
||||
// Host form (legacy modal)
|
||||
'hostForm.title.edit': 'Редактировать хост',
|
||||
'hostForm.title.new': 'Новый хост',
|
||||
'hostForm.desc.edit': 'Обновите параметры подключения для этого хоста',
|
||||
'hostForm.desc.new': 'Создайте новую запись SSH-хоста',
|
||||
'hostForm.field.label': 'Метка',
|
||||
'hostForm.placeholder.label': 'Мой production-сервер',
|
||||
'hostForm.field.hostname': 'Имя хоста / IP',
|
||||
'hostForm.placeholder.hostname': '192.168.1.1',
|
||||
'hostForm.field.port': 'Порт',
|
||||
'hostForm.field.username': 'Имя пользователя',
|
||||
'hostForm.field.osType': 'Тип ОС',
|
||||
'hostForm.placeholder.selectOs': 'Выберите ОС',
|
||||
'hostForm.field.group': 'Группа',
|
||||
'hostForm.placeholder.group': 'например, AWS, DigitalOcean',
|
||||
'hostForm.field.tags': 'Теги',
|
||||
'hostForm.placeholder.addTag': 'Добавить тег...',
|
||||
'hostForm.auth.method': 'Метод аутентификации',
|
||||
'hostForm.auth.password': 'Пароль',
|
||||
'hostForm.auth.sshKey': 'SSH-ключ',
|
||||
'hostForm.auth.selectKey': 'Выберите SSH-ключ',
|
||||
'hostForm.auth.noKeys': 'Нет доступных ключей',
|
||||
'hostForm.auth.noKeysHint': 'В связке ключей не найдено SSH-ключей. Сначала создайте один.',
|
||||
'hostForm.saveHost': 'Сохранить хост',
|
||||
'vault.import.plugins.title': 'Импортёры плагинов',
|
||||
'vault.import.plugins.loading': 'Создание безопасного предварительного просмотра…',
|
||||
'vault.import.plugins.progress': 'Обработано {completed} из {total} {message}',
|
||||
|
||||
@@ -404,14 +404,6 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.reject': '拒绝',
|
||||
'ai.chat.toolLabel': '工具',
|
||||
'ai.chat.targetLabel': '目标',
|
||||
'ai.chat.rawCommand': '命令',
|
||||
'ai.chat.copyCommand': '复制',
|
||||
'ai.chat.commandCopied': '已复制',
|
||||
'ai.chat.approvalSession': '会话',
|
||||
'ai.chat.approvalShell': 'Shell',
|
||||
'ai.chat.approvalCwd': '目录',
|
||||
'ai.chat.approvalReason': '原因',
|
||||
'ai.chat.approvalInvocation': '调用详情',
|
||||
'ai.chat.permissionRequired': '需要权限',
|
||||
'ai.chat.permissionDescription': 'AI Agent 希望执行一个需要你批准的工具调用。',
|
||||
'ai.chat.commandBlocked': '此命令已被安全策略拦截,无法执行。',
|
||||
@@ -451,8 +443,6 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.newChat': '新对话',
|
||||
'ai.chat.allSessions': '所有会话',
|
||||
'ai.chat.loadEarlierMessages': '加载更早的消息(还有 {n} 条)',
|
||||
'ai.chat.jumpNav': '跳转到消息',
|
||||
'ai.chat.jumpUntitled': '(空消息)',
|
||||
'ai.chat.usedTools': '已使用 {n} 个工具',
|
||||
'ai.chat.loadMoreSessions': '加载更多会话(还有 {n} 条)',
|
||||
'ai.chat.noSessions': '没有历史会话',
|
||||
|
||||
@@ -194,9 +194,6 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.sessionLogs.timestamps': '添加时间戳',
|
||||
'settings.sessionLogs.timestampsDesc': '为纯文本和 HTML 日志的每一行添加本地时间。',
|
||||
'settings.sessionLogs.hint': '会话日志用于记录终端输出,便于故障排查和审计。',
|
||||
'settings.sessionLogs.clearAll': '清空所有日志',
|
||||
'settings.sessionLogs.clearAllDesc': '删除保存目录中所有会话日志文件及主机子目录。',
|
||||
'settings.sessionLogs.clearConfirm': '此操作将永久删除保存目录中的所有会话日志文件,且无法撤销,是否继续?',
|
||||
|
||||
// Settings > SSH Debug Logs
|
||||
'settings.sshDebugLogs.title': 'SSH 调试日志',
|
||||
@@ -732,6 +729,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'knownHosts.empty.title': '暂无已知主机',
|
||||
'knownHosts.empty.desc':
|
||||
'Known Hosts 是你之前连接过的 SSH server。导入系统的 known_hosts 文件以开始。',
|
||||
'knownHosts.results.showingLimited': '显示 {shown}/{total} 个主机。使用搜索查找特定主机。',
|
||||
'knownHosts.toast.scanUnavailable': '当前平台无法扫描系统 known_hosts。',
|
||||
'knownHosts.toast.scanNoFile': '未找到系统 known_hosts 文件。',
|
||||
'knownHosts.toast.scanNoEntries': 'known_hosts 中没有可用条目。',
|
||||
@@ -862,7 +860,6 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.transfers.calculatingTotal': '正在统计总大小...',
|
||||
'sftp.transfers.filesCount': '{count} 个文件',
|
||||
'sftp.transfers.filesProgress': '{current}/{total} 个文件',
|
||||
'sftp.transfers.filesDiscoveredProgress': '已完成 {completed} · 已发现 {discovered}',
|
||||
'sftp.transfers.expandChildren': '展开文件',
|
||||
'sftp.transfers.collapseChildren': '收起文件',
|
||||
'sftp.transfers.expandChildList': '展开详情',
|
||||
|
||||
@@ -10,7 +10,6 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.tabs.processes': '进程',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.tabs.gpu': 'GPU',
|
||||
'systemManager.popup.loading': '正在打开终端…',
|
||||
'systemManager.popup.startupFailed': '启动命令未成功。请确认目标仍然可用后重试。',
|
||||
|
||||
@@ -24,7 +23,6 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDockerStats': '加载容器性能数据失败',
|
||||
'systemManager.errors.loadDockerImages': '加载镜像列表失败',
|
||||
'systemManager.errors.loadOverview': '加载系统概览失败',
|
||||
'systemManager.errors.loadGpu': '加载 GPU / NPU 状态失败',
|
||||
'systemManager.errors.sshChannelUnavailable': '服务器拒绝打开新的执行通道。请稍后重试,或重新连接当前主机。',
|
||||
|
||||
'systemManager.overview.empty': '暂无系统概览数据。',
|
||||
@@ -189,23 +187,6 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.start': '启动',
|
||||
'systemManager.docker.stop': '停止',
|
||||
|
||||
'systemManager.gpu.unavailable': '此主机未检测到 NVIDIA GPU 或昇腾 NPU 工具。',
|
||||
'systemManager.gpu.loading': '正在加载加速卡状态…',
|
||||
'systemManager.gpu.empty': '已检测到加速卡工具,但未返回设备信息。',
|
||||
'systemManager.gpu.meta': '{{devices}} 张卡 · {{processes}} 个进程',
|
||||
'systemManager.gpu.devices': '设备',
|
||||
'systemManager.gpu.processes': '计算进程',
|
||||
'systemManager.gpu.noProcesses': '当前没有计算进程。',
|
||||
'systemManager.gpu.vendor.nvidia': 'NVIDIA',
|
||||
'systemManager.gpu.vendor.ascend': '昇腾',
|
||||
'systemManager.gpu.util': '利用率',
|
||||
'systemManager.gpu.memory': '显存',
|
||||
'systemManager.gpu.hbm': 'HBM',
|
||||
'systemManager.gpu.temperature': '温度',
|
||||
'systemManager.gpu.power': '功耗',
|
||||
'systemManager.gpu.fan': '风扇 {{value}}%',
|
||||
'systemManager.gpu.driver': '驱动 {{version}}',
|
||||
|
||||
'systemManager.inspect.status': '状态',
|
||||
'systemManager.inspect.image': '镜像',
|
||||
'systemManager.inspect.created': '创建时间',
|
||||
|
||||
@@ -93,6 +93,13 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.tab.sftpFileAssociations': 'SFTP',
|
||||
'settings.sftp.transferConcurrency': '传输并发数',
|
||||
'settings.sftp.transferConcurrency.desc': '同一目标同时传输的文件数量上限,包括文件夹内文件和单独多选的文件。较高的值可能提高速度,但可能导致某些服务器过载。',
|
||||
'settings.sftp.transferPoolIdleTtl': '传输连接保活时间',
|
||||
'settings.sftp.transferPoolIdleTtl.desc': '专用传输 SFTP 连接空闲多久后关闭。时间更长可减少下次上传/下载的冷启动等待;进行中的传输不会被关闭。',
|
||||
'settings.sftp.transferPoolIdleTtl.1m': '1 分钟',
|
||||
'settings.sftp.transferPoolIdleTtl.5m': '5 分钟',
|
||||
'settings.sftp.transferPoolIdleTtl.15m': '15 分钟',
|
||||
'settings.sftp.transferPoolIdleTtl.30m': '30 分钟',
|
||||
'settings.sftp.transferPoolIdleTtl.never': '直到退出应用',
|
||||
'settings.ssh.transportIdleTtl': 'SSH 连接保活时间',
|
||||
'settings.ssh.transportIdleTtl.desc': '最后一个终端、SFTP 浏览、批量传输或端口转发释放后,共享 SSH 连接再保留多久。保留期间再次打开同主机可跳过重新认证(含 MFA);进行中的会话与传输不会被关闭。',
|
||||
'settings.ssh.transportIdleTtl.1m': '1 分钟',
|
||||
@@ -199,9 +206,6 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.sftp.compressedUpload.enable': '启用文件夹压缩',
|
||||
'settings.sftp.compressedUpload.enableDesc': '自动使用 tar 压缩文件夹后再传输。需要服务器支持 tar 命令,不支持时自动回退到普通传输。',
|
||||
|
||||
'settings.sftp.skipUnchanged.enable': '跳过未改文件',
|
||||
'settings.sftp.skipUnchanged.enableDesc': '当目标文件的大小与修改时间已与源一致时跳过传输(类似 rsync)。替换到暂存路径时自动禁用。',
|
||||
|
||||
// Settings > Terminal
|
||||
'settings.terminal.section.theme': '终端主题',
|
||||
'settings.terminal.themeModal.title': '选择主题',
|
||||
|
||||
@@ -331,8 +331,29 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.plugin.useSsh': '改用 SSH',
|
||||
|
||||
// Host form (legacy modal)
|
||||
// Host form (legacy keys still referenced by TerminalAuthDialog)
|
||||
'hostForm.title.edit': '编辑主机',
|
||||
'hostForm.title.new': '新建主机',
|
||||
'hostForm.desc.edit': '更新该主机的连接信息',
|
||||
'hostForm.desc.new': '创建一个新的 SSH 主机条目',
|
||||
'hostForm.field.label': '名称',
|
||||
'hostForm.placeholder.label': 'My Production Server',
|
||||
'hostForm.field.hostname': 'Hostname / IP',
|
||||
'hostForm.placeholder.hostname': '192.168.1.1',
|
||||
'hostForm.field.port': '端口',
|
||||
'hostForm.field.username': '用户名',
|
||||
'hostForm.field.osType': '操作系统类型',
|
||||
'hostForm.placeholder.selectOs': '选择操作系统',
|
||||
'hostForm.field.group': '分组',
|
||||
'hostForm.placeholder.group': '例如:AWS、DigitalOcean',
|
||||
'hostForm.field.tags': '标签',
|
||||
'hostForm.placeholder.addTag': '添加标签…',
|
||||
'hostForm.auth.method': '认证方式',
|
||||
'hostForm.auth.password': '密码',
|
||||
'hostForm.auth.sshKey': 'SSH密钥',
|
||||
'hostForm.auth.selectKey': '选择 SSH密钥',
|
||||
'hostForm.auth.noKeys': '暂无密钥',
|
||||
'hostForm.auth.noKeysHint': '钥匙串中未找到 SSH密钥,请先创建一个。',
|
||||
'hostForm.saveHost': '保存主机',
|
||||
|
||||
// Connection logs
|
||||
'logs.table.date': '日期',
|
||||
@@ -406,10 +427,6 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.statusbar.copyHostname.tooltip': '复制主机地址({hostname})',
|
||||
'terminal.statusbar.copyHostname.toast': '已复制主机地址:{hostname}',
|
||||
'terminal.statusbar.copyHostname.error': '复制主机地址失败',
|
||||
'terminal.statusbar.disconnect.label': '断开连接',
|
||||
'terminal.statusbar.disconnect.tooltip': '断开当前会话,但保留标签页',
|
||||
'terminal.statusbar.reconnect.label': '重新连接',
|
||||
'terminal.statusbar.reconnect.tooltip': '重新连接当前会话',
|
||||
'terminal.serverStats.cpu': 'CPU 使用率',
|
||||
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
|
||||
'terminal.serverStats.memory': '内存使用',
|
||||
|
||||
@@ -403,14 +403,6 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.reject': '拒絕',
|
||||
'ai.chat.toolLabel': '工具',
|
||||
'ai.chat.targetLabel': '目標',
|
||||
'ai.chat.rawCommand': '指令',
|
||||
'ai.chat.copyCommand': '複製',
|
||||
'ai.chat.commandCopied': '已複製',
|
||||
'ai.chat.approvalSession': '工作階段',
|
||||
'ai.chat.approvalShell': 'Shell',
|
||||
'ai.chat.approvalCwd': '目錄',
|
||||
'ai.chat.approvalReason': '原因',
|
||||
'ai.chat.approvalInvocation': '呼叫詳情',
|
||||
'ai.chat.permissionRequired': '需要權限',
|
||||
'ai.chat.permissionDescription': 'AI Agent 希望執行一個需要你批准的工具呼叫。',
|
||||
'ai.chat.commandBlocked': '此指令已被安全策略攔截,無法執行。',
|
||||
@@ -450,8 +442,6 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.newChat': '新對話',
|
||||
'ai.chat.allSessions': '所有工作階段',
|
||||
'ai.chat.loadEarlierMessages': '載入更早的訊息(還有 {n} 條)',
|
||||
'ai.chat.jumpNav': '跳轉到訊息',
|
||||
'ai.chat.jumpUntitled': '(空訊息)',
|
||||
'ai.chat.usedTools': '已使用 {n} 個工具',
|
||||
'ai.chat.loadMoreSessions': '載入更多工作階段(還有 {n} 條)',
|
||||
'ai.chat.noSessions': '沒有歷史工作階段',
|
||||
|
||||
@@ -194,9 +194,6 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.sessionLogs.timestamps': '新增時間戳',
|
||||
'settings.sessionLogs.timestampsDesc': '為純文字和 HTML 日誌的每一行新增本機時間。',
|
||||
'settings.sessionLogs.hint': '工作階段日誌用於記錄終端輸出,便於故障排查和審計。',
|
||||
'settings.sessionLogs.clearAll': '清空所有日誌',
|
||||
'settings.sessionLogs.clearAllDesc': '刪除儲存目錄中所有工作階段日誌檔案及主機子目錄。',
|
||||
'settings.sessionLogs.clearConfirm': '此操作將永久刪除儲存目錄中的所有工作階段日誌檔案,且無法復原,是否繼續?',
|
||||
|
||||
// Settings > SSH Debug Logs
|
||||
'settings.sshDebugLogs.title': 'SSH 除錯日誌',
|
||||
@@ -732,6 +729,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'knownHosts.empty.title': '暫無已知主機',
|
||||
'knownHosts.empty.desc':
|
||||
'Known Hosts 是你之前連線過的 SSH server。匯入系統的 known_hosts 檔案以開始。',
|
||||
'knownHosts.results.showingLimited': '顯示 {shown}/{total} 個主機。使用搜索查詢特定主機。',
|
||||
'knownHosts.toast.scanUnavailable': '目前平台無法掃描系統 known_hosts。',
|
||||
'knownHosts.toast.scanNoFile': '未找到系統 known_hosts 檔案。',
|
||||
'knownHosts.toast.scanNoEntries': 'known_hosts 中沒有可用條目。',
|
||||
@@ -862,7 +860,6 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sftp.transfers.calculatingTotal': '正在統計總大小...',
|
||||
'sftp.transfers.filesCount': '{count} 個檔案',
|
||||
'sftp.transfers.filesProgress': '{current}/{total} 個檔案',
|
||||
'sftp.transfers.filesDiscoveredProgress': '已完成 {completed} · 已發現 {discovered}',
|
||||
'sftp.transfers.expandChildren': '展開檔案',
|
||||
'sftp.transfers.collapseChildren': '收起檔案',
|
||||
'sftp.transfers.expandChildList': '展開詳情',
|
||||
|
||||
@@ -10,7 +10,6 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.tabs.processes': '程序',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.tabs.gpu': 'GPU',
|
||||
'systemManager.popup.loading': '正在開啟終端…',
|
||||
'systemManager.popup.startupFailed': '啟動指令未順利完成。請確認目標仍可使用後再試一次。',
|
||||
|
||||
@@ -24,7 +23,6 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDockerStats': '載入容器效能資料失敗',
|
||||
'systemManager.errors.loadDockerImages': '載入映像列表失敗',
|
||||
'systemManager.errors.loadOverview': '載入系統概覽失敗',
|
||||
'systemManager.errors.loadGpu': '載入 GPU / NPU 狀態失敗',
|
||||
'systemManager.errors.sshChannelUnavailable': '伺服器拒絕開啟新的執行通道。請稍後重試,或重新連線目前主機。',
|
||||
|
||||
'systemManager.overview.empty': '暫無系統概覽資料。',
|
||||
@@ -189,23 +187,6 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.start': '啟動',
|
||||
'systemManager.docker.stop': '停止',
|
||||
|
||||
'systemManager.gpu.unavailable': '此主機未偵測到 NVIDIA GPU 或昇騰 NPU 工具。',
|
||||
'systemManager.gpu.loading': '正在載入加速卡狀態…',
|
||||
'systemManager.gpu.empty': '已偵測到加速卡工具,但未回傳裝置資訊。',
|
||||
'systemManager.gpu.meta': '{{devices}} 張卡 · {{processes}} 個程序',
|
||||
'systemManager.gpu.devices': '裝置',
|
||||
'systemManager.gpu.processes': '計算程序',
|
||||
'systemManager.gpu.noProcesses': '目前沒有計算程序。',
|
||||
'systemManager.gpu.vendor.nvidia': 'NVIDIA',
|
||||
'systemManager.gpu.vendor.ascend': '昇騰',
|
||||
'systemManager.gpu.util': '使用率',
|
||||
'systemManager.gpu.memory': '顯示記憶體',
|
||||
'systemManager.gpu.hbm': 'HBM',
|
||||
'systemManager.gpu.temperature': '溫度',
|
||||
'systemManager.gpu.power': '功耗',
|
||||
'systemManager.gpu.fan': '風扇 {{value}}%',
|
||||
'systemManager.gpu.driver': '驅動 {{version}}',
|
||||
|
||||
'systemManager.inspect.status': '狀態',
|
||||
'systemManager.inspect.image': '映像',
|
||||
'systemManager.inspect.created': '建立時間',
|
||||
|
||||
@@ -93,6 +93,13 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.tab.sftpFileAssociations': 'SFTP',
|
||||
'settings.sftp.transferConcurrency': '傳輸併發數',
|
||||
'settings.sftp.transferConcurrency.desc': '同一目標同時傳輸的檔案數量上限,包括資料夾內檔案和單獨多選的檔案。較高的值可能提高速度,但可能導致某些伺服器過載。',
|
||||
'settings.sftp.transferPoolIdleTtl': '傳輸連線保活時間',
|
||||
'settings.sftp.transferPoolIdleTtl.desc': '專用傳輸 SFTP 連線閒置多久後關閉。時間更長可減少下次上傳/下載的冷啟動等待;進行中的傳輸不會被關閉。',
|
||||
'settings.sftp.transferPoolIdleTtl.1m': '1 分鐘',
|
||||
'settings.sftp.transferPoolIdleTtl.5m': '5 分鐘',
|
||||
'settings.sftp.transferPoolIdleTtl.15m': '15 分鐘',
|
||||
'settings.sftp.transferPoolIdleTtl.30m': '30 分鐘',
|
||||
'settings.sftp.transferPoolIdleTtl.never': '直到結束應用',
|
||||
'settings.ssh.transportIdleTtl': 'SSH 連線保活時間',
|
||||
'settings.ssh.transportIdleTtl.desc': '最後一個終端、SFTP 瀏覽、批次傳輸或連接埠轉發釋放後,共享 SSH 連線再保留多久。保留期間再次開啟同主機可略過重新驗證(含 MFA);進行中的工作階段與傳輸不會被關閉。',
|
||||
'settings.ssh.transportIdleTtl.1m': '1 分鐘',
|
||||
@@ -199,9 +206,6 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.sftp.compressedUpload.enable': '啟用資料夾壓縮',
|
||||
'settings.sftp.compressedUpload.enableDesc': '自動使用 tar 壓縮資料夾後再傳輸。需要伺服器支援 tar 指令,不支援時自動回退到普通傳輸。',
|
||||
|
||||
'settings.sftp.skipUnchanged.enable': '跳過未改檔案',
|
||||
'settings.sftp.skipUnchanged.enableDesc': '當目標檔案的大小與修改時間已與來源一致時跳過傳輸(類似 rsync)。替換到暫存路徑時自動停用。',
|
||||
|
||||
// Settings > Terminal
|
||||
'settings.terminal.section.theme': '終端主題',
|
||||
'settings.terminal.themeModal.title': '選擇主題',
|
||||
|
||||
@@ -331,8 +331,29 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.plugin.useSsh': '改用 SSH',
|
||||
|
||||
// Host form (legacy modal)
|
||||
// Host form (legacy keys still referenced by TerminalAuthDialog)
|
||||
'hostForm.title.edit': '編輯主機',
|
||||
'hostForm.title.new': '新建主機',
|
||||
'hostForm.desc.edit': '更新該主機的連線資訊',
|
||||
'hostForm.desc.new': '建立一個新的 SSH 主機條目',
|
||||
'hostForm.field.label': '名稱',
|
||||
'hostForm.placeholder.label': 'My Production Server',
|
||||
'hostForm.field.hostname': 'Hostname / IP',
|
||||
'hostForm.placeholder.hostname': '192.168.1.1',
|
||||
'hostForm.field.port': '埠',
|
||||
'hostForm.field.username': '使用者名稱',
|
||||
'hostForm.field.osType': '作業系統型別',
|
||||
'hostForm.placeholder.selectOs': '選擇作業系統',
|
||||
'hostForm.field.group': '分組',
|
||||
'hostForm.placeholder.group': '例如:AWS、DigitalOcean',
|
||||
'hostForm.field.tags': '標籤',
|
||||
'hostForm.placeholder.addTag': '新增標籤…',
|
||||
'hostForm.auth.method': '認證方式',
|
||||
'hostForm.auth.password': '密碼',
|
||||
'hostForm.auth.sshKey': 'SSH金鑰',
|
||||
'hostForm.auth.selectKey': '選擇 SSH金鑰',
|
||||
'hostForm.auth.noKeys': '暫無金鑰',
|
||||
'hostForm.auth.noKeysHint': '鑰匙串中未找到 SSH金鑰,請先建立一個。',
|
||||
'hostForm.saveHost': '儲存主機',
|
||||
|
||||
// Connection logs
|
||||
'logs.table.date': '日期',
|
||||
@@ -406,10 +427,6 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.statusbar.copyHostname.tooltip': '複製主機地址({hostname})',
|
||||
'terminal.statusbar.copyHostname.toast': '已複製主機地址:{hostname}',
|
||||
'terminal.statusbar.copyHostname.error': '複製主機地址失敗',
|
||||
'terminal.statusbar.disconnect.label': '中斷連線',
|
||||
'terminal.statusbar.disconnect.tooltip': '中斷目前工作階段,但保留標籤頁',
|
||||
'terminal.statusbar.reconnect.label': '重新連線',
|
||||
'terminal.statusbar.reconnect.tooltip': '重新連線目前工作階段',
|
||||
'terminal.serverStats.cpu': 'CPU 使用率',
|
||||
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
|
||||
'terminal.serverStats.memory': '記憶體使用',
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import type React from 'react';
|
||||
import {
|
||||
STORAGE_KEY_AI_PANEL_DIAGNOSTIC_HIDE,
|
||||
STORAGE_KEY_AI_PANEL_DIAGNOSTIC_PROFILE,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
|
||||
export const AI_PANEL_DIAGNOSTIC_HIDE_KEY = STORAGE_KEY_AI_PANEL_DIAGNOSTIC_HIDE;
|
||||
export const AI_PANEL_DIAGNOSTIC_PROFILE_KEY = STORAGE_KEY_AI_PANEL_DIAGNOSTIC_PROFILE;
|
||||
export const AI_PANEL_FORCE_HIDE_ALL_CONTENT = false;
|
||||
export const AI_PANEL_FORCE_HIDE_SHELL = false;
|
||||
|
||||
export type AIPanelDiagnosticPart =
|
||||
| 'all'
|
||||
| 'attachments'
|
||||
| 'header'
|
||||
| 'history'
|
||||
| 'input'
|
||||
| 'markdown'
|
||||
| 'messages'
|
||||
| 'recent'
|
||||
| 'toolcalls';
|
||||
|
||||
function readLocalStorageValue(key: string): string {
|
||||
try {
|
||||
return localStorageAdapter.readString(key) ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function getAIPanelDiagnosticHiddenParts(): ReadonlySet<string> {
|
||||
if (AI_PANEL_FORCE_HIDE_ALL_CONTENT) {
|
||||
return new Set(['all']);
|
||||
}
|
||||
const raw = readLocalStorageValue(AI_PANEL_DIAGNOSTIC_HIDE_KEY);
|
||||
return new Set(
|
||||
raw
|
||||
.split(',')
|
||||
.map((part) => part.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
export function isAIPanelDiagnosticPartHidden(
|
||||
part: AIPanelDiagnosticPart,
|
||||
hiddenParts = getAIPanelDiagnosticHiddenParts(),
|
||||
): boolean {
|
||||
return hiddenParts.has('all') || hiddenParts.has(part);
|
||||
}
|
||||
|
||||
export function isAIPanelDiagnosticsProfilingEnabled(): boolean {
|
||||
const raw = readLocalStorageValue(AI_PANEL_DIAGNOSTIC_PROFILE_KEY).trim().toLowerCase();
|
||||
return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
|
||||
}
|
||||
|
||||
export function logAIPanelProfiler(
|
||||
id: string,
|
||||
phase: 'mount' | 'update' | 'nested-update',
|
||||
actualDuration: number,
|
||||
baseDuration: number,
|
||||
): void {
|
||||
if (!isAIPanelDiagnosticsProfilingEnabled()) return;
|
||||
console.info(
|
||||
`[AI panel profile] ${id} ${phase}: actual=${actualDuration.toFixed(1)}ms base=${baseDuration.toFixed(1)}ms`,
|
||||
);
|
||||
}
|
||||
|
||||
export function profileAIPanelCalculation<T>(label: string, calculate: () => T): T {
|
||||
if (!isAIPanelDiagnosticsProfilingEnabled()) return calculate();
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
return calculate();
|
||||
} finally {
|
||||
const elapsed = performance.now() - startedAt;
|
||||
console.info(`[AI panel profile] ${label}: ${elapsed.toFixed(1)}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
export function getAIPanelProfilerProps(id: string): Pick<React.ProfilerProps, 'id' | 'onRender'> {
|
||||
return {
|
||||
id,
|
||||
onRender: logAIPanelProfiler,
|
||||
};
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
getAppearanceChromeSnapshot,
|
||||
publishAppearanceChromeSnapshot,
|
||||
subscribeAppearanceChrome,
|
||||
} from './appearanceChromeStore.ts';
|
||||
|
||||
test('appearanceChromeStore notifies subscribers only when accent fields change', () => {
|
||||
const events: string[] = [];
|
||||
const unsubscribe = subscribeAppearanceChrome(() => {
|
||||
const snap = getAppearanceChromeSnapshot();
|
||||
events.push(`${snap.accentMode}:${snap.customAccent}`);
|
||||
});
|
||||
|
||||
publishAppearanceChromeSnapshot({ accentMode: 'custom', customAccent: '#ff0000' });
|
||||
assert.equal(events.at(-1), 'custom:#ff0000');
|
||||
assert.equal(getAppearanceChromeSnapshot().customAccent, '#ff0000');
|
||||
|
||||
publishAppearanceChromeSnapshot({ accentMode: 'custom', customAccent: '#ff0000' });
|
||||
assert.equal(events.length, 1);
|
||||
|
||||
publishAppearanceChromeSnapshot({ accentMode: 'custom', customAccent: '#00ff00' });
|
||||
assert.equal(events.at(-1), 'custom:#00ff00');
|
||||
|
||||
publishAppearanceChromeSnapshot({ accentMode: 'theme', customAccent: '#00ff00' });
|
||||
assert.equal(events.at(-1), 'theme:#00ff00');
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
export type AppearanceChromeSnapshot = {
|
||||
accentMode: 'theme' | 'custom';
|
||||
customAccent: string;
|
||||
};
|
||||
|
||||
const DEFAULT_SNAPSHOT: AppearanceChromeSnapshot = Object.freeze({
|
||||
accentMode: 'theme',
|
||||
customAccent: '',
|
||||
});
|
||||
|
||||
/**
|
||||
* External store for app accent chrome so Terminal leaves can apply custom
|
||||
* accent without TerminalLayer rebuilding on every color-picker drag tick.
|
||||
*/
|
||||
class AppearanceChromeStore {
|
||||
private snapshot: AppearanceChromeSnapshot = DEFAULT_SNAPSHOT;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
getSnapshot = (): AppearanceChromeSnapshot => this.snapshot;
|
||||
|
||||
subscribe = (listener: Listener): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
setSnapshot(next: AppearanceChromeSnapshot): void {
|
||||
if (
|
||||
this.snapshot.accentMode === next.accentMode
|
||||
&& this.snapshot.customAccent === next.customAccent
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.snapshot = next;
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const appearanceChromeStore = new AppearanceChromeStore();
|
||||
|
||||
export function publishAppearanceChromeSnapshot(
|
||||
snapshot: AppearanceChromeSnapshot,
|
||||
): void {
|
||||
appearanceChromeStore.setSnapshot(snapshot);
|
||||
}
|
||||
|
||||
export function getAppearanceChromeSnapshot(): AppearanceChromeSnapshot {
|
||||
return appearanceChromeStore.getSnapshot();
|
||||
}
|
||||
|
||||
export function subscribeAppearanceChrome(listener: Listener): () => void {
|
||||
return appearanceChromeStore.subscribe(listener);
|
||||
}
|
||||
|
||||
export function useAppearanceChromeStore(): AppearanceChromeSnapshot {
|
||||
return useSyncExternalStore(
|
||||
subscribeAppearanceChrome,
|
||||
getAppearanceChromeSnapshot,
|
||||
getAppearanceChromeSnapshot,
|
||||
);
|
||||
}
|
||||
@@ -343,26 +343,3 @@ test("the race guards are wired into the real settings paths", () => {
|
||||
assert.match(popupSource, /terminalTheme=\{settings\.currentTerminalTheme\}/);
|
||||
assert.match(popupSource, /followAppTerminalTheme=\{settings\.followAppTerminalTheme\}/);
|
||||
});
|
||||
|
||||
test("cloud sync rehydrate picks up follow-app terminal theme from storage", () => {
|
||||
// applySyncPayload writes followAppTerminalTheme to localStorage, then calls
|
||||
// rehydrateAllFromStorage. Without reading TERM_FOLLOW_APP_THEME there, the
|
||||
// open window keeps the pre-sync follow-app flag while terminalThemeId updates
|
||||
// — black default vs synced yellow theme flicker (#2757).
|
||||
const stateSource = readFileSync(new URL("./useSettingsState.ts", import.meta.url), "utf8");
|
||||
const rehydrateIndex = stateSource.indexOf("const rehydrateAllFromStorage = useCallback(() => {");
|
||||
assert.ok(rehydrateIndex >= 0, "rehydrateAllFromStorage must exist");
|
||||
const nextCallbackIndex = stateSource.indexOf("}, [applyIncomingCustomKeyBindings", rehydrateIndex);
|
||||
assert.ok(nextCallbackIndex > rehydrateIndex, "rehydrate callback body must be bounded");
|
||||
const rehydrateBody = stateSource.slice(rehydrateIndex, nextCallbackIndex);
|
||||
assert.match(
|
||||
rehydrateBody,
|
||||
/STORAGE_KEY_TERM_FOLLOW_APP_THEME/,
|
||||
"rehydrate must read the follow-app terminal theme key written by applySyncableSettings",
|
||||
);
|
||||
assert.match(
|
||||
rehydrateBody,
|
||||
/setFollowAppTerminalThemeState/,
|
||||
"rehydrate must update React follow-app state from storage after cloud sync",
|
||||
);
|
||||
});
|
||||
@@ -77,7 +77,7 @@ export function shouldApplyCustomAccentRecord(
|
||||
|
||||
/**
|
||||
* Decide whether a custom-accent state change should be rebroadcast to peer
|
||||
* windows. Incoming IPC/storage updates must not notify again - otherwise a
|
||||
* windows. Incoming IPC/storage updates must not notify again — otherwise a
|
||||
* fast native color-picker drag in the settings window ping-pongs with the
|
||||
* main window and the accent CSS variables oscillate (see #2743, same class
|
||||
* as window-opacity #2018).
|
||||
|
||||
@@ -189,7 +189,7 @@ test("loadDefaultKeyPassphrase removes undecryptable credential placeholders", a
|
||||
globalThis.localStorage.setItem(
|
||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||
JSON.stringify({
|
||||
[keyPath]: "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==",
|
||||
[keyPath]: "enc:v1:djEwYWJj",
|
||||
"/Users/alice/.ssh/id_rsa": "still-valid",
|
||||
}),
|
||||
);
|
||||
@@ -206,7 +206,7 @@ test("loadDefaultKeyPassphrase removes undecryptable credential placeholders", a
|
||||
test("export read reports unavailable encrypted passphrases without deleting them", async (t) => {
|
||||
installLocalStorage(t);
|
||||
const keyPath = "/Users/alice/.ssh/id_ed25519";
|
||||
const encrypted = "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==";
|
||||
const encrypted = "enc:v1:djEwYWJj";
|
||||
globalThis.localStorage.setItem(
|
||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||
JSON.stringify({ [keyPath]: encrypted }),
|
||||
@@ -225,7 +225,7 @@ test("export read reports unavailable encrypted passphrases without deleting the
|
||||
test("export read retries when the passphrase changes during decryption", async (t) => {
|
||||
installLocalStorage(t);
|
||||
const keyPath = "/Users/alice/.ssh/id_ed25519";
|
||||
const encrypted = "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==";
|
||||
const encrypted = "enc:v1:djEwYWJj";
|
||||
let releaseDecrypt: (() => void) | undefined;
|
||||
const decryptGate = new Promise<void>((resolve) => {
|
||||
releaseDecrypt = resolve;
|
||||
@@ -286,7 +286,7 @@ test("remembered passphrase read marks encrypted Keychain placeholders unreadabl
|
||||
installLocalStorage(t);
|
||||
const key = {
|
||||
...referenceKey(),
|
||||
passphrase: "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==",
|
||||
passphrase: "enc:v1:djEwYWJj",
|
||||
savePassphrase: true,
|
||||
};
|
||||
|
||||
@@ -311,7 +311,7 @@ test("passphrase verification reads every alias and preserves unreadable state",
|
||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||
JSON.stringify({
|
||||
"~/.ssh/id_ed25519": "readable-secret",
|
||||
"/Users/alice/.ssh/id_ed25519": "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==",
|
||||
"/Users/alice/.ssh/id_ed25519": "enc:v1:djEwYWJj",
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -455,7 +455,7 @@ test("loadDefaultKeyPassphrase cleanup preserves a passphrase saved concurrently
|
||||
});
|
||||
globalThis.localStorage.setItem(
|
||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||
JSON.stringify({ "/Users/alice/.ssh/id_old": "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==" }),
|
||||
JSON.stringify({ "/Users/alice/.ssh/id_old": "enc:v1:djEwYWJj" }),
|
||||
);
|
||||
|
||||
const pendingLoad = loadDefaultKeyPassphrase("/Users/alice/.ssh/id_old");
|
||||
@@ -549,7 +549,7 @@ test("loadDefaultKeyPassphrase prefers an exact saved path over a stale alias",
|
||||
globalThis.localStorage.setItem(
|
||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||
JSON.stringify({
|
||||
"~/.ssh/id_ed25519": "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==",
|
||||
"~/.ssh/id_ed25519": "enc:v1:djEwYWJj",
|
||||
"/Users/alice/.ssh/id_ed25519": "valid-exact-passphrase",
|
||||
}),
|
||||
);
|
||||
@@ -574,7 +574,7 @@ test("loadDefaultKeyPassphrase falls back to a valid alias and removes an invali
|
||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||
JSON.stringify({
|
||||
"~/.ssh/id_ed25519": "valid-alias-passphrase",
|
||||
"/Users/alice/.ssh/id_ed25519": "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==",
|
||||
"/Users/alice/.ssh/id_ed25519": "enc:v1:djEwYWJj",
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -913,7 +913,7 @@ test("shouldUpdateReferenceKeyPassphrase replaces missing or undecryptable passp
|
||||
assert.equal(
|
||||
shouldUpdateReferenceKeyPassphrase({
|
||||
...referenceKey(),
|
||||
passphrase: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
passphrase: "enc:v1:djEwAAAA",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
EMPTY_NOTES_SNAPSHOT,
|
||||
getEmptyNotesSnapshot,
|
||||
getNotesActions,
|
||||
getNotesSnapshot,
|
||||
publishNotesSnapshot,
|
||||
registerNotesActions,
|
||||
subscribeNotes,
|
||||
subscribeNotesNoop,
|
||||
} from './notesStore.ts';
|
||||
|
||||
test('notesStore notifies subscribers only when snapshot identity changes', () => {
|
||||
const events: number[] = [];
|
||||
const unsubscribe = subscribeNotes(() => {
|
||||
events.push(getNotesSnapshot().notes.length);
|
||||
});
|
||||
|
||||
const firstNotes = [{
|
||||
id: 'n1',
|
||||
title: 'One',
|
||||
content: 'body',
|
||||
tags: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
order: 1000,
|
||||
}];
|
||||
const firstGroups = ['Ops'];
|
||||
publishNotesSnapshot({ notes: firstNotes, noteGroups: firstGroups });
|
||||
assert.equal(events.at(-1), 1);
|
||||
assert.equal(getNotesSnapshot().notes, firstNotes);
|
||||
assert.equal(getNotesSnapshot().noteGroups, firstGroups);
|
||||
|
||||
publishNotesSnapshot({ notes: firstNotes, noteGroups: firstGroups });
|
||||
assert.equal(events.length, 1);
|
||||
|
||||
const secondNotes = [
|
||||
...firstNotes,
|
||||
{
|
||||
id: 'n2',
|
||||
title: 'Two',
|
||||
content: 'more',
|
||||
tags: [],
|
||||
createdAt: 2,
|
||||
updatedAt: 2,
|
||||
order: 2000,
|
||||
},
|
||||
];
|
||||
publishNotesSnapshot({ notes: secondNotes, noteGroups: firstGroups });
|
||||
assert.equal(events.at(-1), 2);
|
||||
|
||||
publishNotesSnapshot({ notes: secondNotes, noteGroups: ['Ops', 'DB'] });
|
||||
assert.equal(events.length, 3);
|
||||
assert.deepEqual(getNotesSnapshot().noteGroups, ['Ops', 'DB']);
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test('notesStore gated helpers stay empty and never notify', () => {
|
||||
let called = 0;
|
||||
const unsub = subscribeNotesNoop(() => {
|
||||
called += 1;
|
||||
});
|
||||
publishNotesSnapshot({
|
||||
notes: [{
|
||||
id: 'n1',
|
||||
title: 'One',
|
||||
content: 'body',
|
||||
tags: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
order: 1000,
|
||||
}],
|
||||
noteGroups: ['Ops'],
|
||||
});
|
||||
assert.equal(called, 0);
|
||||
assert.equal(getEmptyNotesSnapshot(), EMPTY_NOTES_SNAPSHOT);
|
||||
assert.equal(getEmptyNotesSnapshot().notes.length, 0);
|
||||
unsub();
|
||||
});
|
||||
|
||||
test('registerNotesActions exposes update handlers', () => {
|
||||
const calls: string[] = [];
|
||||
registerNotesActions({
|
||||
updateNotes: () => {
|
||||
calls.push('notes');
|
||||
},
|
||||
updateNoteGroups: () => {
|
||||
calls.push('groups');
|
||||
},
|
||||
});
|
||||
const actions = getNotesActions();
|
||||
assert.ok(actions);
|
||||
actions!.updateNotes([]);
|
||||
actions!.updateNoteGroups([]);
|
||||
assert.deepEqual(calls, ['notes', 'groups']);
|
||||
registerNotesActions(null);
|
||||
assert.equal(getNotesActions(), null);
|
||||
});
|
||||
@@ -1,138 +0,0 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
import type { VaultNote } from '../../domain/models';
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
export type NotesSnapshot = {
|
||||
notes: readonly VaultNote[];
|
||||
noteGroups: readonly string[];
|
||||
};
|
||||
|
||||
export type NotesActions = {
|
||||
updateNotes: (notes: VaultNote[]) => void;
|
||||
updateNoteGroups: (groups: string[]) => void;
|
||||
};
|
||||
|
||||
const EMPTY_NOTES: readonly VaultNote[] = Object.freeze([]);
|
||||
const EMPTY_NOTE_GROUPS: readonly string[] = Object.freeze([]);
|
||||
|
||||
export const EMPTY_NOTES_SNAPSHOT: NotesSnapshot = Object.freeze({
|
||||
notes: EMPTY_NOTES,
|
||||
noteGroups: EMPTY_NOTE_GROUPS,
|
||||
});
|
||||
|
||||
/**
|
||||
* External store for vault notes so Notes / AI side-panel consumers can
|
||||
* subscribe without forcing TerminalLayer re-renders on every note edit.
|
||||
*/
|
||||
class NotesStore {
|
||||
private snapshot: NotesSnapshot = EMPTY_NOTES_SNAPSHOT;
|
||||
private actions: NotesActions | null = null;
|
||||
private listeners = new Set<Listener>();
|
||||
private actionListeners = new Set<Listener>();
|
||||
|
||||
getSnapshot = (): NotesSnapshot => this.snapshot;
|
||||
|
||||
subscribe = (listener: Listener): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
setSnapshot(next: NotesSnapshot): void {
|
||||
if (
|
||||
this.snapshot.notes === next.notes
|
||||
&& this.snapshot.noteGroups === next.noteGroups
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.snapshot = next;
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
getActions = (): NotesActions | null => this.actions;
|
||||
|
||||
subscribeActions = (listener: Listener): (() => void) => {
|
||||
this.actionListeners.add(listener);
|
||||
return () => {
|
||||
this.actionListeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
setActions(next: NotesActions | null): void {
|
||||
if (this.actions === next) return;
|
||||
this.actions = next;
|
||||
for (const listener of this.actionListeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const notesStore = new NotesStore();
|
||||
|
||||
export function publishNotesSnapshot(snapshot: NotesSnapshot): void {
|
||||
notesStore.setSnapshot(snapshot);
|
||||
}
|
||||
|
||||
export function getNotesSnapshot(): NotesSnapshot {
|
||||
return notesStore.getSnapshot();
|
||||
}
|
||||
|
||||
export function subscribeNotes(listener: Listener): () => void {
|
||||
return notesStore.subscribe(listener);
|
||||
}
|
||||
|
||||
/** No-op subscribe for gated (hidden) panel mounts. */
|
||||
export function subscribeNotesNoop(_listener: Listener): () => void {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
export function getEmptyNotesSnapshot(): NotesSnapshot {
|
||||
return EMPTY_NOTES_SNAPSHOT;
|
||||
}
|
||||
|
||||
export function registerNotesActions(actions: NotesActions | null): void {
|
||||
notesStore.setActions(actions);
|
||||
}
|
||||
|
||||
export function getNotesActions(): NotesActions | null {
|
||||
return notesStore.getActions();
|
||||
}
|
||||
|
||||
export function subscribeNotesActions(listener: Listener): () => void {
|
||||
return notesStore.subscribeActions(listener);
|
||||
}
|
||||
|
||||
const noopUpdateNotes: NotesActions['updateNotes'] = () => {};
|
||||
const noopUpdateNoteGroups: NotesActions['updateNoteGroups'] = () => {};
|
||||
|
||||
/**
|
||||
* Subscribe to notes catalog + vault mutation actions for Notes / AI panels.
|
||||
*/
|
||||
export function useNotesStore(): {
|
||||
notes: VaultNote[];
|
||||
noteGroups: string[];
|
||||
updateNotes: NotesActions['updateNotes'];
|
||||
updateNoteGroups: NotesActions['updateNoteGroups'];
|
||||
} {
|
||||
const snapshot = useSyncExternalStore(
|
||||
subscribeNotes,
|
||||
getNotesSnapshot,
|
||||
getNotesSnapshot,
|
||||
);
|
||||
const actions = useSyncExternalStore(
|
||||
subscribeNotesActions,
|
||||
getNotesActions,
|
||||
getNotesActions,
|
||||
);
|
||||
return {
|
||||
notes: snapshot.notes as VaultNote[],
|
||||
noteGroups: snapshot.noteGroups as string[],
|
||||
updateNotes: actions?.updateNotes ?? noopUpdateNotes,
|
||||
updateNoteGroups: actions?.updateNoteGroups ?? noopUpdateNoteGroups,
|
||||
};
|
||||
}
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
|
||||
STORAGE_KEY_SSH_TRANSPORT_IDLE_TTL_MS,
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_SFTP_SKIP_UNCHANGED,
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
|
||||
@@ -80,7 +79,6 @@ interface UseSettingsStorageSyncParams {
|
||||
sftpAutoSync: boolean;
|
||||
sftpShowHiddenFiles: boolean;
|
||||
sftpUseCompressedUpload: boolean;
|
||||
sftpSkipUnchanged: boolean;
|
||||
sftpAutoOpenSidebar: boolean;
|
||||
sftpFollowTerminalCwd: boolean;
|
||||
sftpDefaultViewMode: 'list' | 'tree';
|
||||
@@ -127,7 +125,6 @@ interface UseSettingsStorageSyncParams {
|
||||
setSftpAutoSync: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpShowHiddenFiles: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpUseCompressedUpload: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpSkipUnchanged: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpAutoOpenSidebar: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
|
||||
@@ -168,7 +165,7 @@ export function useSettingsStorageSync({
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
@@ -177,7 +174,7 @@ export function useSettingsStorageSync({
|
||||
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
@@ -192,7 +189,7 @@ export function useSettingsStorageSync({
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
@@ -202,7 +199,7 @@ export function useSettingsStorageSync({
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
@@ -382,12 +379,6 @@ export function useSettingsStorageSync({
|
||||
setSftpUseCompressedUpload(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SFTP_SKIP_UNCHANGED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.sftpSkipUnchanged) {
|
||||
setSftpSkipUnchanged(newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP auto-open sidebar setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
@@ -551,7 +542,6 @@ export function useSettingsStorageSync({
|
||||
setSftpTransferConcurrencyState,
|
||||
setSshTransportIdleTtlMsState,
|
||||
setSftpUseCompressedUpload,
|
||||
setSftpSkipUnchanged,
|
||||
setShowOnlyUngroupedHostsInRootState,
|
||||
setShowHostTreeSidebarState,
|
||||
setTerminalSidePanelAutoOpenState,
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
export type SortField = "name" | "size" | "modified" | "type";
|
||||
export type SortOrder = "asc" | "desc";
|
||||
|
||||
export interface ColumnWidths {
|
||||
name: number;
|
||||
modified: number;
|
||||
size: number;
|
||||
type: number;
|
||||
}
|
||||
|
||||
export type SftpColumnVisibility = Record<keyof ColumnWidths, boolean>;
|
||||
|
||||
export const DEFAULT_SFTP_COLUMN_VISIBILITY: SftpColumnVisibility = {
|
||||
name: true,
|
||||
modified: true,
|
||||
size: true,
|
||||
type: true,
|
||||
};
|
||||
|
||||
export const normalizeSftpColumnVisibility = (value: unknown): SftpColumnVisibility => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return DEFAULT_SFTP_COLUMN_VISIBILITY;
|
||||
}
|
||||
|
||||
const stored = value as Partial<Record<keyof ColumnWidths, unknown>>;
|
||||
return {
|
||||
name: true,
|
||||
modified: stored.modified !== false,
|
||||
size: stored.size !== false,
|
||||
type: stored.type !== false,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { resumeCompressedUploadSafely } from "./compressedUploadControl.ts";
|
||||
|
||||
test("live compressed resume failure stays paused instead of starting a second transfer", async () => {
|
||||
const outcome = await resumeCompressedUploadSafely({
|
||||
transferId: "compressed-live",
|
||||
reconnectRequired: false,
|
||||
resume: async () => ({ success: false, reason: "Upload resume is unavailable" }),
|
||||
});
|
||||
assert.deepEqual(outcome, { kind: "failed", reason: "Upload resume is unavailable" });
|
||||
});
|
||||
|
||||
test("restored compressed task may restart when its old worker no longer exists", async () => {
|
||||
const outcome = await resumeCompressedUploadSafely({
|
||||
transferId: "compressed-restored",
|
||||
reconnectRequired: true,
|
||||
resume: async () => ({ success: false, reason: "Compression is not active" }),
|
||||
});
|
||||
assert.deepEqual(outcome, { kind: "restart", reason: "Compression is not active" });
|
||||
});
|
||||
|
||||
test("successful compressed resume rejoins the existing job", async () => {
|
||||
const outcome = await resumeCompressedUploadSafely({
|
||||
transferId: "compressed-live",
|
||||
reconnectRequired: false,
|
||||
resume: async () => ({ success: true }),
|
||||
});
|
||||
assert.deepEqual(outcome, { kind: "resumed" });
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
export type CompressedUploadResumeOutcome =
|
||||
| { kind: "resumed" }
|
||||
| { kind: "restart"; reason?: string }
|
||||
| { kind: "failed"; reason: string };
|
||||
|
||||
export async function resumeCompressedUploadSafely(params: {
|
||||
transferId: string;
|
||||
reconnectRequired: boolean;
|
||||
resume?: (transferId: string) => Promise<{ success: boolean; reason?: string }>;
|
||||
}): Promise<CompressedUploadResumeOutcome> {
|
||||
const result = await (params.resume?.(params.transferId)
|
||||
?? { success: false, reason: "Resume unavailable" });
|
||||
if (result.success) return { kind: "resumed" };
|
||||
if (params.reconnectRequired) return { kind: "restart", reason: result.reason };
|
||||
return { kind: "failed", reason: result.reason ?? "Could not resume the compressed upload." };
|
||||
}
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
isMissingDirectoryReplacePathError,
|
||||
promoteDirectoryReplaceStage as promoteDirectoryReplacePaths,
|
||||
} from "./directoryReplacePromotion";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
|
||||
export interface DedicatedResumeDeps {
|
||||
hosts: readonly Host[];
|
||||
@@ -709,19 +708,8 @@ async function resumeSingleFileWithDedicatedSession(
|
||||
skipAdmission: true,
|
||||
});
|
||||
|
||||
if (streamResult?.superseded === true) {
|
||||
// Live same-id owner still running; wait for terminal events only.
|
||||
for (;;) {
|
||||
if (shouldAbort?.()) throw new Error("Transfer cancelled");
|
||||
const latest = sftpTransferCenterStore.getTask(task.id);
|
||||
const status = latest?.status;
|
||||
if (status === "completed") break;
|
||||
if (status === "failed") throw new Error(latest?.error || "Transfer failed");
|
||||
if (status === "cancelled") throw new Error("Transfer cancelled");
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
} else if (streamResult?.error || streamResult?.cancelled) {
|
||||
throw new Error(streamResult.error || "Transfer cancelled");
|
||||
if (streamResult?.error) {
|
||||
throw new Error(streamResult.error);
|
||||
}
|
||||
}, { retries: 1, delayMs: 600 });
|
||||
return { transferId: task.id };
|
||||
@@ -1270,17 +1258,7 @@ async function resumeDirectoryWithDedicatedSession(
|
||||
skipAdmission: true,
|
||||
});
|
||||
|
||||
if (streamResult?.superseded === true) {
|
||||
for (;;) {
|
||||
if (options?.shouldAbort?.()) throw new Error("Transfer cancelled");
|
||||
const latest = sftpTransferCenterStore.getTask(childBase.id);
|
||||
const status = latest?.status;
|
||||
if (status === "completed") break;
|
||||
if (status === "failed") throw new Error(latest?.error || "Transfer failed");
|
||||
if (status === "cancelled") throw new Error("Transfer cancelled");
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
} else if (streamResult?.error || streamResult?.cancelled) {
|
||||
if (streamResult?.error || streamResult?.cancelled) {
|
||||
throw new Error(streamResult.error || "Transfer cancelled");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { TransferTask } from "../../../domain/models";
|
||||
import {
|
||||
isExternalDragDropFileUpload,
|
||||
retryExternalDragDropFileUpload,
|
||||
} from "./externalDragDropRetry";
|
||||
|
||||
const baseTask = (overrides: Partial<TransferTask> = {}): TransferTask => ({
|
||||
id: "child-1",
|
||||
fileName: "a.txt",
|
||||
sourcePath: "/tmp/docs/a.txt",
|
||||
targetPath: "/remote/docs/a.txt",
|
||||
sourceConnectionId: "external",
|
||||
targetConnectionId: "conn-1",
|
||||
targetHostId: "host-1",
|
||||
direction: "upload",
|
||||
status: "failed",
|
||||
totalBytes: 12,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: 1,
|
||||
isDirectory: false,
|
||||
origin: "drag-drop",
|
||||
parentTaskId: "folder-1",
|
||||
error: "Network error",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("detects progressive drag-drop file children as retryable", () => {
|
||||
assert.equal(isExternalDragDropFileUpload(baseTask()), true);
|
||||
assert.equal(isExternalDragDropFileUpload(baseTask({ isDirectory: true })), false);
|
||||
assert.equal(isExternalDragDropFileUpload(baseTask({ origin: "manual" })), false);
|
||||
assert.equal(isExternalDragDropFileUpload(baseTask({ sourceConnectionId: "local" })), false);
|
||||
assert.equal(isExternalDragDropFileUpload(baseTask({ status: "completed" })), false);
|
||||
assert.equal(isExternalDragDropFileUpload(baseTask({ retryable: false })), false);
|
||||
});
|
||||
|
||||
test("retry reuses the same transfer id and starts a stream upload", async () => {
|
||||
const patches: Array<{ id: string; updates: Partial<TransferTask> }> = [];
|
||||
const streams: Array<Record<string, unknown>> = [];
|
||||
|
||||
// No pool acquire → falls back to browse sftp id.
|
||||
const result = await retryExternalDragDropFileUpload(baseTask(), {
|
||||
getBrowseSftpId: () => "sftp-live",
|
||||
startStreamTransfer: async (options) => {
|
||||
streams.push(options as unknown as Record<string, unknown>);
|
||||
return { transferId: options.transferId };
|
||||
},
|
||||
onPatch: (taskId, updates) => patches.push({ id: taskId, updates }),
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(streams.length, 1);
|
||||
assert.equal(streams[0].transferId, "child-1");
|
||||
assert.equal(streams[0].sourcePath, "/tmp/docs/a.txt");
|
||||
assert.equal(streams[0].targetPath, "/remote/docs/a.txt");
|
||||
assert.equal(streams[0].targetSftpId, "sftp-live");
|
||||
assert.equal(patches[0].updates.status, "transferring");
|
||||
assert.equal(patches.at(-1)?.updates.status, "completed");
|
||||
});
|
||||
|
||||
test("retry prefers a dedicated pool session over the browse sftp id", async () => {
|
||||
let released = false;
|
||||
let acquired = false;
|
||||
const result = await retryExternalDragDropFileUpload(baseTask(), {
|
||||
getBrowseSftpId: () => "sftp-live",
|
||||
acquireTransferSession: async () => {
|
||||
acquired = true;
|
||||
return {
|
||||
sftpId: "sftp-pool",
|
||||
poolKey: "host-1",
|
||||
release: () => { released = true; },
|
||||
discard: () => {},
|
||||
};
|
||||
},
|
||||
startStreamTransfer: async (options) => {
|
||||
assert.equal(options.targetSftpId, "sftp-pool");
|
||||
return {};
|
||||
},
|
||||
onPatch: () => {},
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(acquired, true);
|
||||
assert.equal(released, true);
|
||||
});
|
||||
|
||||
test("retry falls back to browse when pool acquire is unavailable", async () => {
|
||||
const result = await retryExternalDragDropFileUpload(baseTask(), {
|
||||
getBrowseSftpId: () => "sftp-live",
|
||||
startStreamTransfer: async (options) => {
|
||||
assert.equal(options.targetSftpId, "sftp-live");
|
||||
return {};
|
||||
},
|
||||
onPatch: () => {},
|
||||
});
|
||||
assert.equal(result.success, true);
|
||||
});
|
||||
|
||||
test("retry rolls parent to completed when all siblings succeed", async () => {
|
||||
const patches: Array<{ id: string; updates: Partial<TransferTask> }> = [];
|
||||
const result = await retryExternalDragDropFileUpload(baseTask(), {
|
||||
getBrowseSftpId: () => "sftp-live",
|
||||
startStreamTransfer: async () => ({}),
|
||||
getTask: (id) => (
|
||||
id === "folder-1"
|
||||
? baseTask({
|
||||
id: "folder-1",
|
||||
isDirectory: true,
|
||||
status: "failed",
|
||||
sourcePath: "/tmp/docs",
|
||||
targetPath: "/remote/docs",
|
||||
error: "1 of 2 files failed",
|
||||
totalBytes: 2,
|
||||
transferredBytes: 1,
|
||||
parentTaskId: undefined,
|
||||
})
|
||||
: undefined
|
||||
),
|
||||
getChildTasks: () => [
|
||||
baseTask({ id: "child-1", status: "transferring" }),
|
||||
baseTask({ id: "child-2", status: "completed", sourcePath: "/tmp/docs/b.txt" }),
|
||||
],
|
||||
onPatch: (taskId, updates) => patches.push({ id: taskId, updates }),
|
||||
});
|
||||
assert.equal(result.success, true);
|
||||
const parentPatch = patches.find((p) => p.id === "folder-1");
|
||||
assert.ok(parentPatch);
|
||||
assert.equal(parentPatch?.updates.status, "completed");
|
||||
});
|
||||
|
||||
test("retry does not rollup a still-transferring progressive parent", async () => {
|
||||
const patches: Array<{ id: string; updates: Partial<TransferTask> }> = [];
|
||||
await retryExternalDragDropFileUpload(baseTask(), {
|
||||
getBrowseSftpId: () => "sftp-live",
|
||||
startStreamTransfer: async () => ({}),
|
||||
getTask: (id) => (
|
||||
id === "folder-1"
|
||||
? baseTask({
|
||||
id: "folder-1",
|
||||
isDirectory: true,
|
||||
status: "transferring",
|
||||
sourcePath: "/tmp/docs",
|
||||
parentTaskId: undefined,
|
||||
})
|
||||
: undefined
|
||||
),
|
||||
getChildTasks: () => [
|
||||
baseTask({ id: "child-1", status: "transferring" }),
|
||||
baseTask({ id: "child-2", status: "completed", sourcePath: "/tmp/docs/b.txt" }),
|
||||
],
|
||||
onPatch: (taskId, updates) => patches.push({ id: taskId, updates }),
|
||||
});
|
||||
assert.equal(patches.some((p) => p.id === "folder-1"), false);
|
||||
});
|
||||
|
||||
test("retry fails clearly when no sftp session can be opened", async () => {
|
||||
const patches: Array<Partial<TransferTask>> = [];
|
||||
const result = await retryExternalDragDropFileUpload(baseTask(), {
|
||||
getBrowseSftpId: () => undefined,
|
||||
startStreamTransfer: async () => {
|
||||
throw new Error("should not start");
|
||||
},
|
||||
onPatch: (_id, updates) => patches.push(updates),
|
||||
});
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.match(result.error || "", /No SFTP session/);
|
||||
assert.equal(patches.at(-1)?.status, "failed");
|
||||
});
|
||||
|
||||
test("stream error keeps the row failed with the backend message", async () => {
|
||||
const patches: Array<Partial<TransferTask>> = [];
|
||||
const result = await retryExternalDragDropFileUpload(baseTask(), {
|
||||
getBrowseSftpId: () => "sftp-live",
|
||||
startStreamTransfer: async () => ({ error: "Permission denied" }),
|
||||
onPatch: (_id, updates) => patches.push(updates),
|
||||
});
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.equal(result.error, "Permission denied");
|
||||
assert.equal(patches.at(-1)?.status, "failed");
|
||||
assert.equal(patches.at(-1)?.error, "Permission denied");
|
||||
});
|
||||
@@ -1,267 +0,0 @@
|
||||
/**
|
||||
* Low-cost retry for progressive / external drag-drop file uploads.
|
||||
*
|
||||
* These rows use sourceConnectionId "external" and never have dual-pane
|
||||
* endpoints, so the generic processTransfer retry path silently no-ops.
|
||||
* Retry re-opens a single startStreamTransfer with the stored local path.
|
||||
*/
|
||||
|
||||
import type { TransferTask } from "../../../domain/models";
|
||||
import type { TransferConnectionLease } from "./transferConnectionPool";
|
||||
import { isTransferCancelledFlag } from "./transferCancelLatch";
|
||||
|
||||
export function isExternalDragDropFileUpload(
|
||||
task: Pick<
|
||||
TransferTask,
|
||||
| "origin"
|
||||
| "direction"
|
||||
| "sourceConnectionId"
|
||||
| "isDirectory"
|
||||
| "sourcePath"
|
||||
| "targetPath"
|
||||
| "retryable"
|
||||
| "status"
|
||||
>,
|
||||
): boolean {
|
||||
if (task.retryable === false) return false;
|
||||
if (task.origin !== "drag-drop") return false;
|
||||
if (task.direction !== "upload") return false;
|
||||
if (task.sourceConnectionId !== "external") return false;
|
||||
if (task.isDirectory) return false;
|
||||
if (!task.sourcePath || task.sourcePath === "local") return false;
|
||||
if (!task.targetPath) return false;
|
||||
return task.status === "failed" || task.status === "cancelled" || task.status === "attention";
|
||||
}
|
||||
|
||||
export type ExternalDragDropRetryDeps = {
|
||||
getBrowseSftpId: (connectionId: string) => string | undefined;
|
||||
acquireTransferSession?: (
|
||||
hostId: string,
|
||||
transferId: string,
|
||||
) => Promise<TransferConnectionLease>;
|
||||
startStreamTransfer: (options: {
|
||||
transferId: string;
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
sourceType: "local";
|
||||
targetType: "local" | "sftp";
|
||||
targetSftpId?: string;
|
||||
targetHostId?: string;
|
||||
totalBytes?: number;
|
||||
resumable?: boolean;
|
||||
checkpointBytes?: number;
|
||||
}) => Promise<{ error?: string; cancelled?: boolean } | undefined>;
|
||||
clearPendingCancel?: (transferId: string) => Promise<unknown>;
|
||||
cleanupArtifacts?: (task: TransferTask) => Promise<void>;
|
||||
onPatch: (taskId: string, updates: Partial<TransferTask>) => void;
|
||||
/** Live store lookup for terminal cancel races and completion bytes. */
|
||||
getTask?: (taskId: string) => TransferTask | undefined;
|
||||
/** Children of a progressive parent (for rollup after child success). */
|
||||
getChildTasks?: (parentTaskId: string) => TransferTask[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Restart a failed/cancelled external drag-drop file upload in place (same id).
|
||||
* Returns true when the stream completed successfully.
|
||||
*/
|
||||
export async function retryExternalDragDropFileUpload(
|
||||
task: TransferTask,
|
||||
deps: ExternalDragDropRetryDeps,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
if (!isExternalDragDropFileUpload(task)) {
|
||||
return { success: false, error: "Not an external drag-drop file upload" };
|
||||
}
|
||||
|
||||
try {
|
||||
await deps.clearPendingCancel?.(task.id);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
try {
|
||||
await deps.cleanupArtifacts?.(task);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
|
||||
const targetIsLocal = task.targetConnectionId === "local" || !task.targetHostId;
|
||||
let lease: TransferConnectionLease | null = null;
|
||||
let targetSftpId: string | undefined;
|
||||
|
||||
try {
|
||||
if (!targetIsLocal) {
|
||||
// Prefer a dedicated pool session when hostId is known — same policy as
|
||||
// progressive upload (do not pin the browse/tab session).
|
||||
if (task.targetHostId && deps.acquireTransferSession) {
|
||||
lease = await deps.acquireTransferSession(task.targetHostId, `${task.id}:retry`);
|
||||
targetSftpId = lease.sftpId;
|
||||
}
|
||||
if (!targetSftpId) {
|
||||
targetSftpId = deps.getBrowseSftpId(task.targetConnectionId);
|
||||
}
|
||||
if (!targetSftpId) {
|
||||
const error = "No SFTP session available to retry this upload. Reconnect and try again.";
|
||||
deps.onPatch(task.id, {
|
||||
status: "failed",
|
||||
error,
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
});
|
||||
return { success: false, error };
|
||||
}
|
||||
}
|
||||
|
||||
deps.onPatch(task.id, {
|
||||
status: "transferring",
|
||||
error: undefined,
|
||||
transferredBytes: 0,
|
||||
checkpointBytes: 0,
|
||||
speed: 0,
|
||||
endTime: undefined,
|
||||
phase: "transferring",
|
||||
reconnectRequired: false,
|
||||
pauseUnavailableReason: undefined,
|
||||
startTime: Date.now(),
|
||||
});
|
||||
|
||||
const result = await deps.startStreamTransfer({
|
||||
transferId: task.id,
|
||||
sourcePath: task.sourcePath,
|
||||
targetPath: task.targetPath,
|
||||
sourceType: "local",
|
||||
targetType: targetIsLocal ? "local" : "sftp",
|
||||
targetSftpId: targetIsLocal ? undefined : targetSftpId,
|
||||
targetHostId: targetIsLocal ? undefined : task.targetHostId,
|
||||
totalBytes: task.totalBytes > 0 ? task.totalBytes : undefined,
|
||||
resumable: true,
|
||||
checkpointBytes: 0,
|
||||
});
|
||||
|
||||
// Late cancel can settle the row before this return; never resurrect it.
|
||||
if (isTransferCancelledFlag(task.id) || result?.cancelled) {
|
||||
deps.onPatch(task.id, {
|
||||
status: "cancelled",
|
||||
error: undefined,
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
phase: undefined,
|
||||
});
|
||||
return { success: false, error: "Transfer cancelled" };
|
||||
}
|
||||
if (result?.error) {
|
||||
deps.onPatch(task.id, {
|
||||
status: "failed",
|
||||
error: result.error,
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
phase: undefined,
|
||||
});
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
const live = deps.getTask?.(task.id);
|
||||
const completedBytes = Math.max(
|
||||
live?.totalBytes ?? 0,
|
||||
live?.transferredBytes ?? 0,
|
||||
task.totalBytes,
|
||||
0,
|
||||
);
|
||||
deps.onPatch(task.id, {
|
||||
status: "completed",
|
||||
error: undefined,
|
||||
transferredBytes: completedBytes,
|
||||
totalBytes: Math.max(live?.totalBytes ?? 0, task.totalBytes, completedBytes),
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
phase: undefined,
|
||||
});
|
||||
|
||||
// Progressive parents finalize once with "N of M failed". After a child
|
||||
// retry succeeds, re-roll the parent when no failed children remain.
|
||||
if (task.parentTaskId && deps.getChildTasks) {
|
||||
rollupParentAfterChildSuccess(task.parentTaskId, task.id, deps);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (isTransferCancelledFlag(task.id) || /cancel/i.test(message)) {
|
||||
deps.onPatch(task.id, {
|
||||
status: "cancelled",
|
||||
error: undefined,
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
phase: undefined,
|
||||
});
|
||||
return { success: false, error: "Transfer cancelled" };
|
||||
}
|
||||
deps.onPatch(task.id, {
|
||||
status: "failed",
|
||||
error: message,
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
phase: undefined,
|
||||
});
|
||||
if (lease && /session|sftp|disconnect|not found/i.test(message)) {
|
||||
try { lease.discard(); } catch { /* best-effort */ }
|
||||
lease = null;
|
||||
}
|
||||
return { success: false, error: message };
|
||||
} finally {
|
||||
try { lease?.release(); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
function rollupParentAfterChildSuccess(
|
||||
parentTaskId: string,
|
||||
completedChildId: string,
|
||||
deps: ExternalDragDropRetryDeps,
|
||||
): void {
|
||||
// Only re-roll parents that already finished the progressive walk as failed.
|
||||
// Never promote a still-scanning/transferring parent mid-walk.
|
||||
const parent = deps.getTask?.(parentTaskId);
|
||||
if (
|
||||
parent
|
||||
&& parent.status !== "failed"
|
||||
&& parent.status !== "attention"
|
||||
&& parent.status !== "cancelled"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const children = deps.getChildTasks?.(parentTaskId) ?? [];
|
||||
if (children.length === 0) return;
|
||||
const stillFailed = children.some(
|
||||
(child) =>
|
||||
child.id !== completedChildId
|
||||
&& (child.status === "failed" || child.status === "attention"),
|
||||
);
|
||||
if (stillFailed) return;
|
||||
const active = children.some(
|
||||
(child) =>
|
||||
child.id !== completedChildId
|
||||
&& (
|
||||
child.status === "transferring"
|
||||
|| child.status === "pending"
|
||||
|| child.status === "queued"
|
||||
|| child.status === "pausing"
|
||||
|| child.status === "paused"
|
||||
),
|
||||
);
|
||||
if (active) return;
|
||||
const completedCount = children.filter(
|
||||
(child) => child.id === completedChildId || child.status === "completed",
|
||||
).length;
|
||||
const total = Math.max(
|
||||
Number(parent?.totalBytes) || 0,
|
||||
children.length,
|
||||
completedCount,
|
||||
);
|
||||
deps.onPatch(parentTaskId, {
|
||||
status: "completed",
|
||||
error: undefined,
|
||||
transferredBytes: completedCount,
|
||||
totalBytes: total,
|
||||
speed: 0,
|
||||
endTime: Date.now(),
|
||||
phase: undefined,
|
||||
});
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||
import type { SftpBookmark } from "../../../domain/models";
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { STORAGE_KEY_SFTP_LOCAL_BOOKMARKS } from "../../../infrastructure/config/storageKeys";
|
||||
import { createSftpBookmark } from "./bookmarkHelpers";
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
let snapshot: SftpBookmark[] =
|
||||
localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS) ?? [];
|
||||
|
||||
export function subscribeLocalSftpBookmarks(listener: Listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function getLocalSftpBookmarksSnapshot() {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function rehydrateLocalSftpBookmarks() {
|
||||
snapshot = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS) ?? [];
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
export function setLocalSftpBookmarks(
|
||||
next: SftpBookmark[] | ((prev: SftpBookmark[]) => SftpBookmark[]),
|
||||
) {
|
||||
snapshot = typeof next === "function" ? next(snapshot) : next;
|
||||
localStorageAdapter.write(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS, snapshot);
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("storage", (event) => {
|
||||
if (event.key === STORAGE_KEY_SFTP_LOCAL_BOOKMARKS) {
|
||||
rehydrateLocalSftpBookmarks();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
interface UseLocalSftpBookmarksParams {
|
||||
currentPath: string | undefined;
|
||||
}
|
||||
|
||||
export const useLocalSftpBookmarks = ({
|
||||
currentPath,
|
||||
}: UseLocalSftpBookmarksParams) => {
|
||||
const bookmarks = useSyncExternalStore(
|
||||
subscribeLocalSftpBookmarks,
|
||||
getLocalSftpBookmarksSnapshot,
|
||||
getLocalSftpBookmarksSnapshot,
|
||||
);
|
||||
|
||||
const isCurrentPathBookmarked = useMemo(
|
||||
() => !!currentPath && bookmarks.some((b) => b.path === currentPath),
|
||||
[currentPath, bookmarks],
|
||||
);
|
||||
|
||||
const toggleBookmark = useCallback(() => {
|
||||
if (!currentPath) return;
|
||||
if (isCurrentPathBookmarked) {
|
||||
setLocalSftpBookmarks((prev) => prev.filter((b) => b.path !== currentPath));
|
||||
} else {
|
||||
setLocalSftpBookmarks((prev) => [...prev, createSftpBookmark(currentPath)]);
|
||||
}
|
||||
}, [currentPath, isCurrentPathBookmarked]);
|
||||
|
||||
const deleteBookmark = useCallback((id: string) => {
|
||||
setLocalSftpBookmarks((prev) => prev.filter((b) => b.id !== id));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
bookmarks,
|
||||
isCurrentPathBookmarked,
|
||||
toggleBookmark,
|
||||
deleteBookmark,
|
||||
};
|
||||
};
|
||||
@@ -2,13 +2,8 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY,
|
||||
DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY,
|
||||
DEFAULT_SFTP_SKIP_UNCHANGED,
|
||||
resolveSftpDirectoryListingConcurrency,
|
||||
resolveSftpSkipUnchangedEnabled,
|
||||
resolveSftpTransferConcurrency,
|
||||
runBoundedConcurrency,
|
||||
runSftpTransferWorkers,
|
||||
} from "./transferConcurrency";
|
||||
|
||||
@@ -17,20 +12,6 @@ test("defaults folder file transfers to two concurrent files", () => {
|
||||
assert.equal(DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY, 2);
|
||||
});
|
||||
|
||||
test("defaults directory listing fanout to four concurrent readdirs", () => {
|
||||
assert.equal(
|
||||
resolveSftpDirectoryListingConcurrency(() => null),
|
||||
DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY,
|
||||
);
|
||||
assert.equal(DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY, 4);
|
||||
});
|
||||
|
||||
test("defaults skip-unchanged to enabled", () => {
|
||||
assert.equal(resolveSftpSkipUnchangedEnabled(() => null), DEFAULT_SFTP_SKIP_UNCHANGED);
|
||||
assert.equal(DEFAULT_SFTP_SKIP_UNCHANGED, true);
|
||||
assert.equal(resolveSftpSkipUnchangedEnabled(() => false), false);
|
||||
});
|
||||
|
||||
test("keeps explicit folder transfer concurrency within the supported range", () => {
|
||||
assert.equal(resolveSftpTransferConcurrency(() => 1), 1);
|
||||
assert.equal(resolveSftpTransferConcurrency(() => 16), 16);
|
||||
@@ -38,19 +19,6 @@ test("keeps explicit folder transfer concurrency within the supported range", ()
|
||||
assert.equal(resolveSftpTransferConcurrency(() => 17), DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY);
|
||||
});
|
||||
|
||||
test("keeps directory listing concurrency within the supported range", () => {
|
||||
assert.equal(resolveSftpDirectoryListingConcurrency(() => 1), 1);
|
||||
assert.equal(resolveSftpDirectoryListingConcurrency(() => 8), 8);
|
||||
assert.equal(
|
||||
resolveSftpDirectoryListingConcurrency(() => 0),
|
||||
DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY,
|
||||
);
|
||||
assert.equal(
|
||||
resolveSftpDirectoryListingConcurrency(() => 9),
|
||||
DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY,
|
||||
);
|
||||
});
|
||||
|
||||
test("limits default multi-file transfer scheduling to two concurrent workers", async () => {
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
@@ -65,106 +33,6 @@ test("limits default multi-file transfer scheduling to two concurrent workers",
|
||||
assert.equal(maxActive, 2);
|
||||
});
|
||||
|
||||
test("runBoundedConcurrency respects an explicit limit", async () => {
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
await runBoundedConcurrency([1, 2, 3, 4, 5, 6], 3, async () => {
|
||||
active += 1;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
active -= 1;
|
||||
});
|
||||
assert.equal(maxActive, 3);
|
||||
});
|
||||
|
||||
test("runBoundedConcurrency drains siblings and stops new claims after an error", async () => {
|
||||
const started: number[] = [];
|
||||
const finished: number[] = [];
|
||||
let releaseSlow!: () => void;
|
||||
const slowGate = new Promise<void>((resolve) => {
|
||||
releaseSlow = resolve;
|
||||
});
|
||||
|
||||
const run = runBoundedConcurrency([0, 1, 2, 3, 4], 2, async (item) => {
|
||||
started.push(item);
|
||||
if (item === 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
throw new Error("boom");
|
||||
}
|
||||
if (item === 1) {
|
||||
await slowGate;
|
||||
}
|
||||
finished.push(item);
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
|
||||
let settledWhileSiblingRunning = false;
|
||||
await Promise.race([
|
||||
run.then(
|
||||
() => {
|
||||
settledWhileSiblingRunning = true;
|
||||
},
|
||||
() => {
|
||||
settledWhileSiblingRunning = true;
|
||||
},
|
||||
),
|
||||
new Promise((resolve) => setTimeout(resolve, 5)),
|
||||
]);
|
||||
assert.equal(
|
||||
settledWhileSiblingRunning,
|
||||
false,
|
||||
"must wait for in-flight siblings before propagating the error",
|
||||
);
|
||||
assert.ok(started.includes(1));
|
||||
assert.equal(finished.includes(1), false);
|
||||
|
||||
releaseSlow();
|
||||
await assert.rejects(run, /boom/);
|
||||
assert.ok(finished.includes(1), "in-flight sibling must finish before reject");
|
||||
assert.deepEqual(
|
||||
[...started].sort((a, b) => a - b),
|
||||
[0, 1],
|
||||
"must not claim additional queue items after the first error",
|
||||
);
|
||||
});
|
||||
|
||||
test("runSftpTransferWorkers drains siblings and stops new claims after an error", async () => {
|
||||
const started: number[] = [];
|
||||
const finished: number[] = [];
|
||||
let releaseSlow!: () => void;
|
||||
const slowGate = new Promise<void>((resolve) => {
|
||||
releaseSlow = resolve;
|
||||
});
|
||||
|
||||
const run = runSftpTransferWorkers([0, 1, 2, 3, 4], () => 2, async (item) => {
|
||||
started.push(item);
|
||||
if (item === 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
throw new Error("session lost");
|
||||
}
|
||||
if (item === 1) {
|
||||
await slowGate;
|
||||
}
|
||||
finished.push(item);
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
let settledEarly = false;
|
||||
await Promise.race([
|
||||
run.then(
|
||||
() => { settledEarly = true; },
|
||||
() => { settledEarly = true; },
|
||||
),
|
||||
new Promise((resolve) => setTimeout(resolve, 5)),
|
||||
]);
|
||||
assert.equal(settledEarly, false);
|
||||
releaseSlow();
|
||||
await assert.rejects(run, /session lost/);
|
||||
assert.ok(finished.includes(1));
|
||||
assert.deepEqual([...started].sort((a, b) => a - b), [0, 1]);
|
||||
});
|
||||
|
||||
test("beforeClaim runs before claiming the next queue index", async () => {
|
||||
const events: string[] = [];
|
||||
let paused = true;
|
||||
|
||||
@@ -2,20 +2,6 @@ export const DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY = 2;
|
||||
export const MIN_SFTP_FILE_TRANSFER_CONCURRENCY = 1;
|
||||
export const MAX_SFTP_FILE_TRANSFER_CONCURRENCY = 16;
|
||||
|
||||
/**
|
||||
* Bounded parallel directory listings while walking a folder tree.
|
||||
* SFTP has no recursive LIST; FileZilla/WinSCP still walk one dir at a time on
|
||||
* a single control channel. We pipeline several OPENDIR/READDIR requests so
|
||||
* wide trees discover total file counts much faster without a second full scan.
|
||||
* Keep this modest - listing shares the transfer SFTP session with file I/O.
|
||||
*/
|
||||
export const DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY = 4;
|
||||
export const MIN_SFTP_DIRECTORY_LISTING_CONCURRENCY = 1;
|
||||
export const MAX_SFTP_DIRECTORY_LISTING_CONCURRENCY = 8;
|
||||
|
||||
/** Default on: skip size+mtime matches like rsync's generator. */
|
||||
export const DEFAULT_SFTP_SKIP_UNCHANGED = true;
|
||||
|
||||
export function resolveSftpTransferConcurrency(readStoredValue: () => number | null | undefined): number {
|
||||
const stored = readStoredValue();
|
||||
return stored != null &&
|
||||
@@ -25,24 +11,6 @@ export function resolveSftpTransferConcurrency(readStoredValue: () => number | n
|
||||
: DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY;
|
||||
}
|
||||
|
||||
export function resolveSftpDirectoryListingConcurrency(
|
||||
readStoredValue?: () => number | null | undefined,
|
||||
): number {
|
||||
const stored = readStoredValue?.();
|
||||
return stored != null &&
|
||||
stored >= MIN_SFTP_DIRECTORY_LISTING_CONCURRENCY &&
|
||||
stored <= MAX_SFTP_DIRECTORY_LISTING_CONCURRENCY
|
||||
? stored
|
||||
: DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY;
|
||||
}
|
||||
|
||||
export function resolveSftpSkipUnchangedEnabled(
|
||||
readStoredValue: () => boolean | null | undefined,
|
||||
): boolean {
|
||||
const stored = readStoredValue();
|
||||
return stored == null ? DEFAULT_SFTP_SKIP_UNCHANGED : stored;
|
||||
}
|
||||
|
||||
export async function runSftpTransferWorkers<T>(
|
||||
items: T[],
|
||||
readStoredConcurrency: () => number | null | undefined,
|
||||
@@ -58,27 +26,17 @@ export async function runSftpTransferWorkers<T>(
|
||||
): Promise<void> {
|
||||
const concurrency = resolveSftpTransferConcurrency(readStoredConcurrency);
|
||||
let nextIndex = 0;
|
||||
let failed = false;
|
||||
let firstError: unknown;
|
||||
|
||||
const runNext = async () => {
|
||||
while (!failed && nextIndex < items.length) {
|
||||
try {
|
||||
// Wait BEFORE claiming so pause does not leave a claimed-but-not-started
|
||||
// index that arms as soon as soft-drain finishes the previous file.
|
||||
if (options?.beforeClaim) {
|
||||
await options.beforeClaim();
|
||||
}
|
||||
if (failed || nextIndex >= items.length) return;
|
||||
const index = nextIndex++;
|
||||
await worker(items[index], index);
|
||||
} catch (err) {
|
||||
if (!failed) {
|
||||
failed = true;
|
||||
firstError = err;
|
||||
}
|
||||
return;
|
||||
while (nextIndex < items.length) {
|
||||
// Wait BEFORE claiming so pause does not leave a claimed-but-not-started
|
||||
// index that arms as soon as soft-drain finishes the previous file.
|
||||
if (options?.beforeClaim) {
|
||||
await options.beforeClaim();
|
||||
}
|
||||
if (nextIndex >= items.length) return;
|
||||
const index = nextIndex++;
|
||||
await worker(items[index], index);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -86,47 +44,5 @@ export async function runSftpTransferWorkers<T>(
|
||||
{ length: Math.min(concurrency, items.length) },
|
||||
() => runNext(),
|
||||
);
|
||||
// Settle every started worker before propagating - Promise.all would reject
|
||||
// while siblings keep claiming files after the caller releases leases.
|
||||
await Promise.all(workers);
|
||||
if (failed) throw firstError;
|
||||
}
|
||||
|
||||
/** Run workers over a queue with an explicit concurrency (not settings-backed). */
|
||||
export async function runBoundedConcurrency<T>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
worker: (item: T, index: number) => Promise<void>,
|
||||
options?: {
|
||||
beforeClaim?: () => Promise<void>;
|
||||
},
|
||||
): Promise<void> {
|
||||
const limit = Math.max(1, Math.min(Math.floor(concurrency) || 1, items.length || 1));
|
||||
if (items.length === 0) return;
|
||||
let nextIndex = 0;
|
||||
let failed = false;
|
||||
let firstError: unknown;
|
||||
const runNext = async () => {
|
||||
while (!failed && nextIndex < items.length) {
|
||||
try {
|
||||
if (options?.beforeClaim) {
|
||||
await options.beforeClaim();
|
||||
}
|
||||
// Re-check after beforeClaim: a sibling may have failed while we waited.
|
||||
if (failed || nextIndex >= items.length) return;
|
||||
const index = nextIndex++;
|
||||
await worker(items[index], index);
|
||||
} catch (err) {
|
||||
if (!failed) {
|
||||
failed = true;
|
||||
firstError = err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
// Settle every started worker before propagating - Promise.all would reject
|
||||
// while siblings keep claiming directories / transferring after the caller cleans up.
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => runNext()));
|
||||
if (failed) throw firstError;
|
||||
}
|
||||
@@ -146,9 +146,7 @@ test("directory transfer discovers each directory once with bounded listing conc
|
||||
assert.equal(listCalls.size, directoryCount + 1);
|
||||
assert.ok(Array.from(listCalls.values()).every((count) => count === 1));
|
||||
assert.equal(listCalls.has("/source/loop"), false, "canonical symlink cycles must not be listed");
|
||||
// Interleaved walk processes sibling subdirectories sequentially so resume
|
||||
// manifests stay deterministic (no full-tree pre-scan fan-out).
|
||||
assert.equal(maxActiveListings, 1, `expected sequential listings, got ${maxActiveListings}`);
|
||||
assert.equal(maxActiveListings, 1);
|
||||
assert.equal(tasks.find((task) => task.id === root.id)?.totalBytes, directoryCount);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
|
||||
@@ -9,20 +9,11 @@ import {
|
||||
type SftpDirectoryTraversalBudget,
|
||||
shouldFollowSftpSymlinkDirectory,
|
||||
} from "../../../domain/sftpDirectoryCheckpoint";
|
||||
import { isUnchangedTransferCandidate } from "../../../domain/sftpTransferSkip";
|
||||
import {
|
||||
STORAGE_KEY_SFTP_SKIP_UNCHANGED,
|
||||
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
|
||||
} from "../../../infrastructure/config/storageKeys";
|
||||
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from "../../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import {
|
||||
DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY,
|
||||
resolveSftpDirectoryListingConcurrency,
|
||||
resolveSftpSkipUnchangedEnabled,
|
||||
runSftpTransferWorkers,
|
||||
} from "./transferConcurrency";
|
||||
import { runSftpTransferWorkers } from "./transferConcurrency";
|
||||
import { getSftpTransferResourceKeys, globalSftpTransferScheduler } from "./globalTransferScheduler";
|
||||
import { resolveDedicatedStreamEndpointIds } from "../../../domain/sftpDedicatedStreamPolicy";
|
||||
import { isSessionError } from "./errors";
|
||||
@@ -40,49 +31,6 @@ import {
|
||||
import { isTransferOrRootCancelled } from "./transferCancelLatch";
|
||||
import { joinPath, joinTransferTargetPath } from "./utils";
|
||||
|
||||
function createDirectoryListingGate(concurrency = DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY) {
|
||||
const limit = Math.max(1, Math.floor(concurrency) || 1);
|
||||
let active = 0;
|
||||
const waiters: Array<() => void> = [];
|
||||
const acquire = async () => {
|
||||
if (active < limit) {
|
||||
active += 1;
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
waiters.push(() => {
|
||||
active += 1;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
};
|
||||
const release = () => {
|
||||
active = Math.max(0, active - 1);
|
||||
const next = waiters.shift();
|
||||
if (next) next();
|
||||
};
|
||||
return {
|
||||
get active() {
|
||||
return active;
|
||||
},
|
||||
async run<T>(fn: () => Promise<T>): Promise<T> {
|
||||
await acquire();
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type DirectoryDiscoveryProgress = {
|
||||
discoveredFiles: number;
|
||||
nextEntryIndex: number;
|
||||
/** Tree-wide semaphore so nested parallel walks cannot multiply listings. */
|
||||
listingGate?: ReturnType<typeof createDirectoryListingGate>;
|
||||
};
|
||||
|
||||
function isCancelledLocalOrGlobal(
|
||||
cancelledTasksRef: { current: Set<string> },
|
||||
rootTaskId: string,
|
||||
@@ -97,40 +45,6 @@ function isCancelledLocalOrGlobal(
|
||||
return false;
|
||||
}
|
||||
|
||||
function readSkipUnchangedEnabled(): boolean {
|
||||
return resolveSftpSkipUnchangedEnabled(() => localStorageAdapter.readBoolean(STORAGE_KEY_SFTP_SKIP_UNCHANGED));
|
||||
}
|
||||
|
||||
async function tryStatTransferPath(
|
||||
filePath: string,
|
||||
isLocal: boolean,
|
||||
sftpId: string | null,
|
||||
encoding: SftpFilenameEncoding,
|
||||
): Promise<{ size: number; lastModified: number; type: string } | null> {
|
||||
try {
|
||||
if (isLocal) {
|
||||
const stat = await netcattyBridge.get()?.statLocal?.(filePath);
|
||||
if (!stat || stat.type === "directory") return null;
|
||||
return { size: Number(stat.size) || 0, lastModified: Number(stat.lastModified) || 0, type: stat.type };
|
||||
}
|
||||
if (!sftpId) return null;
|
||||
const stat = await netcattyBridge.get()?.statSftp?.(sftpId, filePath, encoding);
|
||||
if (!stat || stat.type === "directory") return null;
|
||||
return { size: Number(stat.size) || 0, lastModified: Number(stat.lastModified) || 0, type: stat.type };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function tryStatTransferTarget(
|
||||
targetPath: string,
|
||||
targetIsLocal: boolean,
|
||||
targetSftpId: string | null,
|
||||
targetEncoding: SftpFilenameEncoding,
|
||||
): Promise<{ size: number; lastModified: number; type: string } | null> {
|
||||
return tryStatTransferPath(targetPath, targetIsLocal, targetSftpId, targetEncoding);
|
||||
}
|
||||
|
||||
export type AcquireTransferSessionFn = (
|
||||
hostId: string,
|
||||
transferId: string,
|
||||
@@ -443,40 +357,13 @@ export function useSftpDirectoryTransferOps({
|
||||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||
}
|
||||
})();
|
||||
let result: { error?: string; cancelled?: boolean; superseded?: boolean } | undefined;
|
||||
let result: { error?: string; cancelled?: boolean } | undefined;
|
||||
try {
|
||||
result = await transferPromise;
|
||||
} finally {
|
||||
watchPaused = false;
|
||||
await pauseWatch.catch(() => {});
|
||||
}
|
||||
// Same-id retry stole ownership while OPEN was pending. The live
|
||||
// owner still drives progress/completion events; this invoke must
|
||||
// not mark the child completed or failed (Codex P2 on b17f64e9).
|
||||
if (result?.superseded === true) {
|
||||
// Wait for the live same-id owner only — no fixed deadline so
|
||||
// long transfers are not failed while still running (Codex P2).
|
||||
for (;;) {
|
||||
if (
|
||||
cancelledTasksRef.current.has(task.id)
|
||||
|| cancelledTasksRef.current.has(rootTaskId)
|
||||
) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
const latest = sftpTransferCenterStore.getTask(task.id)
|
||||
?? transfersRef.current.find((candidate) => candidate.id === task.id);
|
||||
const status = latest?.status;
|
||||
if (status === "completed") break;
|
||||
if (status === "failed") {
|
||||
throw new Error(latest?.error || "Transfer failed");
|
||||
}
|
||||
if (status === "cancelled") {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (result?.error || result?.cancelled) {
|
||||
throw new Error(result.error || "Transfer cancelled");
|
||||
}
|
||||
@@ -535,7 +422,7 @@ export function useSftpDirectoryTransferOps({
|
||||
sameHost?: boolean,
|
||||
symlinkDepth = 0,
|
||||
followSymlinks = false, // Only true for downloadToLocal — uploads/copies treat symlinks as files
|
||||
discoveryProgress?: DirectoryDiscoveryProgress,
|
||||
discoveryProgress?: { discoveredFiles: number; nextEntryIndex: number },
|
||||
traversalBudget?: SftpDirectoryTraversalBudget,
|
||||
) => {
|
||||
// Check if task or root task was cancelled before starting
|
||||
@@ -543,20 +430,8 @@ export function useSftpDirectoryTransferOps({
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
// Always interleave discovery with transfer (rsync --inc-recursive style).
|
||||
// Full-tree pre-scan was removed: non-compressed folder uploads start bytes
|
||||
// as soon as each directory is listed; compressed upload is a separate path.
|
||||
// UI should treat totalBytes as "discovered so far", not a fixed grand total.
|
||||
let totalErrors = 0;
|
||||
const progress = discoveryProgress ?? {
|
||||
discoveredFiles: 0,
|
||||
nextEntryIndex: 0,
|
||||
listingGate: createDirectoryListingGate(resolveSftpDirectoryListingConcurrency()),
|
||||
};
|
||||
if (!progress.listingGate) {
|
||||
progress.listingGate = createDirectoryListingGate(resolveSftpDirectoryListingConcurrency());
|
||||
}
|
||||
const listingGate = progress.listingGate;
|
||||
const progress = discoveryProgress ?? { discoveredFiles: 0, nextEntryIndex: 0 };
|
||||
const traversal = traversalBudget ?? createSftpDirectoryTraversalBudget();
|
||||
let claimedCanonicalPath: string | null = null;
|
||||
let regularFiles: SftpFileEntry[] = [];
|
||||
@@ -598,16 +473,13 @@ export function useSftpDirectoryTransferOps({
|
||||
}
|
||||
|
||||
let files: SftpFileEntry[];
|
||||
// Tree-wide listing gate: parallel siblings share one budget.
|
||||
files = await listingGate.run(async () => {
|
||||
if (sourceIsLocal) {
|
||||
return listLocalFiles(task.sourcePath);
|
||||
}
|
||||
if (sourceSftpId) {
|
||||
return listRemoteFiles(sourceSftpId, task.sourcePath, sourceEncoding);
|
||||
}
|
||||
if (sourceIsLocal) {
|
||||
files = await listLocalFiles(task.sourcePath);
|
||||
} else if (sourceSftpId) {
|
||||
files = await listRemoteFiles(sourceSftpId, task.sourcePath, sourceEncoding);
|
||||
} else {
|
||||
throw new Error("No source connection");
|
||||
});
|
||||
}
|
||||
|
||||
// Filter both "." and ".." — some SFTP servers include "." in readdir
|
||||
const filtered = files.filter((f) => f.name !== ".." && f.name !== ".");
|
||||
@@ -637,25 +509,26 @@ export function useSftpDirectoryTransferOps({
|
||||
regularFiles.sort((left, right) => left.name.localeCompare(right.name));
|
||||
|
||||
// Directory progress is discovered by the same traversal that performs
|
||||
// the transfer (single pass - no separate countDirectoryFiles walk).
|
||||
// Process subdirectories sequentially so directoryEntryIndex / manifest
|
||||
// order stay deterministic for resume, and sibling symlink aliases can
|
||||
// claim the same canonical path after the prior branch releases.
|
||||
// the transfer. This avoids a second full-tree list pass and lets the UI
|
||||
// grow the total incrementally without flooding the server at startup.
|
||||
progress.discoveredFiles += regularFiles.length;
|
||||
setTransfers((prev) => prev.map((candidate) => candidate.id === rootTaskId
|
||||
? {
|
||||
...candidate,
|
||||
totalBytes: Math.max(progress.discoveredFiles, candidate.transferredBytes),
|
||||
// Keep scanning only until the first file completes; later lists must
|
||||
// not flip an in-progress folder bar back to indeterminate thrash.
|
||||
...(candidate.transferredBytes <= 0 ? { phase: "scanning" as const } : null),
|
||||
}
|
||||
: candidate));
|
||||
|
||||
// Process subdirectories sequentially to avoid unbounded concurrent SFTP
|
||||
// requests from nested Promise.all + worker pools across the tree.
|
||||
// File-level concurrency within each directory is still governed by the
|
||||
// shared SFTP transfer worker scheduler below.
|
||||
for (const dir of dirs) {
|
||||
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
// Pause between subfolders — otherwise the walk enters the next tree while
|
||||
// the user thinks the whole folder transfer is paused.
|
||||
await waitWhileTransferPaused(rootTaskId);
|
||||
|
||||
const childTask: TransferTask = {
|
||||
@@ -671,7 +544,7 @@ export function useSftpDirectoryTransferOps({
|
||||
};
|
||||
|
||||
const isSymlink = dir.type === "symlink";
|
||||
totalErrors += await transferDirectory(
|
||||
const subdirErrors = await transferDirectory(
|
||||
childTask,
|
||||
sourceSftpId,
|
||||
targetSftpId,
|
||||
@@ -686,6 +559,7 @@ export function useSftpDirectoryTransferOps({
|
||||
progress,
|
||||
traversal,
|
||||
);
|
||||
totalErrors += subdirErrors;
|
||||
}
|
||||
} finally {
|
||||
// Release on success, cancellation, and traversal errors.
|
||||
@@ -696,12 +570,6 @@ export function useSftpDirectoryTransferOps({
|
||||
|
||||
// Transfer files in parallel with concurrency limit
|
||||
if (regularFiles.length > 0) {
|
||||
setTransfers((prev) => prev.map((candidate) => candidate.id === rootTaskId
|
||||
? {
|
||||
...candidate,
|
||||
phase: "transferring",
|
||||
}
|
||||
: candidate));
|
||||
const errors: Error[] = [];
|
||||
// If the SFTP session dies mid-directory, stop queueing more files
|
||||
// (remaining workers will still finish their current item).
|
||||
@@ -721,10 +589,8 @@ export function useSftpDirectoryTransferOps({
|
||||
const fileSize = getEntrySize(file);
|
||||
const sourcePath = joinPath(task.sourcePath, file.name);
|
||||
const targetPath = joinTransferTargetPath(task.targetPath, file.name);
|
||||
let transferSize = fileSize;
|
||||
let transferLastModified = file.lastModified;
|
||||
const directoryEntryIndex = directoryEntryBase + fileIndex;
|
||||
let directoryEntryIdentity = createDirectoryEntryIdentity({
|
||||
const directoryEntryIdentity = createDirectoryEntryIdentity({
|
||||
sourcePath,
|
||||
targetPath,
|
||||
size: fileSize,
|
||||
@@ -749,104 +615,6 @@ export function useSftpDirectoryTransferOps({
|
||||
if (isPauseLatched(rootTaskId)) {
|
||||
await waitWhileTransferPaused(rootTaskId);
|
||||
}
|
||||
|
||||
// Symlink listing attrs are the link node; transfer follows target bytes.
|
||||
// For regular files, re-stat so skip and transfer use current size/mtime
|
||||
// (Codex P1: listing attrs can go stale during a long interleaved walk).
|
||||
let freshSourceOk = false;
|
||||
if (file.type !== "symlink") {
|
||||
const freshSource = await tryStatTransferPath(
|
||||
sourcePath,
|
||||
sourceIsLocal,
|
||||
sourceSftpId,
|
||||
sourceEncoding,
|
||||
);
|
||||
if (freshSource && freshSource.type !== "directory") {
|
||||
transferSize = freshSource.size;
|
||||
transferLastModified = freshSource.lastModified;
|
||||
directoryEntryIdentity = createDirectoryEntryIdentity({
|
||||
sourcePath,
|
||||
targetPath,
|
||||
size: transferSize,
|
||||
lastModified: transferLastModified,
|
||||
});
|
||||
freshSourceOk = true;
|
||||
} else {
|
||||
// Do not pass stale listing size as an explicit snapshot: the
|
||||
// dedicated transfer session may still read a grown file (Codex P2).
|
||||
// Child totalBytes 0 is omitted at startStreamTransfer via
|
||||
// `|| undefined`, so the bridge re-stats instead of truncating.
|
||||
transferSize = 0;
|
||||
transferLastModified = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const skipUnchanged = readSkipUnchangedEnabled()
|
||||
&& !task.replaceExistingTarget
|
||||
&& !String(task.targetPath).includes(".netcatty-");
|
||||
// Missing fresh metadata must not fall back to listing attrs for skip.
|
||||
if (skipUnchanged && file.type !== "symlink" && freshSourceOk) {
|
||||
const existing = await tryStatTransferTarget(
|
||||
targetPath,
|
||||
targetIsLocal,
|
||||
targetSftpId,
|
||||
targetEncoding,
|
||||
);
|
||||
if (
|
||||
existing
|
||||
&& existing.type !== "directory"
|
||||
&& isUnchangedTransferCandidate(
|
||||
{ size: transferSize, lastModified: transferLastModified, mtimeUnit: "ms" },
|
||||
{ size: existing.size, lastModified: existing.lastModified, mtimeUnit: "ms" },
|
||||
)
|
||||
) {
|
||||
const skippedId = persistedChild?.id ?? crypto.randomUUID();
|
||||
setTransfers((prev) => {
|
||||
const hasChild = prev.some((row) => row.id === skippedId);
|
||||
const base = hasChild
|
||||
? prev.map((row) => row.id === skippedId
|
||||
? {
|
||||
...row,
|
||||
status: "completed" as TransferStatus,
|
||||
transferredBytes: transferSize,
|
||||
totalBytes: transferSize,
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
error: undefined,
|
||||
}
|
||||
: row)
|
||||
: [...prev, {
|
||||
...task,
|
||||
id: skippedId,
|
||||
fileName: file.name,
|
||||
originalFileName: file.name,
|
||||
sourcePath,
|
||||
targetPath,
|
||||
isDirectory: false,
|
||||
progressMode: "bytes" as const,
|
||||
parentTaskId: rootTaskId,
|
||||
totalBytes: transferSize,
|
||||
transferredBytes: transferSize,
|
||||
sourceLastModified: transferLastModified,
|
||||
directoryEntryIndex,
|
||||
directoryEntryIdentity,
|
||||
status: "completed" as TransferStatus,
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
endTime: Date.now(),
|
||||
}];
|
||||
return base.map((row) => {
|
||||
if (row.id !== rootTaskId) return row;
|
||||
if (row.status === "paused" || row.status === "pausing" || isPauseLatched(rootTaskId)) {
|
||||
return { ...row, speed: 0 };
|
||||
}
|
||||
return { ...row, transferredBytes: row.transferredBytes + 1 };
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const fileId = persistedChild?.id ?? crypto.randomUUID();
|
||||
|
||||
// Track child ID outside React state for immediate cancellation visibility
|
||||
@@ -866,11 +634,11 @@ export function useSftpDirectoryTransferOps({
|
||||
isDirectory: false,
|
||||
progressMode: "bytes",
|
||||
parentTaskId: rootTaskId,
|
||||
totalBytes: transferSize,
|
||||
sourceLastModified: transferLastModified || undefined,
|
||||
totalBytes: fileSize,
|
||||
sourceLastModified: file.lastModified,
|
||||
directoryEntryIndex,
|
||||
directoryEntryIdentity,
|
||||
// Inherit retryable from parent - downloadToLocal sets retryable: false
|
||||
// Inherit retryable from parent — downloadToLocal sets retryable: false
|
||||
// because "local" targetConnectionId can't be resolved by retryTransfer
|
||||
retryable: task.retryable,
|
||||
// New/restarted child streams arm at bridge lifecycleEpoch 0. Never
|
||||
@@ -978,7 +746,7 @@ export function useSftpDirectoryTransferOps({
|
||||
? {
|
||||
...t,
|
||||
status: "failed" as TransferStatus,
|
||||
error: "SFTP session lost - reconnect and resume remaining files",
|
||||
error: "SFTP session lost — reconnect and resume remaining files",
|
||||
speed: 0,
|
||||
endTime: Date.now(),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
DEFAULT_SFTP_TRANSFER_POOL_IDLE_TTL_MS,
|
||||
isTransferPoolIdleReclaimDisabled,
|
||||
resolveSftpTransferPoolIdleTtlMs,
|
||||
} from "../../../infrastructure/config/sftpTransferPool.ts";
|
||||
|
||||
test("default pool idle TTL is five minutes", () => {
|
||||
assert.equal(DEFAULT_SFTP_TRANSFER_POOL_IDLE_TTL_MS, 5 * 60_000);
|
||||
assert.equal(resolveSftpTransferPoolIdleTtlMs(() => null), DEFAULT_SFTP_TRANSFER_POOL_IDLE_TTL_MS);
|
||||
});
|
||||
|
||||
test("accepts preset values including never-reclaim zero", () => {
|
||||
assert.equal(resolveSftpTransferPoolIdleTtlMs(() => 60_000), 60_000);
|
||||
assert.equal(resolveSftpTransferPoolIdleTtlMs(() => 0), 0);
|
||||
assert.equal(resolveSftpTransferPoolIdleTtlMs(() => 123), DEFAULT_SFTP_TRANSFER_POOL_IDLE_TTL_MS);
|
||||
});
|
||||
|
||||
test("transfer pool idle reclaim helper is deprecated and always reclaim-enabled", () => {
|
||||
// Channels close when idle; SSH park is separate. The helper always returns false.
|
||||
assert.equal(isTransferPoolIdleReclaimDisabled(0), false);
|
||||
assert.equal(isTransferPoolIdleReclaimDisabled(300_000), false);
|
||||
});
|
||||
@@ -111,4 +111,9 @@ export interface SftpStateOptions {
|
||||
* This keeps side-panel tab switches on the already-authenticated SSH transport.
|
||||
*/
|
||||
resolveBrowseSourceSessionId?: (hostId: string, host?: Host) => string | undefined;
|
||||
/**
|
||||
* @deprecated Transfer channels no longer park independently. SSH keep-alive
|
||||
* is controlled by sshTransportIdleTtlMs in settings.
|
||||
*/
|
||||
transferPoolIdleTtlMs?: number;
|
||||
}
|
||||
@@ -59,96 +59,3 @@ test("upload task callbacks write through the transfer store without page callba
|
||||
assert.equal(patches[1].updates.transferredBytes, 100);
|
||||
assert.deepEqual(dismissed, []);
|
||||
});
|
||||
|
||||
test("progress promotes pending scanning folder rows into transferring", () => {
|
||||
const patches: Array<{ taskId: string; updates: Partial<TransferTask> }> = [];
|
||||
const liveTask: TransferTask = {
|
||||
id: "folder-1",
|
||||
fileName: "docs",
|
||||
sourcePath: "local",
|
||||
targetPath: "/remote/docs",
|
||||
sourceConnectionId: "external",
|
||||
targetConnectionId: "connection-1",
|
||||
direction: "upload",
|
||||
status: "pending",
|
||||
totalBytes: 0,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: 1,
|
||||
isDirectory: true,
|
||||
progressMode: "files",
|
||||
phase: "scanning",
|
||||
};
|
||||
const store = {
|
||||
upsertTasks: () => {},
|
||||
patchTask: (taskId: string, updates: Partial<TransferTask>) => patches.push({ taskId, updates }),
|
||||
dismiss: () => {},
|
||||
getTask: (taskId: string) => (taskId === "folder-1" ? liveTask : undefined),
|
||||
};
|
||||
const callbacks = createUploadTaskCallbacks({
|
||||
ownerId: "owner-1",
|
||||
connectionId: "connection-1",
|
||||
targetPath: "/remote",
|
||||
store,
|
||||
});
|
||||
|
||||
callbacks.onTaskProgress?.("folder-1", {
|
||||
transferred: 12,
|
||||
total: 400,
|
||||
speed: 0,
|
||||
percent: 3,
|
||||
phase: "transferring",
|
||||
});
|
||||
|
||||
assert.equal(patches[0].taskId, "folder-1");
|
||||
assert.equal(patches[0].updates.status, "transferring");
|
||||
assert.equal(patches[0].updates.transferredBytes, 12);
|
||||
assert.equal(patches[0].updates.totalBytes, 400);
|
||||
assert.equal(patches[0].updates.phase, "transferring");
|
||||
});
|
||||
|
||||
test("scanning callbacks expose live file counts in files progress mode", () => {
|
||||
const upserts: TransferTask[][] = [];
|
||||
const patches: Array<{ taskId: string; updates: Partial<TransferTask> }> = [];
|
||||
const store = {
|
||||
upsertTasks: (tasks: readonly TransferTask[]) => upserts.push([...tasks]),
|
||||
patchTask: (taskId: string, updates: Partial<TransferTask>) => patches.push({ taskId, updates }),
|
||||
dismiss: () => {},
|
||||
};
|
||||
const callbacks = createUploadTaskCallbacks({
|
||||
ownerId: "owner-1",
|
||||
connectionId: "connection-1",
|
||||
targetPath: "/remote",
|
||||
store,
|
||||
});
|
||||
|
||||
callbacks.onScanningStart?.("scan-1", { label: "docs" });
|
||||
callbacks.onScanningProgress?.("scan-1", {
|
||||
fileCount: 1284,
|
||||
directoryCount: 40,
|
||||
entryCount: 1324,
|
||||
label: "docs",
|
||||
});
|
||||
|
||||
assert.equal(upserts[0][0].fileName, "docs");
|
||||
assert.equal(upserts[0][0].phase, "scanning");
|
||||
assert.equal(upserts[0][0].progressMode, "files");
|
||||
assert.equal(upserts[0][0].status, "pending");
|
||||
assert.deepEqual(patches[0], {
|
||||
taskId: "scan-1",
|
||||
updates: {
|
||||
// Found files live in totalBytes; completed stays 0 during scan.
|
||||
transferredBytes: 0,
|
||||
totalBytes: 1284,
|
||||
progressMode: "files",
|
||||
phase: "scanning",
|
||||
fileName: "docs",
|
||||
},
|
||||
});
|
||||
|
||||
// Display contract: 0 done · N found (not N done · N found).
|
||||
const discovered = Math.max(patches[0].updates.totalBytes ?? 0, patches[0].updates.transferredBytes ?? 0);
|
||||
const completed = patches[0].updates.transferredBytes ?? 0;
|
||||
assert.equal(completed, 0);
|
||||
assert.equal(discovered, 1284);
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { joinPath } from "./utils";
|
||||
type UploadTransferStore = Pick<
|
||||
typeof sftpTransferCenterStore,
|
||||
"upsertTasks" | "patchTask" | "dismiss"
|
||||
> & Partial<Pick<typeof sftpTransferCenterStore, "getTask">>;
|
||||
>;
|
||||
|
||||
interface UploadTaskCallbacksParams {
|
||||
ownerId: string;
|
||||
@@ -27,11 +27,11 @@ export const createUploadTaskCallbacks = ({
|
||||
targetConnectionKey,
|
||||
store = sftpTransferCenterStore,
|
||||
}: UploadTaskCallbacksParams): UploadCallbacks => ({
|
||||
onScanningStart: (taskId: string, info?: { label?: string }) => {
|
||||
onScanningStart: (taskId: string) => {
|
||||
store.upsertTasks([{
|
||||
id: taskId,
|
||||
ownerId,
|
||||
fileName: info?.label?.trim() || "Scanning files...",
|
||||
fileName: "Scanning files...",
|
||||
sourcePath: "local",
|
||||
targetPath,
|
||||
sourceConnectionId: "external",
|
||||
@@ -42,30 +42,18 @@ export const createUploadTaskCallbacks = ({
|
||||
targetConnectionKey,
|
||||
direction: "upload",
|
||||
status: "pending" as TransferStatus,
|
||||
// Progressive discovery: totalBytes = found so far, completed stays 0
|
||||
// until real transfers start (UI: "0 done · N found").
|
||||
totalBytes: 0,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
isDirectory: true,
|
||||
progressMode: "files",
|
||||
progressMode: "bytes",
|
||||
origin: "drag-drop",
|
||||
background: false,
|
||||
resumable: true,
|
||||
phase: "scanning",
|
||||
}]);
|
||||
},
|
||||
onScanningProgress: (taskId: string, progress) => {
|
||||
store.patchTask(taskId, {
|
||||
// Found count is the growing total; nothing is completed during scan.
|
||||
totalBytes: Math.max(0, progress.fileCount),
|
||||
transferredBytes: 0,
|
||||
progressMode: "files",
|
||||
phase: "scanning",
|
||||
...(progress.label?.trim() ? { fileName: progress.label.trim() } : null),
|
||||
});
|
||||
},
|
||||
onScanningEnd: (taskId: string) => {
|
||||
store.dismiss(taskId);
|
||||
},
|
||||
@@ -102,36 +90,8 @@ export const createUploadTaskCallbacks = ({
|
||||
const durableCheckpoint = Number.isFinite(Number(progress.checkpointBytes))
|
||||
? Math.max(0, Math.trunc(Number(progress.checkpointBytes)))
|
||||
: progress.transferred;
|
||||
// Progressive folder walks keep the scanning row as pending until real
|
||||
// progress arrives. Promote pending/queued → transferring so the panel
|
||||
// leaves "Waiting..." and matches the transfer-center live state.
|
||||
const current = "getTask" in store && typeof store.getTask === "function"
|
||||
? store.getTask(taskId)
|
||||
: undefined;
|
||||
const shouldPromote =
|
||||
!!current
|
||||
&& (current.status === "pending" || current.status === "queued")
|
||||
&& current.reconnectRequired !== true
|
||||
&& (
|
||||
progress.phase === "scanning"
|
||||
|| progress.phase === "transferring"
|
||||
|| progress.transferred > 0
|
||||
|| progress.total > 0
|
||||
);
|
||||
// Only patch fingerprint/checkpoint while paused — do not keep animating
|
||||
// high-water transferred after the user hit Pause.
|
||||
const isPausedLike = current?.status === "paused" || current?.status === "pausing";
|
||||
if (isPausedLike) {
|
||||
store.patchTask(taskId, {
|
||||
checkpointBytes: durableCheckpoint,
|
||||
resumable: progress.resumable,
|
||||
pauseUnavailableReason: progress.pauseUnavailableReason,
|
||||
...("sourceFingerprint" in progress && progress.sourceFingerprint
|
||||
? { sourceFingerprint: progress.sourceFingerprint as string }
|
||||
: null),
|
||||
});
|
||||
return;
|
||||
}
|
||||
store.patchTask(taskId, {
|
||||
transferredBytes: progress.transferred,
|
||||
totalBytes: progress.total,
|
||||
@@ -141,7 +101,6 @@ export const createUploadTaskCallbacks = ({
|
||||
phase: progress.phase,
|
||||
resumable: progress.resumable,
|
||||
pauseUnavailableReason: progress.pauseUnavailableReason,
|
||||
...(shouldPromote ? { status: "transferring" as TransferStatus } : null),
|
||||
// Durable pause identity may arrive on a forced progress event while
|
||||
// status is already paused — keep it for restart/resume safety.
|
||||
...("sourceFingerprint" in progress && progress.sourceFingerprint
|
||||
@@ -162,7 +121,6 @@ export const createUploadTaskCallbacks = ({
|
||||
endTime: Date.now(),
|
||||
transferredBytes: totalBytes,
|
||||
speed: 0,
|
||||
phase: undefined,
|
||||
});
|
||||
},
|
||||
onTaskFailed: (taskId: string, error: string) => {
|
||||
@@ -171,7 +129,6 @@ export const createUploadTaskCallbacks = ({
|
||||
endTime: Date.now(),
|
||||
error,
|
||||
speed: 0,
|
||||
phase: undefined,
|
||||
});
|
||||
},
|
||||
onTaskCancelled: (taskId: string) => {
|
||||
@@ -179,7 +136,6 @@ export const createUploadTaskCallbacks = ({
|
||||
status: "cancelled" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
phase: undefined,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import type { Host, Identity, KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
|
||||
import { createKnownHostFromHostKeyInfo } from "../../../domain/knownHosts";
|
||||
import type { SftpHostKeyInfo, SftpHostKeyVerificationState, SftpPane } from "./types";
|
||||
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
|
||||
import { useSftpHostCredentials } from "./useSftpHostCredentials";
|
||||
@@ -207,7 +206,15 @@ const createKnownHostFromSftpHostKeyInfo = (
|
||||
hostKeyInfo: SftpHostKeyInfo,
|
||||
now = Date.now(),
|
||||
idSuffix = Math.random().toString(36).slice(2, 11),
|
||||
): KnownHost => createKnownHostFromHostKeyInfo(hostKeyInfo, { now, idSuffix });
|
||||
): KnownHost => ({
|
||||
id: hostKeyInfo.knownHostId || `kh-${now}-${idSuffix}`,
|
||||
hostname: hostKeyInfo.hostname,
|
||||
port: hostKeyInfo.port || 22,
|
||||
keyType: hostKeyInfo.keyType,
|
||||
publicKey: hostKeyInfo.publicKey || `SHA256:${hostKeyInfo.fingerprint}`,
|
||||
fingerprint: hostKeyInfo.fingerprint,
|
||||
discoveredAt: now,
|
||||
});
|
||||
|
||||
export const useSftpConnections = ({
|
||||
hosts,
|
||||
|
||||
@@ -15,63 +15,11 @@ import {
|
||||
UploadResult,
|
||||
startUploadScanningTask,
|
||||
} from "../../../lib/uploadService";
|
||||
import { uploadLocalFoldersProgressively } from "../../../lib/progressiveFolderUpload";
|
||||
import {
|
||||
captureDropPayload,
|
||||
formatDropScanLabel,
|
||||
isDropScanCancelledError,
|
||||
localTreeToDropEntries,
|
||||
materializeDropEntries,
|
||||
type DropEntry,
|
||||
type LocalTreeListEntry,
|
||||
} from "../../../lib/sftpFileUtils";
|
||||
|
||||
function isUploadScanCancelled(controller: UploadController, error?: unknown): boolean {
|
||||
return controller.isCancelled() || isDropScanCancelledError(error);
|
||||
}
|
||||
import { extractDropEntries, type DropEntry } from "../../../lib/sftpFileUtils";
|
||||
|
||||
// Re-export UploadResult for external usage
|
||||
export type { UploadResult };
|
||||
|
||||
type LocalTreeScanOptions = {
|
||||
onProgress?: (progress: { fileCount: number; directoryCount: number; entryCount: number }) => void;
|
||||
onEntries?: (entries: LocalTreeListEntry[]) => void;
|
||||
abortSignal?: AbortSignal;
|
||||
};
|
||||
|
||||
function createDropScanCancelledError(): Error {
|
||||
const error = new Error("Drop scan cancelled");
|
||||
(error as Error & { code?: string }).code = "ERR_DROP_SCAN_CANCELLED";
|
||||
return error;
|
||||
}
|
||||
|
||||
async function listLocalTreeWithAbort(
|
||||
bridge: NetcattyBridge,
|
||||
localPath: string,
|
||||
options: LocalTreeScanOptions = {},
|
||||
): Promise<LocalTreeListEntry[]> {
|
||||
const scanId = `drop-scan-${crypto.randomUUID()}`;
|
||||
const abortSignal = options.abortSignal;
|
||||
const requestCancel = () => {
|
||||
void bridge.cancelLocalTreeScan?.(scanId);
|
||||
};
|
||||
if (abortSignal?.aborted) {
|
||||
throw createDropScanCancelledError();
|
||||
}
|
||||
abortSignal?.addEventListener("abort", requestCancel, { once: true });
|
||||
const { abortSignal: _abortSignal, ...bridgeOptions } = options;
|
||||
try {
|
||||
return await bridge.listLocalTree!(localPath, { ...bridgeOptions, scanId });
|
||||
} catch (error) {
|
||||
if (abortSignal?.aborted || isDropScanCancelledError(error)) {
|
||||
throw createDropScanCancelledError();
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
abortSignal?.removeEventListener("abort", requestCancel);
|
||||
}
|
||||
}
|
||||
|
||||
import type { UseSftpExternalOperationsParams, SftpExternalOperationsResult } from "./useSftpExternalOperations.types";
|
||||
import { getSftpTransferResourceKeys, globalSftpTransferScheduler } from "./globalTransferScheduler";
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
@@ -100,10 +48,6 @@ import {
|
||||
registerExternalUploadController,
|
||||
unregisterExternalUploadController,
|
||||
} from "./externalUploadRuntime";
|
||||
import {
|
||||
isTransferPauseLatched,
|
||||
waitWhileTransferOrRootPaused,
|
||||
} from "./transferPauseLatch";
|
||||
|
||||
type UploadConflictResolver = {
|
||||
resolve: (action: FileConflictAction) => void;
|
||||
@@ -207,12 +151,9 @@ export const useSftpExternalOperations = (
|
||||
callbacks: UploadCallbacks,
|
||||
): UploadCallbacks => ({
|
||||
...callbacks,
|
||||
onScanningStart: (taskId, info) => {
|
||||
onScanningStart: (taskId) => {
|
||||
registerUploadController(taskId, controller);
|
||||
callbacks.onScanningStart?.(taskId, info);
|
||||
},
|
||||
onScanningProgress: (taskId, progress) => {
|
||||
callbacks.onScanningProgress?.(taskId, progress);
|
||||
callbacks.onScanningStart?.(taskId);
|
||||
},
|
||||
onTaskCreated: (task) => {
|
||||
registerUploadController(task.id, controller);
|
||||
@@ -823,12 +764,9 @@ export const useSftpExternalOperations = (
|
||||
conflictDefaults.set(conflictType, action);
|
||||
},
|
||||
});
|
||||
controller.addCancelListener(() => {
|
||||
cancelPendingUploadConflicts(controller);
|
||||
});
|
||||
});
|
||||
};
|
||||
}, [cancelPendingUploadConflicts]);
|
||||
}, []);
|
||||
|
||||
// Create upload bridge that wraps netcattyBridge.
|
||||
// Pass connect-time Host so pooled stream uploads open the pinned endpoint
|
||||
@@ -950,271 +888,52 @@ export const useSftpExternalOperations = (
|
||||
|
||||
const uploadExternalFiles = useCallback(
|
||||
async (side: "left" | "right", dataTransfer: DataTransfer, targetPath?: string): Promise<UploadResult[]> => {
|
||||
// DataTransfer is only valid during the drop event. Capture roots BEFORE
|
||||
// DataTransfer is only valid during the drop event. Capture entries BEFORE
|
||||
// any await (session reconnect can take seconds while a transfer is busy).
|
||||
// Native tree expansion (listLocalTree) happens after the scanning UI is up.
|
||||
const dropPayload = captureDropPayload(dataTransfer);
|
||||
if (dropPayload.roots.length === 0 && dropPayload.filesFallback.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) {
|
||||
throw new Error("No active connection");
|
||||
}
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge) {
|
||||
throw new Error("Bridge not available");
|
||||
}
|
||||
|
||||
const uploadTargetPath = targetPath || pane.connection.currentPath;
|
||||
const controller = new UploadController();
|
||||
const callbacks = bindUploadControllerCallbacks(
|
||||
controller,
|
||||
createUploadCallbacks(
|
||||
pane.connection.id,
|
||||
uploadTargetPath,
|
||||
pane.connection.isLocal ? undefined : pane.connection.hostId,
|
||||
pane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(pane.connection.id),
|
||||
pane.connection.isLocal ? undefined : pane.connection.hostLabel,
|
||||
),
|
||||
);
|
||||
|
||||
const pathBackedFolderRoots = dropPayload.roots
|
||||
.filter((root) => root.isDirectory && !!root.localPath)
|
||||
.map((root) => ({ name: root.name, localPath: root.localPath! }));
|
||||
// Progressive only when *every* root is a path-backed folder. Mixed drops
|
||||
// (folder + files, path-less dirs) must fall through to materialize so
|
||||
// sibling items are not silently dropped.
|
||||
const canProgressiveUpload = pathBackedFolderRoots.length > 0
|
||||
&& pathBackedFolderRoots.length === dropPayload.roots.length
|
||||
&& !!bridge.listLocalTree
|
||||
&& !useCompressedUpload;
|
||||
const needsDeepScan = dropPayload.roots.some((root) => root.isDirectory);
|
||||
const scanLabel = formatDropScanLabel(dropPayload.roots);
|
||||
// Instant feedback: scanning / parent row appears before work begins.
|
||||
const scanningTask = needsDeepScan
|
||||
? startUploadScanningTask(callbacks, crypto.randomUUID(), { label: scanLabel })
|
||||
: null;
|
||||
if (scanningTask && typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("netcatty:open-sftp-transfer-center"));
|
||||
}
|
||||
|
||||
const scanAbort = new AbortController();
|
||||
const detachScanCancel = controller.addCancelListener(() => {
|
||||
scanAbort.abort();
|
||||
if (scanningTask?.isOpen()) scanningTask.cancel();
|
||||
});
|
||||
|
||||
// Path-backed folders (no compressed upload): edge-scan + edge-transfer.
|
||||
// Open the SFTP session first, then stream discovery batches into upload
|
||||
// workers so the first files start while the rest of the tree is still walking.
|
||||
if (canProgressiveUpload) {
|
||||
const runProgressive = async (forceReconnect = false): Promise<UploadResult[]> => {
|
||||
const { sftpId, release } = await resolveRemoteSftpId(side, { forceReconnect });
|
||||
const livePane = getActivePane(side) ?? pane;
|
||||
if (!livePane.connection) {
|
||||
release();
|
||||
throw new Error("No active connection");
|
||||
}
|
||||
const uploadPaneId = livePane.id;
|
||||
const liveTargetPath = targetPath || livePane.connection.currentPath;
|
||||
const connectHost = resolveUploadConnectHost(uploadPaneId, livePane.connection.isLocal);
|
||||
const uploadBridge = createUploadBridge(connectHost);
|
||||
const liveCallbacks = livePane.connection.id === pane.connection.id
|
||||
&& liveTargetPath === uploadTargetPath
|
||||
? callbacks
|
||||
: bindUploadControllerCallbacks(
|
||||
controller,
|
||||
createUploadCallbacks(
|
||||
livePane.connection.id,
|
||||
liveTargetPath,
|
||||
livePane.connection.isLocal ? undefined : livePane.connection.hostId,
|
||||
livePane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(livePane.connection.id),
|
||||
livePane.connection.isLocal ? undefined : livePane.connection.hostLabel,
|
||||
),
|
||||
);
|
||||
|
||||
const parentTaskIds = new Map<string, string>();
|
||||
if (scanningTask && pathBackedFolderRoots.length === 1) {
|
||||
parentTaskIds.set(pathBackedFolderRoots[0].name, scanningTask.taskId);
|
||||
// Keep the scanning row as the folder parent (do not dismiss it).
|
||||
scanningTask.complete = () => {
|
||||
/* no-op: progressive path settles via onTaskCompleted/Cancelled */
|
||||
};
|
||||
} else if (scanningTask?.isOpen()) {
|
||||
scanningTask.complete();
|
||||
}
|
||||
|
||||
try {
|
||||
if (controller.isCancelled()) {
|
||||
scanningTask?.cancel();
|
||||
return [{ fileName: "", success: false, cancelled: true }];
|
||||
}
|
||||
const results = await uploadLocalFoldersProgressively(
|
||||
pathBackedFolderRoots,
|
||||
{
|
||||
targetPath: liveTargetPath,
|
||||
sftpId,
|
||||
targetHostId: livePane.connection.isLocal ? undefined : livePane.connection.hostId,
|
||||
isLocal: livePane.connection.isLocal,
|
||||
bridge: uploadBridge,
|
||||
joinPath,
|
||||
callbacks: liveCallbacks,
|
||||
parentTaskIds,
|
||||
abortSignal: scanAbort.signal,
|
||||
// Soft-pause must freeze discovery enqueue + child UI rows, not
|
||||
// only the open streams (otherwise Pause still floods the queue).
|
||||
waitWhilePaused: (parentTaskId) => waitWhileTransferOrRootPaused(parentTaskId),
|
||||
isPaused: (parentTaskId) => isTransferPauseLatched(parentTaskId),
|
||||
resolveConflict: createUploadConflictResolver(controller),
|
||||
listLocalTree: (localPath, treeOptions) => listLocalTreeWithAbort(bridge, localPath, {
|
||||
...treeOptions,
|
||||
abortSignal: scanAbort.signal,
|
||||
}),
|
||||
},
|
||||
controller,
|
||||
);
|
||||
if (clearDirCacheEntry && targetPath) {
|
||||
clearDirCacheEntry(livePane.connection.id, liveTargetPath);
|
||||
}
|
||||
if (liveTargetPath === livePane.connection.currentPath) {
|
||||
await refresh(side, { tabId: uploadPaneId });
|
||||
}
|
||||
return results;
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
return await runProgressive(false);
|
||||
} catch (error) {
|
||||
if (isSessionError(error) && ensureRemoteSftpId) {
|
||||
logger.warn("[SFTP] Progressive upload session lost; reconnecting once", error);
|
||||
try {
|
||||
return await runProgressive(true);
|
||||
} catch (retryError) {
|
||||
if (isUploadScanCancelled(controller, retryError)) {
|
||||
scanningTask?.cancel();
|
||||
return [{ fileName: "", success: false, cancelled: true }];
|
||||
}
|
||||
if (scanningTask?.isOpen()) scanningTask.fail(retryError);
|
||||
throw retryError;
|
||||
}
|
||||
}
|
||||
if (isUploadScanCancelled(controller, error)) {
|
||||
scanningTask?.cancel();
|
||||
return [{ fileName: "", success: false, cancelled: true }];
|
||||
}
|
||||
if (scanningTask?.isOpen()) scanningTask.fail(error);
|
||||
logger.error("[SFTP] Progressive folder upload failed:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
detachScanCancel();
|
||||
unregisterUploadController(controller);
|
||||
}
|
||||
}
|
||||
|
||||
let capturedEntries: DropEntry[] = [];
|
||||
// Otherwise extractDropEntries returns [] and the UI toasts "Uploaded files: 0".
|
||||
let capturedEntries: DropEntry[];
|
||||
try {
|
||||
const scanT0 = performance.now();
|
||||
capturedEntries = await materializeDropEntries(dropPayload, {
|
||||
abortSignal: scanAbort.signal,
|
||||
isCancelled: () => controller.isCancelled(),
|
||||
listLocalTree: bridge.listLocalTree
|
||||
? (localPath, treeOptions) => listLocalTreeWithAbort(bridge, localPath, {
|
||||
...treeOptions,
|
||||
abortSignal: scanAbort.signal,
|
||||
})
|
||||
: undefined,
|
||||
onProgress: scanningTask
|
||||
? (progress) => {
|
||||
callbacks.onScanningProgress?.(scanningTask.taskId, {
|
||||
...progress,
|
||||
label: progress.label || scanLabel,
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
logger.debug(
|
||||
`[SFTP:perf] materializeDropEntries — ${capturedEntries.length} entries — ${(performance.now() - scanT0).toFixed(0)}ms`,
|
||||
);
|
||||
capturedEntries = await extractDropEntries(dataTransfer);
|
||||
} catch (error) {
|
||||
if (scanningTask?.isOpen()) {
|
||||
if (isUploadScanCancelled(controller, error)) {
|
||||
scanningTask.cancel();
|
||||
} else {
|
||||
scanningTask.fail(error);
|
||||
}
|
||||
}
|
||||
detachScanCancel();
|
||||
unregisterUploadController(controller);
|
||||
if (isUploadScanCancelled(controller, error)) {
|
||||
return [{ fileName: "", success: false, cancelled: true }];
|
||||
}
|
||||
logger.error("[SFTP] Failed to read dropped files:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (controller.isCancelled()) {
|
||||
scanningTask?.cancel();
|
||||
detachScanCancel();
|
||||
unregisterUploadController(controller);
|
||||
return [{ fileName: "", success: false, cancelled: true }];
|
||||
}
|
||||
if (capturedEntries.length === 0) {
|
||||
scanningTask?.complete();
|
||||
detachScanCancel();
|
||||
unregisterUploadController(controller);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Keep scanning visible through session open so the UI never goes blank
|
||||
// between "N files found" and the real transfer rows.
|
||||
const run = async (forceReconnect = false): Promise<UploadResult[]> => {
|
||||
const { sftpId, release } = await resolveRemoteSftpId(side, { forceReconnect });
|
||||
const livePane = getActivePane(side) ?? pane;
|
||||
if (!livePane.connection) {
|
||||
release();
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) {
|
||||
throw new Error("No active connection");
|
||||
}
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge) {
|
||||
throw new Error("Bridge not available");
|
||||
}
|
||||
|
||||
const { sftpId, release } = await resolveRemoteSftpId(side, { forceReconnect });
|
||||
const livePane = getActivePane(side) ?? pane;
|
||||
if (!livePane.connection) throw new Error("No active connection");
|
||||
|
||||
const uploadPaneId = livePane.id;
|
||||
const liveTargetPath = targetPath || livePane.connection.currentPath;
|
||||
const uploadTargetPath = targetPath || livePane.connection.currentPath;
|
||||
const controller = new UploadController();
|
||||
const callbacks = bindUploadControllerCallbacks(
|
||||
controller,
|
||||
createUploadCallbacks(
|
||||
livePane.connection.id,
|
||||
uploadTargetPath,
|
||||
livePane.connection.isLocal ? undefined : livePane.connection.hostId,
|
||||
livePane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(livePane.connection.id),
|
||||
livePane.connection.isLocal ? undefined : livePane.connection.hostLabel,
|
||||
),
|
||||
);
|
||||
const connectHost = resolveUploadConnectHost(uploadPaneId, livePane.connection.isLocal);
|
||||
const uploadBridge = createUploadBridge(connectHost);
|
||||
|
||||
// Retarget callbacks if the pane path/id changed after reconnect.
|
||||
const liveCallbacks = livePane.connection.id === pane.connection.id
|
||||
&& liveTargetPath === uploadTargetPath
|
||||
? callbacks
|
||||
: bindUploadControllerCallbacks(
|
||||
controller,
|
||||
createUploadCallbacks(
|
||||
livePane.connection.id,
|
||||
liveTargetPath,
|
||||
livePane.connection.isLocal ? undefined : livePane.connection.hostId,
|
||||
livePane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(livePane.connection.id),
|
||||
livePane.connection.isLocal ? undefined : livePane.connection.hostLabel,
|
||||
),
|
||||
);
|
||||
const transferCallbacks = scanningTask
|
||||
? {
|
||||
...liveCallbacks,
|
||||
onTaskCreated: (task: Parameters<NonNullable<UploadCallbacks["onTaskCreated"]>>[0]) => {
|
||||
scanningTask.complete();
|
||||
liveCallbacks.onTaskCreated?.(task);
|
||||
},
|
||||
}
|
||||
: liveCallbacks;
|
||||
|
||||
let transferSucceeded = false;
|
||||
try {
|
||||
if (controller.isCancelled()) {
|
||||
scanningTask?.cancel();
|
||||
return [{ fileName: "", success: false, cancelled: true }];
|
||||
}
|
||||
const hasDirectory = capturedEntries.some((entry) => (
|
||||
entry.isDirectory || entry.relativePath.replace(/\\/g, "/").includes("/")
|
||||
));
|
||||
@@ -1232,13 +951,13 @@ export const useSftpExternalOperations = (
|
||||
run: async (uploadSftpId) => uploadEntriesDirect(
|
||||
capturedEntries,
|
||||
{
|
||||
targetPath: liveTargetPath,
|
||||
targetPath: uploadTargetPath,
|
||||
sftpId: uploadSftpId,
|
||||
targetHostId: livePane.connection!.isLocal ? undefined : livePane.connection!.hostId,
|
||||
isLocal: livePane.connection!.isLocal,
|
||||
bridge: uploadBridge,
|
||||
joinPath,
|
||||
callbacks: transferCallbacks,
|
||||
callbacks,
|
||||
useCompressedUpload,
|
||||
resolveConflict: createUploadConflictResolver(controller),
|
||||
},
|
||||
@@ -1247,19 +966,15 @@ export const useSftpExternalOperations = (
|
||||
});
|
||||
|
||||
if (clearDirCacheEntry && targetPath) {
|
||||
clearDirCacheEntry(livePane.connection.id, liveTargetPath);
|
||||
clearDirCacheEntry(livePane.connection.id, uploadTargetPath);
|
||||
}
|
||||
if (liveTargetPath === livePane.connection.currentPath) {
|
||||
if (uploadTargetPath === livePane.connection.currentPath) {
|
||||
await refresh(side, { tabId: uploadPaneId });
|
||||
}
|
||||
transferSucceeded = true;
|
||||
return results;
|
||||
} finally {
|
||||
if (scanningTask?.isOpen()) {
|
||||
if (controller.isCancelled()) scanningTask.cancel();
|
||||
else if (transferSucceeded) scanningTask.complete();
|
||||
}
|
||||
release();
|
||||
unregisterUploadController(controller);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1268,26 +983,10 @@ export const useSftpExternalOperations = (
|
||||
} catch (error) {
|
||||
if (isSessionError(error) && ensureRemoteSftpId) {
|
||||
logger.warn("[SFTP] Upload session lost; reconnecting and retrying once", error);
|
||||
try {
|
||||
return await run(true);
|
||||
} catch (retryError) {
|
||||
if (isUploadScanCancelled(controller, retryError)) {
|
||||
scanningTask?.cancel();
|
||||
} else if (scanningTask?.isOpen()) {
|
||||
scanningTask.fail(retryError);
|
||||
}
|
||||
throw retryError;
|
||||
}
|
||||
}
|
||||
if (scanningTask?.isOpen()) {
|
||||
if (controller.isCancelled()) scanningTask.cancel();
|
||||
else scanningTask.fail(error);
|
||||
return await run(true);
|
||||
}
|
||||
logger.error("[SFTP] Upload failed:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
detachScanCancel();
|
||||
unregisterUploadController(controller);
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -1447,125 +1146,105 @@ export const useSftpExternalOperations = (
|
||||
connectionCacheKeyMapRef.current,
|
||||
);
|
||||
|
||||
const initialUploadTargetPath = targetPath || originatingPane.connection.currentPath;
|
||||
const controller = new UploadController();
|
||||
const callbacks = bindUploadControllerCallbacks(
|
||||
controller,
|
||||
createUploadCallbacks(
|
||||
originatingPane.connection.id,
|
||||
initialUploadTargetPath,
|
||||
originatingPane.connection.isLocal ? undefined : originatingPane.connection.hostId,
|
||||
originatingPane.connection.isLocal
|
||||
? undefined
|
||||
: connectionCacheKeyMapRef.current.get(originatingPane.connection.id),
|
||||
originatingPane.connection.isLocal ? undefined : originatingPane.connection.hostLabel,
|
||||
),
|
||||
);
|
||||
const folderName = folderPath.replace(/\\/g, "/").split("/").filter(Boolean).pop()
|
||||
|| folderPath;
|
||||
const scanningTask = startUploadScanningTask(callbacks, crypto.randomUUID(), {
|
||||
label: folderName,
|
||||
});
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("netcatty:open-sftp-transfer-center"));
|
||||
}
|
||||
const scanAbort = new AbortController();
|
||||
const detachScanCancel = controller.addCancelListener(() => {
|
||||
scanAbort.abort();
|
||||
if (scanningTask.isOpen()) scanningTask.cancel();
|
||||
});
|
||||
let capturedEntries: DropEntry[] | null = null;
|
||||
|
||||
const run = async (forceReconnect = false): Promise<UploadResult[]> => {
|
||||
if (controller.isCancelled()) {
|
||||
scanningTask.cancel();
|
||||
return [{ fileName: "", success: false, cancelled: true }];
|
||||
}
|
||||
let release = () => {};
|
||||
const pane = resolveUploadTargetPane({
|
||||
side,
|
||||
tabId: originatingTabId,
|
||||
getActivePane,
|
||||
getPaneByTabId,
|
||||
getPaneByConnectionId,
|
||||
});
|
||||
assertUploadEndpointUnchanged(
|
||||
pane.connection,
|
||||
originatingEndpoint,
|
||||
connectionCacheKeyMapRef.current,
|
||||
);
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge) throw new Error("Bridge not available");
|
||||
if (!bridge.listLocalTree) throw new Error("Folder upload not supported");
|
||||
|
||||
const { sftpId, release } = await resolveRemoteSftpId(side, {
|
||||
forceReconnect,
|
||||
connectionId: pane.connection.id,
|
||||
tabId: originatingTabId,
|
||||
});
|
||||
// Never re-resolve via getActivePane after awaits — focus may have moved.
|
||||
const livePane = resolveUploadTargetPane({
|
||||
side,
|
||||
tabId: originatingTabId,
|
||||
getActivePane,
|
||||
getPaneByTabId,
|
||||
getPaneByConnectionId,
|
||||
});
|
||||
assertUploadEndpointUnchanged(
|
||||
livePane.connection,
|
||||
originatingEndpoint,
|
||||
connectionCacheKeyMapRef.current,
|
||||
);
|
||||
|
||||
const uploadPaneId = livePane.id;
|
||||
const uploadTargetPath = targetPath || livePane.connection.currentPath;
|
||||
const controller = new UploadController();
|
||||
|
||||
const callbacks = bindUploadControllerCallbacks(
|
||||
controller,
|
||||
createUploadCallbacks(
|
||||
livePane.connection.id,
|
||||
uploadTargetPath,
|
||||
livePane.connection.isLocal ? undefined : livePane.connection.hostId,
|
||||
livePane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(livePane.connection.id),
|
||||
livePane.connection.isLocal ? undefined : livePane.connection.hostLabel,
|
||||
),
|
||||
);
|
||||
// Pin connect-time Host before listLocalTree: a slow folder scan can
|
||||
// outlive a same-hostId tab rebind, and resolveUploadConnectHost would
|
||||
// otherwise open the pooled stream bridge on the newly selected endpoint.
|
||||
const uploadConnectHost = resolveUploadConnectHost(
|
||||
uploadPaneId,
|
||||
livePane.connection.isLocal,
|
||||
);
|
||||
const uploadBridge = createUploadBridge(uploadConnectHost);
|
||||
|
||||
const scanningTask = startUploadScanningTask(callbacks);
|
||||
|
||||
try {
|
||||
const pane = resolveUploadTargetPane({
|
||||
side,
|
||||
tabId: originatingTabId,
|
||||
getActivePane,
|
||||
getPaneByTabId,
|
||||
getPaneByConnectionId,
|
||||
});
|
||||
assertUploadEndpointUnchanged(
|
||||
pane.connection,
|
||||
originatingEndpoint,
|
||||
connectionCacheKeyMapRef.current,
|
||||
);
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge) throw new Error("Bridge not available");
|
||||
if (!bridge.listLocalTree) throw new Error("Folder upload not supported");
|
||||
|
||||
const resolved = await resolveRemoteSftpId(side, {
|
||||
forceReconnect,
|
||||
connectionId: pane.connection.id,
|
||||
tabId: originatingTabId,
|
||||
});
|
||||
const sftpId = resolved.sftpId;
|
||||
release = resolved.release;
|
||||
// Never re-resolve via getActivePane after awaits — focus may have moved.
|
||||
const livePane = resolveUploadTargetPane({
|
||||
side,
|
||||
tabId: originatingTabId,
|
||||
getActivePane,
|
||||
getPaneByTabId,
|
||||
getPaneByConnectionId,
|
||||
});
|
||||
assertUploadEndpointUnchanged(
|
||||
livePane.connection,
|
||||
originatingEndpoint,
|
||||
connectionCacheKeyMapRef.current,
|
||||
);
|
||||
|
||||
const uploadPaneId = livePane.id;
|
||||
const uploadTargetPath = targetPath || livePane.connection.currentPath;
|
||||
// Pin connect-time Host before listLocalTree: a slow folder scan can
|
||||
// outlive a same-hostId tab rebind, and resolveUploadConnectHost would
|
||||
// otherwise open the pooled stream bridge on the newly selected endpoint.
|
||||
const uploadConnectHost = resolveUploadConnectHost(
|
||||
uploadPaneId,
|
||||
livePane.connection.isLocal,
|
||||
);
|
||||
const uploadBridge = createUploadBridge(uploadConnectHost);
|
||||
const liveCallbacks = bindUploadControllerCallbacks(
|
||||
controller,
|
||||
createUploadCallbacks(
|
||||
livePane.connection.id,
|
||||
uploadTargetPath,
|
||||
livePane.connection.isLocal ? undefined : livePane.connection.hostId,
|
||||
livePane.connection.isLocal
|
||||
? undefined
|
||||
: connectionCacheKeyMapRef.current.get(livePane.connection.id),
|
||||
livePane.connection.isLocal ? undefined : livePane.connection.hostLabel,
|
||||
),
|
||||
);
|
||||
const transferCallbacks = {
|
||||
...liveCallbacks,
|
||||
onTaskCreated: (task: Parameters<NonNullable<UploadCallbacks["onTaskCreated"]>>[0]) => {
|
||||
scanningTask.complete();
|
||||
liveCallbacks.onTaskCreated?.(task);
|
||||
},
|
||||
};
|
||||
|
||||
if (capturedEntries === null) {
|
||||
const localEntries = await listLocalTreeWithAbort(bridge, folderPath, {
|
||||
abortSignal: scanAbort.signal,
|
||||
onProgress: (progress) => {
|
||||
callbacks.onScanningProgress?.(scanningTask.taskId, {
|
||||
...progress,
|
||||
label: folderName,
|
||||
});
|
||||
},
|
||||
});
|
||||
capturedEntries = localTreeToDropEntries(localEntries);
|
||||
}
|
||||
const localEntries = await bridge.listLocalTree(folderPath);
|
||||
if (controller.isCancelled()) {
|
||||
scanningTask.cancel();
|
||||
return [{ fileName: "", success: false, cancelled: true }];
|
||||
}
|
||||
scanningTask.complete();
|
||||
|
||||
const entries: DropEntry[] = localEntries.map((entry) => {
|
||||
if (entry.type === "directory") {
|
||||
return {
|
||||
file: null,
|
||||
relativePath: entry.relativePath,
|
||||
isDirectory: true,
|
||||
};
|
||||
}
|
||||
|
||||
const file = {
|
||||
name: entry.relativePath.split("/").pop() || entry.relativePath,
|
||||
size: entry.size,
|
||||
lastModified: entry.lastModified,
|
||||
type: "",
|
||||
path: entry.localPath,
|
||||
arrayBuffer: async () => {
|
||||
const currentBridge = netcattyBridge.get();
|
||||
if (!currentBridge?.readLocalFile) {
|
||||
throw new Error("Local file reading not supported");
|
||||
}
|
||||
return currentBridge.readLocalFile(entry.localPath);
|
||||
},
|
||||
} as File & { path?: string };
|
||||
|
||||
return {
|
||||
file,
|
||||
relativePath: entry.relativePath,
|
||||
isDirectory: false,
|
||||
};
|
||||
});
|
||||
|
||||
const results = await runWithCompressedUploadSession({
|
||||
enabled: useCompressedUpload,
|
||||
@@ -1579,7 +1258,7 @@ export const useSftpExternalOperations = (
|
||||
: undefined,
|
||||
shouldDiscard: isSessionError,
|
||||
run: async (uploadSftpId) => uploadEntriesDirect(
|
||||
capturedEntries,
|
||||
entries,
|
||||
{
|
||||
targetPath: uploadTargetPath,
|
||||
sftpId: uploadSftpId,
|
||||
@@ -1587,7 +1266,7 @@ export const useSftpExternalOperations = (
|
||||
isLocal: livePane.connection!.isLocal,
|
||||
bridge: uploadBridge,
|
||||
joinPath,
|
||||
callbacks: transferCallbacks,
|
||||
callbacks,
|
||||
useCompressedUpload,
|
||||
resolveConflict: createUploadConflictResolver(controller),
|
||||
},
|
||||
@@ -1603,8 +1282,18 @@ export const useSftpExternalOperations = (
|
||||
await refresh(refreshSide, { tabId: uploadPaneId });
|
||||
}
|
||||
return results;
|
||||
} catch (error) {
|
||||
if (controller.isCancelled()) {
|
||||
scanningTask.cancel();
|
||||
return [{ fileName: "", success: false, cancelled: true }];
|
||||
}
|
||||
if (scanningTask.isOpen()) {
|
||||
scanningTask.fail(error);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
release();
|
||||
unregisterUploadController(controller);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1613,33 +1302,11 @@ export const useSftpExternalOperations = (
|
||||
} catch (error) {
|
||||
if (isSessionError(error) && ensureRemoteSftpId) {
|
||||
logger.warn("[SFTP] Folder upload session lost; reconnecting and retrying once", error);
|
||||
try {
|
||||
return await run(true);
|
||||
} catch (retryError) {
|
||||
if (isUploadScanCancelled(controller, retryError)) {
|
||||
scanningTask.cancel();
|
||||
return [{ fileName: "", success: false, cancelled: true }];
|
||||
}
|
||||
if (scanningTask.isOpen()) scanningTask.fail(retryError);
|
||||
throw retryError;
|
||||
}
|
||||
return await run(true);
|
||||
}
|
||||
if (isUploadScanCancelled(controller, error)) {
|
||||
scanningTask.cancel();
|
||||
return [{ fileName: "", success: false, cancelled: true }];
|
||||
}
|
||||
if (scanningTask.isOpen()) scanningTask.fail(error);
|
||||
logger.error("[SFTP] Folder picker upload failed:", error);
|
||||
throw error;
|
||||
}
|
||||
finally {
|
||||
detachScanCancel();
|
||||
unregisterUploadController(controller);
|
||||
if (scanningTask.isOpen()) {
|
||||
if (controller.isCancelled()) scanningTask.cancel();
|
||||
else scanningTask.complete();
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
acquireTransferSession,
|
||||
|
||||
@@ -232,7 +232,7 @@ test("buildSftpHostCredentials rejects proxy identities with blank usernames eve
|
||||
label: "Proxy login",
|
||||
username: " ",
|
||||
authMethod: "password",
|
||||
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
created: 1,
|
||||
}],
|
||||
}),
|
||||
@@ -285,7 +285,7 @@ test("buildSftpHostCredentials rejects incomplete proxy identities on jump hosts
|
||||
label: "Proxy login",
|
||||
username: "",
|
||||
authMethod: "password",
|
||||
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
created: 1,
|
||||
}],
|
||||
}),
|
||||
@@ -368,7 +368,7 @@ test("buildSftpHostCredentials rejects undecryptable proxy identity passwords",
|
||||
label: "Proxy login",
|
||||
username: "proxy-user",
|
||||
authMethod: "password",
|
||||
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
created: 1,
|
||||
}],
|
||||
}),
|
||||
@@ -434,7 +434,7 @@ test("buildSftpHostCredentials rejects undecryptable jump host proxy identity pa
|
||||
label: "Proxy login",
|
||||
username: "proxy-user",
|
||||
authMethod: "password",
|
||||
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
created: 1,
|
||||
}],
|
||||
}),
|
||||
@@ -539,7 +539,7 @@ test("buildSftpHostCredentials rejects undecryptable saved password credentials"
|
||||
() => buildSftpHostCredentials({
|
||||
host: host({
|
||||
authMethod: "password",
|
||||
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
}),
|
||||
hosts: [],
|
||||
keys: [],
|
||||
@@ -553,7 +553,7 @@ test("buildSftpHostCredentials keeps automatic target discovery available with a
|
||||
const credentials = buildSftpHostCredentials({
|
||||
host: host({
|
||||
authMethod: "auto",
|
||||
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
}),
|
||||
hosts: [],
|
||||
keys: [],
|
||||
@@ -569,7 +569,7 @@ test("buildSftpHostCredentials keeps automatic jump discovery available with an
|
||||
id: "jump-1",
|
||||
label: "Jump",
|
||||
authMethod: "auto",
|
||||
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
});
|
||||
const credentials = buildSftpHostCredentials({
|
||||
host: host({ hostChain: { hostIds: ["jump-1"] } }),
|
||||
@@ -604,7 +604,7 @@ test("buildSftpHostCredentials rejects undecryptable saved key material without
|
||||
id: "key-1",
|
||||
label: "Imported key",
|
||||
type: "ED25519",
|
||||
privateKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
privateKey: "enc:v1:djEwAAAA",
|
||||
source: "imported",
|
||||
category: "key",
|
||||
created: 1,
|
||||
@@ -626,7 +626,7 @@ test("buildSftpHostCredentials does not use stale local key paths when a selecte
|
||||
id: "key-1",
|
||||
label: "Imported key",
|
||||
type: "ED25519",
|
||||
privateKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
privateKey: "enc:v1:djEwAAAA",
|
||||
source: "imported",
|
||||
category: "key",
|
||||
created: 1,
|
||||
@@ -653,7 +653,7 @@ test("buildSftpHostCredentials uses the system agent when a synced key cannot be
|
||||
label: "Synced key",
|
||||
type: "ED25519",
|
||||
publicKey: "ssh-ed25519 AAAASELECTED",
|
||||
privateKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
privateKey: "enc:v1:djEwAAAA",
|
||||
source: "imported",
|
||||
category: "key",
|
||||
created: 1,
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("./useSftpPaneSorting.ts", import.meta.url), "utf8");
|
||||
|
||||
test("useSftpPaneSorting imports column layout from application state", () => {
|
||||
assert.doesNotMatch(source, /from ["'].*components\//);
|
||||
assert.match(source, /from ["']\.\/columnLayout["']/);
|
||||
});
|
||||
@@ -2,6 +2,25 @@ import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
|
||||
export function useWarmSftpTransferPool(params: {
|
||||
hostIds: readonly string[];
|
||||
activeHostId?: string;
|
||||
warmTransferPoolForHost?: (hostId: string) => void | Promise<void>;
|
||||
}) {
|
||||
const warmRef = useRef(params.warmTransferPoolForHost);
|
||||
warmRef.current = params.warmTransferPoolForHost;
|
||||
const hostIdsKey = [...new Set([
|
||||
...params.hostIds,
|
||||
...(params.activeHostId ? [params.activeHostId] : []),
|
||||
])].sort().join("\u0000");
|
||||
|
||||
useEffect(() => {
|
||||
for (const hostId of hostIdsKey.split("\u0000").filter(Boolean)) {
|
||||
void warmRef.current?.(hostId);
|
||||
}
|
||||
}, [hostIdsKey]);
|
||||
}
|
||||
|
||||
export function useReportSftpTransferOwnerActivity(params: {
|
||||
ownerId: string;
|
||||
activeTransfersCount: number;
|
||||
|
||||
@@ -60,10 +60,6 @@ import {
|
||||
} from "./transferConflictLifecycle";
|
||||
import { getParentPath, joinPath } from "./utils";
|
||||
import { promoteDirectoryReplaceStage as promoteDirectoryReplacePaths } from "./directoryReplacePromotion";
|
||||
import {
|
||||
isExternalDragDropFileUpload,
|
||||
retryExternalDragDropFileUpload,
|
||||
} from "./externalDragDropRetry";
|
||||
|
||||
/** Keep the MutableRefObject mirror in sync with the process-global latch set. */
|
||||
function syncPausedTasksRef(ref: { current: Set<string> }, taskId: string, latched: boolean) {
|
||||
@@ -1298,62 +1294,8 @@ export const useSftpTransfers = ({
|
||||
|
||||
const retryTransfer = useCallback(
|
||||
async (transferId: string) => {
|
||||
// Prefer the live owner list; fall back to the center store (drag-drop
|
||||
// children are upserted there and mirrored via subscribe).
|
||||
const task = transfersRef.current.find((t) => t.id === transferId)
|
||||
?? sftpTransferCenterStore.getTask(transferId);
|
||||
const task = transfersRef.current.find((t) => t.id === transferId);
|
||||
if (!task || task.retryable === false) return;
|
||||
|
||||
// Progressive / external drag-drop children never have dual-pane endpoints
|
||||
// (sourceConnectionId is "external"). Re-run startStreamTransfer in place.
|
||||
if (isExternalDragDropFileUpload(task)) {
|
||||
if (inFlightTransferIdsRef.current.has(transferId)) return;
|
||||
inFlightTransferIdsRef.current.add(transferId);
|
||||
clearTransferCancelled(transferId);
|
||||
cancelledTasksRef.current.delete(transferId);
|
||||
try {
|
||||
const result = await retryExternalDragDropFileUpload(task, {
|
||||
getBrowseSftpId: (connectionId) => sftpSessionsRef.current.get(connectionId),
|
||||
acquireTransferSession: acquireTransferSession
|
||||
? (hostId, id) => acquireTransferSession(hostId, id)
|
||||
: undefined,
|
||||
startStreamTransfer: async (options) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.startStreamTransfer) {
|
||||
return { error: "Stream transfer is unavailable" };
|
||||
}
|
||||
return bridge.startStreamTransfer(options);
|
||||
},
|
||||
clearPendingCancel: (id) => netcattyBridge.get()?.clearPendingTransferCancel?.(id),
|
||||
cleanupArtifacts: cleanupTaskArtifacts,
|
||||
getTask: (id) => sftpTransferCenterStore.getTask(id)
|
||||
?? transfersRef.current.find((row) => row.id === id),
|
||||
getChildTasks: (parentId) => {
|
||||
const fromStore = sftpTransferCenterStore.getOwnerTasks(ownerId)
|
||||
.filter((row) => row.parentTaskId === parentId);
|
||||
if (fromStore.length > 0) return fromStore;
|
||||
return transfersRef.current.filter((row) => row.parentTaskId === parentId);
|
||||
},
|
||||
onPatch: (taskId, updates) => {
|
||||
transferRuntime.patchTask(taskId, updates);
|
||||
setTransfers((prev) => {
|
||||
if (!prev.some((row) => row.id === taskId)) {
|
||||
// Store-only child: mirror the patch by re-pulling owner rows.
|
||||
return sftpTransferCenterStore.getOwnerTasks(ownerId);
|
||||
}
|
||||
return prev.map((row) => (row.id === taskId ? { ...row, ...updates } : row));
|
||||
});
|
||||
},
|
||||
});
|
||||
if (!result.success && result.error && !/cancelled/i.test(result.error)) {
|
||||
notify.warning(result.error, "SFTP");
|
||||
}
|
||||
} finally {
|
||||
inFlightTransferIdsRef.current.delete(transferId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await cleanupTaskArtifacts(task);
|
||||
|
||||
const retriedTask: TransferTask = {
|
||||
@@ -1374,13 +1316,7 @@ export const useSftpTransfers = ({
|
||||
};
|
||||
|
||||
const endpoints = resolveTaskEndpoints(task);
|
||||
if (!endpoints) {
|
||||
notify.warning(
|
||||
"Could not resolve transfer endpoints for retry. Reconnect the target and try again.",
|
||||
"SFTP",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!endpoints) return;
|
||||
const { targetSide, sourcePane, targetPane } = endpoints;
|
||||
|
||||
const completionHandler = completionHandlersRef.current.get(transferId);
|
||||
@@ -1399,7 +1335,7 @@ export const useSftpTransfers = ({
|
||||
await processTransfer(retriedTask, sourcePane, targetPane, targetSide);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- processTransfer is defined inline
|
||||
[acquireTransferSession, cleanupTaskArtifacts, ownerId, resolveTaskEndpoints, setTransfers, sftpSessionsRef],
|
||||
[cleanupTaskArtifacts, resolveTaskEndpoints, setTransfers],
|
||||
);
|
||||
|
||||
const clearCompletedTransfers = useCallback(() => {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { SftpFileEntry, TransferTask } from "../../../domain/models";
|
||||
import { formatBytes } from "../../../domain/formatBytes";
|
||||
|
||||
export const formatFileSize = (bytes: number): string => {
|
||||
return formatBytes(bytes, {
|
||||
zeroDisplay: "--",
|
||||
includeTB: true,
|
||||
fractionDigits: 2,
|
||||
byteUnit: "Bytes",
|
||||
});
|
||||
if (bytes === 0) return "--";
|
||||
const units = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
const size = bytes / Math.pow(1024, i);
|
||||
return `${size.toFixed(i === 0 ? 0 : 2)} ${units[i]}`;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -2318,7 +2318,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
const browserPersistence: StorePersistence | undefined = typeof globalThis.localStorage === "undefined"
|
||||
? undefined
|
||||
: {
|
||||
read: () => localStorageAdapter.readString(STORAGE_KEY_SFTP_TRANSFER_CENTER),
|
||||
read: () => globalThis.localStorage.getItem(STORAGE_KEY_SFTP_TRANSFER_CENTER),
|
||||
write: (value) => { localStorageAdapter.writeString(STORAGE_KEY_SFTP_TRANSFER_CENTER, value); },
|
||||
};
|
||||
|
||||
|
||||
@@ -172,6 +172,15 @@ export const useUIFontsLoaded = (): boolean => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a font ID is valid
|
||||
*/
|
||||
export const isValidUiFontId = (fontId: string): boolean => {
|
||||
// Local fonts are always considered valid (they start with 'local-')
|
||||
if (fontId.startsWith('local-')) return true;
|
||||
return uiFontStore.getAvailableFonts().some(f => f.id === fontId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize UI fonts eagerly
|
||||
*/
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("./useAIChatStreaming.ts", import.meta.url), "utf8");
|
||||
|
||||
test("useAIChatStreaming imports streaming support from infrastructure", () => {
|
||||
assert.match(source, /from ['"]\.\.\/\.\.\/infrastructure\/ai\/aiChatStreamingSupport['"]/);
|
||||
assert.doesNotMatch(source, /from ['"].*components\//);
|
||||
});
|
||||
@@ -191,48 +191,3 @@ test("auto-sync skips only the exact remote-applied data hash", () => {
|
||||
"remote-apply skip hash must survive temporary sync blockers and clear only after a successful sync",
|
||||
);
|
||||
});
|
||||
|
||||
test("startup local-wins and merge round-trips refuse device-bound credential placeholders", () => {
|
||||
const source = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
|
||||
const uploadLocalIndex = source.indexOf("if (conflictAction === 'upload-local')");
|
||||
const uploadGuardIndex = source.indexOf(
|
||||
"findSyncPayloadEncryptedCredentialPaths(localPayload)",
|
||||
uploadLocalIndex,
|
||||
);
|
||||
const uploadPushIndex = source.indexOf("manager.syncAllProviders(localPayload)", uploadGuardIndex);
|
||||
const mergeRoundTripBlockIndex = source.indexOf(
|
||||
"stripSyncPayloadEncryptedCredentials(mergeResult.payload)",
|
||||
);
|
||||
const mergeLocalHealIndex = source.indexOf(
|
||||
"healPoisonedSecretsForMerge(localPayload, remoteRaw, base)",
|
||||
);
|
||||
const mergeRemoteHealIndex = source.indexOf(
|
||||
"healPoisonedSecretsForMerge(remoteRaw, localPayload, base)",
|
||||
);
|
||||
const mergePushIndex = source.indexOf(
|
||||
"manager.syncAllProviders(portableMerge)",
|
||||
mergeRoundTripBlockIndex,
|
||||
);
|
||||
const stripRemoteIndex = source.indexOf(
|
||||
"stripSyncPayloadEncryptedCredentials(remoteRaw)",
|
||||
);
|
||||
|
||||
assert.notEqual(uploadLocalIndex, -1);
|
||||
assert.notEqual(uploadGuardIndex, -1);
|
||||
assert.notEqual(uploadPushIndex, -1);
|
||||
assert.notEqual(mergeLocalHealIndex, -1);
|
||||
assert.notEqual(mergeRemoteHealIndex, -1);
|
||||
assert.notEqual(mergeRoundTripBlockIndex, -1);
|
||||
assert.notEqual(mergePushIndex, -1);
|
||||
assert.notEqual(stripRemoteIndex, -1);
|
||||
assert.ok(
|
||||
uploadGuardIndex < uploadPushIndex,
|
||||
"startup local-wins must guard placeholders before syncAllProviders",
|
||||
);
|
||||
assert.ok(
|
||||
mergeLocalHealIndex < mergeRoundTripBlockIndex
|
||||
&& mergeRemoteHealIndex < mergeRoundTripBlockIndex
|
||||
&& mergeRoundTripBlockIndex < mergePushIndex,
|
||||
"startup merge must heal local+remote secrets, then strip, then upload",
|
||||
);
|
||||
});
|
||||
@@ -14,8 +14,6 @@ import { getCloudSyncManager } from '../../infrastructure/services/CloudSyncMana
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
healPoisonedSecretsForMerge,
|
||||
stripSyncPayloadEncryptedCredentials,
|
||||
} from '../../domain/credentials';
|
||||
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
|
||||
import { mergeSyncPayloads } from '../../domain/syncMerge';
|
||||
@@ -437,14 +435,13 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
|
||||
for (const result of resultList) {
|
||||
if (result.mergedPayload && !result.mergedPayloadApplied) {
|
||||
const portableMerged = stripSyncPayloadEncryptedCredentials(result.mergedPayload);
|
||||
await Promise.resolve(onApplyPayload(portableMerged));
|
||||
await Promise.resolve(onApplyPayload(result.mergedPayload));
|
||||
if (result.remoteFile) {
|
||||
await sync.commitRemoteInspection(result.provider, result.remoteFile, portableMerged, {
|
||||
await sync.commitRemoteInspection(result.provider, result.remoteFile, result.mergedPayload, {
|
||||
recordDownload: true,
|
||||
});
|
||||
}
|
||||
skipNextSyncHashRef.current = allProvidersSynced ? getSyncPayloadDataHash(portableMerged) : null;
|
||||
skipNextSyncHashRef.current = allProvidersSynced ? getSyncPayloadDataHash(result.mergedPayload) : null;
|
||||
if (!allProvidersSynced) {
|
||||
console.warn('[AutoSync] Remote payload applied locally, but not every provider synced; leaving next auto-sync enabled for retry.');
|
||||
}
|
||||
@@ -495,7 +492,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
} finally {
|
||||
isSyncRunningRef.current = false;
|
||||
}
|
||||
}, [enabled, sync, buildPayload, onApplyConvergentPayload, onApplyPayload, t]);
|
||||
}, [enabled, sync, buildPayload, getDataHash, onApplyConvergentPayload, onApplyPayload, t]);
|
||||
|
||||
const syncNowRef = useRef(syncNow);
|
||||
useEffect(() => {
|
||||
@@ -710,11 +707,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
inspectedRemoteChange = true;
|
||||
|
||||
const remoteFile = inspection.remoteFile;
|
||||
const remoteRaw = inspection.payload;
|
||||
// Strip device-bound enc:v1 for download/apply paths so poisoned cloud
|
||||
// snapshots restore usable host shells. Smart-merge heals from local/base
|
||||
// separately below so good secrets are not treated as remote deletions.
|
||||
const remotePayload = stripSyncPayloadEncryptedCredentials(remoteRaw);
|
||||
const remotePayload = inspection.payload;
|
||||
const localPayload = await buildPayloadRef.current();
|
||||
|
||||
// If local vault is empty but cloud has data, this almost certainly
|
||||
@@ -785,14 +778,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}
|
||||
|
||||
if (conflictAction === 'upload-local') {
|
||||
const encryptedCredentialPaths = findSyncPayloadEncryptedCredentialPaths(localPayload);
|
||||
if (encryptedCredentialPaths.length > 0) {
|
||||
console.warn(
|
||||
'[AutoSync] Startup local-wins blocked: encrypted credential placeholders found at:',
|
||||
encryptedCredentialPaths.join(', '),
|
||||
);
|
||||
throw new Error(tRef.current('sync.credentialsUnavailable'));
|
||||
}
|
||||
const pushResults = await manager.syncAllProviders(localPayload);
|
||||
const results = Array.from(pushResults.values());
|
||||
commitPluginSidecarsAfterSuccessfulSync(localPayload, results);
|
||||
@@ -812,20 +797,15 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
throw new Error('Startup local-wins sync failed for one or more providers');
|
||||
}
|
||||
|
||||
// Prefer usable secrets from the opposite side / base over enc:v1
|
||||
// placeholders before merge. Otherwise smart-merge can pick a "changed"
|
||||
// local entity whose only secret change is ciphertext, then strip +
|
||||
// upload and wipe usable cloud passwords. Same for remote-side poison.
|
||||
const localHealed = healPoisonedSecretsForMerge(localPayload, remoteRaw, base);
|
||||
const remoteHealed = healPoisonedSecretsForMerge(remoteRaw, localPayload, base);
|
||||
const mergeResult = mergeSyncPayloads(base, localHealed, remoteHealed);
|
||||
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
|
||||
|
||||
// Apply merged payload to local state BEFORE committing. If the apply
|
||||
// throws, the next startup will re-run the merge with fresh data.
|
||||
const portableMerge = stripSyncPayloadEncryptedCredentials(mergeResult.payload);
|
||||
await Promise.resolve(onApplyPayloadRef.current(portableMerge));
|
||||
// Base is the last-agreed remote snapshot; store the portable remote
|
||||
// view so future diffs never treat device-bound enc:v1 as cloud truth.
|
||||
await Promise.resolve(onApplyPayloadRef.current(mergeResult.payload));
|
||||
// Base is the last-agreed remote snapshot; `commitRemoteInspection`
|
||||
// stores remotePayload as the base so the next diff is computed
|
||||
// against what the cloud actually has, not against the merged
|
||||
// local-only state.
|
||||
await manager.commitRemoteInspection(connectedProvider, remoteFile, remotePayload);
|
||||
startupConsistent = true;
|
||||
markCurrentDataSynced = false;
|
||||
@@ -846,9 +826,9 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// that only approximated the correct ordering.
|
||||
if (mergeResult.payload) {
|
||||
try {
|
||||
const roundTripResults = await manager.syncAllProviders(portableMerge);
|
||||
const roundTripResults = await manager.syncAllProviders(mergeResult.payload);
|
||||
const roundTripResultList = Array.from(roundTripResults.values());
|
||||
commitPluginSidecarsAfterSuccessfulSync(portableMerge, roundTripResultList);
|
||||
commitPluginSidecarsAfterSuccessfulSync(mergeResult.payload, roundTripResultList);
|
||||
const wasShrinkBlocked = roundTripResultList.some((r) => r.shrinkBlocked === true);
|
||||
const roundTripFullySynced = roundTripResultList.length > 0
|
||||
&& roundTripResultList.every((result) => result.success);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { buildAITerminalSessionInfo } from '../../domain/buildAITerminalSessionInfo';
|
||||
import { buildAITerminalSessionInfo } from '../../components/terminalLayer/TerminalLayerSupport';
|
||||
import { detectLocalOs } from '../../lib/localShell';
|
||||
import type { Host, PortForwardingRule, TerminalSession } from '../../types';
|
||||
import { STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED } from '../../infrastructure/config/storageKeys';
|
||||
|
||||
@@ -144,26 +144,12 @@ export function readExternalMcpMode(): ExternalMcpMode {
|
||||
return normalizeExternalMcpMode(localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE));
|
||||
}
|
||||
|
||||
export function writeExternalMcpMode(mode: ExternalMcpMode): ExternalMcpMode {
|
||||
const normalized = normalizeExternalMcpMode(mode);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, normalized);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_MODE);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function readExternalMcpIdleTimeoutMinutes(): number {
|
||||
return normalizeExternalMcpIdleTimeoutMinutes(
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES),
|
||||
);
|
||||
}
|
||||
|
||||
export function writeExternalMcpIdleTimeoutMinutes(minutes: number): number {
|
||||
const normalized = normalizeExternalMcpIdleTimeoutMinutes(minutes);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES, normalized);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** Whether host_open should surface/focus the main window. Defaults to true (existing behavior). */
|
||||
export function readExternalMcpFocusOnHostOpen(): boolean {
|
||||
return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN) ?? true;
|
||||
@@ -201,13 +187,6 @@ export function readSessionIdleTimeoutMinutes(): number {
|
||||
);
|
||||
}
|
||||
|
||||
export function writeSessionIdleTimeoutMinutes(minutes: number): number {
|
||||
const normalized = normalizeSessionIdleTimeoutMinutes(minutes);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES, normalized);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function shouldStartExternalMcpOnStartup({
|
||||
enabled,
|
||||
mode,
|
||||
|
||||
@@ -782,9 +782,6 @@ export function useOutputTriggers({
|
||||
resetTriggerScanState();
|
||||
},
|
||||
schedule: (callback) => {
|
||||
// Prefer setTimeout over rAF: terminal output can continue while the
|
||||
// Electron renderer is hidden/minimized, when rAF is throttled or paused.
|
||||
// Pending scan buffers are capped, so delayed flushes can drop matches.
|
||||
const timer = globalThis.setTimeout(callback, OUTPUT_TRIGGER_SCAN_DELAY_MS);
|
||||
return () => globalThis.clearTimeout(timer);
|
||||
},
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("./usePortForwardHostKeyVerification.ts", import.meta.url), "utf8");
|
||||
|
||||
test("usePortForwardHostKeyVerification stays below the UI layer", () => {
|
||||
assert.doesNotMatch(source, /from ["'].*components\//);
|
||||
assert.match(source, /from ["']\.\.\/\.\.\/domain\/portForwardHostKey["']/);
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { KnownHost } from "../../domain/models";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
import type { HostKeyInfo } from "../../domain/hostKey";
|
||||
import type { HostKeyInfo } from "../../components/terminal/TerminalHostKeyVerification";
|
||||
import {
|
||||
createKnownHostFromPortForwardHostKeyInfo,
|
||||
enqueuePortForwardHostKeyVerification,
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
toPendingPortForwardHostKeyVerification,
|
||||
type PendingPortForwardHostKeyVerification,
|
||||
type PortForwardHostKeyRequest,
|
||||
} from "../../domain/portForwardHostKey";
|
||||
} from "../../components/port-forwarding/hostKeyVerification";
|
||||
|
||||
export interface PortForwardHostKeyVerificationState {
|
||||
hostKeyInfo: HostKeyInfo;
|
||||
|
||||
@@ -619,26 +619,17 @@ export const useSessionState = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const submitWorkspaceRename = useCallback((workspaceId?: string, name?: string) => {
|
||||
if (workspaceId !== undefined && name !== undefined) {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
setWorkspaces(prev => prev.map(w => (
|
||||
w.id === workspaceId ? { ...w, title: trimmed, autoTitle: false } : w
|
||||
)));
|
||||
return;
|
||||
}
|
||||
|
||||
const submitWorkspaceRename = useCallback(() => {
|
||||
setWorkspaceRenameValue(prevValue => {
|
||||
const trimmed = prevValue.trim();
|
||||
if (!trimmed) return prevValue;
|
||||
|
||||
const name = prevValue.trim();
|
||||
if (!name) return prevValue;
|
||||
|
||||
setWorkspaceRenameTarget(prevTarget => {
|
||||
if (!prevTarget) return prevTarget;
|
||||
setWorkspaces(prev => prev.map(w => w.id === prevTarget.id ? { ...w, title: trimmed, autoTitle: false } : w));
|
||||
setWorkspaces(prev => prev.map(w => w.id === prevTarget.id ? { ...w, title: name, autoTitle: false } : w));
|
||||
return null;
|
||||
});
|
||||
|
||||
|
||||
return '';
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
|
||||
|
||||
import { runThemeTransition, type ThemeTransitionMode } from './themeTransition';
|
||||
import { publishAppearanceChromeSnapshot } from './appearanceChromeStore';
|
||||
import { SyncConfig, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
|
||||
import {
|
||||
DEFAULT_HTTP_NETWORK_PROXY,
|
||||
@@ -36,7 +35,6 @@ import {
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
|
||||
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
|
||||
STORAGE_KEY_SFTP_SKIP_UNCHANGED,
|
||||
STORAGE_KEY_SSH_TRANSPORT_IDLE_TTL_MS,
|
||||
STORAGE_KEY_SFTP_TRANSFER_POOL_IDLE_TTL_MS,
|
||||
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
|
||||
@@ -86,10 +84,7 @@ import { DEFAULT_UI_FONT_ID, withWindowsEmojiFallback } from '../../infrastructu
|
||||
import { uiFontStore, useUIFontsLoaded } from './uiFontStore';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import {
|
||||
resolveSftpTransferConcurrency,
|
||||
resolveSftpSkipUnchangedEnabled,
|
||||
} from './sftp/transferConcurrency';
|
||||
import { resolveSftpTransferConcurrency } from './sftp/transferConcurrency';
|
||||
import { resolveSshTransportIdleTtlMs } from '../../infrastructure/config/sshTransportIdleTtl';
|
||||
import {
|
||||
DEFAULT_ACCENT_MODE,
|
||||
@@ -195,7 +190,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
});
|
||||
// Track the OS color scheme preference (updated by matchMedia listener)
|
||||
const [systemPreference, setSystemPreference] = useState<'light' | 'dark'>(getSystemPreference);
|
||||
// resolvedTheme is always 'light' or 'dark' - derived synchronously from theme + OS preference
|
||||
// resolvedTheme is always 'light' or 'dark' — derived synchronously from theme + OS preference
|
||||
const resolvedTheme: 'light' | 'dark' = theme === 'system' ? systemPreference : theme;
|
||||
const [lightUiThemeId, setLightUiThemeId] = useState<string>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_UI_THEME_LIGHT);
|
||||
@@ -423,11 +418,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
);
|
||||
});
|
||||
// Folder transfer concurrency is renderer-only (runSftpTransferWorkers).
|
||||
// Do not push it into main-process host admission - that made multi-select
|
||||
// Do not push it into main-process host admission — that made multi-select
|
||||
// top-level files queue against each other and against folder children.
|
||||
const [sftpSkipUnchanged, setSftpSkipUnchanged] = useState<boolean>(() =>
|
||||
resolveSftpSkipUnchangedEnabled(() => localStorageAdapter.readBoolean(STORAGE_KEY_SFTP_SKIP_UNCHANGED)),
|
||||
);
|
||||
|
||||
const [sshTransportIdleTtlMs, setSshTransportIdleTtlMsState] = useState<number>(() => {
|
||||
// Prefer the new SSH transport key; if absent (upgrade), migrate the legacy
|
||||
@@ -590,7 +582,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const explorerContextMenuEnabledRef = useRef(explorerContextMenuEnabled);
|
||||
const explorerContextMenuSetRequestIdRef = useRef(0);
|
||||
|
||||
// Fix 1: Mount guard - skip redundant IPC broadcasts & localStorage writes on initial mount.
|
||||
// Fix 1: Mount guard — skip redundant IPC broadcasts & localStorage writes on initial mount.
|
||||
// Set to true by the LAST useEffect declaration; all persist effects see false on first render.
|
||||
const persistMountedRef = useRef(false);
|
||||
const appearanceTransitionModeRef = useRef<ThemeTransitionMode>('view');
|
||||
@@ -734,7 +726,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const clamped = Math.max(1, Math.min(16, Math.round(value)));
|
||||
setSftpTransferConcurrencyState(clamped);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY, String(clamped));
|
||||
// Intentionally not calling setGlobalTransferConcurrency - this setting
|
||||
// Intentionally not calling setGlobalTransferConcurrency — this setting
|
||||
// only caps files inside a single folder transfer job.
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY, clamped);
|
||||
}, [notifySettingsChanged]);
|
||||
@@ -841,13 +833,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
// Terminal
|
||||
const storedTermTheme = readStoredString(STORAGE_KEY_TERM_THEME);
|
||||
if (storedTermTheme) setTerminalThemeId(storedTermTheme);
|
||||
// Cloud sync writes follow-app via applySyncableSettings; without this the
|
||||
// open window keeps the pre-sync flag while terminalThemeId updates, which
|
||||
// flickers between the local default theme and the synced one (#2757).
|
||||
const storedFollowAppTermTheme = readStoredString(STORAGE_KEY_TERM_FOLLOW_APP_THEME);
|
||||
if (storedFollowAppTermTheme === 'true' || storedFollowAppTermTheme === 'false') {
|
||||
setFollowAppTerminalThemeState(storedFollowAppTermTheme === 'true');
|
||||
}
|
||||
const storedTermThemeDark = readStoredString(STORAGE_KEY_TERM_THEME_DARK);
|
||||
if (storedTermThemeDark) setTerminalThemeDarkId(storedTermThemeDark);
|
||||
const storedTermThemeLight = readStoredString(STORAGE_KEY_TERM_THEME_LIGHT);
|
||||
@@ -898,8 +883,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
if (storedHidden === 'true' || storedHidden === 'false') setSftpShowHiddenFiles(storedHidden === 'true');
|
||||
const storedCompress = readStoredString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD);
|
||||
if (storedCompress === 'true' || storedCompress === 'false') setSftpUseCompressedUpload(storedCompress === 'true');
|
||||
const storedSkipUnchanged = localStorageAdapter.readBoolean(STORAGE_KEY_SFTP_SKIP_UNCHANGED);
|
||||
if (storedSkipUnchanged != null) setSftpSkipUnchanged(storedSkipUnchanged);
|
||||
const storedAutoOpenSidebar = readStoredString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
|
||||
if (storedAutoOpenSidebar === 'true' || storedAutoOpenSidebar === 'false') setSftpAutoOpenSidebar(storedAutoOpenSidebar === 'true');
|
||||
const storedFollowTerminalCwd = readStoredString(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD);
|
||||
@@ -968,8 +951,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const persistedAppearanceChanged = previousAppearance === null
|
||||
|| hasPersistedAppearanceChanged(previousAppearance, appearanceRender);
|
||||
previousAppearanceRenderRef.current = appearanceRender;
|
||||
// Terminal leaves subscribe here so accent drag does not rebuild TerminalLayer.
|
||||
publishAppearanceChromeSnapshot({ accentMode, customAccent });
|
||||
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
|
||||
const apply = () => applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
|
||||
const transitionMode = appearanceTransitionModeRef.current;
|
||||
@@ -1133,7 +1114,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
@@ -1143,7 +1124,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize: applyIncomingTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled, setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled, setExplorerContextMenuEnabledState: applyIncomingExplorerContextMenuEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
@@ -1371,12 +1352,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, sftpUseCompressedUpload);
|
||||
}, [sftpUseCompressedUpload, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SFTP_SKIP_UNCHANGED, sftpSkipUnchanged);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_SKIP_UNCHANGED, sftpSkipUnchanged);
|
||||
}, [sftpSkipUnchanged, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP auto-open sidebar setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, sftpAutoOpenSidebar ? 'true' : 'false');
|
||||
@@ -1762,8 +1737,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setSftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
setSftpUseCompressedUpload,
|
||||
sftpSkipUnchanged,
|
||||
setSftpSkipUnchanged,
|
||||
sftpAutoOpenSidebar,
|
||||
setSftpAutoOpenSidebar,
|
||||
sftpFollowTerminalCwd,
|
||||
@@ -1844,11 +1817,11 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
// Opaque version that changes when any synced setting changes, used by useAutoSync.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
settingsVersion: useMemo(() => Math.random(), [
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode,
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
uiFontFamilyId, uiLanguage, customCSS,
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
|
||||
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
]),
|
||||
|
||||
@@ -399,6 +399,14 @@ export const useSftpState = (
|
||||
[hosts, identities, keys, openPoolSftpSession, transferKnownHosts, transferPoolKeyCache, transferTerminalSettings],
|
||||
);
|
||||
|
||||
/**
|
||||
* @deprecated No-op. SSH transport idle park keeps connections warm; opening
|
||||
* a background transfer channel is unnecessary and could re-trigger MFA.
|
||||
*/
|
||||
const warmTransferPoolForHost = useCallback(async (_hostId: string) => {
|
||||
// Intentionally empty — unified transport registry owns keep-alive.
|
||||
}, []);
|
||||
|
||||
/** True after browse channels were soft-closed while this owner stayed mounted. */
|
||||
const browseParkedRef = useRef(false);
|
||||
const browseLifecycleGenRef = useRef(0);
|
||||
@@ -816,6 +824,7 @@ export const useSftpState = (
|
||||
rejectHostKeyVerification,
|
||||
acceptHostKeyVerification,
|
||||
acceptAndSaveHostKeyVerification,
|
||||
warmTransferPoolForHost,
|
||||
};
|
||||
const methodsRef = useRef(currentMethods);
|
||||
methodsRef.current = currentMethods;
|
||||
@@ -898,6 +907,8 @@ export const useSftpState = (
|
||||
rejectHostKeyVerification: () => methodsRef.current.rejectHostKeyVerification(),
|
||||
acceptHostKeyVerification: () => methodsRef.current.acceptHostKeyVerification(),
|
||||
acceptAndSaveHostKeyVerification: () => methodsRef.current.acceptAndSaveHostKeyVerification(),
|
||||
warmTransferPoolForHost: (...args: Parameters<typeof warmTransferPoolForHost>) =>
|
||||
methodsRef.current.warmTransferPoolForHost(...args),
|
||||
activeFileWatchCountRef,
|
||||
}), [activeFileWatchCountRef]); // activeFileWatchCountRef is a stable ref
|
||||
|
||||
|
||||
@@ -112,14 +112,6 @@ export function useSystemManagerBackend() {
|
||||
return bridge.getDockerStats(options);
|
||||
}, []);
|
||||
|
||||
const listAccelerators = useCallback(async (sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listAccelerators) {
|
||||
return { success: false as const, error: 'listAccelerators unavailable' };
|
||||
}
|
||||
return bridge.listAccelerators(sessionId);
|
||||
}, []);
|
||||
|
||||
const dockerInspect = useCallback(async (options: { sessionId: string; containerId: string }) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.dockerInspect) {
|
||||
@@ -180,7 +172,6 @@ export function useSystemManagerBackend() {
|
||||
listDockerContainers,
|
||||
listDockerImages,
|
||||
getDockerStats,
|
||||
listAccelerators,
|
||||
dockerInspect,
|
||||
dockerImageInspect,
|
||||
dockerAction,
|
||||
@@ -199,7 +190,6 @@ export function useSystemManagerBackend() {
|
||||
listDockerContainers,
|
||||
listDockerImages,
|
||||
getDockerStats,
|
||||
listAccelerators,
|
||||
dockerInspect,
|
||||
dockerImageInspect,
|
||||
dockerAction,
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("./useTerminalAiContexts.ts", import.meta.url), "utf8");
|
||||
|
||||
test("useTerminalAiContexts imports AI context primitives from domain", () => {
|
||||
assert.match(source, /from ['"]\.\.\/\.\.\/domain\/buildAITerminalSessionInfo['"]/);
|
||||
assert.doesNotMatch(source, /from ['"].*components\//);
|
||||
});
|
||||
@@ -276,9 +276,9 @@ export const useTerminalBackend = () => {
|
||||
netcattyBridge.get()?.notifyTerminalSessionDisplayReady?.(sessionId);
|
||||
}, []);
|
||||
|
||||
const closeSession = useCallback(async (sessionId: string, options?: { bootEpoch?: number }) => {
|
||||
const closeSession = useCallback(async (sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
await bridge?.closeSession?.(sessionId, options);
|
||||
await bridge?.closeSession?.(sessionId);
|
||||
}, []);
|
||||
|
||||
const rebindSessionOutput = useCallback(async (sessionId: string, authorization: string) => {
|
||||
@@ -351,17 +351,17 @@ export const useTerminalBackend = () => {
|
||||
return bridge.onSessionExit(sessionId, cb);
|
||||
}, []);
|
||||
|
||||
const onTelnetAutoLoginComplete = useCallback((sessionId: string, cb: (evt: { sessionId: string; bootEpoch?: number }) => void) => {
|
||||
const onTelnetAutoLoginComplete = useCallback((sessionId: string, cb: (evt: { sessionId: string }) => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onTelnetAutoLoginComplete?.(sessionId, cb);
|
||||
}, []);
|
||||
|
||||
const onTelnetAutoLoginCancelled = useCallback((sessionId: string, cb: (evt: { sessionId: string; bootEpoch?: number }) => void) => {
|
||||
const onTelnetAutoLoginCancelled = useCallback((sessionId: string, cb: (evt: { sessionId: string }) => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onTelnetAutoLoginCancelled?.(sessionId, cb);
|
||||
}, []);
|
||||
|
||||
const onMoshSessionReady = useCallback((sessionId: string, cb: (evt: { sessionId: string; bootEpoch?: number }) => void) => {
|
||||
const onMoshSessionReady = useCallback((sessionId: string, cb: (evt: { sessionId: string }) => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onMoshSessionReady?.(sessionId, cb);
|
||||
}, []);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { TerminalTheme } from '../../domain/models';
|
||||
import {
|
||||
@@ -42,9 +42,7 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
|
||||
|
||||
const [userIntent, setUserIntent] = useState<ThemeUserIntent>(idleThemeUserIntent());
|
||||
|
||||
// Theme-id settings without accent — keeps resolveFocusedAppearance identity
|
||||
// stable during color-picker drag (TerminalLayer memo depends on it).
|
||||
const appearanceSettingsBase = useMemo((): Omit<TerminalAppearanceSettings, 'accentMode' | 'customAccent'> => ({
|
||||
const appearanceSettings = useMemo((): TerminalAppearanceSettings => ({
|
||||
terminalThemeId,
|
||||
terminalThemeDarkId,
|
||||
terminalThemeLightId,
|
||||
@@ -52,6 +50,8 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
|
||||
resolvedTheme,
|
||||
lightUiThemeId,
|
||||
darkUiThemeId,
|
||||
accentMode,
|
||||
customAccent,
|
||||
}), [
|
||||
terminalThemeId,
|
||||
terminalThemeDarkId,
|
||||
@@ -60,17 +60,9 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
|
||||
resolvedTheme,
|
||||
lightUiThemeId,
|
||||
darkUiThemeId,
|
||||
]);
|
||||
|
||||
const accentRef = useRef({ accentMode, customAccent });
|
||||
accentRef.current = { accentMode, customAccent };
|
||||
|
||||
// Chrome / injection consumers need live accent on every settings change.
|
||||
const appearanceSettings = useMemo((): TerminalAppearanceSettings => ({
|
||||
...appearanceSettingsBase,
|
||||
accentMode,
|
||||
customAccent,
|
||||
}), [appearanceSettingsBase, accentMode, customAccent]);
|
||||
]);
|
||||
|
||||
const globalAppearance = useMemo(() => resolveGlobalTerminalAppearance({
|
||||
userIntent,
|
||||
@@ -78,21 +70,14 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
|
||||
customThemes,
|
||||
}), [userIntent, appearanceSettings, customThemes]);
|
||||
|
||||
// Read accent from a ref so this callback identity does not churn on drag.
|
||||
// Callers that invoke it after an accent-only update still get the latest
|
||||
// accent; Terminal panes also re-apply appearanceChromeStore at the leaf.
|
||||
const resolveFocusedAppearance = useCallback((hostScope: TerminalAppearanceHostScope): ResolvedAppearance => (
|
||||
resolveTerminalAppearance({
|
||||
userIntent,
|
||||
settings: {
|
||||
...appearanceSettingsBase,
|
||||
accentMode: accentRef.current.accentMode,
|
||||
customAccent: accentRef.current.customAccent,
|
||||
},
|
||||
settings: appearanceSettings,
|
||||
hostScope,
|
||||
customThemes,
|
||||
})
|
||||
), [userIntent, appearanceSettingsBase, customThemes]);
|
||||
), [userIntent, appearanceSettings, customThemes]);
|
||||
|
||||
const applyFollowAppSettingsForPick = useCallback((themeId: string) => {
|
||||
const update = getFollowAppTerminalThemeSelectionUpdate(themeId);
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("./useVaultImportHandlers.ts", import.meta.url), "utf8");
|
||||
|
||||
test("useVaultImportHandlers stays below the UI layer", () => {
|
||||
assert.doesNotMatch(source, /from ["'].*components\//);
|
||||
assert.doesNotMatch(source, /from ["'].*\/toast["']/);
|
||||
assert.match(source, /notify: VaultImportNotifier/);
|
||||
assert.match(source, /from ["']\.\/vaultImportOptions["']/);
|
||||
});
|
||||
@@ -58,7 +58,6 @@ import {
|
||||
} from "../../domain/globalHistory";
|
||||
import {
|
||||
buildTerminalDataMapFromLogs,
|
||||
connectionLogTerminalDataMapsEqual,
|
||||
mergeConnectionLogsFromStorage,
|
||||
mergeTerminalDataIntoLogs,
|
||||
mergeTerminalDataMapsForStorage,
|
||||
@@ -66,10 +65,6 @@ import {
|
||||
} from "../../domain/connectionLogTerminalData";
|
||||
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
|
||||
import { loadSanitizedShellHistory } from "./shellHistoryPersistence";
|
||||
import {
|
||||
publishNotesSnapshot,
|
||||
registerNotesActions,
|
||||
} from "./notesStore";
|
||||
import { publishShellHistorySnapshot } from "./shellHistoryStore";
|
||||
import { setVaultInitialized } from "./vaultInitStore";
|
||||
import {
|
||||
@@ -262,7 +257,7 @@ const writeConnectionLogTerminalDataMap = (
|
||||
localMaps,
|
||||
buildPersistedLogIds(logs, usePersistedLogIdsFromDisk),
|
||||
);
|
||||
if (connectionLogTerminalDataMapsEqual(existing, pruned)) return true;
|
||||
if (JSON.stringify(existing) === JSON.stringify(pruned)) return true;
|
||||
return localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA, pruned);
|
||||
};
|
||||
|
||||
@@ -285,13 +280,9 @@ export const useVaultState = () => {
|
||||
const customGroupsRef = useRef<string[]>([]);
|
||||
const managedSourcesRef = useRef<ManagedSource[]>([]);
|
||||
const hostsRef = useRef<Host[]>([]);
|
||||
const notesRef = useRef<VaultNote[]>([]);
|
||||
const noteGroupsRef = useRef<string[]>([]);
|
||||
customGroupsRef.current = customGroups;
|
||||
managedSourcesRef.current = managedSources;
|
||||
hostsRef.current = hosts;
|
||||
notesRef.current = notes;
|
||||
noteGroupsRef.current = noteGroups;
|
||||
|
||||
// Write-version counters prevent out-of-order async writes from overwriting
|
||||
// newer data. Each update bumps the counter; the .then() callback only
|
||||
@@ -600,20 +591,14 @@ export const useVaultState = () => {
|
||||
|
||||
const updateNotes = useCallback((data: Partial<VaultNote>[]) => {
|
||||
const cleaned = normalizeVaultNotes(data);
|
||||
notesRef.current = cleaned;
|
||||
setNotes(cleaned);
|
||||
localStorageAdapter.write(STORAGE_KEY_NOTES, cleaned);
|
||||
// Publish synchronously so Notes→AI handoff does not read a stale catalog
|
||||
// while React state is still committing (useLayoutEffect republishes too).
|
||||
publishNotesSnapshot({ notes: cleaned, noteGroups: noteGroupsRef.current });
|
||||
}, []);
|
||||
|
||||
const updateNoteGroups = useCallback((data: unknown) => {
|
||||
const cleaned = normalizeNoteGroups(data);
|
||||
noteGroupsRef.current = cleaned;
|
||||
setNoteGroups(cleaned);
|
||||
localStorageAdapter.write(STORAGE_KEY_NOTE_GROUPS, cleaned);
|
||||
publishNotesSnapshot({ notes: notesRef.current, noteGroups: cleaned });
|
||||
}, []);
|
||||
|
||||
const updateCustomGroups = useCallback((
|
||||
@@ -843,18 +828,6 @@ export const useVaultState = () => {
|
||||
publishShellHistorySnapshot(shellHistory);
|
||||
}, [shellHistory]);
|
||||
|
||||
// Notes catalog for Notes / AI side panels — keep TerminalLayer off the hot path.
|
||||
useLayoutEffect(() => {
|
||||
publishNotesSnapshot({ notes, noteGroups });
|
||||
}, [notes, noteGroups]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
registerNotesActions({ updateNotes, updateNoteGroups });
|
||||
return () => {
|
||||
registerNotesActions(null);
|
||||
};
|
||||
}, [updateNotes, updateNoteGroups]);
|
||||
|
||||
const addShellHistoryEntry = useCallback(
|
||||
(entry: Omit<ShellHistoryEntry, "id" | "timestamp">) => {
|
||||
setShellHistory((prev) => {
|
||||
@@ -1016,10 +989,6 @@ export const useVaultState = () => {
|
||||
),
|
||||
);
|
||||
setHosts(sanitized);
|
||||
// Always re-encrypt the batch. Stale enc:v1 placeholders are left
|
||||
// unchanged by encryptCredentialValue (no double-wrap); plaintext
|
||||
// siblings still need encryption when safeStorage was previously
|
||||
// unavailable for some records.
|
||||
encryptHosts(sanitized).then((enc) => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
@@ -1402,12 +1371,9 @@ export const useVaultState = () => {
|
||||
|
||||
const updateHostLastConnected = useCallback((hostId: string) => {
|
||||
setHosts((prev) => {
|
||||
const idx = prev.findIndex((h) => h.id === hostId);
|
||||
if (idx < 0) return prev;
|
||||
const now = Date.now();
|
||||
if (prev[idx].lastConnectedAt === now) return prev;
|
||||
const next = prev.slice();
|
||||
next[idx] = { ...prev[idx], lastConnectedAt: now };
|
||||
const next = prev.map((h) =>
|
||||
h.id === hostId ? { ...h, lastConnectedAt: Date.now() } : h,
|
||||
);
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
const encryptPromise = encryptHosts(next);
|
||||
hostsEncryptPendingRef.current = encryptPromise.then(() => undefined);
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { VaultImportDestination } from "../../domain/vaultImport";
|
||||
import type { VaultImportFileEncoding } from "./vaultImportFile";
|
||||
|
||||
/** Options for a vault host-file import (kept out of UI modules). */
|
||||
export type VaultImportOptions = {
|
||||
managed?: boolean;
|
||||
filePath?: string;
|
||||
encoding?: VaultImportFileEncoding;
|
||||
destination?: VaultImportDestination;
|
||||
};
|
||||
|
||||
/** Notification sink so application/state hooks do not import UI toast. */
|
||||
export type VaultImportNotifier = {
|
||||
success: (message: string, title?: string) => void;
|
||||
error: (message: string, title?: string) => void;
|
||||
warning: (message: string, title?: string) => void;
|
||||
};
|
||||
+28
-143
@@ -139,56 +139,6 @@ test("sync payloads preserve opaque plugin hosts without requiring the plugin to
|
||||
assert.deepEqual(imported?.hosts, [pluginHost]);
|
||||
});
|
||||
|
||||
test("applySyncPayload preserves host startup command and appearance overrides (#2757)", async () => {
|
||||
const host = {
|
||||
id: "host-1",
|
||||
label: "Prod",
|
||||
hostname: "prod.example",
|
||||
username: "root",
|
||||
port: 22,
|
||||
protocol: "ssh" as const,
|
||||
tags: [],
|
||||
os: "linux" as const,
|
||||
startupCommand: "tmux attach || tmux",
|
||||
startupCommandRunMode: "lineDelay" as const,
|
||||
theme: "solarized-dark",
|
||||
themeOverride: true,
|
||||
};
|
||||
const groupConfigs = [
|
||||
{
|
||||
path: "prod",
|
||||
startupCommand: "cd /srv && exec bash -l",
|
||||
theme: "solarized-dark",
|
||||
themeOverride: true,
|
||||
},
|
||||
];
|
||||
const payload = buildSyncPayload({
|
||||
...vault(),
|
||||
hosts: [host],
|
||||
groupConfigs,
|
||||
});
|
||||
|
||||
assert.equal(payload.hosts[0]?.startupCommand, "tmux attach || tmux");
|
||||
assert.equal(payload.hosts[0]?.theme, "solarized-dark");
|
||||
assert.equal(payload.hosts[0]?.themeOverride, true);
|
||||
assert.equal(payload.groupConfigs?.[0]?.startupCommand, "cd /srv && exec bash -l");
|
||||
|
||||
let imported: Record<string, unknown> | null = null;
|
||||
await applySyncPayload(payload, {
|
||||
importVaultData: (json) => { imported = JSON.parse(json); },
|
||||
});
|
||||
|
||||
const importedHosts = imported?.hosts as typeof payload.hosts;
|
||||
assert.equal(importedHosts?.[0]?.startupCommand, "tmux attach || tmux");
|
||||
assert.equal(importedHosts?.[0]?.startupCommandRunMode, "lineDelay");
|
||||
assert.equal(importedHosts?.[0]?.theme, "solarized-dark");
|
||||
assert.equal(importedHosts?.[0]?.themeOverride, true);
|
||||
assert.equal(
|
||||
(imported?.groupConfigs as typeof groupConfigs)?.[0]?.startupCommand,
|
||||
"cd /srv && exec bash -l",
|
||||
);
|
||||
});
|
||||
|
||||
test("buildCloudSyncPayload includes notes and note groups", async () => {
|
||||
const payload = await buildCloudSyncPayload({
|
||||
...vault([]),
|
||||
@@ -212,13 +162,13 @@ test("buildSyncPayload includes AI configuration settings", () => {
|
||||
id: "openai-main",
|
||||
providerId: "openai",
|
||||
name: "OpenAI",
|
||||
apiKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
apiKey: "enc:v1:test",
|
||||
defaultModel: "gpt-test",
|
||||
enabled: true,
|
||||
}];
|
||||
const webSearch = {
|
||||
providerId: "tavily",
|
||||
apiKey: "enc:v1:djEwd2ViAAAAAAAAAAAAAAAAAA==",
|
||||
apiKey: "enc:v1:web",
|
||||
enabled: true,
|
||||
maxResults: 7,
|
||||
};
|
||||
@@ -239,11 +189,8 @@ test("buildSyncPayload includes AI configuration settings", () => {
|
||||
|
||||
const payload = buildSyncPayload(vault([]));
|
||||
|
||||
// Device-bound enc:v1 apiKeys are stripped from portable sync settings.
|
||||
const { apiKey: _providerKey, ...providerWithoutKey } = providers[0]!;
|
||||
const { apiKey: _webKey, ...webSearchWithoutKey } = webSearch;
|
||||
assert.deepEqual(payload.settings?.ai, {
|
||||
providers: [providerWithoutKey],
|
||||
providers,
|
||||
activeProviderId: "openai-main",
|
||||
activeModelId: "gpt-test",
|
||||
globalPermissionMode: "auto",
|
||||
@@ -254,7 +201,7 @@ test("buildSyncPayload includes AI configuration settings", () => {
|
||||
maxIterations: 10,
|
||||
agentModelMap: { codex: "gpt-test" },
|
||||
agentProviderMap: { catty: "openai-main" },
|
||||
webSearchConfig: webSearchWithoutKey,
|
||||
webSearchConfig: webSearch,
|
||||
showTerminalSelectionAction: false,
|
||||
});
|
||||
});
|
||||
@@ -343,12 +290,12 @@ test("buildSyncPayload omits device-bound encrypted AI API keys", () => {
|
||||
id: "openai-main",
|
||||
providerId: "openai",
|
||||
name: "OpenAI",
|
||||
apiKey: "enc:v1:djEwQUFBQQAAAAAAAAAAAAAAAA==",
|
||||
apiKey: "enc:v1:djEwAAAA",
|
||||
enabled: true,
|
||||
}]));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
|
||||
providerId: "tavily",
|
||||
apiKey: "enc:v1:djEwQUFBQQAAAAAAAAAAAAAAAA==",
|
||||
apiKey: "enc:v1:djEwAAAA",
|
||||
enabled: true,
|
||||
}));
|
||||
|
||||
@@ -363,8 +310,8 @@ test("buildCloudSyncPayload includes decrypted AI API keys for portable cloud sy
|
||||
value: {
|
||||
netcatty: {
|
||||
credentialsDecrypt: async (value: string) => {
|
||||
if (value === "enc:v1:djEwUFJPVklERVIAAAAAAAAAAA==") return "sk-provider";
|
||||
if (value === "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==") return "sk-web";
|
||||
if (value === "enc:v1:djEwPROVIDER") return "sk-provider";
|
||||
if (value === "enc:v1:djEwWEB") return "sk-web";
|
||||
return value;
|
||||
},
|
||||
},
|
||||
@@ -376,12 +323,12 @@ test("buildCloudSyncPayload includes decrypted AI API keys for portable cloud sy
|
||||
id: "openai-main",
|
||||
providerId: "openai",
|
||||
name: "OpenAI",
|
||||
apiKey: "enc:v1:djEwUFJPVklERVIAAAAAAAAAAA==",
|
||||
apiKey: "enc:v1:djEwPROVIDER",
|
||||
enabled: true,
|
||||
}]));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
|
||||
providerId: "tavily",
|
||||
apiKey: "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==",
|
||||
apiKey: "enc:v1:djEwWEB",
|
||||
enabled: true,
|
||||
}));
|
||||
|
||||
@@ -405,7 +352,7 @@ test("buildCloudSyncPayload fails instead of deleting API keys when decrypt fail
|
||||
id: "openai-main",
|
||||
providerId: "openai",
|
||||
name: "OpenAI",
|
||||
apiKey: "enc:v1:djEwUFJPVklERVIAAAAAAAAAAA==",
|
||||
apiKey: "enc:v1:djEwPROVIDER",
|
||||
enabled: true,
|
||||
}]));
|
||||
|
||||
@@ -420,12 +367,12 @@ test("applySyncPayload restores AI configuration settings", async () => {
|
||||
id: "anthropic-main",
|
||||
providerId: "anthropic",
|
||||
name: "Anthropic",
|
||||
apiKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
apiKey: "enc:v1:test",
|
||||
enabled: true,
|
||||
}];
|
||||
const webSearch = {
|
||||
providerId: "exa",
|
||||
apiKey: "enc:v1:djEwd2ViAAAAAAAAAAAAAAAAAA==",
|
||||
apiKey: "enc:v1:web",
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
@@ -476,12 +423,7 @@ test("applySyncPayload encrypts synced plaintext AI API keys before saving local
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: {
|
||||
netcatty: {
|
||||
credentialsEncrypt: async (value: string) => {
|
||||
const body = Buffer.alloc(19, 0);
|
||||
Buffer.from("v10", "utf8").copy(body, 0);
|
||||
Buffer.from(`LOCAL_${value}`.slice(0, 16), "utf8").copy(body, 3);
|
||||
return `enc:v1:${body.toString("base64")}`;
|
||||
},
|
||||
credentialsEncrypt: async (value: string) => `enc:v1:djEwLOCAL_${value}`,
|
||||
},
|
||||
dispatchEvent: () => true,
|
||||
},
|
||||
@@ -509,20 +451,8 @@ test("applySyncPayload encrypts synced plaintext AI API keys before saving local
|
||||
|
||||
const provider = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!)[0];
|
||||
const webSearch = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!);
|
||||
const expectedProviderKey = (() => {
|
||||
const body = Buffer.alloc(19, 0);
|
||||
Buffer.from("v10", "utf8").copy(body, 0);
|
||||
Buffer.from("LOCAL_sk-provider".slice(0, 16), "utf8").copy(body, 3);
|
||||
return `enc:v1:${body.toString("base64")}`;
|
||||
})();
|
||||
const expectedWebKey = (() => {
|
||||
const body = Buffer.alloc(19, 0);
|
||||
Buffer.from("v10", "utf8").copy(body, 0);
|
||||
Buffer.from("LOCAL_sk-web".slice(0, 16), "utf8").copy(body, 3);
|
||||
return `enc:v1:${body.toString("base64")}`;
|
||||
})();
|
||||
assert.equal(provider.apiKey, expectedProviderKey);
|
||||
assert.equal(webSearch.apiKey, expectedWebKey);
|
||||
assert.equal(provider.apiKey, "enc:v1:djEwLOCAL_sk-provider");
|
||||
assert.equal(webSearch.apiKey, "enc:v1:djEwLOCAL_sk-web");
|
||||
});
|
||||
|
||||
test("applySyncPayload restores host tree sidebar visibility setting", async () => {
|
||||
@@ -678,14 +608,14 @@ test("applySyncPayload preserves local AI provider apiKeys when synced payload o
|
||||
id: "openai-main",
|
||||
providerId: "openai",
|
||||
name: "OpenAI",
|
||||
apiKey: "enc:v1:djEwTE9DQUwAAAAAAAAAAAAAAA==",
|
||||
apiKey: "enc:v1:djEwLOCAL",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "anthropic-main",
|
||||
providerId: "anthropic",
|
||||
name: "Anthropic",
|
||||
apiKey: "enc:v1:djEwQU5USFJPUElDAAAAAAAAAA==",
|
||||
apiKey: "enc:v1:djEwANTHROPIC",
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
@@ -716,14 +646,14 @@ test("applySyncPayload preserves local AI provider apiKeys when synced payload o
|
||||
id: "openai-main",
|
||||
providerId: "openai",
|
||||
name: "OpenAI (renamed)",
|
||||
apiKey: "enc:v1:djEwTE9DQUwAAAAAAAAAAAAAAA==",
|
||||
apiKey: "enc:v1:djEwLOCAL",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "anthropic-main",
|
||||
providerId: "anthropic",
|
||||
name: "Anthropic",
|
||||
apiKey: "enc:v1:djEwQU5USFJPUElDAAAAAAAAAA==",
|
||||
apiKey: "enc:v1:djEwANTHROPIC",
|
||||
enabled: false,
|
||||
},
|
||||
]);
|
||||
@@ -731,7 +661,7 @@ test("applySyncPayload preserves local AI provider apiKeys when synced payload o
|
||||
|
||||
test("applySyncPayload prefers explicit synced apiKey over local apiKey", async () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([
|
||||
{ id: "openai-main", providerId: "openai", name: "OpenAI", apiKey: "enc:v1:djEwTE9DQUwAAAAAAAAAAAAAAA==", enabled: true },
|
||||
{ id: "openai-main", providerId: "openai", name: "OpenAI", apiKey: "enc:v1:djEwLOCAL", enabled: true },
|
||||
]));
|
||||
|
||||
const payload: SyncPayload = {
|
||||
@@ -759,7 +689,7 @@ test("applySyncPayload prefers explicit synced apiKey over local apiKey", async
|
||||
test("applySyncPayload preserves local web-search apiKey when synced config omits it", async () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
|
||||
providerId: "tavily",
|
||||
apiKey: "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==",
|
||||
apiKey: "enc:v1:djEwWEB",
|
||||
enabled: true,
|
||||
maxResults: 7,
|
||||
}));
|
||||
@@ -783,7 +713,7 @@ test("applySyncPayload preserves local web-search apiKey when synced config omit
|
||||
const stored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!);
|
||||
assert.deepEqual(stored, {
|
||||
providerId: "tavily",
|
||||
apiKey: "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==",
|
||||
apiKey: "enc:v1:djEwWEB",
|
||||
enabled: false,
|
||||
maxResults: 12,
|
||||
});
|
||||
@@ -792,7 +722,7 @@ test("applySyncPayload preserves local web-search apiKey when synced config omit
|
||||
test("applySyncPayload drops local web-search apiKey when synced config switches provider", async () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
|
||||
providerId: "tavily",
|
||||
apiKey: "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==",
|
||||
apiKey: "enc:v1:djEwWEB",
|
||||
enabled: true,
|
||||
}));
|
||||
|
||||
@@ -1066,19 +996,19 @@ test("buildLocalVaultPayload preserves local AI API keys for protective backups"
|
||||
id: "openai-main",
|
||||
providerId: "openai",
|
||||
name: "OpenAI",
|
||||
apiKey: "enc:v1:djEwUFJPVklERVIAAAAAAAAAAA==",
|
||||
apiKey: "enc:v1:djEwPROVIDER",
|
||||
enabled: true,
|
||||
}]));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
|
||||
providerId: "tavily",
|
||||
apiKey: "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==",
|
||||
apiKey: "enc:v1:djEwWEB",
|
||||
enabled: true,
|
||||
}));
|
||||
|
||||
const payload = buildLocalVaultPayload(vault([]));
|
||||
|
||||
assert.equal(payload.settings?.ai?.providers?.[0]?.apiKey, "enc:v1:djEwUFJPVklERVIAAAAAAAAAAA==");
|
||||
assert.equal(payload.settings?.ai?.webSearchConfig?.apiKey, "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==");
|
||||
assert.equal(payload.settings?.ai?.providers?.[0]?.apiKey, "enc:v1:djEwPROVIDER");
|
||||
assert.equal(payload.settings?.ai?.webSearchConfig?.apiKey, "enc:v1:djEwWEB");
|
||||
});
|
||||
|
||||
test("applySyncPayload ignores legacy cloud known hosts", async () => {
|
||||
@@ -1517,51 +1447,6 @@ test("prepareLocalVaultPayloadApply does not import until the prepared callback
|
||||
assert.deepEqual(calls, ["prepare", "import", "commit"]);
|
||||
});
|
||||
|
||||
test("prepareLocalVaultPayloadApply sanitizes enc:v1 before convergent prepare and apply", async () => {
|
||||
const completeBlob = Buffer.alloc(31, 0);
|
||||
Buffer.from("v10", "utf8").copy(completeBlob, 0);
|
||||
const ENC = `enc:v1:${completeBlob.toString("base64")}`;
|
||||
const preparedPayloads: SyncPayload[] = [];
|
||||
const imported: Array<Record<string, unknown>> = [];
|
||||
|
||||
const applyPreparedPayload = await prepareLocalVaultPayloadApply({
|
||||
hosts: [{
|
||||
id: "h1",
|
||||
label: "prod",
|
||||
hostname: "prod.example",
|
||||
username: "root",
|
||||
password: ENC,
|
||||
port: 22,
|
||||
os: "linux",
|
||||
group: "",
|
||||
tags: [],
|
||||
protocol: "ssh",
|
||||
}],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
}, {
|
||||
importVaultData: (json) => {
|
||||
imported.push(JSON.parse(json) as Record<string, unknown>);
|
||||
},
|
||||
}, {
|
||||
prepareConvergentRestore: async (payload) => {
|
||||
preparedPayloads.push(payload);
|
||||
return async () => undefined;
|
||||
},
|
||||
});
|
||||
|
||||
await applyPreparedPayload();
|
||||
|
||||
assert.equal(preparedPayloads.length, 1);
|
||||
assert.equal(preparedPayloads[0]?.hosts[0]?.password, undefined);
|
||||
assert.equal(imported.length, 1);
|
||||
const hosts = imported[0]?.hosts as Array<{ password?: string }> | undefined;
|
||||
assert.equal(hosts?.[0]?.password, undefined);
|
||||
});
|
||||
|
||||
test("applyLocalVaultPayload leaves local data untouched when convergent preparation fails", async () => {
|
||||
let imported = false;
|
||||
const payload: SyncPayload = {
|
||||
|
||||
+33
-48
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Sync Payload Builders - Single source of truth for constructing and applying
|
||||
* Sync Payload Builders — Single source of truth for constructing and applying
|
||||
* the encrypted cloud-sync payload.
|
||||
*
|
||||
* Both the main window (App.tsx) and the settings window (SettingsSyncTab.tsx)
|
||||
@@ -32,10 +32,7 @@ import {
|
||||
parseCustomKeyBindingsStorageRecord,
|
||||
serializeCustomKeyBindingsStorageRecord,
|
||||
} from '../domain/customKeyBindings';
|
||||
import {
|
||||
isEncryptedCredentialPlaceholder,
|
||||
stripSyncPayloadEncryptedCredentials,
|
||||
} from '../domain/credentials';
|
||||
import { isEncryptedCredentialPlaceholder } from '../domain/credentials';
|
||||
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
|
||||
import { decryptField, encryptField } from '../infrastructure/persistence/secureFieldAdapter';
|
||||
import { sanitizeQuickMessages } from '../infrastructure/ai/quickMessages';
|
||||
@@ -74,7 +71,6 @@ import {
|
||||
STORAGE_KEY_SFTP_AUTO_SYNC,
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_SFTP_SKIP_UNCHANGED,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
|
||||
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
|
||||
@@ -287,7 +283,6 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_SFTP_AUTO_SYNC,
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_SFTP_SKIP_UNCHANGED,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
|
||||
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
|
||||
@@ -389,7 +384,7 @@ const mergeAiProvidersPreservingLocalApiKeys = (
|
||||
|
||||
/**
|
||||
* Same rationale as `mergeAiProvidersPreservingLocalApiKeys`. Only restores the
|
||||
* local apiKey when the incoming config still points at the same providerId -
|
||||
* local apiKey when the incoming config still points at the same providerId —
|
||||
* switching providers must not silently leak a key meant for a different one.
|
||||
*/
|
||||
const mergeWebSearchConfigPreservingLocalApiKey = (
|
||||
@@ -492,8 +487,6 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (hidden === 'true' || hidden === 'false') settings.sftpShowHiddenFiles = hidden === 'true';
|
||||
const compress = localStorageAdapter.readString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD);
|
||||
if (compress === 'true' || compress === 'false') settings.sftpUseCompressedUpload = compress === 'true';
|
||||
const skipUnchanged = localStorageAdapter.readBoolean(STORAGE_KEY_SFTP_SKIP_UNCHANGED);
|
||||
if (skipUnchanged != null) settings.sftpSkipUnchanged = skipUnchanged;
|
||||
const autoOpenSidebar = localStorageAdapter.readString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
|
||||
if (autoOpenSidebar === 'true' || autoOpenSidebar === 'false') settings.sftpAutoOpenSidebar = autoOpenSidebar === 'true';
|
||||
const followTerminalCwd = localStorageAdapter.readString(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD);
|
||||
@@ -501,7 +494,7 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
const defaultViewMode = localStorageAdapter.readString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
|
||||
if (defaultViewMode === 'list' || defaultViewMode === 'tree') settings.sftpDefaultViewMode = defaultViewMode;
|
||||
|
||||
// SFTP Bookmarks (global only - local bookmarks are device-specific)
|
||||
// SFTP Bookmarks (global only — local bookmarks are device-specific)
|
||||
const globalBookmarks = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS);
|
||||
if (globalBookmarks && Array.isArray(globalBookmarks)) settings.sftpGlobalBookmarks = globalBookmarks;
|
||||
|
||||
@@ -670,7 +663,7 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB, settings.terminalSidePanelAutoOpenTab);
|
||||
}
|
||||
|
||||
// Terminal settings - merge with existing to preserve platform-specific keys
|
||||
// Terminal settings — merge with existing to preserve platform-specific keys
|
||||
if (settings.terminalSettings) {
|
||||
let existing: Record<string, unknown> = {};
|
||||
const raw = localStorageAdapter.readString(STORAGE_KEY_TERM_SETTINGS);
|
||||
@@ -730,7 +723,6 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
|
||||
if (settings.sftpAutoSync != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_SYNC, String(settings.sftpAutoSync));
|
||||
if (settings.sftpShowHiddenFiles != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, String(settings.sftpShowHiddenFiles));
|
||||
if (settings.sftpUseCompressedUpload != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, String(settings.sftpUseCompressedUpload));
|
||||
if (settings.sftpSkipUnchanged != null) localStorageAdapter.writeBoolean(STORAGE_KEY_SFTP_SKIP_UNCHANGED, settings.sftpSkipUnchanged);
|
||||
if (settings.sftpAutoOpenSidebar != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, String(settings.sftpAutoOpenSidebar));
|
||||
if (settings.sftpFollowTerminalCwd != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD, String(settings.sftpFollowTerminalCwd));
|
||||
if (settings.sftpDefaultViewMode != null) {
|
||||
@@ -811,7 +803,7 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
|
||||
// After all AI writes, reconcile per-agent bindings against the final
|
||||
// provider list. Sync payloads can land with a new `providers` set but
|
||||
// no `agentProviderMap`, or with a stale `agentProviderMap` that
|
||||
// points at ids the synced provider set doesn't include - either way
|
||||
// points at ids the synced provider set doesn't include — either way
|
||||
// we'd leak overrides bound to ghost providers. Mirrors the same
|
||||
// cleanup `removeProvider` does for explicit user deletes.
|
||||
pruneOrphanPerAgentBindings();
|
||||
@@ -876,7 +868,7 @@ function pruneOrphanPerAgentBindings(): void {
|
||||
nextProviderMap[agentId] = providerId;
|
||||
} else {
|
||||
providerChanged = true;
|
||||
// Drop the saved model too - that id belonged to the now-missing
|
||||
// Drop the saved model too — that id belonged to the now-missing
|
||||
// provider and isn't trustworthy against any other binding.
|
||||
if (agentId in nextModelMap) {
|
||||
delete nextModelMap[agentId];
|
||||
@@ -1087,35 +1079,31 @@ function applyPayload(
|
||||
applyPluginSidecars?: PluginSyncSidecarApplier;
|
||||
},
|
||||
): Promise<void> {
|
||||
// Portable payloads must never keep device-bound enc:v1 blobs. Strip them
|
||||
// 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 legacyLineTimestampsEnabled = sanitizedPayload.settings?.terminalSettings?.showLineTimestamps === true;
|
||||
const legacyLineTimestampsEnabled = payload.settings?.terminalSettings?.showLineTimestamps === true;
|
||||
// Build the vault import object. Cloud sync intentionally ignores
|
||||
// local-only trust records even if legacy cloud snapshots still carry them.
|
||||
const vaultImport: Record<string, unknown> = {
|
||||
hosts: migrateHostsFromLegacyLineTimestamps(sanitizedPayload.hosts, legacyLineTimestampsEnabled),
|
||||
keys: sanitizedPayload.keys,
|
||||
identities: sanitizedPayload.identities,
|
||||
proxyProfiles: sanitizedPayload.proxyProfiles,
|
||||
snippets: sanitizedPayload.snippets,
|
||||
customGroups: sanitizedPayload.customGroups,
|
||||
hosts: migrateHostsFromLegacyLineTimestamps(payload.hosts, legacyLineTimestampsEnabled),
|
||||
keys: payload.keys,
|
||||
identities: payload.identities,
|
||||
proxyProfiles: payload.proxyProfiles,
|
||||
snippets: payload.snippets,
|
||||
customGroups: payload.customGroups,
|
||||
};
|
||||
if (sanitizedPayload.snippetPackages !== undefined) {
|
||||
vaultImport.snippetPackages = sanitizedPayload.snippetPackages;
|
||||
if (payload.snippetPackages !== undefined) {
|
||||
vaultImport.snippetPackages = payload.snippetPackages;
|
||||
}
|
||||
if (sanitizedPayload.notes !== undefined) {
|
||||
vaultImport.notes = sanitizedPayload.notes;
|
||||
if (payload.notes !== undefined) {
|
||||
vaultImport.notes = payload.notes;
|
||||
}
|
||||
if (sanitizedPayload.noteGroups !== undefined) {
|
||||
vaultImport.noteGroups = sanitizedPayload.noteGroups;
|
||||
if (payload.noteGroups !== undefined) {
|
||||
vaultImport.noteGroups = payload.noteGroups;
|
||||
}
|
||||
if (options.includeLocalOnlyData && sanitizedPayload.knownHosts !== undefined) {
|
||||
vaultImport.knownHosts = sanitizedPayload.knownHosts;
|
||||
if (options.includeLocalOnlyData && payload.knownHosts !== undefined) {
|
||||
vaultImport.knownHosts = payload.knownHosts;
|
||||
}
|
||||
if (Array.isArray(sanitizedPayload.groupConfigs)) {
|
||||
vaultImport.groupConfigs = sanitizedPayload.groupConfigs;
|
||||
if (Array.isArray(payload.groupConfigs)) {
|
||||
vaultImport.groupConfigs = payload.groupConfigs;
|
||||
}
|
||||
|
||||
return Promise.resolve(importers.importVaultData(JSON.stringify(vaultImport))).then(async () => {
|
||||
@@ -1123,23 +1111,23 @@ function applyPayload(
|
||||
// them. Absent field = "payload was created before this feature existed",
|
||||
// so local rules are preserved. Explicitly present [] = "remote has no
|
||||
// rules, clear local state".
|
||||
if (sanitizedPayload.portForwardingRules !== undefined && importers.importPortForwardingRules) {
|
||||
importers.importPortForwardingRules(sanitizedPayload.portForwardingRules);
|
||||
if (payload.portForwardingRules !== undefined && importers.importPortForwardingRules) {
|
||||
importers.importPortForwardingRules(payload.portForwardingRules);
|
||||
}
|
||||
|
||||
// Apply synced settings
|
||||
if (sanitizedPayload.settings) {
|
||||
await applySyncableSettings(sanitizedPayload.settings);
|
||||
if (payload.settings) {
|
||||
await applySyncableSettings(payload.settings);
|
||||
// Rehydrate in-memory bookmark snapshot after localStorage was updated
|
||||
if (sanitizedPayload.settings.sftpGlobalBookmarks != null) rehydrateGlobalSftpBookmarks();
|
||||
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalSftpBookmarks();
|
||||
importers.onSettingsApplied?.();
|
||||
}
|
||||
|
||||
// Plugin encrypted sidecars: only apply when the field is present so
|
||||
// legacy payloads without pluginSidecars do not wipe installed settings.
|
||||
if (Object.prototype.hasOwnProperty.call(sanitizedPayload, 'pluginSidecars')) {
|
||||
if (Object.prototype.hasOwnProperty.call(payload, 'pluginSidecars')) {
|
||||
const applySidecars = options.applyPluginSidecars ?? defaultApplyPluginSyncSidecars;
|
||||
await applySidecars(sanitizedPayload.pluginSidecars ?? { version: 1, entries: [] });
|
||||
await applySidecars(payload.pluginSidecars ?? { version: 1, entries: [] });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1166,14 +1154,11 @@ export async function prepareLocalVaultPayloadApply(
|
||||
) => Promise<() => Promise<void>>;
|
||||
} = {},
|
||||
): Promise<() => Promise<void>> {
|
||||
// Sanitize once so vault import and convergent replica commit share the
|
||||
// same portable secrets view (no device-bound enc:v1 leftovers).
|
||||
const sanitizedPayload = stripSyncPayloadEncryptedCredentials(payload);
|
||||
const prepareConvergentRestore = dependencies.prepareConvergentRestore
|
||||
?? prepareRestoredPayloadConvergentWrites;
|
||||
const commitConvergentRestore = await prepareConvergentRestore(sanitizedPayload);
|
||||
const commitConvergentRestore = await prepareConvergentRestore(payload);
|
||||
return async () => {
|
||||
await applyPayload(sanitizedPayload, importers, { includeLocalOnlyData: true });
|
||||
await applyPayload(payload, importers, { includeLocalOnlyData: true });
|
||||
await commitConvergentRestore();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ test("CSV credentials exclude an explicitly unsaved Keychain passphrase for a di
|
||||
test("CSV credentials warn when a saved reference-key passphrase cannot be read", async () => {
|
||||
const result = await buildVaultCsvCredentialOptions(
|
||||
[host("key-1")],
|
||||
[referenceKey({ savePassphrase: true, passphrase: "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==" })],
|
||||
[referenceKey({ savePassphrase: true, passphrase: "enc:v1:djEwYWJj" })],
|
||||
async () => ({ values: [], unreadable: false, present: false }),
|
||||
);
|
||||
|
||||
|
||||
@@ -49,8 +49,8 @@ interface AIChatPanelContentProps {
|
||||
handleDeleteSession: (event: React.MouseEvent, sessionId: string) => void;
|
||||
messages: ChatMessage[];
|
||||
isStreaming: boolean;
|
||||
activeCompaction?: import('../application/state/useAgentCompactionUi').ActiveCompactionUi | null;
|
||||
contextUsage?: import('../application/state/useAgentCompactionUi').AgentContextUsage | null;
|
||||
activeCompaction?: import('./ai/hooks/useAgentCompactionUi').ActiveCompactionUi | null;
|
||||
contextUsage?: import('./ai/hooks/useAgentCompactionUi').AgentContextUsage | null;
|
||||
inputValue: string;
|
||||
setInputValue: (value: string) => void;
|
||||
handleSend: () => void;
|
||||
|
||||
@@ -252,68 +252,3 @@ test('AI side panel skips re-render when only a sibling scope session object cha
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('AI side panel skips re-render when sibling stream only bumps updatedAt', () => {
|
||||
const own = session({
|
||||
id: 'session-1',
|
||||
scope: { type: 'terminal', targetId: 'terminal-1' },
|
||||
updatedAt: 10,
|
||||
});
|
||||
const siblingA = session({
|
||||
id: 'session-2',
|
||||
scope: { type: 'terminal', targetId: 'terminal-2' },
|
||||
title: 'Sibling',
|
||||
updatedAt: 10,
|
||||
});
|
||||
const siblingStreamed = session({
|
||||
id: 'session-2',
|
||||
scope: { type: 'terminal', targetId: 'terminal-2' },
|
||||
title: 'Sibling',
|
||||
updatedAt: 99,
|
||||
});
|
||||
const prev = baseProps({
|
||||
isVisible: true,
|
||||
scopeType: 'terminal',
|
||||
scopeTargetId: 'terminal-1',
|
||||
sessions: [own, siblingA],
|
||||
});
|
||||
assert.equal(
|
||||
aiChatSidePanelPropsAreEqual(prev, {
|
||||
...prev,
|
||||
sessions: [own, siblingStreamed],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('AI side panel re-renders when sibling history chrome title changes', () => {
|
||||
const own = session({
|
||||
id: 'session-1',
|
||||
scope: { type: 'terminal', targetId: 'terminal-1' },
|
||||
});
|
||||
const siblingA = session({
|
||||
id: 'session-2',
|
||||
scope: { type: 'terminal', targetId: 'terminal-2' },
|
||||
title: 'Old',
|
||||
updatedAt: 10,
|
||||
});
|
||||
const siblingRenamed = session({
|
||||
id: 'session-2',
|
||||
scope: { type: 'terminal', targetId: 'terminal-2' },
|
||||
title: 'New',
|
||||
updatedAt: 10,
|
||||
});
|
||||
const prev = baseProps({
|
||||
isVisible: true,
|
||||
scopeType: 'terminal',
|
||||
scopeTargetId: 'terminal-1',
|
||||
sessions: [own, siblingA],
|
||||
});
|
||||
assert.equal(
|
||||
aiChatSidePanelPropsAreEqual(prev, {
|
||||
...prev,
|
||||
sessions: [own, siblingRenamed],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -49,7 +49,7 @@ import {
|
||||
getNetcattyBridge,
|
||||
isAIChatSessionStreaming,
|
||||
type DefaultTargetSessionHint,
|
||||
} from '../application/state/useAIChatStreaming';
|
||||
} from './ai/hooks/useAIChatStreaming';
|
||||
import { getScopedHistorySessions } from './ai/scopedHistorySessions';
|
||||
import { aiSessionIdSetEqual, exactScopeAISessionsEqual } from '../domain/aiSessionsForScope';
|
||||
import { buildExternalAgentHistoryMessagesForBridge } from './ai/externalAgentHistory';
|
||||
@@ -60,7 +60,7 @@ import { stopAgentTurn } from '../infrastructure/ai/harness/agentStop';
|
||||
import { getAgentRuntime } from '../infrastructure/ai/harness/globalAgentRuntime';
|
||||
import { useAIPermissionGrantsState } from '../application/state/useAIPermissionGrantsState';
|
||||
import { useConversationExport } from './ai/hooks/useConversationExport';
|
||||
import { useAgentContextUsage } from '../application/state/useAgentCompactionUi';
|
||||
import { useAgentContextUsage } from './ai/hooks/useAgentCompactionUi';
|
||||
import type { AIChatSidePanelProps } from './AIChatSidePanel.types';
|
||||
import {
|
||||
buildCursorListModelsAgentEnv,
|
||||
@@ -1599,23 +1599,12 @@ export function aiChatSidePanelPropsAreEqual(
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
// History drawer / recent list need create/delete/title chrome for the full
|
||||
// list (fuzzy ranking still receives every session). updatedAt is compared
|
||||
// for this panel's exact scope (+ selected resume) and, for workspace
|
||||
// panels, sibling workspace timestamps so history sort/relative time stay
|
||||
// fresh — without letting unrelated terminal stream ticks invalidate.
|
||||
// History drawer / recent list need create/delete/title/updatedAt chrome.
|
||||
// Only when the panel is (or becomes) visible so hidden retained panels do
|
||||
// not re-render on every sibling stream that bumps updatedAt.
|
||||
const prevVisible = prev.isVisible ?? true;
|
||||
const nextVisible = next.isVisible ?? true;
|
||||
if (
|
||||
(prevVisible || nextVisible)
|
||||
&& !aiSessionIdSetEqual(
|
||||
prev.sessions,
|
||||
next.sessions,
|
||||
prev.scopeType,
|
||||
prev.scopeTargetId,
|
||||
selectedSessionId,
|
||||
)
|
||||
) {
|
||||
if ((prevVisible || nextVisible) && !aiSessionIdSetEqual(prev.sessions, next.sessions)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
stripSyncPayloadEncryptedCredentials,
|
||||
} from '../domain/credentials';
|
||||
import {
|
||||
isProviderReadyForSync,
|
||||
@@ -676,10 +675,9 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
// requested provider fails, another provider can verify a newer
|
||||
// joined replica that must be applied before reporting the error.
|
||||
if (result.mergedPayload && !result.mergedPayloadApplied && onApplyPayload) {
|
||||
const portableMerged = stripSyncPayloadEncryptedCredentials(result.mergedPayload);
|
||||
await Promise.resolve(onApplyPayload(portableMerged));
|
||||
await Promise.resolve(onApplyPayload(result.mergedPayload));
|
||||
if (result.remoteFile) {
|
||||
await sync.commitRemoteInspection(result.provider, result.remoteFile, portableMerged, {
|
||||
await sync.commitRemoteInspection(result.provider, result.remoteFile, result.mergedPayload, {
|
||||
recordDownload: true,
|
||||
});
|
||||
}
|
||||
@@ -705,14 +703,13 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
// USE_REMOTE applies cloud data over local — same data-loss
|
||||
// shape as a local backup restore, so gate auto-sync in
|
||||
// every other window the same way.
|
||||
const portableRemote = stripSyncPayloadEncryptedCredentials(remoteResult.payload);
|
||||
await withRestoreBarrier(async () => {
|
||||
await Promise.resolve(onApplyPayload(portableRemote));
|
||||
await Promise.resolve(onApplyPayload(remoteResult.payload));
|
||||
});
|
||||
await sync.commitRemoteInspection(
|
||||
remoteResult.provider,
|
||||
remoteResult.remoteFile,
|
||||
portableRemote,
|
||||
remoteResult.payload,
|
||||
{ recordDownload: true },
|
||||
);
|
||||
toast.success(t('cloudSync.resolve.downloaded'));
|
||||
@@ -749,10 +746,9 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
// reflects what's now on cloud (in case remote changed during the merge).
|
||||
for (const result of (results as Map<CloudProvider, SyncResult>).values()) {
|
||||
if (result.mergedPayload && !result.mergedPayloadApplied) {
|
||||
const portableMerged = stripSyncPayloadEncryptedCredentials(result.mergedPayload);
|
||||
await Promise.resolve(onApplyPayload(portableMerged));
|
||||
await Promise.resolve(onApplyPayload(result.mergedPayload));
|
||||
if (result.remoteFile) {
|
||||
await sync.commitRemoteInspection(result.provider, result.remoteFile, portableMerged, {
|
||||
await sync.commitRemoteInspection(result.provider, result.remoteFile, result.mergedPayload, {
|
||||
recordDownload: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -129,11 +129,8 @@ test("directory parent detection matches side-queue file-count semantics", () =>
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("folder parent progress uses progressive discovery labels not byte formatting", () => {
|
||||
test("folder parent progress uses file counts not byte formatting", () => {
|
||||
const t = (key: string, params?: Record<string, string | number>) => {
|
||||
if (key === "sftp.transfers.filesDiscoveredProgress") {
|
||||
return `${params?.completed} done · ${params?.discovered} found`;
|
||||
}
|
||||
if (key === "sftp.transfers.filesProgress") {
|
||||
return `${params?.current}/${params?.total} files`;
|
||||
}
|
||||
@@ -149,35 +146,14 @@ test("folder parent progress uses progressive discovery labels not byte formatti
|
||||
totalBytes: 12,
|
||||
transferredBytes: 1,
|
||||
speed: 0,
|
||||
phase: "transferring",
|
||||
}, t);
|
||||
assert.equal(display.percent, (1 / 12) * 100);
|
||||
assert.equal(display.detail, "1 done · 12 found");
|
||||
assert.equal(display.detail, "1/12 files");
|
||||
assert.equal(display.indeterminate, false);
|
||||
// Must not look like "1 Bytes / 12 Bytes"
|
||||
assert.doesNotMatch(display.detail, /Bytes/i);
|
||||
});
|
||||
|
||||
test("folder parent scanning stays indeterminate while nothing has completed", () => {
|
||||
const t = (key: string, params?: Record<string, string | number>) => {
|
||||
if (key === "sftp.transfers.filesDiscoveredProgress") {
|
||||
return `${params?.completed} done · ${params?.discovered} found`;
|
||||
}
|
||||
return key;
|
||||
};
|
||||
const display = buildGlobalTransferProgressDisplay({
|
||||
status: "transferring",
|
||||
isDirectory: true,
|
||||
progressMode: "files",
|
||||
totalBytes: 40,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
phase: "scanning",
|
||||
}, t);
|
||||
assert.equal(display.detail, "0 done · 40 found");
|
||||
assert.equal(display.indeterminate, true);
|
||||
});
|
||||
|
||||
test("single-file progress still shows byte totals", () => {
|
||||
const t = (key: string) => key;
|
||||
const display = buildGlobalTransferProgressDisplay({
|
||||
|
||||
@@ -181,56 +181,28 @@ export type GlobalTransferProgressDisplay = {
|
||||
/**
|
||||
* Build progress labels for a top-level row. Directory parents use file counts
|
||||
* so we never show "1 Bytes / 12 Bytes" for n/m files.
|
||||
*
|
||||
* Folder walks interleave discovery with transfer: totalBytes is "found so far",
|
||||
* not a fixed grand total. Active rows use progressive copy so the UI does not
|
||||
* look like a lying overall percentage.
|
||||
*/
|
||||
export function buildGlobalTransferProgressDisplay(
|
||||
task: Pick<TransferTask, "status" | "isDirectory" | "parentTaskId" | "progressMode" | "totalBytes" | "transferredBytes" | "speed" | "phase">,
|
||||
task: Pick<TransferTask, "status" | "isDirectory" | "parentTaskId" | "progressMode" | "totalBytes" | "transferredBytes" | "speed">,
|
||||
t: (key: string, params?: Record<string, string | number>) => string,
|
||||
): GlobalTransferProgressDisplay {
|
||||
const isDirParent = isDirectoryParentTask(task);
|
||||
const percent = getGlobalTransferProgressPercent(task);
|
||||
const hasTotal = task.totalBytes > 0;
|
||||
const discovered = Math.max(task.totalBytes, task.transferredBytes);
|
||||
|
||||
if (isDirParent) {
|
||||
const isActive = task.status === "transferring"
|
||||
|| task.status === "pausing"
|
||||
|| task.status === "queued"
|
||||
|| task.status === "pending";
|
||||
// Still discovering, or no completed files yet — pulse rather than freeze a fake %.
|
||||
const indeterminate = isActive && (
|
||||
task.phase === "scanning"
|
||||
|| !hasTotal
|
||||
|| task.transferredBytes <= 0
|
||||
);
|
||||
const indeterminate = task.status === "transferring" && !hasTotal;
|
||||
let detail = "";
|
||||
if (isActive) {
|
||||
if (discovered > 0) {
|
||||
detail = t("sftp.transfers.filesDiscoveredProgress", {
|
||||
completed: task.transferredBytes,
|
||||
discovered,
|
||||
});
|
||||
}
|
||||
if (task.status === "transferring" || task.status === "pausing" || task.status === "queued" || task.status === "pending") {
|
||||
detail = hasTotal
|
||||
? t("sftp.transfers.filesProgress", { current: task.transferredBytes, total: task.totalBytes })
|
||||
: t("sftp.transfers.filesCount", { count: task.transferredBytes });
|
||||
} else if (task.status === "completed" && hasTotal) {
|
||||
detail = t("sftp.transfers.filesCount", { count: task.totalBytes });
|
||||
} else if (discovered > 0) {
|
||||
detail = t("sftp.transfers.filesDiscoveredProgress", {
|
||||
completed: task.transferredBytes,
|
||||
discovered,
|
||||
});
|
||||
} else if (hasTotal) {
|
||||
detail = t("sftp.transfers.filesProgress", { current: task.transferredBytes, total: task.totalBytes });
|
||||
}
|
||||
// Soft percent of files found so far (discovered may still grow).
|
||||
const softPercent = discovered > 0
|
||||
? Math.min(100, (task.transferredBytes / discovered) * 100)
|
||||
: 0;
|
||||
return {
|
||||
percent: task.status === "completed" ? 100 : softPercent,
|
||||
detail,
|
||||
indeterminate,
|
||||
};
|
||||
return { percent, detail, indeterminate };
|
||||
}
|
||||
|
||||
const detailParts: string[] = [];
|
||||
@@ -422,14 +394,6 @@ function TransferRow({
|
||||
if (task.status === "pausing" || task.status === "paused" || task.status === "interrupted") {
|
||||
return t(statusLabelKey(task.status));
|
||||
}
|
||||
// Scanning runs as pending/transferring; never override terminal statuses
|
||||
// (cancelled/failed/completed) that still carry a stale phase value.
|
||||
if (
|
||||
task.phase === "scanning"
|
||||
&& (task.status === "pending" || task.status === "queued" || task.status === "transferring")
|
||||
) {
|
||||
return t("sftp.transferCenter.phase.scanning");
|
||||
}
|
||||
if (task.phase && task.status === "transferring") {
|
||||
return t(`sftp.transferCenter.phase.${task.phase}`);
|
||||
}
|
||||
@@ -723,13 +687,6 @@ export function GlobalSftpTransferCenter() {
|
||||
// progress ticks used to re-render this TopTabs child on every byte.
|
||||
const badge = useSftpTransferCenterBadge();
|
||||
const [open, setOpen] = useState(false);
|
||||
// Folder drops fire this so the scanning row is visible immediately without
|
||||
// requiring the user to notice the badge first.
|
||||
useEffect(() => {
|
||||
const openCenter = () => setOpen(true);
|
||||
window.addEventListener("netcatty:open-sftp-transfer-center", openCenter);
|
||||
return () => window.removeEventListener("netcatty:open-sftp-transfer-center", openCenter);
|
||||
}, []);
|
||||
const snapshot = useSftpTransferCenterWhenOpen(open);
|
||||
const [bucket, setBucket] = useState<GlobalTransferBucket>("all");
|
||||
const [showBackground, setShowBackground] = useState(false);
|
||||
|
||||
@@ -406,7 +406,7 @@ test("validateProxyConfigForSave rejects unreadable proxy identity passwords", (
|
||||
label: "Proxy login",
|
||||
username: "proxy-user",
|
||||
authMethod: "password",
|
||||
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
created: 1,
|
||||
}],
|
||||
}),
|
||||
@@ -459,7 +459,7 @@ test("prepareProxyConfigForSave returns identity errors before save handlers bui
|
||||
label: "Proxy login",
|
||||
username: "",
|
||||
authMethod: "password",
|
||||
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
created: 1,
|
||||
}],
|
||||
}),
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { getKnownHostsGridColumnCount } from "./knownHostsGridLayout.ts";
|
||||
|
||||
test("known-host grids use container-width column steps", () => {
|
||||
assert.equal(getKnownHostsGridColumnCount(400), 2);
|
||||
assert.equal(getKnownHostsGridColumnCount(640), 3);
|
||||
assert.equal(getKnownHostsGridColumnCount(1000), 3);
|
||||
assert.equal(getKnownHostsGridColumnCount(1280), 4);
|
||||
assert.equal(getKnownHostsGridColumnCount(1800), 4);
|
||||
});
|
||||
|
||||
test("virtualized and non-virtual known hosts share container column policy", () => {
|
||||
const source = readFileSync(new URL("./KnownHostsManager.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /getColumnCount=\{\(width, mode\) =>/);
|
||||
assert.match(source, /getKnownHostsGridColumnCount\(width\)/);
|
||||
assert.match(source, /getElementContentWidth\(el\)/);
|
||||
assert.match(source, /gridTemplateColumns: `repeat\(\$\{gridColumns\}, minmax\(0, 1fr\)\)`/);
|
||||
assert.doesNotMatch(source, /sm:grid-cols-3|xl:grid-cols-4/);
|
||||
assert.doesNotMatch(source, /getKnownHostsGridColumnCount\(el\.clientWidth\)/);
|
||||
});
|
||||
|
||||
test("virtualized known-host cards expose a focus target for keyboard nav", () => {
|
||||
const source = readFileSync(new URL("./KnownHostsManager.tsx", import.meta.url), "utf8");
|
||||
// Grid and list HostItem roots both need a focus target for VirtualizedHostCollection.
|
||||
assert.equal([...source.matchAll(/data-vault-focus-target/g)].length, 2);
|
||||
assert.equal([...source.matchAll(/tabIndex=\{0\}/g)].length, 2);
|
||||
});
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
ContextMenuTrigger,
|
||||
} from "./ui/context-menu";
|
||||
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import { SortDropdown, SortMode } from "./ui/sort-dropdown";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
import { toast } from "./ui/toast";
|
||||
@@ -54,13 +55,8 @@ import {
|
||||
} from "./vault/VaultPageHeader";
|
||||
import { VaultDeleteConfirmDialog } from "./vault/VaultDeleteConfirmDialog";
|
||||
import { VaultEntityIcon, vaultPrimaryIconClass } from "./vault/VaultEntityIcon";
|
||||
import { VirtualizedHostCollection } from "./vault/VirtualizedHostCollection";
|
||||
import { getKnownHostsGridColumnCount } from "./knownHostsGridLayout";
|
||||
import { getElementContentWidth } from "./vault/vaultHostGridLayout";
|
||||
import { useVaultItemReorder } from "./vault/vaultReorderDrag";
|
||||
|
||||
const VIRTUALIZE_THRESHOLD = 30;
|
||||
|
||||
interface KnownHostsManagerProps {
|
||||
knownHosts: KnownHost[];
|
||||
hosts: Host[];
|
||||
@@ -211,8 +207,6 @@ const HostItem = React.memo<HostItemProps>(
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
{...reorderProps}
|
||||
data-vault-focus-target
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
reorderProps && "vault-drop-indicator-row",
|
||||
"group cursor-pointer soft-card elevate rounded-xl h-[68px] px-3 py-2",
|
||||
@@ -262,8 +256,6 @@ const HostItem = React.memo<HostItemProps>(
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
{...reorderProps}
|
||||
data-vault-focus-target
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
reorderProps && "vault-drop-indicator-row",
|
||||
"group flex items-center gap-3 px-3 py-2 h-14 rounded-lg hover:bg-secondary/60 transition-colors cursor-pointer",
|
||||
@@ -336,8 +328,8 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
const [sortMode, setSortMode] = useState<SortMode>("manual");
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const hasScannedRef = React.useRef(false);
|
||||
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [gridColumns, setGridColumns] = useState(2);
|
||||
const listRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const RENDER_LIMIT = 100; // Limit rendered items for performance
|
||||
|
||||
// Define handleScanSystem before useEffect that depends on it
|
||||
const handleScanSystem = useCallback(async (silent = false) => {
|
||||
@@ -462,25 +454,12 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
return sortMode === "manual" ? sortByVaultOrder(result) : result;
|
||||
}, [knownHosts, deferredSearch, sortMode]);
|
||||
|
||||
const shouldVirtualize = filteredHosts.length >= VIRTUALIZE_THRESHOLD;
|
||||
// Limit rendered items for performance
|
||||
const displayedHosts = useMemo(() => {
|
||||
return filteredHosts.slice(0, RENDER_LIMIT);
|
||||
}, [filteredHosts]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el || viewMode !== "grid" || typeof ResizeObserver === "undefined") return;
|
||||
const recompute = () => {
|
||||
setGridColumns(getKnownHostsGridColumnCount(getElementContentWidth(el)));
|
||||
};
|
||||
recompute();
|
||||
const observer = new ResizeObserver(recompute);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [viewMode, filteredHosts.length]);
|
||||
const collectionLayoutKey = [
|
||||
viewMode,
|
||||
deferredSearch.trim() ? "search" : "browse",
|
||||
sortMode,
|
||||
filteredHosts.length,
|
||||
].join("|");
|
||||
const hasMore = filteredHosts.length > RENDER_LIMIT;
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -562,22 +541,22 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
const openFilePicker = useCallback(() => fileInputRef.current?.click(), []);
|
||||
|
||||
const knownHostReorder = useVaultItemReorder({
|
||||
containerRef: scrollRef,
|
||||
containerRef: listRef,
|
||||
viewMode,
|
||||
dragType: "known-host-id",
|
||||
targetAttribute: "data-known-host-id",
|
||||
disabled: deferredSearch.trim().length > 0,
|
||||
// Virtualized grid cards can unmount mid-drag — use indicators, not live reorder.
|
||||
preferDropIndicator: shouldVirtualize,
|
||||
onReorder: (sourceId, targetId, position) => {
|
||||
onReorder(reorderVaultItems(knownHosts, sourceId, targetId, position));
|
||||
setSortMode("manual");
|
||||
},
|
||||
});
|
||||
|
||||
const renderKnownHostItem = useCallback(
|
||||
(knownHost: KnownHost) => (
|
||||
// Memoize the rendered list to prevent re-renders
|
||||
const renderedItems = useMemo(() => {
|
||||
return displayedHosts.map((knownHost) => (
|
||||
<HostItem
|
||||
key={knownHost.id}
|
||||
knownHost={knownHost}
|
||||
converted={convertedMap.get(knownHost.id) || false}
|
||||
viewMode={viewMode}
|
||||
@@ -585,15 +564,15 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
onDelete={handleDelete}
|
||||
onConvertToHost={handleConvertToHost}
|
||||
/>
|
||||
),
|
||||
[
|
||||
convertedMap,
|
||||
viewMode,
|
||||
handleDelete,
|
||||
handleConvertToHost,
|
||||
knownHostReorder,
|
||||
],
|
||||
);
|
||||
));
|
||||
}, [
|
||||
displayedHosts,
|
||||
convertedMap,
|
||||
viewMode,
|
||||
handleDelete,
|
||||
handleConvertToHost,
|
||||
knownHostReorder,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
@@ -678,75 +657,74 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
</VaultPageHeader>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-auto p-4"
|
||||
onDragOverCapture={knownHostReorder.handleDragOverCapture}
|
||||
onDragOver={knownHostReorder.handleDragOver}
|
||||
onDropCapture={knownHostReorder.handleDropCapture}
|
||||
onDragEndCapture={knownHostReorder.handleDragEndCapture}
|
||||
>
|
||||
{filteredHosts.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
|
||||
<Shield size={32} className="opacity-60" />
|
||||
<ScrollArea className="flex-1">
|
||||
<div
|
||||
ref={listRef}
|
||||
className={cn(
|
||||
"p-4",
|
||||
viewMode === "grid"
|
||||
? "grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-4 gap-3"
|
||||
: "flex flex-col gap-0",
|
||||
)}
|
||||
onDragOverCapture={knownHostReorder.handleDragOverCapture}
|
||||
onDragOver={knownHostReorder.handleDragOver}
|
||||
onDropCapture={knownHostReorder.handleDropCapture}
|
||||
onDragEndCapture={knownHostReorder.handleDragEndCapture}
|
||||
>
|
||||
{displayedHosts.length === 0 ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center py-16 text-muted-foreground",
|
||||
viewMode === "grid" && "col-span-full",
|
||||
)}
|
||||
>
|
||||
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
|
||||
<Shield size={32} className="opacity-60" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
{t("knownHosts.empty.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-center max-w-sm mb-4">
|
||||
{t("knownHosts.empty.desc")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => handleScanSystem()}
|
||||
disabled={isScanning}
|
||||
>
|
||||
<RefreshCw
|
||||
size={14}
|
||||
className={cn("mr-2", isScanning && "animate-spin")}
|
||||
/>
|
||||
{t("knownHosts.action.scanSystem")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={openFilePicker}>
|
||||
<FolderOpen size={14} className="mr-2" />
|
||||
{t("knownHosts.action.browseFile")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
{t("knownHosts.empty.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-center max-w-sm mb-4">
|
||||
{t("knownHosts.empty.desc")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => handleScanSystem()}
|
||||
disabled={isScanning}
|
||||
>
|
||||
<RefreshCw
|
||||
size={14}
|
||||
className={cn("mr-2", isScanning && "animate-spin")}
|
||||
/>
|
||||
{t("knownHosts.action.scanSystem")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={openFilePicker}>
|
||||
<FolderOpen size={14} className="mr-2" />
|
||||
{t("knownHosts.action.browseFile")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : shouldVirtualize ? (
|
||||
<VirtualizedHostCollection
|
||||
items={filteredHosts}
|
||||
itemKey={(knownHost) => knownHost.id}
|
||||
scrollRef={scrollRef}
|
||||
viewMode={viewMode}
|
||||
layoutKey={collectionLayoutKey}
|
||||
ariaLabel={t("vault.nav.knownHosts")}
|
||||
getColumnCount={(width, mode) => (
|
||||
mode === "grid" ? getKnownHostsGridColumnCount(width) : 1
|
||||
)}
|
||||
renderItem={renderKnownHostItem}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
viewMode === "grid"
|
||||
? "grid gap-3"
|
||||
: "flex flex-col gap-0",
|
||||
)}
|
||||
style={viewMode === "grid" ? {
|
||||
gridTemplateColumns: `repeat(${gridColumns}, minmax(0, 1fr))`,
|
||||
} : undefined}
|
||||
>
|
||||
{filteredHosts.map((knownHost) => (
|
||||
<React.Fragment key={knownHost.id}>
|
||||
{renderKnownHostItem(knownHost)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{renderedItems}
|
||||
{hasMore && (
|
||||
<div
|
||||
className={cn(
|
||||
"text-center py-4 text-sm text-muted-foreground",
|
||||
viewMode === "grid" && "col-span-full",
|
||||
)}
|
||||
>
|
||||
{t("knownHosts.results.showingLimited", {
|
||||
shown: displayedHosts.length,
|
||||
total: filteredHosts.length,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<VaultDeleteConfirmDialog
|
||||
open={Boolean(deleteTargetId)}
|
||||
title={t("vault.deleteConfirm.title", {
|
||||
|
||||
@@ -21,8 +21,6 @@ export interface PassphraseRequest {
|
||||
keyPath: string;
|
||||
keyName: string;
|
||||
hostname?: string;
|
||||
sessionId?: string;
|
||||
bootEpoch?: number;
|
||||
}
|
||||
|
||||
/** Opt-in only: do not default to remembering key passphrases (#2024). */
|
||||
|
||||
@@ -178,7 +178,7 @@ test("ProxyPanel warns when a saved proxy profile has an unreadable identity pas
|
||||
},
|
||||
}],
|
||||
selectedProxyProfileId: proxyProfile.id,
|
||||
identities: [{ ...proxyIdentity, password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==" }],
|
||||
identities: [{ ...proxyIdentity, password: "enc:v1:djEwAAAA" }],
|
||||
});
|
||||
|
||||
assert.match(markup, /Proxy identity password cannot be read/);
|
||||
|
||||
@@ -193,7 +193,7 @@ test("prepareProxyProfileForSave rejects unreadable proxy identity passwords", (
|
||||
identityId: proxyIdentity.id,
|
||||
},
|
||||
},
|
||||
[{ ...proxyIdentity, password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==" }],
|
||||
[{ ...proxyIdentity, password: "enc:v1:djEwAAAA" }],
|
||||
).error,
|
||||
"unreadableIdentity",
|
||||
);
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useSftpState } from "../application/state/useSftpState";
|
||||
import {
|
||||
useReportSftpTransferOwnerActivity,
|
||||
useWarmSftpTransferPool,
|
||||
} from "../application/state/sftp/useSftpTransferLifecycle";
|
||||
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
|
||||
import {
|
||||
@@ -60,7 +61,7 @@ import { SftpContextProvider } from "./sftp";
|
||||
import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks";
|
||||
import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs";
|
||||
import { useSftpKeyboardShortcuts } from "./sftp/hooks/useSftpKeyboardShortcuts";
|
||||
import { sftpFocusStore } from "../application/state/sftp/sftpFocusStore";
|
||||
import { sftpFocusStore } from "./sftp/hooks/useSftpFocusedPane";
|
||||
import { keepOnlyPaneSelections } from "./sftp/hooks/selectionScope";
|
||||
import { KeyBinding, HotkeyScheme } from "../domain/models";
|
||||
import {
|
||||
@@ -299,6 +300,12 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
const sftpRef = useRef(sftp);
|
||||
sftpRef.current = sftp;
|
||||
|
||||
useWarmSftpTransferPool({
|
||||
hostIds: connectedHosts.map((entry) => entry.host.id),
|
||||
activeHostId: activeHost?.protocol === "serial" ? undefined : activeHost?.id,
|
||||
warmTransferPoolForHost: sftp.warmTransferPoolForHost,
|
||||
});
|
||||
|
||||
const { getConnectionCacheKey, leftPane } = sftp;
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -64,70 +64,6 @@ test("renders active transfer cancel action with an item-specific label", () =>
|
||||
assert.match(markup, /aria-label="Pause: archive\.tar\.gz"/);
|
||||
});
|
||||
|
||||
test("directory parents show transfer-center done/found progress instead of Waiting", () => {
|
||||
const markup = renderTransferItem({
|
||||
...baseTask,
|
||||
id: "folder-1",
|
||||
fileName: "big-tree",
|
||||
isDirectory: true,
|
||||
progressMode: "files",
|
||||
status: "pending",
|
||||
phase: "transferring",
|
||||
transferredBytes: 65,
|
||||
totalBytes: 108202,
|
||||
error: undefined,
|
||||
speed: 0,
|
||||
});
|
||||
|
||||
assert.match(markup, /65 done · 108202 found/);
|
||||
assert.doesNotMatch(markup, />Waiting\.\.\.</);
|
||||
});
|
||||
|
||||
test("scanning directory parents keep scanning label with live discovery counts", () => {
|
||||
const markup = renderTransferItem({
|
||||
...baseTask,
|
||||
id: "folder-scan",
|
||||
fileName: "docs",
|
||||
isDirectory: true,
|
||||
progressMode: "files",
|
||||
status: "pending",
|
||||
phase: "scanning",
|
||||
transferredBytes: 0,
|
||||
totalBytes: 1284,
|
||||
error: undefined,
|
||||
speed: 0,
|
||||
});
|
||||
|
||||
assert.match(markup, /Scanning/);
|
||||
assert.match(markup, /0 done · 1284 found/);
|
||||
});
|
||||
|
||||
test("child rows reserve an action column wide enough for pause and cancel", () => {
|
||||
const markup = renderTransferItem(
|
||||
{
|
||||
...baseTask,
|
||||
id: "child-transfer-actions",
|
||||
parentTaskId: "transfer-1",
|
||||
status: "transferring",
|
||||
error: undefined,
|
||||
transferredBytes: 256,
|
||||
totalBytes: 1024,
|
||||
speed: 128,
|
||||
resumable: true,
|
||||
},
|
||||
{
|
||||
isChild: true,
|
||||
childNameColumnWidth: 260,
|
||||
onResizeNameColumn: () => {},
|
||||
onSetNameColumnWidth: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
assert.match(markup, /grid-template-columns:24px 260px 10px minmax\(0, 1fr\) 56px/);
|
||||
assert.match(markup, /aria-label="Pause: archive\.tar\.gz"/);
|
||||
assert.match(markup, /aria-label="Cancel: archive\.tar\.gz"/);
|
||||
});
|
||||
|
||||
test("renders paused transfer resume action", () => {
|
||||
const markup = renderTransferItem({
|
||||
...baseTask,
|
||||
|
||||
@@ -29,6 +29,7 @@ import { Host, Identity, KnownHost, ProxyProfile, SSHKey, TransferTask } from ".
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
|
||||
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
|
||||
import { useWarmSftpTransferPool } from "../application/state/sftp/useSftpTransferLifecycle";
|
||||
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
|
||||
import { toast } from "./ui/toast";
|
||||
|
||||
@@ -43,7 +44,7 @@ import { SftpContextProvider, activeTabStore } from "./sftp";
|
||||
import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks";
|
||||
import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs";
|
||||
import { useSftpKeyboardShortcuts } from "./sftp/hooks/useSftpKeyboardShortcuts";
|
||||
import { sftpFocusStore, SftpFocusedSide, useSftpFocusedSide } from "../application/state/sftp/sftpFocusStore";
|
||||
import { sftpFocusStore, SftpFocusedSide, useSftpFocusedSide } from "./sftp/hooks/useSftpFocusedPane";
|
||||
import { keepOnlyActivePaneSelections, keepOnlyPaneSelections } from "./sftp/hooks/selectionScope";
|
||||
|
||||
|
||||
@@ -185,6 +186,11 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
const sftpRef = useRef(sftp);
|
||||
sftpRef.current = sftp;
|
||||
|
||||
useWarmSftpTransferPool({
|
||||
hostIds: connectedHosts.map((entry) => entry.host.id),
|
||||
warmTransferPoolForHost: sftp.warmTransferPoolForHost,
|
||||
});
|
||||
|
||||
// Register this useSftpState's writeTextFileByConnection with the bridge so
|
||||
// the editor tab's save path can reach the active SFTP session. The bridge
|
||||
// supports multiple simultaneous writers (SftpSidePanel inside terminals
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { getSnippetsGridColumnCount } from "./snippetsGridLayout.ts";
|
||||
|
||||
test("full-width snippet grids use container-width column steps", () => {
|
||||
assert.equal(getSnippetsGridColumnCount(500, { hasSidePanel: false }), 1);
|
||||
assert.equal(getSnippetsGridColumnCount(768, { hasSidePanel: false }), 2);
|
||||
assert.equal(getSnippetsGridColumnCount(1279, { hasSidePanel: false }), 2);
|
||||
assert.equal(getSnippetsGridColumnCount(1280, { hasSidePanel: false }), 3);
|
||||
assert.equal(getSnippetsGridColumnCount(2000, { hasSidePanel: false }), 3);
|
||||
});
|
||||
|
||||
test("split-view snippet grids keep the card-width column count", () => {
|
||||
assert.equal(getSnippetsGridColumnCount(219, { hasSidePanel: true }), 1);
|
||||
assert.equal(getSnippetsGridColumnCount(452, { hasSidePanel: true }), 2);
|
||||
assert.equal(getSnippetsGridColumnCount(916, { hasSidePanel: true }), 4);
|
||||
});
|
||||
|
||||
test("virtualized and CSS snippet grids share container column policy", () => {
|
||||
const source = readFileSync(new URL("./SnippetsManager.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /getColumnCount=\{\(width, mode\) =>/);
|
||||
assert.match(source, /getSnippetsGridColumnCount\(width, \{ hasSidePanel: hasSnippetsSidePanel \}\)/);
|
||||
assert.match(source, /getSnippetsGridColumnCount\(getElementContentWidth\(el\)/);
|
||||
assert.match(source, /snippetGridStyle/);
|
||||
assert.doesNotMatch(source, /md:grid-cols-2|xl:grid-cols-3/);
|
||||
assert.doesNotMatch(source, /getSnippetsGridColumnCount\(el\.clientWidth/);
|
||||
});
|
||||
|
||||
test("virtualized snippet drops reset drag state without waiting for dragend", () => {
|
||||
const source = readFileSync(new URL("./SnippetsManager.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /handleReorderDrop = useCallback/);
|
||||
assert.match(source, /finally \{\s*\/\/ Virtualized cards[\s\S]*resetSnippetDragState\(\);/);
|
||||
assert.match(source, /resetSnippetDragState,/);
|
||||
});
|
||||
+150
-187
@@ -49,9 +49,6 @@ import {
|
||||
vaultSnippetIconClass,
|
||||
} from './vault/VaultEntityIcon';
|
||||
import { VaultDeleteConfirmDialog } from './vault/VaultDeleteConfirmDialog';
|
||||
import { VirtualizedHostCollection } from './vault/VirtualizedHostCollection';
|
||||
import { getSnippetsGridColumnCount } from './snippetsGridLayout';
|
||||
import { getElementContentWidth } from './vault/vaultHostGridLayout';
|
||||
import {
|
||||
clearVaultDropIndicator as clearSnippetDropIndicator,
|
||||
getVaultDropIntent as getPackageDropIntent,
|
||||
@@ -62,8 +59,6 @@ import {
|
||||
useVaultGridLayoutAnimation,
|
||||
} from './vault/vaultReorderDrag';
|
||||
|
||||
const VIRTUALIZE_THRESHOLD = 30;
|
||||
|
||||
interface SnippetsManagerProps {
|
||||
snippets: Snippet[];
|
||||
packages: string[];
|
||||
@@ -526,18 +521,22 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
const prepareGridLayoutAnimation = useVaultGridLayoutAnimation(listRef);
|
||||
const hasSnippetsSidePanel = rightPanelMode !== 'none';
|
||||
const splitGridColsRef = useRef(2);
|
||||
const snippetGridStyle = viewMode === 'grid'
|
||||
const splitViewGridStyle = hasSnippetsSidePanel && viewMode === 'grid'
|
||||
? { gridTemplateColumns: 'var(--snippets-grid-cols, repeat(2, minmax(0, 1fr)))' }
|
||||
: undefined;
|
||||
|
||||
useEffect(() => {
|
||||
const el = listRef.current;
|
||||
if (!el || viewMode !== 'grid' || typeof ResizeObserver === 'undefined') return;
|
||||
if (!el || typeof ResizeObserver === 'undefined') return;
|
||||
|
||||
const GAP = 12;
|
||||
const MIN_CARD = 220;
|
||||
const PADDING_X = 32;
|
||||
|
||||
const recompute = () => {
|
||||
const next = getSnippetsGridColumnCount(getElementContentWidth(el), {
|
||||
hasSidePanel: hasSnippetsSidePanel,
|
||||
});
|
||||
const usable = el.clientWidth - PADDING_X;
|
||||
if (usable <= 0) return;
|
||||
const next = Math.max(1, Math.floor((usable + GAP) / (MIN_CARD + GAP)));
|
||||
if (next === splitGridColsRef.current) return;
|
||||
splitGridColsRef.current = next;
|
||||
el.style.setProperty('--snippets-grid-cols', `repeat(${next}, minmax(0, 1fr))`);
|
||||
@@ -1016,16 +1015,6 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
}, [snippets, selectedPackage, search, sortMode]);
|
||||
|
||||
const isSearchActive = search.trim().length > 0;
|
||||
const shouldVirtualizeSnippets = displayedSnippets.length >= VIRTUALIZE_THRESHOLD;
|
||||
const snippetCollectionLayoutKey = [
|
||||
viewMode,
|
||||
search.trim() ? 'search' : 'browse',
|
||||
selectedPackage ?? 'root',
|
||||
displayedPackages.length > 0 && !search.trim() ? 'packages' : 'no-packages',
|
||||
hasSnippetsSidePanel ? 'panel' : 'full',
|
||||
sortMode,
|
||||
displayedSnippets.length,
|
||||
].join('|');
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedSnippetIds((prev) => {
|
||||
@@ -1456,11 +1445,6 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
const sourceSnippetId = draggingSnippetIdRef.current || event.dataTransfer.getData('snippet-id');
|
||||
const position = getDropPosition(target, event.clientX, event.clientY, isGrid);
|
||||
if (isGrid && sourceSnippetId && sourceSnippetId !== targetSnippetId) {
|
||||
// Virtualized cards can unmount mid-drag — use drop indicator instead of live reorder.
|
||||
if (shouldVirtualizeSnippets) {
|
||||
markSnippetDropIndicator(target, position, 'x');
|
||||
return;
|
||||
}
|
||||
const targetSnippet = snippets.find((snippet) => snippet.id === targetSnippetId);
|
||||
const sourceSnippet = snippets.find((snippet) => snippet.id === sourceSnippetId);
|
||||
if (!targetSnippet || !sourceSnippet) return;
|
||||
@@ -1529,13 +1513,11 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
packages,
|
||||
parentOfPackage,
|
||||
prepareGridLayoutAnimation,
|
||||
shouldVirtualizeSnippets,
|
||||
snippets,
|
||||
viewMode,
|
||||
]);
|
||||
|
||||
const handleReorderDrop = useCallback((event: React.DragEvent<HTMLDivElement>) => {
|
||||
try {
|
||||
const target = (event.target as Element | null)?.closest('[data-snippet-id], [data-pkg-path]');
|
||||
clearSnippetDropIndicator();
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
@@ -1595,18 +1577,12 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
lastPreviewReorderRef.current = null;
|
||||
setSortMode('manual');
|
||||
}
|
||||
} finally {
|
||||
// Virtualized cards can unmount mid-drag so dragend never fires — always
|
||||
// clear snippet/package drag ids on drop instead of relying on capture end.
|
||||
resetSnippetDragState();
|
||||
}
|
||||
}, [
|
||||
onBulkSave,
|
||||
onPackagesChange,
|
||||
packages,
|
||||
parentOfPackage,
|
||||
prepareGridLayoutAnimation,
|
||||
resetSnippetDragState,
|
||||
snippets,
|
||||
viewMode,
|
||||
]);
|
||||
@@ -1726,129 +1702,6 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
/>
|
||||
);
|
||||
|
||||
const renderSnippetItem = (snippet: Snippet) => {
|
||||
const isSelected = selectedSnippetIds.has(snippet.id);
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
className={cn(
|
||||
"vault-drop-indicator-row group cursor-pointer overflow-hidden",
|
||||
isSelected && (viewMode === 'grid' ? "ring-2 ring-primary/45 bg-primary/5" : "bg-primary/5"),
|
||||
viewMode === 'grid'
|
||||
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
|
||||
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors"
|
||||
)}
|
||||
data-snippet-id={isSearchActive ? undefined : snippet.id}
|
||||
data-vault-grid-item={`snippet:${snippet.id}`}
|
||||
data-vault-reorder-grid={viewMode === 'grid' ? 'true' : undefined}
|
||||
data-vault-reorder-dragging={draggingSnippetId === snippet.id ? 'true' : undefined}
|
||||
draggable={!isSearchActive && !isMultiSelectMode}
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('snippet-id', snippet.id);
|
||||
draggingSnippetIdRef.current = snippet.id;
|
||||
setDraggingSnippetId(snippet.id);
|
||||
lastPreviewReorderRef.current = null;
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isMultiSelectMode) {
|
||||
toggleSnippetSelection(snippet.id);
|
||||
} else {
|
||||
handleEdit(snippet);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full min-w-0">
|
||||
{isMultiSelectMode && (
|
||||
<div
|
||||
className="shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleSnippetSelection(snippet.id);
|
||||
}}
|
||||
>
|
||||
{isSelected ? (
|
||||
<CheckSquare size={18} className="text-primary" />
|
||||
) : (
|
||||
<Square size={18} className="text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<VaultEntityIcon
|
||||
className={isScriptSnippet(snippet) ? vaultAutomationScriptIconClass : vaultSnippetIconClass}
|
||||
icon={isScriptSnippet(snippet) ? (
|
||||
<Play size={18} />
|
||||
) : (
|
||||
<Zap size={18} />
|
||||
)}
|
||||
/>
|
||||
<div className="w-0 flex-1">
|
||||
<div className="text-sm font-semibold truncate">{snippet.label}</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="text-[11px] text-muted-foreground font-mono leading-4 truncate">
|
||||
{flattenSnippetCommandPreview(snippet.command) || t('snippets.commandFallback')}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<SnippetCommandTooltipContent
|
||||
command={snippet.command}
|
||||
fallback={t('snippets.commandFallback')}
|
||||
/>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{snippet.shortkey && (
|
||||
<div className="shrink-0 px-2 py-1 text-[10px] font-mono rounded border border-border bg-muted/50 text-muted-foreground">
|
||||
{snippet.shortkey}
|
||||
</div>
|
||||
)}
|
||||
{viewMode === 'list' && !isMultiSelectMode && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
||||
onClick={(e) => { e.stopPropagation(); handleEdit(snippet); }}
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
const runTargets = getRunnableHostsForSnippet(snippet, hosts);
|
||||
if (runTargets.length > 0) {
|
||||
onRunSnippet?.(snippet, runTargets);
|
||||
return;
|
||||
}
|
||||
toast.error(t('scripts.actions.noRunnableHosts'));
|
||||
}}
|
||||
disabled={getRunnableHostsForSnippet(snippet, hosts).length === 0}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" /> {t('action.run')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => handleEdit(snippet)}>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => handleCopy(snippet.id, snippet.command)}>
|
||||
<Copy className="mr-2 h-4 w-4" /> {t('action.copy')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => exportSingleSnippet(snippet)}>
|
||||
<Download className="mr-2 h-4 w-4" /> {t('snippets.export.snippet')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem className="text-destructive" onClick={() => requestDeleteSnippet(snippet.id)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="flex flex-1 min-h-0 min-w-0 relative">
|
||||
@@ -2051,10 +1904,15 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
</div>
|
||||
<div className={cn(
|
||||
viewMode === 'grid'
|
||||
? "grid gap-3"
|
||||
? cn(
|
||||
"grid gap-3",
|
||||
hasSnippetsSidePanel
|
||||
? "grid-cols-1"
|
||||
: "grid-cols-1 md:grid-cols-2 xl:grid-cols-3",
|
||||
)
|
||||
: "flex flex-col gap-0"
|
||||
)}
|
||||
style={snippetGridStyle}
|
||||
style={splitViewGridStyle}
|
||||
>
|
||||
{displayedPackages.map((pkg) => (
|
||||
<ContextMenu key={pkg.path}>
|
||||
@@ -2125,36 +1983,141 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
{displayedSnippets.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className={vaultSectionTitleClass}>{t('snippets.section.snippets')}</h3>
|
||||
{shouldVirtualizeSnippets ? (
|
||||
<VirtualizedHostCollection
|
||||
items={displayedSnippets}
|
||||
itemKey={(snippet) => snippet.id}
|
||||
scrollRef={listRef}
|
||||
viewMode={viewMode}
|
||||
layoutKey={snippetCollectionLayoutKey}
|
||||
ariaLabel={t('vault.nav.snippets')}
|
||||
getColumnCount={(width, mode) => (
|
||||
mode === 'grid'
|
||||
? getSnippetsGridColumnCount(width, { hasSidePanel: hasSnippetsSidePanel })
|
||||
: 1
|
||||
)}
|
||||
renderItem={renderSnippetItem}
|
||||
/>
|
||||
) : (
|
||||
<div className={cn(
|
||||
viewMode === 'grid'
|
||||
? "grid gap-3"
|
||||
: "flex flex-col gap-0"
|
||||
)}
|
||||
style={snippetGridStyle}
|
||||
>
|
||||
{displayedSnippets.map((snippet) => (
|
||||
<React.Fragment key={snippet.id}>
|
||||
{renderSnippetItem(snippet)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div className={cn(
|
||||
viewMode === 'grid'
|
||||
? cn(
|
||||
"grid gap-3",
|
||||
hasSnippetsSidePanel
|
||||
? "grid-cols-1"
|
||||
: "grid-cols-1 md:grid-cols-2 xl:grid-cols-3",
|
||||
)
|
||||
: "flex flex-col gap-0"
|
||||
)}
|
||||
style={splitViewGridStyle}
|
||||
>
|
||||
{displayedSnippets.map((snippet) => {
|
||||
const isSelected = selectedSnippetIds.has(snippet.id);
|
||||
return (
|
||||
<ContextMenu key={snippet.id}>
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
className={cn(
|
||||
"vault-drop-indicator-row group cursor-pointer overflow-hidden",
|
||||
isSelected && (viewMode === 'grid' ? "ring-2 ring-primary/45 bg-primary/5" : "bg-primary/5"),
|
||||
viewMode === 'grid'
|
||||
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
|
||||
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors"
|
||||
)}
|
||||
data-snippet-id={isSearchActive ? undefined : snippet.id}
|
||||
data-vault-grid-item={`snippet:${snippet.id}`}
|
||||
data-vault-reorder-grid={viewMode === 'grid' ? 'true' : undefined}
|
||||
data-vault-reorder-dragging={draggingSnippetId === snippet.id ? 'true' : undefined}
|
||||
draggable={!isSearchActive && !isMultiSelectMode}
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('snippet-id', snippet.id);
|
||||
draggingSnippetIdRef.current = snippet.id;
|
||||
setDraggingSnippetId(snippet.id);
|
||||
lastPreviewReorderRef.current = null;
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isMultiSelectMode) {
|
||||
toggleSnippetSelection(snippet.id);
|
||||
} else {
|
||||
handleEdit(snippet);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full min-w-0">
|
||||
{isMultiSelectMode && (
|
||||
<div
|
||||
className="shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleSnippetSelection(snippet.id);
|
||||
}}
|
||||
>
|
||||
{isSelected ? (
|
||||
<CheckSquare size={18} className="text-primary" />
|
||||
) : (
|
||||
<Square size={18} className="text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<VaultEntityIcon
|
||||
className={isScriptSnippet(snippet) ? vaultAutomationScriptIconClass : vaultSnippetIconClass}
|
||||
icon={isScriptSnippet(snippet) ? (
|
||||
<Play size={18} />
|
||||
) : (
|
||||
<Zap size={18} />
|
||||
)}
|
||||
/>
|
||||
<div className="w-0 flex-1">
|
||||
<div className="text-sm font-semibold truncate">{snippet.label}</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="text-[11px] text-muted-foreground font-mono leading-4 truncate">
|
||||
{flattenSnippetCommandPreview(snippet.command) || t('snippets.commandFallback')}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<SnippetCommandTooltipContent
|
||||
command={snippet.command}
|
||||
fallback={t('snippets.commandFallback')}
|
||||
/>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{snippet.shortkey && (
|
||||
<div className="shrink-0 px-2 py-1 text-[10px] font-mono rounded border border-border bg-muted/50 text-muted-foreground">
|
||||
{snippet.shortkey}
|
||||
</div>
|
||||
)}
|
||||
{viewMode === 'list' && !isMultiSelectMode && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
||||
onClick={(e) => { e.stopPropagation(); handleEdit(snippet); }}
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
const runTargets = getRunnableHostsForSnippet(snippet, hosts);
|
||||
if (runTargets.length > 0) {
|
||||
onRunSnippet?.(snippet, runTargets);
|
||||
return;
|
||||
}
|
||||
toast.error(t('scripts.actions.noRunnableHosts'));
|
||||
}}
|
||||
disabled={getRunnableHostsForSnippet(snippet, hosts).length === 0}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" /> {t('action.run')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => handleEdit(snippet)}>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => handleCopy(snippet.id, snippet.command)}>
|
||||
<Copy className="mr-2 h-4 w-4" /> {t('action.copy')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => exportSingleSnippet(snippet)}>
|
||||
<Download className="mr-2 h-4 w-4" /> {t('snippets.export.snippet')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem className="text-destructive" onClick={() => requestDeleteSnippet(snippet.id)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{search.trim() && displayedSnippets.length === 0 && (snippets.length > 0 || displayedPackages.length > 0) && (
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("./Terminal.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("Terminal re-resolves catalog theme before applying store accent", () => {
|
||||
// appearanceTheme may still carry a baked custom accent while TerminalLayer
|
||||
// memo ignores accent churn. Switching accentMode back to `theme` must clear
|
||||
// cursor/selection by starting from the clean catalog theme id.
|
||||
assert.match(source, /useAppearanceChromeStore\(\)/);
|
||||
assert.match(
|
||||
source,
|
||||
/getBuiltinTerminalThemeById\(appearanceTheme\.id\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/applyCustomAccentToTerminalTheme\(resolveBase\(\), accentMode, customAccent\)/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/if \(appearanceTheme\) return appearanceTheme;/,
|
||||
);
|
||||
});
|
||||
Loaded 100 of 365 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user