mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-02 11:52:35 +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,
|
||||
@@ -665,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,
|
||||
@@ -828,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;
|
||||
@@ -857,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
|
||||
@@ -878,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,
|
||||
}]);
|
||||
});
|
||||
|
||||
@@ -909,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]);
|
||||
|
||||
|
||||
@@ -464,6 +464,8 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
identities={identities}
|
||||
snippets={snippets}
|
||||
snippetPackages={snippetPackages}
|
||||
notes={notes}
|
||||
noteGroups={noteGroups}
|
||||
sessions={sessions}
|
||||
workspaces={workspaces}
|
||||
knownHosts={effectiveKnownHosts}
|
||||
@@ -527,6 +529,8 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
updateHosts={updateTerminalHosts}
|
||||
updateSnippets={updateSnippets}
|
||||
updateSnippetPackages={updateSnippetPackages}
|
||||
updateNotes={updateNotes}
|
||||
updateNoteGroups={updateNoteGroups}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
||||
sftpAutoSync={sftpAutoSync}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -11,7 +11,6 @@ import { getSftpTransferResourceKeys, globalSftpTransferScheduler } from '../sta
|
||||
import { hasNewSourceFingerprint } from '../state/sftp/transferProgressMetadata';
|
||||
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from '../../infrastructure/config/storageKeys';
|
||||
import type { TransferTask } from '../../domain/models';
|
||||
import { isTerminalBootEpochCurrent } from '../../domain/terminalBootEpoch';
|
||||
import {
|
||||
canApplyDedicatedResumeProgress,
|
||||
createDedicatedResumeChildUpdateBatcher,
|
||||
@@ -25,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(
|
||||
@@ -37,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>(
|
||||
@@ -419,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,
|
||||
@@ -433,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);
|
||||
@@ -529,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, {
|
||||
@@ -552,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',
|
||||
|
||||
@@ -268,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',
|
||||
@@ -562,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',
|
||||
|
||||
@@ -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': 'Использование памяти',
|
||||
|
||||
@@ -305,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': 'Показать детали',
|
||||
@@ -598,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': 'Перейти к',
|
||||
|
||||
@@ -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 调试日志',
|
||||
@@ -863,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': '创建时间',
|
||||
|
||||
@@ -206,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': '选择主题',
|
||||
|
||||
@@ -427,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 除錯日誌',
|
||||
@@ -863,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': '建立時間',
|
||||
|
||||
@@ -206,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': '選擇主題',
|
||||
|
||||
@@ -427,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,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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,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,
|
||||
@@ -1848,7 +1821,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
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,
|
||||
]),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
@@ -65,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 {
|
||||
@@ -832,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) => {
|
||||
@@ -1005,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);
|
||||
|
||||
+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 }),
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
}],
|
||||
}),
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
+23
-218
@@ -3,7 +3,7 @@ import { FitAddon } from "@xterm/addon-fit";
|
||||
import { SerializeAddon } from "@xterm/addon-serialize";
|
||||
import { SearchAddon } from "@xterm/addon-search";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { Activity, Cpu, Clock3, Copy, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine, RefreshCcw, Sparkles, SquareArrowOutUpRight, Unplug } from "lucide-react";
|
||||
import { Activity, Cpu, Clock3, Copy, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine, Sparkles, SquareArrowOutUpRight } from "lucide-react";
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { detectLocalOs } from "../lib/localShell";
|
||||
@@ -48,7 +48,6 @@ import { supportsZmodemTerminalDragDrop } from "../lib/zmodemDragDrop";
|
||||
import { resolveHostAuth, resolveHostAutofillPassword } from "../domain/sshAuth";
|
||||
import { resolveEffectiveTerminalProtocol } from "../domain/terminalProtocol";
|
||||
import { isPluginHostProtocol } from "../domain/pluginConnection";
|
||||
import { clearTerminalBootEpoch, setTerminalBootEpoch } from "../domain/terminalBootEpoch";
|
||||
import {
|
||||
appendTerminalPromptSecurityTail,
|
||||
isConfirmedTerminalShellPrompt,
|
||||
@@ -66,7 +65,6 @@ import { useStoredBoolean } from "../application/state/useStoredBoolean";
|
||||
import { readOptionalStoredStringValue, useStoredString } from "../application/state/useStoredString";
|
||||
import { useSessionLogBackend } from "../application/state/useSessionLogBackend";
|
||||
import { useTerminalLayoutSuppressActive } from "../application/state/terminalLayoutSuppressStore";
|
||||
import { useAppearanceChromeStore } from "../application/state/appearanceChromeStore";
|
||||
import {
|
||||
shouldPublishPluginTerminalSessionMountLifecycle,
|
||||
usePluginTerminalSessionLifecycle,
|
||||
@@ -261,6 +259,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
fontSize,
|
||||
terminalTheme,
|
||||
followAppTerminalTheme = false,
|
||||
accentMode = "theme",
|
||||
customAccent = "",
|
||||
terminalSettings,
|
||||
sessionId,
|
||||
workspaceId,
|
||||
@@ -448,7 +448,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const hibernateContextViewportSnapshotRef = useRef("");
|
||||
const hibernateContextScrollbackSnapshotRef = useRef("");
|
||||
const hibernatePendingBufferRef = useRef("");
|
||||
const hibernatePendingCapDisabledRef = useRef(false);
|
||||
const hibernateAlternateScreenRef = useRef(false);
|
||||
const hibernateRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const fullHibernateRuntimeRef = useRef<(() => Promise<boolean>) | null>(null);
|
||||
@@ -458,48 +457,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const wakePromiseRef = useRef<Promise<boolean> | null>(null);
|
||||
const sessionRef = useRef<string | null>(null);
|
||||
const sessionCleanupPromiseRef = useRef<Promise<void> | null>(null);
|
||||
/** Epoch owned by an in-flight disconnect/teardown close (before bump). */
|
||||
const pendingCloseBootEpochRef = useRef<number | undefined>(undefined);
|
||||
const trackSessionCleanup = (promise: Promise<unknown>) => {
|
||||
const settled = Promise.resolve(promise).then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
const previous = sessionCleanupPromiseRef.current;
|
||||
const next = previous
|
||||
? Promise.all([previous, settled]).then(() => undefined)
|
||||
: settled;
|
||||
sessionCleanupPromiseRef.current = next;
|
||||
void next.finally(() => {
|
||||
if (sessionCleanupPromiseRef.current === next) {
|
||||
sessionCleanupPromiseRef.current = null;
|
||||
}
|
||||
});
|
||||
};
|
||||
const isBootActiveRef = useRef(false);
|
||||
const bootEpochRef = useRef(0);
|
||||
const publishBootEpoch = () => {
|
||||
setTerminalBootEpoch(sessionId, bootEpochRef.current);
|
||||
};
|
||||
const bumpBootEpoch = () => {
|
||||
bootEpochRef.current += 1;
|
||||
publishBootEpoch();
|
||||
};
|
||||
/** Invalidate the current boot and remember its epoch for the matching closeSession. */
|
||||
const invalidateBootEpochForClose = () => {
|
||||
const closingEpoch = bootEpochRef.current;
|
||||
bumpBootEpoch();
|
||||
pendingCloseBootEpochRef.current = closingEpoch;
|
||||
return closingEpoch;
|
||||
};
|
||||
const resolveCloseBootEpoch = () => {
|
||||
if (pendingCloseBootEpochRef.current !== undefined) {
|
||||
const epoch = pendingCloseBootEpochRef.current;
|
||||
pendingCloseBootEpochRef.current = undefined;
|
||||
return epoch;
|
||||
}
|
||||
return bootEpochRef.current;
|
||||
};
|
||||
const hasConnectedRef = useRef(false);
|
||||
const hasRunStartupCommandRef = useRef(false);
|
||||
const restoreCwdIntentRef = useRef<{ cwd: string; command: string } | null>(null);
|
||||
@@ -518,10 +476,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
/** Connected wake for multi-tab snippet fan-out (reattaches session listeners). */
|
||||
const wakeHibernatedRuntimeForConnectedRef = useRef<(() => Promise<boolean>) | null>(null);
|
||||
const reconnectWakeInFlightRef = useRef(false);
|
||||
// When a hibernated reconnect wake is invalidated: 'dispose' for unmount/
|
||||
// session teardown (orphan runtime must go), 'keep' for Disconnect so the
|
||||
// pane retains an xterm instance and can reconnect without being remounted.
|
||||
const reconnectWakeInvalidateModeRef = useRef<"dispose" | "keep">("dispose");
|
||||
const reconnectWakeTokenRef = useRef<symbol | null>(null);
|
||||
const manualReconnectRequestRef = useRef<() => void>(() => {});
|
||||
const terminalDataCapturedRef = useRef(false);
|
||||
@@ -543,7 +497,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}, [onSessionExit]);
|
||||
|
||||
useEffect(() => () => {
|
||||
reconnectWakeInvalidateModeRef.current = "dispose";
|
||||
reconnectWakeTokenRef.current = null;
|
||||
}, [sessionId]);
|
||||
|
||||
@@ -1263,8 +1216,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
});
|
||||
}, [availableFonts, fontFamilyId, hasFontFamilyOverride, host.fontFamily, terminalSettings?.fallbackFont]);
|
||||
|
||||
const { accentMode, customAccent } = useAppearanceChromeStore();
|
||||
|
||||
const baseEffectiveTheme = useMemo(() => {
|
||||
if (appearanceTheme) return appearanceTheme;
|
||||
if (followAppTerminalTheme) {
|
||||
@@ -1595,17 +1546,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
if (!closingSessionId) {
|
||||
disposeSessionListeners();
|
||||
// Still notify main so in-flight SSH passphrase prompts for this UI
|
||||
// sessionId are aborted even before a backend session was attached.
|
||||
try {
|
||||
await terminalBackend.closeSession(sessionId, { bootEpoch: resolveCloseBootEpoch() });
|
||||
} catch (err) {
|
||||
logger.warn("Failed to cancel pending session boot on disconnect", err);
|
||||
}
|
||||
const postDisposeCleanup = sessionCleanupPromiseRef.current;
|
||||
if (postDisposeCleanup) {
|
||||
await postDisposeCleanup;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1707,7 +1647,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
clearTerminalSessionFlowAck(closingSessionId);
|
||||
}
|
||||
try {
|
||||
await terminalBackend.closeSession(closingSessionId, { bootEpoch: resolveCloseBootEpoch() });
|
||||
await terminalBackend.closeSession(closingSessionId);
|
||||
} catch (err) {
|
||||
logger.warn("Failed to close SSH session", err);
|
||||
}
|
||||
@@ -1845,12 +1785,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
backendId,
|
||||
(chunk, meta) => {
|
||||
observeTerminalInputPrompt(chunk, meta);
|
||||
hibernatePendingBufferRef.current = hibernatePendingCapDisabledRef.current
|
||||
? hibernatePendingBufferRef.current + chunk
|
||||
: appendHibernatePendingBuffer(
|
||||
hibernatePendingBufferRef.current,
|
||||
chunk,
|
||||
);
|
||||
hibernatePendingBufferRef.current = appendHibernatePendingBuffer(
|
||||
hibernatePendingBufferRef.current,
|
||||
chunk,
|
||||
);
|
||||
const pluginPipelineIngressBytes = Number.isFinite(meta?.pluginPipelineIngressBytes)
|
||||
? Math.max(0, Number(meta.pluginPipelineIngressBytes))
|
||||
: chunk.length;
|
||||
@@ -1870,12 +1808,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
setError(evt.error);
|
||||
}
|
||||
const exitMessage = `\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`;
|
||||
hibernatePendingBufferRef.current = hibernatePendingCapDisabledRef.current
|
||||
? hibernatePendingBufferRef.current + exitMessage
|
||||
: appendHibernatePendingBuffer(
|
||||
hibernatePendingBufferRef.current,
|
||||
exitMessage,
|
||||
);
|
||||
hibernatePendingBufferRef.current = appendHibernatePendingBuffer(
|
||||
hibernatePendingBufferRef.current,
|
||||
exitMessage,
|
||||
);
|
||||
onSessionExitRef.current?.(sessionId, evt);
|
||||
scheduleAutoReconnect({ evt });
|
||||
});
|
||||
@@ -2131,14 +2067,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
useEffect(() => () => {
|
||||
clearKittyKeyboardBroadcastSession(sessionId);
|
||||
clearTerminalBootEpoch(sessionId);
|
||||
}, [sessionId]);
|
||||
|
||||
const teardown = () => {
|
||||
// Capture the live epoch before invalidating so closeSession still matches
|
||||
// the registered backend session / pending passphrase boot.
|
||||
invalidateBootEpochForClose();
|
||||
clearTerminalBootEpoch(sessionId);
|
||||
isBootActiveRef.current = false;
|
||||
retryTokenRef.current = null;
|
||||
reconnectPreparationTokenRef.current = null;
|
||||
@@ -2269,7 +2200,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
isPaneVisibleRef: isVisibleRef,
|
||||
isVisibleRef: isRendererActiveRef,
|
||||
isBootActiveRef,
|
||||
bootEpochRef,
|
||||
pendingOutputScrollRef,
|
||||
sessionRef,
|
||||
hasConnectedRef,
|
||||
@@ -2277,7 +2207,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
restoreCwdIntentRef,
|
||||
disposeDataRef,
|
||||
disposeExitRef,
|
||||
trackSessionCleanup,
|
||||
disposeTelnetEchoModeRef,
|
||||
fitAddonRef,
|
||||
serializeAddonRef,
|
||||
@@ -2400,15 +2329,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return;
|
||||
}
|
||||
setMoshShellReady(false);
|
||||
disposeMoshReadyRef.current = terminalBackend.onMoshSessionReady(sessionId, (evt) => {
|
||||
const currentEpoch = bootEpochRef.current;
|
||||
if (
|
||||
Number.isFinite(currentEpoch)
|
||||
&& Number.isFinite(evt?.bootEpoch)
|
||||
&& evt.bootEpoch !== currentEpoch
|
||||
) {
|
||||
return;
|
||||
}
|
||||
disposeMoshReadyRef.current = terminalBackend.onMoshSessionReady(sessionId, () => {
|
||||
setMoshShellReady(true);
|
||||
}) ?? null;
|
||||
}, [effectiveTerminalProtocol, sessionId, terminalBackend]);
|
||||
@@ -3175,50 +3096,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onCloseSession?.(sessionId);
|
||||
};
|
||||
|
||||
/** Tear down the live transport but keep the tab/pane for a later reconnect. */
|
||||
const handleDisconnect = () => {
|
||||
if (attachExistingSession) return;
|
||||
if (statusRef.current === "disconnected") return;
|
||||
if (pendingHostKeyRequestId) {
|
||||
void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, false);
|
||||
}
|
||||
clearAutoReconnect({ stopLoop: true });
|
||||
retryTokenRef.current = null;
|
||||
reconnectPreparationTokenRef.current = null;
|
||||
restoreCwdIntentRef.current = null;
|
||||
// A hibernated reconnect may still be waking the runtime; invalidate that
|
||||
// continuation so it cannot re-arm boot after this disconnect. Keep any
|
||||
// runtime already created — wakeHibernatedRuntime clears hibernatedRef
|
||||
// before returning, and disposing here would leave no term and no
|
||||
// hibernation marker, permanently blocking later Reconnect.
|
||||
reconnectWakeInvalidateModeRef.current = "keep";
|
||||
reconnectWakeTokenRef.current = null;
|
||||
reconnectWakeInFlightRef.current = false;
|
||||
// Cancel closes the tab (effect cleanup flips boot-active). Disconnect keeps
|
||||
// the pane mounted, so mark boot inactive here or a late startSSH/startMosh
|
||||
// attach can still bring the session back after the user asked to stop.
|
||||
// Bump the epoch so a later reconnect cannot revive this aborted attempt,
|
||||
// but closeSession must still target the pre-bump epoch.
|
||||
invalidateBootEpochForClose();
|
||||
isBootActiveRef.current = false;
|
||||
setIsCancelling(true);
|
||||
auth.setNeedsAuth(false);
|
||||
auth.setAuthRetryMessage(null);
|
||||
setNeedsHostKeyVerification(false);
|
||||
setPendingHostKeyInfo(null);
|
||||
setPendingHostKeyRequestId(null);
|
||||
setError(null);
|
||||
setProgressLogs((prev) => [...prev, "Disconnected by user."]);
|
||||
void cleanupSession();
|
||||
updateStatus("disconnected");
|
||||
setChainProgress(null);
|
||||
setIsDisconnectedDialogDismissed(false);
|
||||
window.dispatchEvent(new CustomEvent("netcatty:terminal-session-disconnected", {
|
||||
detail: { sessionId },
|
||||
}));
|
||||
setTimeout(() => setIsCancelling(false), 600);
|
||||
};
|
||||
|
||||
const handleDismissDisconnectedDialog = () => {
|
||||
setIsDisconnectedDialogDismissed(true);
|
||||
queueMicrotask(() => termRef.current?.focus());
|
||||
@@ -3279,15 +3156,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
reconnectWakeInFlightRef.current = true;
|
||||
const wakeToken = Symbol();
|
||||
reconnectWakeInvalidateModeRef.current = "dispose";
|
||||
reconnectWakeTokenRef.current = wakeToken;
|
||||
updateStatus("connecting");
|
||||
void wakeForReconnect().then((woke) => {
|
||||
if (reconnectWakeTokenRef.current !== wakeToken) {
|
||||
reconnectWakeInFlightRef.current = false;
|
||||
if (reconnectWakeInvalidateModeRef.current === "dispose") {
|
||||
disposeRuntimeOnly();
|
||||
}
|
||||
disposeRuntimeOnly();
|
||||
return;
|
||||
}
|
||||
reconnectWakeTokenRef.current = null;
|
||||
@@ -3299,10 +3172,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
updateStatus("disconnected");
|
||||
}).catch(() => {
|
||||
if (reconnectWakeTokenRef.current !== wakeToken) {
|
||||
reconnectWakeInFlightRef.current = false;
|
||||
if (reconnectWakeInvalidateModeRef.current === "dispose") {
|
||||
disposeRuntimeOnly();
|
||||
}
|
||||
disposeRuntimeOnly();
|
||||
return;
|
||||
}
|
||||
reconnectWakeTokenRef.current = null;
|
||||
@@ -3360,8 +3230,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// ghost backend session with no owning UI.
|
||||
const retryStillActive = () => retryTokenStillCurrent() && termRef.current === term;
|
||||
|
||||
bootEpochRef.current += 1;
|
||||
publishBootEpoch();
|
||||
isBootActiveRef.current = true;
|
||||
auth.resetForRetry();
|
||||
terminalDataCapturedRef.current = false;
|
||||
@@ -3818,22 +3686,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
wakeInProgressRef.current = true;
|
||||
setPasswordPickerState(null);
|
||||
|
||||
const stopHibernateDataListener = () => {
|
||||
const stopHibernateListeners = () => {
|
||||
const backendId = sessionRef.current;
|
||||
disposeDataRef.current?.();
|
||||
disposeDataRef.current = null;
|
||||
};
|
||||
|
||||
const stopHibernateListeners = (opts?: { keepPaused?: boolean }) => {
|
||||
const backendId = sessionRef.current;
|
||||
stopHibernateDataListener();
|
||||
disposeExitRef.current?.();
|
||||
disposeExitRef.current = null;
|
||||
if (backendId) {
|
||||
flushTerminalSessionFlowAck(backendId);
|
||||
clearTerminalSessionFlowAck(backendId);
|
||||
if (!opts?.keepPaused) {
|
||||
terminalBackend.setSessionFlowPaused?.(backendId, false);
|
||||
}
|
||||
terminalBackend.setSessionFlowPaused?.(backendId, false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3842,64 +3704,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
runtimeContext,
|
||||
container,
|
||||
getPayload,
|
||||
prepareWakeFlow: async () => {
|
||||
const backendId = sessionRef.current;
|
||||
if (!backendId) return true;
|
||||
// Always pause when a backend exists, including reconnect wakes
|
||||
// (sessionConnected=false) that will not reattach. Stopping the
|
||||
// hibernate listener without a pause drops live output into the
|
||||
// preload backlog without flow ACKs until cleanupSession.
|
||||
if (terminalBackend.setSessionFlowPausedAndWait) {
|
||||
const result = await terminalBackend.setSessionFlowPausedAndWait(backendId, true);
|
||||
return result?.success === true;
|
||||
}
|
||||
terminalBackend.setSessionFlowPaused?.(backendId, true);
|
||||
return false;
|
||||
},
|
||||
takePendingBuffer: () => {
|
||||
const pending = hibernatePendingBufferRef.current;
|
||||
hibernatePendingBufferRef.current = "";
|
||||
return pending;
|
||||
},
|
||||
stopHibernateDataListener,
|
||||
setHibernatePendingCapDisabled: (disabled) => {
|
||||
hibernatePendingCapDisabledRef.current = disabled;
|
||||
},
|
||||
stopHibernateListeners: () => stopHibernateListeners({ keepPaused: true }),
|
||||
restoreAfterFailedWake: (takenPending) => {
|
||||
hibernatePendingCapDisabledRef.current = false;
|
||||
const pendingStillInRef = hibernatePendingBufferRef.current;
|
||||
disposeRuntimeOnly();
|
||||
const backendId = sessionRef.current;
|
||||
if (!backendId) {
|
||||
// No backend to reattach listeners to; still keep taken bytes for a
|
||||
// later wake of a disconnected hibernated tab.
|
||||
hibernatePendingBufferRef.current = appendHibernatePendingBuffer(
|
||||
takenPending || "",
|
||||
pendingStillInRef,
|
||||
);
|
||||
return;
|
||||
}
|
||||
beginHibernatedSessionListeners(backendId);
|
||||
// beginHibernatedSessionListeners clears pending; restore take-and-cleared
|
||||
// bytes from this wake plus anything that arrived after the last take.
|
||||
const restored = appendHibernatePendingBuffer(
|
||||
takenPending || "",
|
||||
pendingStillInRef,
|
||||
);
|
||||
if (restored) {
|
||||
hibernatePendingBufferRef.current = appendHibernatePendingBuffer(
|
||||
restored,
|
||||
hibernatePendingBufferRef.current,
|
||||
);
|
||||
}
|
||||
},
|
||||
resumeAfterReattach: () => {
|
||||
hibernatePendingCapDisabledRef.current = false;
|
||||
const backendId = sessionRef.current;
|
||||
if (!backendId) return;
|
||||
terminalBackend.setSessionFlowPaused?.(backendId, false);
|
||||
},
|
||||
stopHibernateListeners,
|
||||
sessionConnected: options.sessionConnected,
|
||||
getSessionConnected: () => getSessionConnectedRef.current(),
|
||||
reattachSession: (term) => {
|
||||
@@ -3925,7 +3730,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
});
|
||||
wakePromiseRef.current = wakePromise;
|
||||
return wakePromise;
|
||||
}, [sessionId, terminalBackend, terminalRuntimeRefs, resizeSession, terminalSettings, beginHibernatedSessionListeners]);
|
||||
}, [sessionId, terminalBackend, terminalRuntimeRefs, resizeSession, terminalSettings]);
|
||||
|
||||
const wakeHibernatedRuntime = useCallback(async (sessionConnected: boolean): Promise<boolean> => {
|
||||
if (!hibernatedRef.current) {
|
||||
@@ -4000,11 +3805,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onWake: wakeFromHibernateRuntime,
|
||||
});
|
||||
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
|
||||
|
||||
return (
|
||||
<>
|
||||
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, RefreshCcw, Sparkles, SquareArrowOutUpRight, Unplug, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost: !!onUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI: onAddSelectionToAI ? handleAddSelectionToAI : undefined, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect: (attachExistingSession || compactToolbar) ? undefined : handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
|
||||
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost: !!onUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI: onAddSelectionToAI ? handleAddSelectionToAI : undefined, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
|
||||
<ScriptExecutionOverlay
|
||||
run={activeScriptRun}
|
||||
onPause={() => { void pauseScriptRun(activeScriptRun.runId); }}
|
||||
@@ -4013,7 +3818,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onDismiss={dismissScriptOverlay}
|
||||
compactTopChrome={terminalSettings?.showHostInfoBar === false}
|
||||
/>
|
||||
) : null, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showConnectionControls: !attachExistingSession && !compactToolbar, showLogs, showSelectionAIAction: Boolean(showSelectionAIAction && onAddSelectionToAI), isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect: handlePasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />
|
||||
) : null, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction: Boolean(showSelectionAIAction && onAddSelectionToAI), isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect: handlePasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />
|
||||
<ScriptSaveRecordingDialog
|
||||
open={saveRecordingOpen}
|
||||
code={recordedCode}
|
||||
|
||||
@@ -217,32 +217,6 @@ test("TerminalLayer ignores shellHistory prop churn (reads shellHistoryStore ins
|
||||
);
|
||||
});
|
||||
|
||||
test("TerminalLayer ignores notes prop churn (reads notesStore instead)", () => {
|
||||
assert.equal(
|
||||
terminalLayerAreEqual(
|
||||
baseProps as never,
|
||||
{
|
||||
...baseProps,
|
||||
notes: [{ id: "n1", title: "One", content: "body", tags: [], createdAt: 1, updatedAt: 1, order: 1000 }],
|
||||
noteGroups: ["Ops"],
|
||||
updateNotes: () => {},
|
||||
updateNoteGroups: () => {},
|
||||
} as never,
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("TerminalLayer ignores customAccent prop churn (reads appearanceChromeStore instead)", () => {
|
||||
assert.equal(
|
||||
terminalLayerAreEqual(
|
||||
baseProps as never,
|
||||
{ ...baseProps, accentMode: "custom", customAccent: "#ff00aa" } as never,
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("TerminalLayer ignores sessions dynamicTitle-only updates", () => {
|
||||
const sessionsA = [{
|
||||
id: "s1",
|
||||
|
||||
@@ -173,6 +173,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
identities,
|
||||
snippets,
|
||||
snippetPackages,
|
||||
notes,
|
||||
noteGroups,
|
||||
openNoteRequest,
|
||||
onOpenVaultNoteFromChat,
|
||||
onOpenVaultHostFromChat,
|
||||
@@ -235,6 +237,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
updateHosts,
|
||||
updateSnippets,
|
||||
updateSnippetPackages,
|
||||
updateNotes,
|
||||
updateNoteGroups,
|
||||
sftpDefaultViewMode,
|
||||
sftpDoubleClickBehavior,
|
||||
sftpAutoSync,
|
||||
@@ -2161,6 +2165,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
programmaticCommandLogRewriteHandlersRef,
|
||||
snippetPackages,
|
||||
snippets,
|
||||
noteGroups,
|
||||
notes,
|
||||
onOpenVaultHostFromChat,
|
||||
onOpenVaultNoteFromChat: handleOpenVaultNoteFromAiPanel,
|
||||
onOpenVaultSectionFromChat,
|
||||
@@ -2183,6 +2189,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
updateHosts,
|
||||
updateNoteGroups,
|
||||
updateNotes,
|
||||
updateSnippetPackages,
|
||||
updateSnippets,
|
||||
X,
|
||||
|
||||
@@ -25,17 +25,12 @@ export const ConversationContent = ({ className, ...props }: ConversationContent
|
||||
/>
|
||||
);
|
||||
|
||||
export const ConversationScrollButton = ({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
|
||||
export const ConversationScrollButton = ({ className, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
|
||||
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
||||
|
||||
const handleClick = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onClick?.(event);
|
||||
const handleClick = useCallback(() => {
|
||||
scrollToBottom();
|
||||
}, [onClick, scrollToBottom]);
|
||||
}, [scrollToBottom]);
|
||||
|
||||
if (isAtBottom) return null;
|
||||
|
||||
|
||||
@@ -1,24 +1,12 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
approvalArgsHaveExtraContext,
|
||||
approvalCommandWasUnwrapped,
|
||||
extractApprovalExecutionContext,
|
||||
extractDisplayCommand,
|
||||
isNestedInteractiveApprovalTarget,
|
||||
MAX_TOOL_COMMAND_TOOLTIP_CHARS,
|
||||
truncateToolCommandTooltip,
|
||||
} from './tool-call';
|
||||
|
||||
const toolCallSource = readFileSync(
|
||||
join(dirname(fileURLToPath(import.meta.url)), 'tool-call.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// Codex (SDK) emits command_execution.command as a STRING that wraps the real
|
||||
// command in `<shell> -lc '<full>'`. Under Skills + CLI the real command is a
|
||||
// netcatty-tool-cli call. The title must unwrap the shell layer first, else the
|
||||
@@ -56,30 +44,6 @@ test('raw (unwrapped) netcatty-tool-cli exec still works', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('netcatty-tool-cli.cjs wrapper still unwraps to remote command', () => {
|
||||
assert.equal(
|
||||
extractDisplayCommand({
|
||||
command: `/bin/zsh -lc '"/abs/netcatty-tool-cli.cjs" exec --session X -- "uptime"'`,
|
||||
}),
|
||||
'uptime',
|
||||
);
|
||||
assert.equal(
|
||||
extractDisplayCommand({
|
||||
command: `"/Resources/netcatty-tool-cli.cjs" exec --session X -- "df -h"`,
|
||||
}),
|
||||
'df -h',
|
||||
);
|
||||
});
|
||||
|
||||
test('netcatty-tool-cli.cmd wrapper still unwraps to remote command', () => {
|
||||
assert.equal(
|
||||
extractDisplayCommand({
|
||||
command: `"C:\\\\App\\\\netcatty-tool-cli.cmd" exec --session X -- "whoami"`,
|
||||
}),
|
||||
'whoami',
|
||||
);
|
||||
});
|
||||
|
||||
test('netcatty-tool-cli env -> list sessions', () => {
|
||||
assert.equal(extractDisplayCommand({ command: 'netcatty-tool-cli env' }), 'netcatty: list sessions');
|
||||
});
|
||||
@@ -106,116 +70,3 @@ test('empty / missing args -> null', () => {
|
||||
assert.equal(extractDisplayCommand(undefined), null);
|
||||
assert.equal(extractDisplayCommand({ command: '' }), null);
|
||||
});
|
||||
|
||||
test('extractApprovalExecutionContext surfaces session/cwd/shell without rewriting command', () => {
|
||||
assert.deepEqual(
|
||||
extractApprovalExecutionContext({
|
||||
sessionId: 'term-1',
|
||||
cwd: '/var/log',
|
||||
command: ['zsh', '-lc', 'df -h | sort'],
|
||||
}),
|
||||
{ sessionId: 'term-1', cwd: '/var/log', shell: 'zsh', reason: undefined },
|
||||
);
|
||||
assert.deepEqual(
|
||||
extractApprovalExecutionContext({
|
||||
command: `/bin/bash -lc 'uptime'`,
|
||||
}),
|
||||
{ sessionId: undefined, cwd: undefined, shell: 'bash', reason: undefined },
|
||||
);
|
||||
assert.equal(extractApprovalExecutionContext({ path: '/tmp' }), null);
|
||||
});
|
||||
|
||||
test('extractApprovalExecutionContext reads --session from netcatty-tool-cli wrappers', () => {
|
||||
assert.deepEqual(
|
||||
extractApprovalExecutionContext({
|
||||
command: `/bin/zsh -lc '"/abs/netcatty-tool-cli" exec --session term-9 --chat-session chat-1 -- "uptime"'`,
|
||||
}),
|
||||
{ sessionId: 'term-9', cwd: undefined, shell: 'zsh', reason: undefined },
|
||||
);
|
||||
});
|
||||
|
||||
test('extractApprovalExecutionContext surfaces Codex reason for approval review', () => {
|
||||
assert.deepEqual(
|
||||
extractApprovalExecutionContext({
|
||||
command: 'rm -rf /tmp/x',
|
||||
cwd: '/tmp',
|
||||
reason: 'Clean stale build artifacts',
|
||||
commandActions: [{ type: 'delete' }],
|
||||
}),
|
||||
{
|
||||
sessionId: undefined,
|
||||
cwd: '/tmp',
|
||||
shell: undefined,
|
||||
reason: 'Clean stale build artifacts',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('approvalCommandWasUnwrapped detects Skills+CLI display unwrap', () => {
|
||||
const args = {
|
||||
command: `/bin/zsh -lc '"/abs/netcatty-tool-cli" exec --session X -- "uptime"'`,
|
||||
};
|
||||
const display = extractDisplayCommand(args);
|
||||
assert.equal(display, 'uptime');
|
||||
assert.equal(approvalCommandWasUnwrapped(args, display), true);
|
||||
assert.equal(approvalCommandWasUnwrapped({ command: 'uptime' }, 'uptime'), false);
|
||||
});
|
||||
|
||||
test('approvalArgsHaveExtraContext keeps commandActions visible beside the command block', () => {
|
||||
assert.equal(
|
||||
approvalArgsHaveExtraContext({
|
||||
command: 'echo hi',
|
||||
reason: 'demo',
|
||||
commandActions: [{ type: 'read' }],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
approvalArgsHaveExtraContext({ command: 'echo hi', cwd: '/tmp', reason: 'demo' }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('isNestedInteractiveApprovalTarget ignores Enter on Copy/Expand review controls', () => {
|
||||
const card = { id: 'card' };
|
||||
const copyBtn = {
|
||||
closest: (selector: string) => (selector.includes('button') ? copyBtn : null),
|
||||
};
|
||||
const plainSpan = {
|
||||
closest: () => null,
|
||||
};
|
||||
|
||||
assert.equal(isNestedInteractiveApprovalTarget(copyBtn, card), true);
|
||||
assert.equal(isNestedInteractiveApprovalTarget(plainSpan, card), false);
|
||||
assert.equal(isNestedInteractiveApprovalTarget(card, card), false);
|
||||
assert.equal(isNestedInteractiveApprovalTarget(null, card), false);
|
||||
});
|
||||
|
||||
// Pending approvals with no display command (Codex file-change/permissions,
|
||||
// write tools with JSON-only args) only render the overflow args <pre>. Wheel /
|
||||
// trackpad scroll must re-arm idle the same way the command block does.
|
||||
test('args overflow block re-arms review timers on scroll/wheel while pending', () => {
|
||||
assert.match(
|
||||
toolCallSource,
|
||||
/JSON\.stringify\(args,\s*null,\s*2\)[\s\S]{0,200}?<\/pre>/,
|
||||
);
|
||||
// Both review surfaces (command + args) wire markReviewing for continuous scroll.
|
||||
const markReviewingScrollBindings = toolCallSource.match(
|
||||
/onScroll=\{(?:isPendingApproval \? markReviewing : undefined|markReviewing)\}/g,
|
||||
);
|
||||
const markReviewingWheelBindings = toolCallSource.match(
|
||||
/onWheel=\{(?:isPendingApproval \? markReviewing : undefined|markReviewing)\}/g,
|
||||
);
|
||||
assert.ok(
|
||||
markReviewingScrollBindings && markReviewingScrollBindings.length >= 2,
|
||||
'command and args overflow blocks must both call markReviewing onScroll',
|
||||
);
|
||||
assert.ok(
|
||||
markReviewingWheelBindings && markReviewingWheelBindings.length >= 2,
|
||||
'command and args overflow blocks must both call markReviewing onWheel',
|
||||
);
|
||||
assert.match(
|
||||
toolCallSource,
|
||||
/onScroll=\{isPendingApproval \? markReviewing : undefined\}[\s\S]{0,80}onWheel=\{isPendingApproval \? markReviewing : undefined\}[\s\S]{0,80}JSON\.stringify\(args/,
|
||||
);
|
||||
});
|
||||
@@ -1,36 +1,13 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Check, ChevronDown, ChevronRight, CheckCircle2, Copy, Loader2, ShieldAlert, X, XCircle, Slash } from 'lucide-react';
|
||||
import { Check, ChevronDown, ChevronRight, CheckCircle2, Loader2, ShieldAlert, X, XCircle, Slash } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cancelApprovalTimeout } from '../../infrastructure/ai/shared/approvalGate';
|
||||
|
||||
export const MAX_TOOL_COMMAND_TOOLTIP_CHARS = 240;
|
||||
/** Collapsed approval command block max height (px). Full text remains scrollable. */
|
||||
export const APPROVAL_COMMAND_COLLAPSED_MAX_HEIGHT_PX = 144;
|
||||
/** Expanded approval command block max height (px). */
|
||||
export const APPROVAL_COMMAND_EXPANDED_MAX_HEIGHT_PX = 384;
|
||||
/** Prefer expand control when the raw command exceeds this many characters. */
|
||||
export const APPROVAL_COMMAND_EXPAND_CHAR_THRESHOLD = 180;
|
||||
|
||||
const NESTED_INTERACTIVE_SELECTOR = 'button, a, input, textarea, select, [role="button"]';
|
||||
|
||||
/**
|
||||
* Enter on the pending-card root means Approve Once. Enter on nested review
|
||||
* controls (Copy / Expand / action buttons) must not approve — those controls
|
||||
* also stopPropagation on Enter so the card handler is a second line of defense.
|
||||
*/
|
||||
export function isNestedInteractiveApprovalTarget(
|
||||
target: { closest?: (selector: string) => unknown } | null,
|
||||
currentTarget: unknown,
|
||||
): boolean {
|
||||
if (!target || target === currentTarget) return false;
|
||||
if (typeof target.closest !== 'function') return false;
|
||||
return Boolean(target.closest(NESTED_INTERACTIVE_SELECTOR));
|
||||
}
|
||||
|
||||
export function truncateToolCommandTooltip(
|
||||
command: string,
|
||||
@@ -95,14 +72,10 @@ export function extractDisplayCommand(args: Record<string, unknown> | undefined)
|
||||
if (strWrap) cmdString = strWrap[2];
|
||||
|
||||
// Netcatty CLI wrapper extraction.
|
||||
// Packaged / Windows paths may be `netcatty-tool-cli.cjs` or `.cmd`; strip the
|
||||
// optional extension so the subcommand after the binary is still found.
|
||||
const cliIdx = cmdString.search(/netcatty-tool-cli(?:\.(?:cjs|cmd|exe|js))?/i);
|
||||
const cliIdx = cmdString.indexOf('netcatty-tool-cli');
|
||||
if (cliIdx >= 0) {
|
||||
const cliMatch = cmdString.slice(cliIdx).match(/^netcatty-tool-cli(?:\.(?:cjs|cmd|exe|js))?/i);
|
||||
const cliTokenLen = cliMatch?.[0]?.length ?? 'netcatty-tool-cli'.length;
|
||||
const afterCli = cmdString
|
||||
.slice(cliIdx + cliTokenLen)
|
||||
.slice(cliIdx + 'netcatty-tool-cli'.length)
|
||||
.replace(/^["']?\s*/, '');
|
||||
const subMatch = afterCli.match(/^(\S+)/);
|
||||
const sub = subMatch ? subMatch[1] : '';
|
||||
@@ -133,110 +106,6 @@ export function extractDisplayCommand(args: Record<string, unknown> | undefined)
|
||||
return cmdString;
|
||||
}
|
||||
|
||||
export interface ApprovalExecutionContext {
|
||||
sessionId?: string;
|
||||
cwd?: string;
|
||||
shell?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
function rawCommandString(args: Record<string, unknown> | undefined): string | null {
|
||||
if (!args) return null;
|
||||
const raw = (args as { command?: unknown }).command;
|
||||
if (typeof raw === 'string') return raw || null;
|
||||
if (Array.isArray(raw) && raw.length > 0) return raw.map((p) => String(p)).join(' ');
|
||||
return null;
|
||||
}
|
||||
|
||||
const APPROVAL_CONTEXT_ARG_KEYS = new Set([
|
||||
'command',
|
||||
'cwd',
|
||||
'working_directory',
|
||||
'workdir',
|
||||
'workingDirectory',
|
||||
'sessionId',
|
||||
'shell',
|
||||
'reason',
|
||||
]);
|
||||
|
||||
/**
|
||||
* True when pending args still carry review-relevant fields beyond the
|
||||
* command block / execution-context strip (e.g. commandActions).
|
||||
*/
|
||||
export function approvalArgsHaveExtraContext(
|
||||
args: Record<string, unknown> | undefined,
|
||||
): boolean {
|
||||
if (!args) return false;
|
||||
return Object.keys(args).some((key) => !APPROVAL_CONTEXT_ARG_KEYS.has(key));
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the reviewable display command was unwrapped from a Skills+CLI /
|
||||
* shell wrapper — the pending card should still surface target flags.
|
||||
*/
|
||||
export function approvalCommandWasUnwrapped(
|
||||
args: Record<string, unknown> | undefined,
|
||||
displayCommand: string | null,
|
||||
): boolean {
|
||||
if (!displayCommand) return false;
|
||||
const raw = rawCommandString(args);
|
||||
if (!raw || raw === displayCommand) return false;
|
||||
return raw.includes('netcatty-tool-cli') || /(?:^|\/)(sh|bash|zsh|fish|ash|dash)\s+-l?c\s+/.test(raw)
|
||||
|| (Array.isArray(args?.command) && args.command.length >= 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort execution context for approval review (session / cwd / shell).
|
||||
* Never invents host names; only surfaces fields already present on tool args
|
||||
* or explicit netcatty-tool-cli flags in the command string.
|
||||
*/
|
||||
export function extractApprovalExecutionContext(
|
||||
args: Record<string, unknown> | undefined,
|
||||
): ApprovalExecutionContext | null {
|
||||
if (!args) return null;
|
||||
|
||||
let sessionId = typeof args.sessionId === 'string' && args.sessionId.trim()
|
||||
? args.sessionId.trim()
|
||||
: undefined;
|
||||
|
||||
const cwdCandidate = [args.cwd, args.working_directory, args.workdir, args.workingDirectory]
|
||||
.find((value) => typeof value === 'string' && value.trim());
|
||||
const cwd = typeof cwdCandidate === 'string' ? cwdCandidate.trim() : undefined;
|
||||
|
||||
let shell = typeof args.shell === 'string' && args.shell.trim()
|
||||
? args.shell.trim()
|
||||
: undefined;
|
||||
|
||||
const reason = typeof args.reason === 'string' && args.reason.trim()
|
||||
? args.reason.trim()
|
||||
: undefined;
|
||||
|
||||
const raw = (args as { command?: unknown }).command;
|
||||
if (!shell) {
|
||||
if (Array.isArray(raw) && raw.length >= 2) {
|
||||
const first = String(raw[0] ?? '');
|
||||
const shellMatch = first.match(/(?:^|\/)(sh|bash|zsh|fish|ash|dash)$/);
|
||||
if (shellMatch) shell = shellMatch[1];
|
||||
} else if (typeof raw === 'string') {
|
||||
const shellMatch = raw.match(/^(?:\S*\/)?(sh|bash|zsh|fish|ash|dash)\s+-l?c\s+/);
|
||||
if (shellMatch) shell = shellMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Skills+CLI wrappers keep the Netcatty target only on CLI flags after unwrap.
|
||||
if (!sessionId) {
|
||||
const cmd = rawCommandString(args);
|
||||
if (cmd && cmd.includes('netcatty-tool-cli')) {
|
||||
const sessionMatch = cmd.match(/--session(?:\s+|=)(?:"([^"]+)"|'([^']+)'|(\S+))/);
|
||||
const fromFlag = sessionMatch?.[1] ?? sessionMatch?.[2] ?? sessionMatch?.[3];
|
||||
if (fromFlag) sessionId = fromFlag;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionId && !cwd && !shell && !reason) return null;
|
||||
return { sessionId, cwd, shell, reason };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format tool result for display. Extracts stdout/stderr from structured
|
||||
* command results for terminal-like output.
|
||||
@@ -280,8 +149,6 @@ export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
|
||||
isInterrupted?: boolean;
|
||||
/** Approval state for this tool call (from the approval gate). */
|
||||
approvalStatus?: 'pending' | 'approved' | 'denied';
|
||||
/** Pending approval id used to cancel the auto-deny timer on review. */
|
||||
approvalId?: string;
|
||||
/** Called when user approves this tool call. */
|
||||
onApprove?: () => void;
|
||||
/** Called when user rejects this tool call. */
|
||||
@@ -294,72 +161,18 @@ export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
|
||||
alwaysAllowLabel?: string;
|
||||
}
|
||||
|
||||
async function copyTextToClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
try {
|
||||
if (typeof document === 'undefined') return false;
|
||||
const el = document.createElement('textarea');
|
||||
el.value = text;
|
||||
el.setAttribute('readonly', '');
|
||||
el.style.position = 'fixed';
|
||||
el.style.left = '-9999px';
|
||||
document.body.appendChild(el);
|
||||
el.select();
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(el);
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const ToolCall = ({
|
||||
name, args, result, isError, isLoading, isInterrupted,
|
||||
approvalStatus, approvalId, onApprove, onReject, onApproveOnce, onAlwaysAllow, alwaysAllowLabel,
|
||||
approvalStatus, onApprove, onReject, onApproveOnce, onAlwaysAllow, alwaysAllowLabel,
|
||||
className, ...props
|
||||
}: ToolCallProps) => {
|
||||
const { t } = useI18n();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [commandExpanded, setCommandExpanded] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [frozenCommand, setFrozenCommand] = useState<string | null>(null);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const approveBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const [responded, setResponded] = useState(false);
|
||||
|
||||
const isPendingApproval = approvalStatus === 'pending' && !responded;
|
||||
const liveDisplayCommand = extractDisplayCommand(args);
|
||||
const reviewCommand = isPendingApproval
|
||||
? (frozenCommand ?? liveDisplayCommand)
|
||||
: liveDisplayCommand;
|
||||
const executionContext = extractApprovalExecutionContext(args);
|
||||
const showApprovalCommand = Boolean(isPendingApproval && reviewCommand);
|
||||
const showArgsAlongsideCommand = Boolean(
|
||||
showApprovalCommand
|
||||
&& args
|
||||
&& Object.keys(args).length > 0
|
||||
&& (approvalCommandWasUnwrapped(args, reviewCommand) || approvalArgsHaveExtraContext(args)),
|
||||
);
|
||||
const commandNeedsExpand = Boolean(
|
||||
reviewCommand
|
||||
&& (reviewCommand.length > APPROVAL_COMMAND_EXPAND_CHAR_THRESHOLD
|
||||
|| reviewCommand.includes('\n')),
|
||||
);
|
||||
|
||||
// Each review interaction re-arms the Catty idle window (capped by hard
|
||||
// deadline). Do not one-shot cancel — subsequent focus/scroll/key events
|
||||
// must keep extending idle while the user is still deciding.
|
||||
const markReviewing = useCallback(() => {
|
||||
if (!isPendingApproval || !approvalId) return;
|
||||
cancelApprovalTimeout(approvalId);
|
||||
}, [approvalId, isPendingApproval]);
|
||||
|
||||
const handleApproveOnce = useCallback(() => {
|
||||
if (!isPendingApproval) return;
|
||||
@@ -379,68 +192,28 @@ export const ToolCall = ({
|
||||
onReject?.();
|
||||
}, [isPendingApproval, onReject]);
|
||||
|
||||
const handleCopyCommand = useCallback(async () => {
|
||||
if (!reviewCommand) return;
|
||||
markReviewing();
|
||||
const ok = await copyTextToClipboard(reviewCommand);
|
||||
if (!ok) return;
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
}, [markReviewing, reviewCommand]);
|
||||
|
||||
// Keyboard: Enter = approve, Escape = reject (when pending).
|
||||
// Ignore Enter from nested controls (Copy / Expand / action buttons) so it
|
||||
// activates that control instead of approving the pending command.
|
||||
// Keyboard: Enter = approve, Escape = reject (when pending)
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (!isPendingApproval) return;
|
||||
if (e.key === 'Enter') {
|
||||
if (isNestedInteractiveApprovalTarget(e.target as HTMLElement | null, e.currentTarget)) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
handleApproveOnce();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleReject();
|
||||
} else {
|
||||
// Typing / navigation while reviewing cancels the idle auto-deny timer.
|
||||
markReviewing();
|
||||
}
|
||||
}, [isPendingApproval, handleApproveOnce, handleReject, markReviewing]);
|
||||
if (e.key === 'Enter') { e.preventDefault(); handleApproveOnce(); }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); handleReject(); }
|
||||
}, [isPendingApproval, handleApproveOnce, handleReject]);
|
||||
|
||||
// Auto-focus and auto-scroll when approval is pending.
|
||||
// Do not treat this programmatic expand/focus as user review (timeout stays armed).
|
||||
// Auto-focus and auto-scroll when approval is pending
|
||||
useEffect(() => {
|
||||
if (!isPendingApproval || !cardRef.current) return;
|
||||
cardRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
// Small delay to let the UI render, then expand and focus
|
||||
setExpanded(true);
|
||||
const focusTimer = setTimeout(() => approveBtnRef.current?.focus(), 100);
|
||||
return () => clearTimeout(focusTimer);
|
||||
}, [isPendingApproval]);
|
||||
|
||||
// Freeze the reviewable command for the life of this pending approval.
|
||||
// Do not reset review/timeout state when args identity churns while still pending.
|
||||
// Reset responded state when approvalStatus changes (e.g. new approval)
|
||||
useEffect(() => {
|
||||
if (approvalStatus === 'pending') {
|
||||
setResponded(false);
|
||||
setCommandExpanded(false);
|
||||
setFrozenCommand(extractDisplayCommand(args));
|
||||
return;
|
||||
}
|
||||
setFrozenCommand(null);
|
||||
setCommandExpanded(false);
|
||||
// Intentionally depend only on approvalStatus so late arg patches cannot
|
||||
// replace the command the user is already reviewing.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- freeze on pending enter
|
||||
if (approvalStatus === 'pending') setResponded(false);
|
||||
}, [approvalStatus]);
|
||||
|
||||
// If the first pending paint had no command yet, accept the first non-empty one.
|
||||
useEffect(() => {
|
||||
if (!isPendingApproval || frozenCommand) return;
|
||||
const next = extractDisplayCommand(args);
|
||||
if (next) setFrozenCommand(next);
|
||||
}, [args, frozenCommand, isPendingApproval]);
|
||||
|
||||
// Border/bg color based on approval status
|
||||
const borderClass = approvalStatus === 'pending'
|
||||
? 'border-yellow-500/30 bg-yellow-500/[0.04]'
|
||||
@@ -463,48 +236,46 @@ export const ToolCall = ({
|
||||
<CheckCircle2 size={12} className={cn('text-green-400/70', statusIconClass)} />
|
||||
) : null;
|
||||
|
||||
const headerCommand = reviewCommand ?? liveDisplayCommand;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
tabIndex={isPendingApproval ? 0 : undefined}
|
||||
onKeyDown={isPendingApproval ? handleKeyDown : undefined}
|
||||
onPointerDownCapture={isPendingApproval ? markReviewing : undefined}
|
||||
className={cn('min-w-0 rounded-md border overflow-hidden text-[12px] outline-none', borderClass, className)}
|
||||
{...props}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isPendingApproval) markReviewing();
|
||||
setExpanded((e) => !e);
|
||||
}}
|
||||
onClick={() => setExpanded(e => !e)}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-muted/20 transition-colors cursor-pointer"
|
||||
>
|
||||
{expanded
|
||||
? <ChevronDown size={12} className="text-muted-foreground/40 shrink-0" />
|
||||
: <ChevronRight size={12} className="text-muted-foreground/40 shrink-0" />
|
||||
}
|
||||
{headerCommand ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="font-mono text-muted-foreground/70 truncate cursor-default">
|
||||
<span className="text-muted-foreground/40">$ </span>{headerCommand}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="start"
|
||||
collisionPadding={12}
|
||||
className="w-[calc(100vw-24px)] max-w-[420px] whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed [overflow-wrap:anywhere]"
|
||||
>
|
||||
{truncateToolCommandTooltip(headerCommand)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="font-mono text-muted-foreground/70 truncate">{name}</span>
|
||||
)}
|
||||
{(() => {
|
||||
const displayCmd = extractDisplayCommand(args);
|
||||
if (displayCmd) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="font-mono text-muted-foreground/70 truncate cursor-default">
|
||||
<span className="text-muted-foreground/40">$ </span>{displayCmd}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="start"
|
||||
collisionPadding={12}
|
||||
className="w-[calc(100vw-24px)] max-w-[420px] whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed [overflow-wrap:anywhere]"
|
||||
>
|
||||
{truncateToolCommandTooltip(displayCmd)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return <span className="font-mono text-muted-foreground/70 truncate">{name}</span>;
|
||||
})()}
|
||||
<span className="flex-1" />
|
||||
{/* Approval badge for resolved approvals */}
|
||||
{approvalStatus === 'approved' && (
|
||||
@@ -522,103 +293,10 @@ export const ToolCall = ({
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t border-border/20">
|
||||
{showApprovalCommand && reviewCommand && (
|
||||
<div className="px-3 py-2 space-y-1.5">
|
||||
{executionContext && (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-[10px] text-muted-foreground/45">
|
||||
<span className="font-medium uppercase tracking-wider text-muted-foreground/30">
|
||||
{t('ai.chat.targetLabel')}
|
||||
</span>
|
||||
{executionContext.sessionId && (
|
||||
<span className="font-mono truncate" title={executionContext.sessionId}>
|
||||
{t('ai.chat.approvalSession')}: {executionContext.sessionId}
|
||||
</span>
|
||||
)}
|
||||
{executionContext.shell && (
|
||||
<span className="font-mono">
|
||||
{t('ai.chat.approvalShell')}: {executionContext.shell}
|
||||
</span>
|
||||
)}
|
||||
{executionContext.cwd && (
|
||||
<span className="font-mono truncate" title={executionContext.cwd}>
|
||||
{t('ai.chat.approvalCwd')}: {executionContext.cwd}
|
||||
</span>
|
||||
)}
|
||||
{executionContext.reason && (
|
||||
<span className="truncate" title={executionContext.reason}>
|
||||
{t('ai.chat.approvalReason')}: {executionContext.reason}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30">
|
||||
{t('ai.chat.rawCommand')}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{commandNeedsExpand && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] text-muted-foreground/50 hover:text-muted-foreground px-1.5 py-0.5 rounded hover:bg-muted/30"
|
||||
onClick={() => {
|
||||
markReviewing();
|
||||
setCommandExpanded((v) => !v);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// Keep Enter on this control; let Escape bubble to card reject.
|
||||
if (e.key === 'Enter') e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{commandExpanded ? t('ai.chat.collapse') : t('ai.chat.expand')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 text-[10px] text-muted-foreground/50 hover:text-muted-foreground px-1.5 py-0.5 rounded hover:bg-muted/30"
|
||||
onClick={() => { void handleCopyCommand(); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Copy size={10} className="shrink-0" />
|
||||
{copied ? t('ai.chat.commandCopied') : t('ai.chat.copyCommand')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre
|
||||
className={cn(
|
||||
'overflow-auto rounded-md border border-border/25 bg-muted/20 px-2.5 py-2',
|
||||
'text-[11px] font-mono leading-relaxed text-foreground/80',
|
||||
'whitespace-pre-wrap break-words [overflow-wrap:anywhere]',
|
||||
)}
|
||||
style={{
|
||||
maxHeight: commandExpanded
|
||||
? APPROVAL_COMMAND_EXPANDED_MAX_HEIGHT_PX
|
||||
: APPROVAL_COMMAND_COLLAPSED_MAX_HEIGHT_PX,
|
||||
}}
|
||||
onScroll={markReviewing}
|
||||
onWheel={markReviewing}
|
||||
>
|
||||
{reviewCommand}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{args && Object.keys(args).length > 0 && (!showApprovalCommand || showArgsAlongsideCommand) && (
|
||||
{args && Object.keys(args).length > 0 && (
|
||||
<div className="px-3 py-2">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">
|
||||
{showArgsAlongsideCommand ? t('ai.chat.approvalInvocation') : 'Arguments'}
|
||||
</div>
|
||||
{/*
|
||||
Args-only approvals (Codex file-change/permissions, write tools with
|
||||
JSON args) have no command pre — wheel/trackpad scroll must re-arm
|
||||
idle the same way the command overflow block does.
|
||||
*/}
|
||||
<pre
|
||||
className="max-h-64 overflow-auto text-[11px] font-mono text-muted-foreground/50 whitespace-pre [overflow-wrap:normal]"
|
||||
onScroll={isPendingApproval ? markReviewing : undefined}
|
||||
onWheel={isPendingApproval ? markReviewing : undefined}
|
||||
>
|
||||
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">Arguments</div>
|
||||
<pre className="max-h-64 overflow-auto text-[11px] font-mono text-muted-foreground/50 whitespace-pre [overflow-wrap:normal]">
|
||||
{JSON.stringify(args, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
/**
|
||||
* Floating jump list for long AI chat sessions (user-turn TOC).
|
||||
*/
|
||||
|
||||
import { ListTree, X } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useStickToBottomContext } from 'use-stick-to-bottom';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { ChatJumpEntry } from '../../domain/chatJumpNav';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export interface ChatJumpNavProps {
|
||||
entries: ChatJumpEntry[];
|
||||
activeMessageId: string | null;
|
||||
/** When true, ignore transient isAtBottom flips from streaming resize. */
|
||||
isStreaming?: boolean;
|
||||
onSelect: (messageId: string) => void;
|
||||
/** Fired after the user leaves the jump target and returns to the bottom. */
|
||||
onReleasePin?: () => void;
|
||||
}
|
||||
|
||||
const ChatJumpNav: React.FC<ChatJumpNavProps> = ({
|
||||
entries,
|
||||
activeMessageId,
|
||||
isStreaming = false,
|
||||
onSelect,
|
||||
onReleasePin,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { stopScroll, isAtBottom } = useStickToBottomContext();
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
// Only release after the viewport has left the bottom; avoids clearing a pin
|
||||
// on the same tick as select while isAtBottom is still true.
|
||||
const leftBottomWhilePinnedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Node | null;
|
||||
if (target && rootRef.current && !rootRef.current.contains(target)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointerdown', onPointerDown);
|
||||
return () => document.removeEventListener('pointerdown', onPointerDown);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeMessageId) {
|
||||
leftBottomWhilePinnedRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!isAtBottom) {
|
||||
leftBottomWhilePinnedRef.current = true;
|
||||
return;
|
||||
}
|
||||
if (leftBottomWhilePinnedRef.current) {
|
||||
// Streaming content growth / smooth resize can flip isAtBottom without the
|
||||
// user intending to leave the jump target; keep the pin until streaming ends
|
||||
// or they explicitly scroll to bottom via the scroll button.
|
||||
if (isStreaming) return;
|
||||
leftBottomWhilePinnedRef.current = false;
|
||||
onReleasePin?.();
|
||||
return;
|
||||
}
|
||||
// Jump target was already in the bottom window, so the viewport never left
|
||||
// isAtBottom. Clear the pin after scrollIntoView has had a chance to run.
|
||||
if (isStreaming) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
onReleasePin?.();
|
||||
}, 100);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [activeMessageId, isAtBottom, isStreaming, onReleasePin]);
|
||||
|
||||
const handleSelect = useCallback((messageId: string) => {
|
||||
stopScroll();
|
||||
onSelect(messageId);
|
||||
setOpen(false);
|
||||
}, [onSelect, stopScroll]);
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="absolute top-3 right-3 z-20 flex flex-col items-end gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'h-7 w-7 rounded-full border border-border/40 bg-background/90 backdrop-blur-sm',
|
||||
'flex items-center justify-center shadow-sm',
|
||||
'text-muted-foreground hover:text-foreground hover:bg-muted transition-colors cursor-pointer',
|
||||
open && 'text-foreground bg-muted',
|
||||
)}
|
||||
aria-label={t('ai.chat.jumpNav')}
|
||||
aria-expanded={open}
|
||||
title={t('ai.chat.jumpNav')}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
{open ? <X size={14} /> : <ListTree size={14} />}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className={cn(
|
||||
'w-[min(220px,calc(100vw-2rem))] max-h-[min(320px,50vh)] overflow-y-auto',
|
||||
'rounded-md border border-border/50 bg-background/95 backdrop-blur-sm shadow-md',
|
||||
'py-1',
|
||||
)}
|
||||
role="listbox"
|
||||
aria-label={t('ai.chat.jumpNav')}
|
||||
>
|
||||
{entries.map((entry) => {
|
||||
const selected = entry.messageId === activeMessageId;
|
||||
return (
|
||||
<button
|
||||
key={entry.messageId}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
className={cn(
|
||||
'flex w-full items-start gap-2 px-2.5 py-1.5 text-left text-[12px] leading-snug',
|
||||
'hover:bg-muted/70 transition-colors cursor-pointer',
|
||||
selected
|
||||
? 'bg-muted text-foreground'
|
||||
: 'text-foreground/80',
|
||||
)}
|
||||
onClick={() => handleSelect(entry.messageId)}
|
||||
>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground/70 w-4 text-right">
|
||||
{entry.index}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{entry.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatJumpNav;
|
||||
@@ -132,63 +132,6 @@ test("ChatMessageList only renders the recent message batch by default", () => {
|
||||
assert.match(markup, /message-59/);
|
||||
});
|
||||
|
||||
test("ChatMessageList exposes jump navigation once there are enough user turns", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{ id: "u1", role: "user", content: "first turn", timestamp: 1 },
|
||||
{ id: "a1", role: "assistant", content: "ok", timestamp: 2 },
|
||||
{ id: "u2", role: "user", content: "second turn", timestamp: 3 },
|
||||
{ id: "a2", role: "assistant", content: "ok", timestamp: 4 },
|
||||
{ id: "u3", role: "user", content: "third turn", timestamp: 5 },
|
||||
{ id: "a3", role: "assistant", content: "ok", timestamp: 6 },
|
||||
];
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(ChatMessageList, { messages }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
assert.match(markup, /aria-label="Jump to message"/);
|
||||
assert.match(markup, /id="ai-chat-msg-u1"/);
|
||||
assert.match(markup, /id="ai-chat-msg-u3"/);
|
||||
});
|
||||
|
||||
test("jump pin release does not reset the loaded message tail", () => {
|
||||
const source = readFileSync(new URL("./ChatMessageList.tsx", import.meta.url), "utf8");
|
||||
const releaseHandler = source.match(
|
||||
/const handleReleaseJumpPin = useCallback\(\(\) => \{[\s\S]*?\}, \[\]\);/,
|
||||
)?.[0] ?? "";
|
||||
|
||||
assert.match(releaseHandler, /setActiveJumpMessageId\(null\)/);
|
||||
assert.match(releaseHandler, /setPendingJumpMessageId\(null\)/);
|
||||
assert.doesNotMatch(releaseHandler, /setRenderedTailCount/);
|
||||
});
|
||||
|
||||
test("load earlier advances from the effective pinned tail", () => {
|
||||
const source = readFileSync(new URL("./ChatMessageList.tsx", import.meta.url), "utf8");
|
||||
assert.match(
|
||||
source,
|
||||
/setRenderedTailCount\(\(count\) =>\s*Math\.max\(count, effectiveTailCount\) \+ MESSAGE_RENDER_STEP\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("jump pin ignores streaming isAtBottom flips and releases on scroll button", () => {
|
||||
const jumpSource = readFileSync(new URL("./ChatJumpNav.tsx", import.meta.url), "utf8");
|
||||
const listSource = readFileSync(new URL("./ChatMessageList.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(jumpSource, /isStreaming\?: boolean/);
|
||||
assert.match(jumpSource, /if \(isStreaming\) return;/);
|
||||
assert.match(jumpSource, /window\.setTimeout/);
|
||||
assert.match(listSource, /isStreaming=\{!!isStreaming\}/);
|
||||
assert.match(listSource, /<ConversationScrollButton onClick=\{handleReleaseJumpPin\} \/>/);
|
||||
});
|
||||
|
||||
test("ChatMessageList renders Codex activities and actual usage", () => {
|
||||
const messages: ChatMessage[] = [{
|
||||
id: "assistant-activity",
|
||||
|
||||
@@ -73,12 +73,6 @@ import {
|
||||
getAIPanelProfilerProps,
|
||||
isAIPanelDiagnosticPartHidden,
|
||||
} from './aiPanelDiagnostics';
|
||||
import {
|
||||
buildChatJumpEntries,
|
||||
chatMessageDomId,
|
||||
resolveTailCountForJumpTarget,
|
||||
} from '../../domain/chatJumpNav';
|
||||
import ChatJumpNav from './ChatJumpNav';
|
||||
|
||||
interface ChatMessageListProps {
|
||||
messages: ChatMessage[];
|
||||
@@ -360,13 +354,9 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
const hideMarkdown = isAIPanelDiagnosticPartHidden('markdown', hiddenParts);
|
||||
const hideToolCalls = isAIPanelDiagnosticPartHidden('toolcalls', hiddenParts);
|
||||
const [renderedTailCount, setRenderedTailCount] = useState(MESSAGE_RENDER_BATCH);
|
||||
const [activeJumpMessageId, setActiveJumpMessageId] = useState<string | null>(null);
|
||||
const [pendingJumpMessageId, setPendingJumpMessageId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setRenderedTailCount(MESSAGE_RENDER_BATCH);
|
||||
setActiveJumpMessageId(null);
|
||||
setPendingJumpMessageId(null);
|
||||
}, [activeSessionId]);
|
||||
|
||||
const visibleMessages = useMemo(
|
||||
@@ -374,47 +364,11 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
[messages],
|
||||
);
|
||||
|
||||
// While a jump target is active, re-resolve the tail against the current list
|
||||
// so streaming appends cannot slide the window past the selected message.
|
||||
const effectiveTailCount = activeJumpMessageId
|
||||
? resolveTailCountForJumpTarget(visibleMessages, activeJumpMessageId, renderedTailCount)
|
||||
: renderedTailCount;
|
||||
|
||||
const hiddenMessageCount = Math.max(0, visibleMessages.length - effectiveTailCount);
|
||||
const hiddenMessageCount = Math.max(0, visibleMessages.length - renderedTailCount);
|
||||
const displayedMessages = hiddenMessageCount > 0
|
||||
? visibleMessages.slice(-effectiveTailCount)
|
||||
? visibleMessages.slice(-renderedTailCount)
|
||||
: visibleMessages;
|
||||
|
||||
const jumpEntries = useMemo(
|
||||
() => buildChatJumpEntries(visibleMessages, {
|
||||
emptyLabel: t('ai.chat.jumpUntitled'),
|
||||
}),
|
||||
[t, visibleMessages],
|
||||
);
|
||||
|
||||
const handleJumpToMessage = useCallback((messageId: string) => {
|
||||
setActiveJumpMessageId(messageId);
|
||||
// Persist the expanded window so releasing the pin (or a spurious isAtBottom
|
||||
// flip) cannot unmount the jump target. Load-earlier progress is preserved
|
||||
// because we never reset renderedTailCount on pin release.
|
||||
setRenderedTailCount((count) =>
|
||||
resolveTailCountForJumpTarget(visibleMessages, messageId, count));
|
||||
setPendingJumpMessageId(messageId);
|
||||
}, [visibleMessages]);
|
||||
|
||||
const handleReleaseJumpPin = useCallback(() => {
|
||||
setActiveJumpMessageId(null);
|
||||
setPendingJumpMessageId(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingJumpMessageId) return;
|
||||
const target = document.getElementById(chatMessageDomId(pendingJumpMessageId));
|
||||
if (!target) return;
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
setPendingJumpMessageId(null);
|
||||
}, [displayedMessages, pendingJumpMessageId]);
|
||||
|
||||
const resolvedToolCallIds = new Set(
|
||||
displayedMessages
|
||||
.filter((m) => m.role === 'tool')
|
||||
@@ -478,7 +432,6 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
args={request.args}
|
||||
isInterrupted={false}
|
||||
approvalStatus="pending"
|
||||
approvalId={approvalId}
|
||||
onApproveOnce={() => handleApproveOnce(approvalId)}
|
||||
onAlwaysAllow={request.allowSession === false
|
||||
? undefined
|
||||
@@ -510,7 +463,6 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
isInterrupted={options.historical ? !isPending : undefined}
|
||||
isLoading={options.historical ? undefined : Boolean(options.isToolRunning && !isPending)}
|
||||
approvalStatus={approvalStatus}
|
||||
approvalId={isPending ? toolCall.id : undefined}
|
||||
onApproveOnce={() => handleApproveOnce(toolCall.id)}
|
||||
onAlwaysAllow={() => handleAlwaysAllow(toolCall.id, pendingRequest ?? {
|
||||
toolCallId: toolCall.id,
|
||||
@@ -531,8 +483,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
{hiddenMessageCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRenderedTailCount((count) =>
|
||||
Math.max(count, effectiveTailCount) + MESSAGE_RENDER_STEP)}
|
||||
onClick={() => setRenderedTailCount((count) => count + MESSAGE_RENDER_STEP)}
|
||||
className="w-full py-2 text-center text-[12px] text-muted-foreground/50 hover:text-muted-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
{t('ai.chat.loadEarlierMessages').replace('{n}', String(hiddenMessageCount))}
|
||||
@@ -640,11 +591,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
const isThisStreaming = isStreaming && isLastAssistant;
|
||||
|
||||
return (
|
||||
<Message
|
||||
key={message.id}
|
||||
id={chatMessageDomId(message.id)}
|
||||
from={message.role}
|
||||
>
|
||||
<Message key={message.id} from={message.role}>
|
||||
<MessageContent from={message.role}>
|
||||
{/* Thinking block */}
|
||||
{!isUser && message.thinking && (
|
||||
@@ -815,7 +762,6 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
isLoading={false}
|
||||
isInterrupted={false}
|
||||
approvalStatus={'pending'}
|
||||
approvalId={id}
|
||||
onApproveOnce={() => handleApproveOnce(id)}
|
||||
onAlwaysAllow={() => handleAlwaysAllow(id, req)}
|
||||
onReject={() => handleReject(id)}
|
||||
@@ -835,7 +781,6 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
isLoading={false}
|
||||
isInterrupted={false}
|
||||
approvalStatus="pending"
|
||||
approvalId={approvalId}
|
||||
onApproveOnce={() => handleApproveOnce(approvalId)}
|
||||
onAlwaysAllow={request.allowSession === false
|
||||
? undefined
|
||||
@@ -888,14 +833,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</ConversationContent>
|
||||
<ChatJumpNav
|
||||
entries={jumpEntries}
|
||||
activeMessageId={activeJumpMessageId}
|
||||
isStreaming={!!isStreaming}
|
||||
onSelect={handleJumpToMessage}
|
||||
onReleasePin={handleReleaseJumpPin}
|
||||
/>
|
||||
<ConversationScrollButton onClick={handleReleaseJumpPin} />
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
|
||||
{/* Image preview lightbox */}
|
||||
|
||||
@@ -100,7 +100,6 @@ export const ExternalMcpApprovalsHost: React.FC = () => {
|
||||
isLoading={false}
|
||||
isInterrupted={false}
|
||||
approvalStatus="pending"
|
||||
approvalId={id}
|
||||
onApproveOnce={() => handleApproveOnce(id)}
|
||||
onAlwaysAllow={() => handleAlwaysAllow(id, req)}
|
||||
onReject={() => handleReject(id)}
|
||||
|
||||
@@ -2,7 +2,6 @@ import React, { type Dispatch, type MutableRefObject, type SetStateAction } from
|
||||
import { AlertTriangle, Cloud, Database, Download, History, Key, Loader2, ShieldCheck, Trash2 } from 'lucide-react';
|
||||
import type { CloudProvider, ConflictResolution, SyncPayload, SyncResult, WebDAVAuthType } from '../../domain/sync';
|
||||
import type { ShrinkFinding } from '../../domain/syncGuards';
|
||||
import { stripSyncPayloadEncryptedCredentials } from '../../domain/credentials';
|
||||
import type { useCloudSync } from '../../application/state/useCloudSync';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Button } from '../ui/button';
|
||||
@@ -902,10 +901,9 @@ export const CloudSyncDialogs: React.FC<CloudSyncDialogsProps> = ({
|
||||
// re-deleting the remote additions we just merged in.
|
||||
for (const result of results.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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -40,8 +40,6 @@ export default function SettingsFileAssociationsTab() {
|
||||
sftpAutoSync, setSftpAutoSync,
|
||||
sftpShowHiddenFiles, setSftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, setSftpUseCompressedUpload,
|
||||
|
||||
sftpSkipUnchanged, setSftpSkipUnchanged,
|
||||
sftpAutoOpenSidebar, setSftpAutoOpenSidebar,
|
||||
sftpFollowTerminalCwd, setSftpFollowTerminalCwd,
|
||||
sftpDefaultViewMode, setSftpDefaultViewMode,
|
||||
@@ -211,12 +209,6 @@ export default function SettingsFileAssociationsTab() {
|
||||
</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t('settings.sftp.skipUnchanged.enable')}
|
||||
description={t('settings.sftp.skipUnchanged.enableDesc')}
|
||||
>
|
||||
<Toggle checked={sftpSkipUnchanged} onChange={setSftpSkipUnchanged} />
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t('settings.ssh.transportIdleTtl')}
|
||||
description={t('settings.ssh.transportIdleTtl.desc')}
|
||||
|
||||
@@ -175,8 +175,6 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
const [crashLogClearResult, setCrashLogClearResult] = useState<{ deletedCount: number } | null>(null);
|
||||
const [sshDebugLogInfo, setSshDebugLogInfo] = useState<SshDebugLogInfo | null>(null);
|
||||
const [isLoadingSshDebugLogInfo, setIsLoadingSshDebugLogInfo] = useState(false);
|
||||
const [isClearingSessionLogs, setIsClearingSessionLogs] = useState(false);
|
||||
const [sessionLogsClearResult, setSessionLogsClearResult] = useState<{ deletedCount: number; failedCount: number } | null>(null);
|
||||
|
||||
const [appVersion, setAppVersion] = useState('');
|
||||
|
||||
@@ -357,25 +355,6 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
}
|
||||
}, [sessionLogsDir]);
|
||||
|
||||
const handleClearSessionLogs = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!sessionLogsDir || !bridge?.clearSessionLogsDir) return;
|
||||
if (!window.confirm(t("settings.sessionLogs.clearConfirm"))) return;
|
||||
|
||||
setIsClearingSessionLogs(true);
|
||||
setSessionLogsClearResult(null);
|
||||
try {
|
||||
const result = await bridge.clearSessionLogsDir(sessionLogsDir);
|
||||
if (result.success) {
|
||||
setSessionLogsClearResult({ deletedCount: result.deletedCount, failedCount: result.failedCount });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[SettingsSystemTab] Failed to clear session logs:", err);
|
||||
} finally {
|
||||
setIsClearingSessionLogs(false);
|
||||
}
|
||||
}, [sessionLogsDir, t]);
|
||||
|
||||
const handleOpenSshDebugLogDir = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.openSshDebugLogDir) return;
|
||||
@@ -1005,34 +984,6 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
onChange={setSessionLogsTimestampsEnabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{/* Clear All Logs */}
|
||||
<div className="space-y-2 pt-2 border-t border-border/60">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t("settings.sessionLogs.clearAll")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("settings.sessionLogs.clearAllDesc")}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClearSessionLogs}
|
||||
disabled={isClearingSessionLogs || !sessionLogsDir}
|
||||
className="gap-1.5 shrink-0 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{isClearingSessionLogs ? t("settings.system.clearing") : t("settings.sessionLogs.clearAll")}
|
||||
</Button>
|
||||
</div>
|
||||
{sessionLogsClearResult && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.system.clearResult", {
|
||||
deleted: sessionLogsClearResult.deletedCount,
|
||||
failed: sessionLogsClearResult.failedCount,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingCard>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
@@ -21,11 +18,6 @@ import {
|
||||
import type { SftpPane } from "../../application/state/sftp/types.ts";
|
||||
import { TooltipProvider } from "../ui/tooltip.tsx";
|
||||
|
||||
const toolbarSource = fs.readFileSync(
|
||||
path.join(path.dirname(fileURLToPath(import.meta.url)), "SftpPaneToolbar.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
test("single SFTP view-mode button toggles to the other mode", () => {
|
||||
assert.equal(getNextSftpViewMode("list"), "tree");
|
||||
assert.equal(getNextSftpViewMode("tree"), "list");
|
||||
@@ -44,7 +36,7 @@ test("narrow SFTP toolbar spills non-pinned show items into overflow without cha
|
||||
assert.ok(!narrow.inlineIds.includes("newFolder"));
|
||||
assert.ok(narrow.overflowIds.includes("newFolder"));
|
||||
assert.ok(narrow.overflowIds.includes("encoding"));
|
||||
// hide is already excluded from shown/collapsed by partition - not reintroduced here
|
||||
// hide is already excluded from shown/collapsed by partition — not reintroduced here
|
||||
assert.ok(!narrow.inlineIds.includes("encoding"));
|
||||
});
|
||||
|
||||
@@ -140,6 +132,7 @@ test("toolbar renders one view-mode toggle instead of separate list and tree but
|
||||
handlePathKeyDown: () => {},
|
||||
handlePathDoubleClick: () => {},
|
||||
handlePathSubmit: () => {},
|
||||
startTransition: (callback: () => void) => callback(),
|
||||
getNextUntitledName: () => "untitled",
|
||||
setNewFileName: () => {},
|
||||
setFileNameError: () => {},
|
||||
@@ -218,6 +211,7 @@ test("toolbar exposes copy-current-path action for the active directory", () =>
|
||||
handlePathKeyDown: () => {},
|
||||
handlePathDoubleClick: () => {},
|
||||
handlePathSubmit: () => {},
|
||||
startTransition: (callback: () => void) => callback(),
|
||||
getNextUntitledName: () => "untitled",
|
||||
setNewFileName: () => {},
|
||||
setFileNameError: () => {},
|
||||
@@ -283,105 +277,6 @@ test("copy-current-path action reports clipboard failures", async () => {
|
||||
assert.equal(errorMessage, "Could not copy current path");
|
||||
});
|
||||
|
||||
test("SFTP filter input guards CJK IME composition instead of deferring controlled value writes", () => {
|
||||
assert.match(toolbarSource, /onCompositionStart=\{/);
|
||||
assert.match(toolbarSource, /onCompositionEnd=\{/);
|
||||
assert.match(toolbarSource, /shouldCommitImeControlledChange/);
|
||||
assert.match(toolbarSource, /value=\{filterDraft\}/);
|
||||
assert.doesNotMatch(
|
||||
toolbarSource,
|
||||
/onChange=\{\(e\) => startTransition\(\(\) => onSetFilter\(e\.target\.value\)\)\}/,
|
||||
);
|
||||
});
|
||||
|
||||
test("SFTP filter honors an external filter change over a stale draft when composition ends", () => {
|
||||
// If navigation clears pane.filter mid-composition, compositionEnd must adopt the
|
||||
// external value instead of committing the stale draft, preserving the invariant
|
||||
// that different-directory navigation clears the filter.
|
||||
assert.match(toolbarSource, /filterAtComposeStartRef\.current = pane\.filter;/);
|
||||
assert.match(toolbarSource, /filterPathAtComposeStartRef\.current = pane\.connection\?\.currentPath/);
|
||||
assert.match(
|
||||
toolbarSource,
|
||||
/pane\.filter !== filterAtComposeStartRef\.current\s*\|\|\s*pathChangedDuringCompose\s*\|\|\s*filterCompositionSupersededRef\.current/,
|
||||
);
|
||||
assert.match(toolbarSource, /filterCompositionSupersededRef\.current = true;/);
|
||||
assert.match(toolbarSource, /setFilterDraft\(pane\.filter\);/);
|
||||
});
|
||||
|
||||
test("SFTP filter adopts external navigation-cleared filters during an open IME composition", () => {
|
||||
// Sync path must pass valueAtComposeStart while composing so pane.filter="" from
|
||||
// different-directory navigation supersedes the draft mid-composition.
|
||||
assert.match(toolbarSource, /valueAtComposeStart:\s*composing/);
|
||||
assert.match(toolbarSource, /filterAtComposeStartRef\.current/);
|
||||
assert.match(toolbarSource, /pathChangedDuringCompose/);
|
||||
assert.match(
|
||||
toolbarSource,
|
||||
/filterCompositionSupersededRef\.current = true;/,
|
||||
);
|
||||
});
|
||||
|
||||
test("SFTP filter supersedes IME draft on path navigation even when committed filter was already empty", () => {
|
||||
// When the filter was already "" at compose start, navigation sets filter to ""
|
||||
// again - pane.filter does not change - so path-at-compose-start must drive
|
||||
// supersede; otherwise compositionend commits the stale draft.
|
||||
assert.match(toolbarSource, /filterPathAtComposeStartRef/);
|
||||
assert.match(
|
||||
toolbarSource,
|
||||
/currentPath !== filterPathAtComposeStartRef\.current/,
|
||||
);
|
||||
assert.match(
|
||||
toolbarSource,
|
||||
/\[pane\.filter, pane\.connection\?\.currentPath\]/,
|
||||
);
|
||||
// Hide-filter reset must still clear composing + supersede and resync draft.
|
||||
assert.match(
|
||||
toolbarSource,
|
||||
/if \(!showFilterBar\) \{\s*filterComposingRef\.current = false;\s*filterCompositionSupersededRef\.current = false;\s*setFilterDraft\(pane\.filter\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test("SFTP filter suppresses the post-composition onChange after external supersede", () => {
|
||||
// Browsers may fire onChange with composing=false after compositionend; that event
|
||||
// must not re-commit the stale composed draft over a navigation-cleared filter.
|
||||
assert.match(toolbarSource, /resolveSupersededImeInputEvent/);
|
||||
assert.match(toolbarSource, /superseded\.ignoreEventValue/);
|
||||
assert.match(toolbarSource, /compositionExternallySuperseded:\s*filterCompositionSupersededRef\.current/);
|
||||
});
|
||||
|
||||
test("SFTP filter clears the supersede latch if no post-composition change arrives", () => {
|
||||
// Some IME/browser paths never fire the post-compositionend onChange that would
|
||||
// clear filterCompositionSupersededRef; without a deferred clear, the next
|
||||
// ordinary keystroke is treated as the stale supersede follow-up and dropped.
|
||||
// Guard the clear so an intervening onChange clear or new compositionstart wins.
|
||||
assert.match(
|
||||
toolbarSource,
|
||||
/filterCompositionSupersededRef\.current = true;\s*setFilterDraft\(pane\.filter\);\s*window\.setTimeout\(\(\) => \{/,
|
||||
);
|
||||
assert.match(
|
||||
toolbarSource,
|
||||
/if \(\s*filterCompositionSupersededRef\.current\s*&&\s*!filterComposingRef\.current\s*\) \{\s*filterCompositionSupersededRef\.current = false;/,
|
||||
);
|
||||
});
|
||||
|
||||
test("SFTP filter commit path clears the IME composing guard so clears/commits can't leave it stuck", () => {
|
||||
// commitFilterValue backs composition end, Escape, inline clear and close; it must
|
||||
// drop the guard so a programmatic clear mid-composition isn't blocked or undone.
|
||||
assert.match(
|
||||
toolbarSource,
|
||||
/const commitFilterValue = useCallback\(\(value: string\) => \{[\s\S]*?filterComposingRef\.current = false;[\s\S]*?filterCompositionSupersededRef\.current = false;/,
|
||||
);
|
||||
});
|
||||
|
||||
test("SFTP filter clears the IME composing guard and resyncs the draft when the filter bar closes", () => {
|
||||
// If the input unmounts mid-composition, compositionend never fires; the guard
|
||||
// must be reset (or every later onSetFilter is blocked) and the draft resynced to
|
||||
// the committed filter so a reopened bar never shows stale, uncommitted text.
|
||||
assert.match(
|
||||
toolbarSource,
|
||||
/if \(!showFilterBar\) \{\s*filterComposingRef\.current = false;\s*filterCompositionSupersededRef\.current = false;\s*setFilterDraft\(pane\.filter\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test("toolbar display path keeps the previous confirmed path while loading the same connection", () => {
|
||||
assert.equal(
|
||||
getNextSftpToolbarDisplayPath({
|
||||
|
||||
@@ -18,11 +18,6 @@ import type { SftpFilenameEncoding } from "../../types";
|
||||
import type { SftpPane } from "../../application/state/sftp/types";
|
||||
import type { SftpBookmark } from "../../domain/models";
|
||||
import { isWindowsPath } from "../../application/state/sftp/utils";
|
||||
import {
|
||||
resolveSupersededImeInputEvent,
|
||||
shouldAdoptExternalImeControlledValue,
|
||||
shouldCommitImeControlledChange,
|
||||
} from "../../domain/imeControlledInput";
|
||||
import { toast } from "../ui/toast";
|
||||
|
||||
export const SFTP_TOOLBAR_ITEM_IDS = [
|
||||
@@ -198,6 +193,7 @@ interface SftpPaneToolbarProps {
|
||||
handlePathKeyDown: (e: React.KeyboardEvent) => void;
|
||||
handlePathDoubleClick: () => void;
|
||||
handlePathSubmit: (pathOverride?: string) => void;
|
||||
startTransition: React.TransitionStartFunction;
|
||||
getNextUntitledName: (existingNames: string[]) => string;
|
||||
setNewFileName: (value: string) => void;
|
||||
setFileNameError: (value: string | null) => void;
|
||||
@@ -307,6 +303,7 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
handlePathKeyDown,
|
||||
handlePathDoubleClick,
|
||||
handlePathSubmit,
|
||||
startTransition,
|
||||
getNextUntitledName,
|
||||
setNewFileName,
|
||||
setFileNameError,
|
||||
@@ -330,16 +327,6 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
onListDrives,
|
||||
}) => {
|
||||
const [displayPath, setDisplayPath] = useState(pane.connection?.currentPath ?? "");
|
||||
const [filterDraft, setFilterDraft] = useState(pane.filter);
|
||||
const filterComposingRef = useRef(false);
|
||||
const filterAtComposeStartRef = useRef(pane.filter);
|
||||
// Directory at compositionstart. Navigation that leaves the filter already ""
|
||||
// does not change pane.filter, so path is the signal that must supersede the
|
||||
// draft (compositionend would otherwise commit stale IME text).
|
||||
const filterPathAtComposeStartRef = useRef(pane.connection?.currentPath ?? "");
|
||||
// Set when pane.filter / path changes externally during (or just after)
|
||||
// composition so the post-compositionend onChange cannot re-commit a stale draft.
|
||||
const filterCompositionSupersededRef = useRef(false);
|
||||
const prevDisplayConnectionIdRef = useRef(pane.connection?.id);
|
||||
const toolbarLayout = useToolbarItemLayout(
|
||||
STORAGE_KEY_SFTP_TOOLBAR_LAYOUT,
|
||||
@@ -360,60 +347,6 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
);
|
||||
}, [pane.connection?.currentPath, pane.connection?.id, pane.loading]);
|
||||
|
||||
useEffect(() => {
|
||||
setFilterDraft((draftValue) => {
|
||||
const composing = filterComposingRef.current;
|
||||
const currentPath = pane.connection?.currentPath ?? "";
|
||||
const pathChangedDuringCompose = composing
|
||||
&& currentPath !== filterPathAtComposeStartRef.current;
|
||||
const shouldAdopt = shouldAdoptExternalImeControlledValue({
|
||||
isComposingSession: composing,
|
||||
draftValue,
|
||||
externalValue: pane.filter,
|
||||
// Allow navigation-cleared filters to supersede an open IME composition so
|
||||
// compositionend / post-composition onChange cannot resurrect the draft.
|
||||
valueAtComposeStart: composing
|
||||
? filterAtComposeStartRef.current
|
||||
: undefined,
|
||||
}) || pathChangedDuringCompose;
|
||||
if (
|
||||
shouldAdopt
|
||||
&& composing
|
||||
&& (
|
||||
pane.filter !== filterAtComposeStartRef.current
|
||||
|| pathChangedDuringCompose
|
||||
)
|
||||
) {
|
||||
filterCompositionSupersededRef.current = true;
|
||||
}
|
||||
return shouldAdopt ? pane.filter : draftValue;
|
||||
});
|
||||
}, [pane.filter, pane.connection?.currentPath]);
|
||||
|
||||
// The filter input only mounts while the bar is open, so a composition that is
|
||||
// still active when the bar closes never fires `compositionend`. Clear the guard
|
||||
// (otherwise a stuck `true` blocks every later commit) and resync the draft to the
|
||||
// committed filter so a reopened bar never shows stale, uncommitted text.
|
||||
useEffect(() => {
|
||||
if (!showFilterBar) {
|
||||
filterComposingRef.current = false;
|
||||
filterCompositionSupersededRef.current = false;
|
||||
setFilterDraft(pane.filter);
|
||||
}
|
||||
}, [showFilterBar, pane.filter]);
|
||||
|
||||
const commitFilterValue = useCallback((value: string) => {
|
||||
// Committing/clearing finalizes any IME session, so drop the guard here. This
|
||||
// covers every commit path (composition end, Escape, inline clear, close) so a
|
||||
// stale composing flag can't block later commits or resurrect a pre-clear value.
|
||||
filterComposingRef.current = false;
|
||||
filterCompositionSupersededRef.current = false;
|
||||
setFilterDraft(value);
|
||||
// Keep parent filter in lockstep with the input. Deferred updates against a
|
||||
// controlled value fight CJK IME composition and can echo/drop candidates.
|
||||
onSetFilter(value);
|
||||
}, [onSetFilter]);
|
||||
|
||||
const handleNewFolder = useCallback(() => {
|
||||
setNewFolderName("");
|
||||
setShowNewFolderDialog(true);
|
||||
@@ -1051,91 +984,23 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
/>
|
||||
<Input
|
||||
ref={filterInputRef}
|
||||
value={filterDraft}
|
||||
onChange={(e) => {
|
||||
const superseded = resolveSupersededImeInputEvent({
|
||||
compositionExternallySuperseded: filterCompositionSupersededRef.current,
|
||||
isComposingSession: filterComposingRef.current,
|
||||
nativeEventIsComposing: e.nativeEvent.isComposing,
|
||||
});
|
||||
if (superseded.ignoreEventValue) {
|
||||
// Keep draft on the external filter (e.g. navigation clear). Do not
|
||||
// apply the stale composed text from a post-compositionend change.
|
||||
if (superseded.clearSupersedeLatch) {
|
||||
filterCompositionSupersededRef.current = false;
|
||||
}
|
||||
setFilterDraft(pane.filter);
|
||||
return;
|
||||
}
|
||||
const next = e.target.value;
|
||||
setFilterDraft(next);
|
||||
if (
|
||||
shouldCommitImeControlledChange({
|
||||
isComposingSession: filterComposingRef.current,
|
||||
nativeEventIsComposing: e.nativeEvent.isComposing,
|
||||
compositionExternallySuperseded: filterCompositionSupersededRef.current,
|
||||
})
|
||||
) {
|
||||
onSetFilter(next);
|
||||
}
|
||||
}}
|
||||
onCompositionStart={() => {
|
||||
filterComposingRef.current = true;
|
||||
filterCompositionSupersededRef.current = false;
|
||||
filterAtComposeStartRef.current = pane.filter;
|
||||
filterPathAtComposeStartRef.current = pane.connection?.currentPath ?? "";
|
||||
}}
|
||||
onCompositionEnd={(e) => {
|
||||
filterComposingRef.current = false;
|
||||
// We never self-commit while composing, so any change to pane.filter
|
||||
// or directory during the session is external (e.g. follow-CWD
|
||||
// navigation). Honor it and drop the stale composed draft instead of
|
||||
// letting the commit overwrite the navigation-cleared filter. Path
|
||||
// matters when the committed filter was already "" before compose -
|
||||
// navigation sets filter to "" again, so filter-only checks miss it.
|
||||
// Keep the supersede latch armed so a browser post-compositionend
|
||||
// onChange cannot reassert the composed text with composing=false.
|
||||
// Some IME paths never fire that follow-up change; disarm on the
|
||||
// next macrotask so ordinary keystrokes commit. Guard the clear so
|
||||
// an intervening post-composition onChange or a new compositionstart
|
||||
// is not clobbered.
|
||||
const pathChangedDuringCompose =
|
||||
(pane.connection?.currentPath ?? "") !== filterPathAtComposeStartRef.current;
|
||||
if (
|
||||
pane.filter !== filterAtComposeStartRef.current
|
||||
|| pathChangedDuringCompose
|
||||
|| filterCompositionSupersededRef.current
|
||||
) {
|
||||
filterCompositionSupersededRef.current = true;
|
||||
setFilterDraft(pane.filter);
|
||||
window.setTimeout(() => {
|
||||
if (
|
||||
filterCompositionSupersededRef.current
|
||||
&& !filterComposingRef.current
|
||||
) {
|
||||
filterCompositionSupersededRef.current = false;
|
||||
}
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
commitFilterValue(e.currentTarget.value);
|
||||
}}
|
||||
value={pane.filter}
|
||||
onChange={(e) => startTransition(() => onSetFilter(e.target.value))}
|
||||
placeholder={t("sftp.filter.placeholder")}
|
||||
className="h-6 w-full pl-7 pr-7 text-xs bg-background"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
if (filterComposingRef.current || e.nativeEvent.isComposing) return;
|
||||
if (filterDraft || pane.filter) {
|
||||
commitFilterValue("");
|
||||
if (pane.filter) {
|
||||
startTransition(() => onSetFilter(""));
|
||||
} else {
|
||||
setShowFilterBar(false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{filterDraft && (
|
||||
{pane.filter && (
|
||||
<button
|
||||
onClick={() => commitFilterValue("")}
|
||||
onClick={() => startTransition(() => onSetFilter(""))}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X size={12} />
|
||||
@@ -1149,7 +1014,7 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={() => {
|
||||
commitFilterValue("");
|
||||
startTransition(() => onSetFilter(""));
|
||||
setShowFilterBar(false);
|
||||
}}
|
||||
>
|
||||
@@ -1237,7 +1102,7 @@ function SftpBookmarkPopoverBody({
|
||||
);
|
||||
}
|
||||
|
||||
/** Nested bookmark opener inside ⋮ - keeps overflow open until a leaf action. */
|
||||
/** Nested bookmark opener inside ⋮ — keeps overflow open until a leaf action. */
|
||||
function SftpOverflowNestedBookmark({
|
||||
menuItemClass,
|
||||
bookmarkButtonLabel,
|
||||
|
||||
@@ -551,6 +551,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
handlePathKeyDown={handlePathKeyDown}
|
||||
handlePathDoubleClick={handlePathDoubleClick}
|
||||
handlePathSubmit={handlePathSubmit}
|
||||
startTransition={startTransition}
|
||||
getNextUntitledName={getNextUntitledName}
|
||||
setNewFileName={setNewFileName}
|
||||
setFileNameError={setFileNameError}
|
||||
|
||||
@@ -26,17 +26,10 @@ import { getParentPath } from '../../application/state/sftp/utils';
|
||||
import { useSftpTransferTask } from '../../application/state/sftpTransferCenterStore';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { TransferTask } from '../../types';
|
||||
import {
|
||||
buildGlobalTransferProgressDisplay,
|
||||
isDirectoryParentTask,
|
||||
} from '../GlobalSftpTransferCenter';
|
||||
import { Button } from '../ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
|
||||
import { formatSpeed, formatTransferBytes } from './utils';
|
||||
|
||||
/** Child rows need room for Pause + Cancel (2×24px icons + gap). */
|
||||
const CHILD_ACTIONS_COLUMN_PX = 56;
|
||||
|
||||
interface SftpTransferItemProps {
|
||||
task: TransferTask;
|
||||
isChild?: boolean;
|
||||
@@ -139,20 +132,16 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
|
||||
// Optimistic spinner from click until store status moves off paused/interrupted.
|
||||
const [resumeClicked, setResumeClicked] = useState(false);
|
||||
|
||||
// Same progress model as the global transfer center (done · found for folders).
|
||||
const isDirParent = isDirectoryParentTask(task);
|
||||
const centerProgress = buildGlobalTransferProgressDisplay(task, t);
|
||||
const hasKnownTotal = isDirParent
|
||||
? task.totalBytes > 0 && task.transferredBytes > 0 && task.phase !== 'scanning'
|
||||
: task.totalBytes > 0 || !!task.sourceLastModified;
|
||||
const progress = isDirParent
|
||||
? centerProgress.percent
|
||||
: hasKnownTotal
|
||||
? Math.min((task.transferredBytes / task.totalBytes) * 100, 100)
|
||||
: 0;
|
||||
const isIndeterminate = isDirParent
|
||||
? centerProgress.indeterminate && (task.status === 'transferring' || task.status === 'pending' || task.status === 'queued' || task.status === 'pausing')
|
||||
: task.status === 'transferring' && !hasKnownTotal;
|
||||
// Align with global transfer center: directory parents default to file-count
|
||||
// progress unless explicitly compressed/byte mode.
|
||||
const progressMode = task.progressMode
|
||||
?? (task.isDirectory && !task.parentTaskId ? 'files' : 'bytes');
|
||||
const isDirParent = task.isDirectory && !task.parentTaskId && progressMode === 'files';
|
||||
const hasKnownTotal = task.totalBytes > 0 || (!isDirParent && !!task.sourceLastModified);
|
||||
const progress = hasKnownTotal
|
||||
? Math.min((task.transferredBytes / task.totalBytes) * 100, 100)
|
||||
: 0;
|
||||
const isIndeterminate = task.status === 'transferring' && !hasKnownTotal;
|
||||
const isActiveTransfer = task.status === 'transferring' || task.status === 'pausing';
|
||||
// Reconnect / dedicated resume window — keep the action slot as a spinner
|
||||
// until the first real progress clears reconnectRequired.
|
||||
@@ -189,9 +178,13 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
|
||||
? formatTransferBytes(task.totalBytes)
|
||||
: '';
|
||||
|
||||
// Prefer the transfer-center detail string so the panel never lags behind
|
||||
// "N done · M found" while status is still pending during progressive walks.
|
||||
const fileCountDisplay = isDirParent ? centerProgress.detail : '';
|
||||
const fileCountDisplay = isDirParent && (isActiveTransfer || isPausedLike)
|
||||
? (task.totalBytes > 0
|
||||
? t('sftp.transfers.filesProgress', { current: task.transferredBytes, total: task.totalBytes })
|
||||
: t('sftp.transfers.filesCount', { count: task.transferredBytes }))
|
||||
: isDirParent && task.status === 'completed' && task.totalBytes > 0
|
||||
? t('sftp.transfers.filesCount', { count: task.totalBytes })
|
||||
: '';
|
||||
|
||||
const speedFormatted = effectiveSpeed > 0 ? formatSpeed(effectiveSpeed) : '';
|
||||
const targetDirectoryPath = task.isDirectory ? task.targetPath : getParentPath(task.targetPath);
|
||||
@@ -200,42 +193,29 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
|
||||
// the backend drained in-flight chunks ("finish current step").
|
||||
const pausingLabel = t('sftp.transferCenter.status.pausing');
|
||||
const resumingLabel = t('sftp.transferCenter.status.resuming');
|
||||
const isLiveScanning = task.phase === 'scanning'
|
||||
&& (task.status === 'pending' || task.status === 'queued' || task.status === 'transferring');
|
||||
const progressOverlayText = isResuming
|
||||
? resumingLabel
|
||||
: isLiveScanning
|
||||
? (fileCountDisplay
|
||||
? `${t('sftp.transferCenter.phase.scanning')} · ${fileCountDisplay}`
|
||||
: t('sftp.transferCenter.phase.scanning'))
|
||||
: task.status === 'pending'
|
||||
? t('sftp.task.waiting')
|
||||
: task.status === 'pausing'
|
||||
? pausingLabel
|
||||
: isDirParent
|
||||
? (fileCountDisplay
|
||||
|| (task.status === 'pending' || task.status === 'queued'
|
||||
? t('sftp.task.waiting')
|
||||
: isIndeterminate
|
||||
? '...'
|
||||
: `${Math.round(progress)}%`))
|
||||
: task.status === 'pending'
|
||||
? t('sftp.task.waiting')
|
||||
: isIndeterminate
|
||||
? t('sftp.transfer.preparing')
|
||||
: bytesDisplay
|
||||
? `${bytesDisplay}${hasKnownTotal ? ` • ${Math.round(progress)}%` : ''}`
|
||||
: hasKnownTotal
|
||||
? `${Math.round(progress)}%`
|
||||
: '...';
|
||||
: isIndeterminate
|
||||
? t('sftp.transfer.preparing')
|
||||
: isDirParent
|
||||
? (fileCountDisplay
|
||||
? `${fileCountDisplay}${hasKnownTotal ? ` • ${Math.round(progress)}%` : ''}`
|
||||
: hasKnownTotal
|
||||
? `${Math.round(progress)}%`
|
||||
: '...')
|
||||
: bytesDisplay
|
||||
? `${bytesDisplay}${hasKnownTotal ? ` • ${Math.round(progress)}%` : ''}`
|
||||
: hasKnownTotal
|
||||
? `${Math.round(progress)}%`
|
||||
: '...';
|
||||
|
||||
const progressBarWidth = isDirParent
|
||||
? (centerProgress.indeterminate || isLiveScanning
|
||||
? '100%'
|
||||
: `${progress}%`)
|
||||
: task.status === 'pending'
|
||||
|| (task.status === 'transferring' && !hasKnownTotal)
|
||||
|| isIndeterminate
|
||||
? (task.status === 'pending' || !hasKnownTotal ? '100%' : `${progress}%`)
|
||||
: `${progress}%`;
|
||||
const progressBarWidth = task.status === 'pending' || (task.status === 'transferring' && !hasKnownTotal) || isIndeterminate
|
||||
? (task.status === 'pending' || !hasKnownTotal ? '100%' : `${progress}%`)
|
||||
: `${progress}%`;
|
||||
|
||||
const statusIcon = isResuming
|
||||
? <Loader2 size={12} className="animate-spin text-primary" />
|
||||
@@ -289,12 +269,7 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
const progressSummaryText = isResuming
|
||||
|| isActiveTransfer
|
||||
|| isPausedLike
|
||||
|| task.status === 'pending'
|
||||
|| task.status === 'queued'
|
||||
|| (isDirParent && !!fileCountDisplay)
|
||||
const progressSummaryText = isResuming || isActiveTransfer || isPausedLike || task.status === 'pending'
|
||||
? [speedFormatted, progressOverlayText].filter(Boolean).join(' • ')
|
||||
: '';
|
||||
const showTransferSizeCalculation = task.status === 'transferring' && !hasKnownTotal && !isDirParent;
|
||||
@@ -462,9 +437,7 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
|
||||
data-transfer-status={task.status}
|
||||
data-transfer-direction={task.direction}
|
||||
style={{
|
||||
// Last column reserves space for Pause + Cancel so the
|
||||
// progress bar never paints under the action buttons.
|
||||
gridTemplateColumns: `24px ${childNameColumnWidth}px 10px minmax(0, 1fr) ${CHILD_ACTIONS_COLUMN_PX}px`,
|
||||
gridTemplateColumns: `24px ${childNameColumnWidth}px 10px minmax(0, 1fr) 24px`,
|
||||
}}
|
||||
>
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
@@ -495,10 +468,10 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{resizeNameColumnLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
<div className="min-w-0">
|
||||
{childProgressBar}
|
||||
</div>
|
||||
<div className="flex h-full min-w-0 items-center justify-end gap-0.5 pl-1">
|
||||
<div className="flex h-full items-center justify-center">
|
||||
{actionButtons}
|
||||
</div>
|
||||
</div>
|
||||
@@ -578,16 +551,12 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
|
||||
{toggleChildrenButton}
|
||||
|
||||
{progressSummaryText && (
|
||||
<span className="ml-auto min-w-0 max-w-[50%] truncate whitespace-nowrap text-right text-[10px] text-muted-foreground font-mono">
|
||||
<span className="ml-auto shrink-0 whitespace-nowrap text-[10px] text-muted-foreground font-mono">
|
||||
{progressSummaryText}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Keep pause/cancel outside the progress summary so long
|
||||
"N done · M found" labels never crowd the action buttons. */}
|
||||
<div className="ml-1 shrink-0">
|
||||
{actionButtons}
|
||||
</div>
|
||||
{actionButtons}
|
||||
</div>
|
||||
|
||||
{showBelowParentProgress && (
|
||||
@@ -653,9 +622,6 @@ const arePropsEqual = (
|
||||
if (prev.fileName !== next.fileName) return false;
|
||||
if (prev.targetPath !== next.targetPath) return false;
|
||||
if (prev.totalBytes !== next.totalBytes) return false;
|
||||
if (prev.transferredBytes !== next.transferredBytes) return false;
|
||||
if (prev.phase !== next.phase) return false;
|
||||
if (prev.progressMode !== next.progressMode) return false;
|
||||
if ((prevProps.canRevealTarget ?? false) !== (nextProps.canRevealTarget ?? false)) return false;
|
||||
if ((prevProps.canCopyTargetPath ?? false) !== (nextProps.canCopyTargetPath ?? false)) return false;
|
||||
if ((prevProps.isChild ?? false) !== (nextProps.isChild ?? false)) return false;
|
||||
@@ -668,10 +634,20 @@ const arePropsEqual = (
|
||||
if ((prevProps.childListId ?? '') !== (nextProps.childListId ?? '')) return false;
|
||||
if ((prevProps.resizeHandleTabIndex ?? 0) !== (nextProps.resizeHandleTabIndex ?? 0)) return false;
|
||||
|
||||
if (next.status === 'transferring' || next.status === 'pausing' || next.status === 'pending' || next.status === 'queued') {
|
||||
if (next.status === 'transferring' || next.status === 'pausing') {
|
||||
if (next.totalBytes <= 0 && prev.transferredBytes !== next.transferredBytes) return false;
|
||||
|
||||
const prevProgress = prev.totalBytes > 0 ? (prev.transferredBytes / prev.totalBytes) * 100 : 0;
|
||||
const nextProgress = next.totalBytes > 0 ? (next.transferredBytes / next.totalBytes) * 100 : 0;
|
||||
if (Math.abs(nextProgress - prevProgress) >= 0.1) return false;
|
||||
|
||||
if (next.speed !== prev.speed) return false;
|
||||
}
|
||||
|
||||
if (next.status === 'pending') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
@@ -463,7 +463,7 @@ export const useSftpKeyboardShortcuts = ({
|
||||
const hasClipboardItems = (dataTransfer?.items?.length ?? 0) > 0;
|
||||
// webkitGetAsEntry must be invoked synchronously during the paste event.
|
||||
const dropEntriesPromise = dataTransfer?.items?.length
|
||||
? extractDropEntries(dataTransfer).catch(() => [])
|
||||
? extractDropEntries(dataTransfer)
|
||||
: null;
|
||||
const pastedFileSnapshot = dataTransfer?.files?.length
|
||||
? Array.from(dataTransfer.files).filter((file) => file.name)
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type { SftpFileEntry } from "../../../types";
|
||||
import type { TransferStatus } from "../../../domain/models";
|
||||
import { getParentPath, joinPath as joinFsPath } from "../../../application/state/sftp/utils";
|
||||
import {
|
||||
DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY,
|
||||
runBoundedConcurrency,
|
||||
} from "../../../application/state/sftp/transferConcurrency";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { toast } from "../../ui/toast";
|
||||
import { getFileExtension, getLanguageId, FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
|
||||
@@ -17,14 +12,6 @@ import type { TextEditorModalSnapshot } from "../../TextEditorModal";
|
||||
import type { UseSftpViewFileOpsParams, UseSftpViewFileOpsResult } from "./useSftpViewFileOps.types";
|
||||
import { assertSftpFileFitsBuiltinEditor } from "../sftpEditorFileLimits";
|
||||
|
||||
/** Local multi-select blob downloads read whole files into ArrayBuffers. */
|
||||
const LOCAL_BLOB_DOWNLOAD_CONCURRENCY = 1;
|
||||
/**
|
||||
* Multi-select roots each start their own interleaved folder walk / session
|
||||
* work. Bound them so many selected directories cannot stampede the scheduler.
|
||||
*/
|
||||
const MULTI_SELECT_ROOT_DOWNLOAD_CONCURRENCY = DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY;
|
||||
|
||||
export const useSftpViewFileOps = ({
|
||||
sftpRef,
|
||||
behaviorRef,
|
||||
@@ -551,14 +538,9 @@ export const useSftpViewFileOps = ({
|
||||
if (!pane.connection) return;
|
||||
|
||||
if (pane.connection.isLocal) {
|
||||
// Sequential: each local download materializes a full ArrayBuffer.
|
||||
await runBoundedConcurrency(
|
||||
files,
|
||||
LOCAL_BLOB_DOWNLOAD_CONCURRENCY,
|
||||
async (file) => {
|
||||
await handleDownloadFileForSide(side, file);
|
||||
},
|
||||
);
|
||||
for (const file of files) {
|
||||
await handleDownloadFileForSide(side, file);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -576,42 +558,27 @@ export const useSftpViewFileOps = ({
|
||||
const selectedDirectory = await selectDirectory(t("sftp.context.download"));
|
||||
if (!selectedDirectory) return;
|
||||
|
||||
// Bound root jobs: each directory root walks and transfers independently,
|
||||
// so unbounded Promise.allSettled would multiply session / LIST pressure.
|
||||
const results: Array<PromiseSettledResult<{ file: SftpFileEntry; status: TransferStatus }>> = [];
|
||||
await runBoundedConcurrency(
|
||||
files,
|
||||
MULTI_SELECT_ROOT_DOWNLOAD_CONCURRENCY,
|
||||
async (file, index) => {
|
||||
try {
|
||||
const sourcePath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
|
||||
const targetPath = joinFsPath(selectedDirectory, file.name);
|
||||
const isDirectory = isNavigableDirectory(file);
|
||||
const fileSize = typeof file.size === "string" ? parseInt(file.size, 10) || 0 : (file.size || 0);
|
||||
// Sequential enqueue; each file uses dedicated transfer-pool sessions
|
||||
// via downloadToLocal so the browse connection stays responsive.
|
||||
for (const file of files) {
|
||||
const sourcePath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
|
||||
const targetPath = joinFsPath(selectedDirectory, file.name);
|
||||
const isDirectory = isNavigableDirectory(file);
|
||||
const fileSize = typeof file.size === "string" ? parseInt(file.size, 10) || 0 : (file.size || 0);
|
||||
|
||||
const status = await sftpRef.current.downloadToLocal({
|
||||
fileName: file.name,
|
||||
sourcePath,
|
||||
targetPath,
|
||||
sftpId,
|
||||
connectionId: pane.connection.id,
|
||||
sourceHostId: pane.connection.hostId,
|
||||
sourceHostLabel: pane.connection.hostLabel,
|
||||
sourceEncoding: pane.filenameEncoding,
|
||||
isDirectory,
|
||||
totalBytes: isDirectory ? undefined : fileSize,
|
||||
});
|
||||
results[index] = { status: "fulfilled", value: { file, status } };
|
||||
} catch (reason) {
|
||||
results[index] = { status: "rejected", reason };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
if (!result) continue;
|
||||
if (result.status === "fulfilled") {
|
||||
const { file, status } = result.value;
|
||||
try {
|
||||
const status = await sftpRef.current.downloadToLocal({
|
||||
fileName: file.name,
|
||||
sourcePath,
|
||||
targetPath,
|
||||
sftpId,
|
||||
connectionId: pane.connection.id,
|
||||
sourceHostId: pane.connection.hostId,
|
||||
sourceHostLabel: pane.connection.hostLabel,
|
||||
sourceEncoding: pane.filenameEncoding,
|
||||
isDirectory,
|
||||
totalBytes: isDirectory ? undefined : fileSize,
|
||||
});
|
||||
if (status === "completed") {
|
||||
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
|
||||
} else if (status === "failed") {
|
||||
@@ -619,9 +586,9 @@ export const useSftpViewFileOps = ({
|
||||
} else if (status === "attention") {
|
||||
toast.error(`${file.name}: another transfer for this path is already in progress`, "SFTP");
|
||||
}
|
||||
} else {
|
||||
logger.error("[SftpView] Failed to download file:", result.reason);
|
||||
const errorMessage = result.reason instanceof Error ? result.reason.message : String(result.reason);
|
||||
} catch (e) {
|
||||
logger.error("[SftpView] Failed to download file:", e);
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
const isCancelError = errorMessage.includes("cancelled") || errorMessage.includes("canceled");
|
||||
if (!isCancelError) toast.error(errorMessage || t("sftp.error.downloadFailed"), "SFTP");
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
test("gpu tab keeps loading while accelerator query is pending", () => {
|
||||
const source = readFileSync(new URL("./GpuManagerTab.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /setGpuListPending\(true\)/);
|
||||
assert.match(source, /if \(result\.pending\)/);
|
||||
assert.match(source, /isRefreshActive = loading \|\| gpuListPending/);
|
||||
assert.match(source, /if \(!data\)/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/if \(result\.pending\) return null;\s*\n\s*if \(!result\.success\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("gpu tab still renders compute processes when device list is empty", () => {
|
||||
const source = readFileSync(new URL("./GpuManagerTab.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /if \(!devices\.length && !processes\.length\)/);
|
||||
assert.match(source, /processes\.map\(\(process\) =>/);
|
||||
});
|
||||
|
||||
test("gpu tab passes null utilization to ResourceBar instead of zero", () => {
|
||||
const source = readFileSync(new URL("./GpuManagerTab.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /value=\{util\}/);
|
||||
assert.match(source, /value=\{memPct\}/);
|
||||
assert.doesNotMatch(source, /value=\{util \?\? 0\}/);
|
||||
assert.doesNotMatch(source, /value=\{memPct \?\? 0\}/);
|
||||
});
|
||||
@@ -1,294 +0,0 @@
|
||||
import { CircuitBoard, Cpu, Thermometer, Zap } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
|
||||
import type {
|
||||
AcceleratorDeviceInfo,
|
||||
AcceleratorProcessInfo,
|
||||
AcceleratorSnapshot,
|
||||
} from '../../domain/systemManager/types';
|
||||
import { ResourceBar } from './ResourceBar';
|
||||
import {
|
||||
SystemPanelEmpty,
|
||||
SystemPanelError,
|
||||
SystemPanelInlineError,
|
||||
SystemPanelList,
|
||||
SystemPanelLoading,
|
||||
SystemPanelRefreshButton,
|
||||
SystemPanelRow,
|
||||
SystemPanelShell,
|
||||
SystemPanelStatusBadge,
|
||||
SystemPanelToolbar,
|
||||
} from './SystemPanelUi';
|
||||
import { usePolling, useStableTranslate } from './hooks/useSystemManager';
|
||||
|
||||
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||
|
||||
interface GpuManagerTabProps {
|
||||
sessionId: string;
|
||||
isVisible: boolean;
|
||||
backend: Backend;
|
||||
refreshIntervalSec: number;
|
||||
}
|
||||
|
||||
function formatMb(mb: number | null | undefined): string {
|
||||
if (!Number.isFinite(mb)) return '--';
|
||||
const value = Number(mb);
|
||||
if (value >= 1024) return `${(value / 1024).toFixed(1)} GB`;
|
||||
return `${Math.round(value)} MB`;
|
||||
}
|
||||
|
||||
function memoryPercent(device: AcceleratorDeviceInfo): number | null {
|
||||
if (!Number.isFinite(device.memoryUsedMb) || !Number.isFinite(device.memoryTotalMb)) return null;
|
||||
if (!device.memoryTotalMb || device.memoryTotalMb <= 0) return null;
|
||||
return Math.max(0, Math.min(100, (Number(device.memoryUsedMb) / Number(device.memoryTotalMb)) * 100));
|
||||
}
|
||||
|
||||
function vendorLabel(
|
||||
vendor: AcceleratorDeviceInfo['vendor'],
|
||||
t: ReturnType<typeof useI18n>['t'],
|
||||
): string {
|
||||
return vendor === 'nvidia'
|
||||
? t('systemManager.gpu.vendor.nvidia')
|
||||
: t('systemManager.gpu.vendor.ascend');
|
||||
}
|
||||
|
||||
const DeviceCard = memo(function DeviceCard({
|
||||
device,
|
||||
}: {
|
||||
device: AcceleratorDeviceInfo;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const memPct = memoryPercent(device);
|
||||
const util = Number.isFinite(device.utilizationPercent) ? Number(device.utilizationPercent) : null;
|
||||
|
||||
return (
|
||||
<div className="border-b border-border/30 px-3 py-2.5 space-y-2">
|
||||
<div className="flex items-start justify-between gap-2 min-w-0">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="text-xs font-medium truncate">
|
||||
[{device.index}] {device.name}
|
||||
</span>
|
||||
<SystemPanelStatusBadge tone="muted">
|
||||
{vendorLabel(device.vendor, t)}
|
||||
</SystemPanelStatusBadge>
|
||||
{device.health ? (
|
||||
<SystemPanelStatusBadge tone={/ok|healthy|good/i.test(device.health) ? 'success' : 'warning'}>
|
||||
{device.health}
|
||||
</SystemPanelStatusBadge>
|
||||
) : null}
|
||||
</div>
|
||||
{device.driverVersion ? (
|
||||
<div className="text-[10px] text-muted-foreground mt-0.5">
|
||||
{t('systemManager.gpu.driver', { version: device.driverVersion })}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 text-[10px] text-muted-foreground tabular-nums">
|
||||
{Number.isFinite(device.temperatureC) ? (
|
||||
<span className="inline-flex items-center gap-0.5" title={t('systemManager.gpu.temperature')}>
|
||||
<Thermometer size={10} />
|
||||
{Math.round(Number(device.temperatureC))}°C
|
||||
</span>
|
||||
) : null}
|
||||
{Number.isFinite(device.powerDrawW) ? (
|
||||
<span className="inline-flex items-center gap-0.5" title={t('systemManager.gpu.power')}>
|
||||
<Zap size={10} />
|
||||
{Number(device.powerDrawW).toFixed(0)}
|
||||
{Number.isFinite(device.powerLimitW) ? `/${Math.round(Number(device.powerLimitW))}` : ''}
|
||||
W
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<ResourceBar
|
||||
label={t('systemManager.gpu.util')}
|
||||
value={util}
|
||||
/>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<ResourceBar
|
||||
label={device.vendor === 'ascend' ? t('systemManager.gpu.hbm') : t('systemManager.gpu.memory')}
|
||||
value={memPct}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-[10px] tabular-nums text-muted-foreground shrink-0">
|
||||
{formatMb(device.memoryUsedMb)} / {formatMb(device.memoryTotalMb)}
|
||||
</span>
|
||||
</div>
|
||||
{Number.isFinite(device.fanPercent) ? (
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{t('systemManager.gpu.fan', { value: Math.round(Number(device.fanPercent)) })}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const ProcessRow = memo(function ProcessRow({
|
||||
process,
|
||||
}: {
|
||||
process: AcceleratorProcessInfo;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<SystemPanelRow
|
||||
title={process.processName || '—'}
|
||||
subtitle={`${vendorLabel(process.vendor, t)} #${process.gpuIndex}`}
|
||||
trailing={(
|
||||
<div className="flex items-center gap-2 text-[10px] text-muted-foreground tabular-nums">
|
||||
<span>PID {process.pid}</span>
|
||||
<span>{formatMb(process.memoryUsedMb)}</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const GpuManagerTab = memo(function GpuManagerTab({
|
||||
sessionId,
|
||||
isVisible,
|
||||
backend,
|
||||
refreshIntervalSec,
|
||||
}: GpuManagerTabProps) {
|
||||
const { t } = useI18n();
|
||||
const stableT = useStableTranslate();
|
||||
const intervalMs = Math.max(2, refreshIntervalSec) * 1000;
|
||||
const [gpuListPending, setGpuListPending] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setGpuListPending(false);
|
||||
}, [sessionId]);
|
||||
|
||||
const fetcher = useCallback(async (): Promise<AcceleratorSnapshot | null> => {
|
||||
const result = await backend.listAccelerators(sessionId);
|
||||
if (result.pending) {
|
||||
setGpuListPending(true);
|
||||
return null;
|
||||
}
|
||||
setGpuListPending(false);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || stableT('systemManager.errors.loadGpu'));
|
||||
}
|
||||
return {
|
||||
devices: result.devices || [],
|
||||
processes: result.processes || [],
|
||||
nvidiaDriverVersion: result.nvidiaDriverVersion ?? null,
|
||||
probedAt: result.probedAt || Date.now(),
|
||||
};
|
||||
}, [backend, sessionId, stableT]);
|
||||
|
||||
const { data, error, loading, refresh } = usePolling(
|
||||
fetcher,
|
||||
intervalMs,
|
||||
isVisible,
|
||||
undefined,
|
||||
{ resetKey: sessionId },
|
||||
);
|
||||
|
||||
const devices = useMemo(() => data?.devices ?? [], [data?.devices]);
|
||||
const processes = useMemo(() => data?.processes ?? [], [data?.processes]);
|
||||
const isRefreshActive = loading || gpuListPending;
|
||||
|
||||
const meta = useMemo(() => {
|
||||
const nvidiaCount = devices.filter((d) => d.vendor === 'nvidia').length;
|
||||
const ascendCount = devices.filter((d) => d.vendor === 'ascend').length;
|
||||
return t('systemManager.gpu.meta', {
|
||||
devices: devices.length,
|
||||
processes: processes.length,
|
||||
nvidia: nvidiaCount,
|
||||
ascend: ascendCount,
|
||||
});
|
||||
}, [devices, processes, t]);
|
||||
|
||||
if (!isVisible && !data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
if (error && !isRefreshActive) {
|
||||
return (
|
||||
<SystemPanelShell section="system-manager-gpu">
|
||||
<SystemPanelError
|
||||
message={error}
|
||||
retryLabel={t('history.action.refresh')}
|
||||
onRetry={() => void refresh()}
|
||||
/>
|
||||
</SystemPanelShell>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SystemPanelShell section="system-manager-gpu">
|
||||
<SystemPanelLoading message={t('systemManager.gpu.loading')} />
|
||||
</SystemPanelShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (!devices.length && !processes.length) {
|
||||
return (
|
||||
<SystemPanelShell section="system-manager-gpu">
|
||||
<SystemPanelToolbar trailing={(
|
||||
<SystemPanelRefreshButton
|
||||
title={t('history.action.refresh')}
|
||||
loading={isRefreshActive}
|
||||
onClick={() => void refresh()}
|
||||
/>
|
||||
)}>
|
||||
<span className="text-[11px] text-muted-foreground truncate">{meta}</span>
|
||||
</SystemPanelToolbar>
|
||||
<SystemPanelEmpty icon={CircuitBoard} message={t('systemManager.gpu.empty')} />
|
||||
</SystemPanelShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SystemPanelShell section="system-manager-gpu">
|
||||
<SystemPanelToolbar trailing={(
|
||||
<SystemPanelRefreshButton
|
||||
title={t('history.action.refresh')}
|
||||
loading={isRefreshActive}
|
||||
onClick={() => void refresh()}
|
||||
/>
|
||||
)}>
|
||||
<span className="text-[11px] text-muted-foreground truncate">{meta}</span>
|
||||
</SystemPanelToolbar>
|
||||
{error && !isRefreshActive ? (
|
||||
<SystemPanelInlineError
|
||||
message={error}
|
||||
retryLabel={t('history.action.refresh')}
|
||||
onRetry={() => void refresh()}
|
||||
/>
|
||||
) : null}
|
||||
<SystemPanelList>
|
||||
<div className="px-3 py-1.5 text-[10px] uppercase tracking-wide text-muted-foreground flex items-center gap-1">
|
||||
<Cpu size={10} />
|
||||
{t('systemManager.gpu.devices')}
|
||||
</div>
|
||||
{devices.length === 0 ? (
|
||||
<div className="px-3 py-2 text-[11px] text-muted-foreground">
|
||||
{t('systemManager.gpu.empty')}
|
||||
</div>
|
||||
) : (
|
||||
devices.map((device) => (
|
||||
<DeviceCard key={`${device.vendor}-${device.index}-${device.uuid || device.name}`} device={device} />
|
||||
))
|
||||
)}
|
||||
<div className="px-3 pt-3 pb-1.5 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{t('systemManager.gpu.processes')}
|
||||
</div>
|
||||
{processes.length === 0 ? (
|
||||
<div className="px-3 py-2 text-[11px] text-muted-foreground">
|
||||
{t('systemManager.gpu.noProcesses')}
|
||||
</div>
|
||||
) : (
|
||||
processes.map((process) => (
|
||||
<ProcessRow
|
||||
key={`${process.vendor}-${process.gpuIndex}-${process.pid}-${process.processName}`}
|
||||
process={process}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</SystemPanelList>
|
||||
</SystemPanelShell>
|
||||
);
|
||||
});
|
||||
@@ -3,13 +3,12 @@ import { cn } from '../../lib/utils';
|
||||
|
||||
interface ResourceBarProps {
|
||||
label: string;
|
||||
value: number | null | undefined;
|
||||
value: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ResourceBar = memo(function ResourceBar({ label, value, className }: ResourceBarProps) {
|
||||
const finite = typeof value === 'number' && Number.isFinite(value);
|
||||
const clamped = finite ? Math.max(0, Math.min(100, value)) : 0;
|
||||
const clamped = Math.max(0, Math.min(100, value));
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2 min-w-0', className)}>
|
||||
<span className="text-[10px] text-muted-foreground w-7 shrink-0">{label}</span>
|
||||
@@ -17,14 +16,13 @@ export const ResourceBar = memo(function ResourceBar({ label, value, className }
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full',
|
||||
finite && clamped > 85 ? 'bg-destructive/70' : 'bg-primary/70',
|
||||
!finite && 'opacity-0',
|
||||
clamped > 85 ? 'bg-destructive/70' : 'bg-primary/70',
|
||||
)}
|
||||
style={{ width: `${clamped}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] tabular-nums text-muted-foreground w-10 text-right shrink-0">
|
||||
{finite ? `${value.toFixed(1)}%` : '--'}
|
||||
{Number.isFinite(value) ? `${value.toFixed(1)}%` : '--'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Activity, Box, CircuitBoard, Gauge, LayoutList, Loader2, TerminalSquare } from 'lucide-react';
|
||||
import { Activity, Box, Gauge, LayoutList, Loader2, TerminalSquare } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import React, { memo, useMemo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
@@ -11,7 +11,6 @@ import { buildSystemManagerTabs, shouldCollectServerStats } from '../../domain/s
|
||||
import type { Snippet, TerminalSession } from '../../types';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { DockerManagerTab } from './DockerManagerTab';
|
||||
import { GpuManagerTab } from './GpuManagerTab';
|
||||
import { ProcessManagerTab } from './ProcessManagerTab';
|
||||
import { SystemOverviewTab } from './SystemOverviewTab';
|
||||
import { TmuxManagerTab } from './TmuxManagerTab';
|
||||
@@ -166,12 +165,10 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
{ id: 'processes', icon: LayoutList, label: t('systemManager.tabs.processes') },
|
||||
{ id: 'tmux', icon: TerminalSquare, label: t('systemManager.tabs.tmux') },
|
||||
{ id: 'docker', icon: Box, label: t('systemManager.tabs.docker') },
|
||||
{ id: 'gpu', icon: CircuitBoard, label: t('systemManager.tabs.gpu') },
|
||||
];
|
||||
|
||||
const tmuxReady = capabilities?.hasTmux === true;
|
||||
const dockerReady = capabilities?.hasDocker === true;
|
||||
const gpuReady = capabilities?.hasNvidiaSmi === true || capabilities?.hasNpuSmi === true;
|
||||
const tmuxPanelState = resolveCapabilityPanelState({
|
||||
isActive: resolvedTab === 'tmux',
|
||||
ready: tmuxReady,
|
||||
@@ -182,11 +179,6 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
ready: dockerReady,
|
||||
capabilitiesKnown: capabilities !== undefined,
|
||||
});
|
||||
const gpuPanelState = resolveCapabilityPanelState({
|
||||
isActive: resolvedTab === 'gpu',
|
||||
ready: gpuReady,
|
||||
capabilitiesKnown: capabilities !== undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<SystemPanelShell section="system-manager-panel">
|
||||
@@ -275,24 +267,6 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{gpuPanelState === 'unavailable' ? (
|
||||
<div className="flex-1 min-h-0">
|
||||
<SystemPanelEmpty icon={CircuitBoard} message={t('systemManager.gpu.unavailable')} />
|
||||
</div>
|
||||
) : gpuPanelState === 'checking' ? (
|
||||
<div className="flex-1 min-h-0">
|
||||
<SystemPanelChecking message={t('systemManager.common.checkingAvailability')} />
|
||||
</div>
|
||||
) : gpuPanelState === 'ready' ? (
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', resolvedTab !== 'gpu' && 'hidden')}>
|
||||
<GpuManagerTab
|
||||
sessionId={sessionId}
|
||||
isVisible={isVisible && resolvedTab === 'gpu'}
|
||||
backend={backend}
|
||||
refreshIntervalSec={terminalSettings.systemManagerProcessRefreshInterval}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SystemPanelShell>
|
||||
);
|
||||
|
||||
@@ -11,12 +11,9 @@ import {
|
||||
resolveTerminalRightInset,
|
||||
resolveTerminalTopOffsets,
|
||||
shouldBlockTerminalReconnectForTarget,
|
||||
shouldEnableStatusBarDisconnect,
|
||||
shouldEnableStatusBarReconnect,
|
||||
shouldReconnectTerminalOnEnterKey,
|
||||
shouldShowSelectionAIOverlay,
|
||||
shouldShowLineTimestampToolbarToggle,
|
||||
shouldShowStatusBarConnectionControls,
|
||||
} from "./TerminalView.tsx";
|
||||
|
||||
test("line timestamp toggle creates a persistent host update", () => {
|
||||
@@ -308,8 +305,6 @@ test("hidden host information keeps terminal actions rendered", () => {
|
||||
const copyAction = source.indexOf('aria-label={t("terminal.statusbar.copyHostname.label")}');
|
||||
const timestampAction = source.indexOf("shouldShowLineTimestampToolbarToggle", copyAction);
|
||||
const systemAction = source.indexOf('aria-label={t("terminal.layer.system")}', timestampAction);
|
||||
const disconnectAction = source.indexOf('aria-label={t("terminal.statusbar.disconnect.label")}', systemAction);
|
||||
const reconnectAction = source.indexOf('aria-label={t("terminal.statusbar.reconnect.label")}', disconnectAction);
|
||||
const actionsStart = source.indexOf('className="flex items-center gap-0.5 flex-shrink-0"');
|
||||
const controls = source.indexOf("{renderControls({ showClose: inWorkspace })}");
|
||||
const compactDragHandle = source.indexOf('data-terminal-detach-drag-handle="true"');
|
||||
@@ -319,8 +314,6 @@ test("hidden host information keeps terminal actions rendered", () => {
|
||||
assert.notEqual(copyAction, -1);
|
||||
assert.notEqual(timestampAction, -1);
|
||||
assert.notEqual(systemAction, -1);
|
||||
assert.notEqual(disconnectAction, -1);
|
||||
assert.notEqual(reconnectAction, -1);
|
||||
assert.notEqual(actionsStart, -1);
|
||||
assert.notEqual(controls, -1);
|
||||
assert.notEqual(compactDragHandle, -1);
|
||||
@@ -331,100 +324,11 @@ test("hidden host information keeps terminal actions rendered", () => {
|
||||
assert.ok(hostInfoEnd < copyAction);
|
||||
assert.ok(copyAction < timestampAction);
|
||||
assert.ok(timestampAction < systemAction);
|
||||
assert.ok(systemAction < disconnectAction);
|
||||
assert.ok(disconnectAction < reconnectAction);
|
||||
assert.ok(reconnectAction < actionsStart);
|
||||
assert.ok(systemAction < actionsStart);
|
||||
assert.ok(actionsStart < controls);
|
||||
assert.ok(compactDragHandle < hostInfoStart);
|
||||
});
|
||||
|
||||
test("status bar disconnect stays enabled while connected or connecting", () => {
|
||||
assert.equal(shouldEnableStatusBarDisconnect("connected"), true);
|
||||
assert.equal(shouldEnableStatusBarDisconnect("connecting"), true);
|
||||
assert.equal(shouldEnableStatusBarDisconnect("disconnected"), false);
|
||||
assert.equal(shouldEnableStatusBarDisconnect(undefined), false);
|
||||
});
|
||||
|
||||
test("status bar reconnect matches tab-menu reconnect gating", () => {
|
||||
assert.equal(shouldEnableStatusBarReconnect("connected"), true);
|
||||
assert.equal(shouldEnableStatusBarReconnect("disconnected"), true);
|
||||
assert.equal(shouldEnableStatusBarReconnect("connecting"), false);
|
||||
assert.equal(shouldEnableStatusBarReconnect(undefined), false);
|
||||
});
|
||||
|
||||
test("status bar connection controls require an owned session surface", () => {
|
||||
assert.equal(
|
||||
shouldShowStatusBarConnectionControls({
|
||||
showConnectionControls: true,
|
||||
hasDisconnectHandler: true,
|
||||
hasReconnectHandler: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldShowStatusBarConnectionControls({
|
||||
showConnectionControls: false,
|
||||
hasDisconnectHandler: true,
|
||||
hasReconnectHandler: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldShowStatusBarConnectionControls({
|
||||
showConnectionControls: true,
|
||||
hasDisconnectHandler: false,
|
||||
hasReconnectHandler: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("manual disconnect keeps the session pane for reconnect", () => {
|
||||
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
|
||||
const disconnectStart = source.indexOf("const handleDisconnect = () => {");
|
||||
const disconnectEnd = source.indexOf("const handleDismissDisconnectedDialog", disconnectStart);
|
||||
assert.notEqual(disconnectStart, -1);
|
||||
assert.notEqual(disconnectEnd, -1);
|
||||
const body = source.slice(disconnectStart, disconnectEnd);
|
||||
assert.match(body, /clearAutoReconnect\(\{ stopLoop: true \}\)/);
|
||||
assert.match(body, /reconnectWakeTokenRef\.current = null/);
|
||||
assert.match(body, /reconnectWakeInFlightRef\.current = false/);
|
||||
assert.match(body, /netcatty:terminal-session-disconnected/);
|
||||
assert.match(body, /invalidateBootEpochForClose\(\)/);
|
||||
assert.match(body, /isBootActiveRef\.current = false/);
|
||||
assert.match(body, /setIsCancelling\(true\)/);
|
||||
assert.match(body, /updateStatus\("disconnected"\)/);
|
||||
assert.match(body, /void cleanupSession\(\)/);
|
||||
assert.match(source, /trackSessionCleanup/);
|
||||
assert.doesNotMatch(body, /onCloseSession/);
|
||||
assert.match(source, /handleDisconnect: \(attachExistingSession \|\| compactToolbar\) \? undefined : handleDisconnect/);
|
||||
assert.match(source, /showConnectionControls: !attachExistingSession && !compactToolbar/);
|
||||
assert.match(source, /setTerminalBootEpoch/);
|
||||
|
||||
const startersSource = readFileSync(
|
||||
new URL("./runtime/createTerminalSessionStarters.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(startersSource, /createBootAttemptGuard\(ctx\)/);
|
||||
assert.match(startersSource, /setTerminalBootEpoch\(ctx\.sessionId, bootEpoch\)/);
|
||||
|
||||
const effectsSource = readFileSync(new URL("./useTerminalEffects.ts", import.meta.url), "utf8");
|
||||
assert.match(
|
||||
effectsSource,
|
||||
/!isBootActiveRef\.current[\s\S]*statusRef\.current === "disconnected"[\s\S]*bootEpochMismatch/,
|
||||
);
|
||||
assert.match(
|
||||
effectsSource,
|
||||
/respondHostKeyVerification\?\.\(request\.requestId, false\)/,
|
||||
);
|
||||
assert.match(effectsSource, /request\.bootEpoch/);
|
||||
assert.match(
|
||||
startersSource,
|
||||
/if \(!isCurrentAttempt\(\)\) return;/,
|
||||
);
|
||||
assert.match(startersSource, /bootEpoch,/);
|
||||
});
|
||||
|
||||
test("hidden host information reveals actions without permanently covering terminal content", () => {
|
||||
const source = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ChevronsLeft, GripVertical, Network, X as XIcon } from 'lucide-react';
|
||||
import { isSessionReconnectDisabled } from '../top-tabs/SessionTabContextMenuContent';
|
||||
|
||||
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
|
||||
import { classifyDistroId } from '../../domain/host';
|
||||
@@ -48,32 +47,6 @@ export function shouldShowLineTimestampToolbarToggle(
|
||||
return lineTimestampsAvailable !== false && Boolean(onUpdateHost);
|
||||
}
|
||||
|
||||
/** Keep the tab/pane; only tear down the live transport. */
|
||||
export function shouldEnableStatusBarDisconnect(
|
||||
status: 'connecting' | 'connected' | 'disconnected' | undefined,
|
||||
): boolean {
|
||||
return status === 'connected' || status === 'connecting';
|
||||
}
|
||||
|
||||
export function shouldEnableStatusBarReconnect(
|
||||
status: 'connecting' | 'connected' | 'disconnected' | undefined,
|
||||
): boolean {
|
||||
if (!status) return false;
|
||||
return !isSessionReconnectDisabled(status);
|
||||
}
|
||||
|
||||
export function shouldShowStatusBarConnectionControls({
|
||||
showConnectionControls,
|
||||
hasDisconnectHandler,
|
||||
hasReconnectHandler,
|
||||
}: {
|
||||
showConnectionControls?: boolean;
|
||||
hasDisconnectHandler?: boolean;
|
||||
hasReconnectHandler?: boolean;
|
||||
}): boolean {
|
||||
return Boolean(showConnectionControls && (hasDisconnectHandler || hasReconnectHandler));
|
||||
}
|
||||
|
||||
export function shouldEnableYmodemAction({
|
||||
isSerialConnection,
|
||||
status,
|
||||
@@ -314,7 +287,7 @@ function terminalViewCtxEqual(
|
||||
}
|
||||
|
||||
function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
|
||||
const { Activity, Button, Clock3, Copy, Maximize2, Radio, RefreshCcw, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, Unplug, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTerminalProtocol, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onCloseSession, onDetach, onDetachPointerDown, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scriptExecutionOverlay, searchMatchCount, searchFocusToken, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showConnectionControls, showLogs, showSelectionAIAction, isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem } = ctx;
|
||||
const { Activity, Button, Clock3, Copy, Maximize2, Radio, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTerminalProtocol, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onCloseSession, onDetach, onDetachPointerDown, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scriptExecutionOverlay, searchMatchCount, searchFocusToken, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem } = ctx;
|
||||
// Context menu only needs a snapshot at open; avoid selection state lifting into Terminal.
|
||||
const [contextMenuHasSelection, setContextMenuHasSelection] = useState(false);
|
||||
const isNetworkDevice = host.deviceType === 'network'
|
||||
@@ -673,60 +646,6 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
|
||||
<TooltipContent side="bottom">{t("terminal.layer.system")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{shouldShowStatusBarConnectionControls({
|
||||
showConnectionControls,
|
||||
hasDisconnectHandler: Boolean(handleDisconnect),
|
||||
hasReconnectHandler: Boolean(handleRetry),
|
||||
}) && (
|
||||
<>
|
||||
{handleDisconnect && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"ml-0.5 p-0.5 rounded transition-colors flex-shrink-0",
|
||||
"hover:bg-[color:var(--terminal-toolbar-btn-hover)]",
|
||||
shouldEnableStatusBarDisconnect(status)
|
||||
? "opacity-60 hover:opacity-100"
|
||||
: "opacity-30 cursor-not-allowed",
|
||||
)}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={handleDisconnect}
|
||||
disabled={!shouldEnableStatusBarDisconnect(status)}
|
||||
aria-label={t("terminal.statusbar.disconnect.label")}
|
||||
>
|
||||
<Unplug size={10} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t("terminal.statusbar.disconnect.tooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{handleRetry && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"ml-0.5 p-0.5 rounded transition-colors flex-shrink-0",
|
||||
"hover:bg-[color:var(--terminal-toolbar-btn-hover)]",
|
||||
shouldEnableStatusBarReconnect(status)
|
||||
? "opacity-60 hover:opacity-100"
|
||||
: "opacity-30 cursor-not-allowed",
|
||||
)}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={handleRetry}
|
||||
disabled={!shouldEnableStatusBarReconnect(status)}
|
||||
aria-label={t("terminal.statusbar.reconnect.label")}
|
||||
>
|
||||
<RefreshCcw size={10} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t("terminal.statusbar.reconnect.tooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{showHostInfoBar && !compactToolbar && (
|
||||
<TerminalServerStats
|
||||
|
||||
@@ -111,16 +111,4 @@ describe('extractRootPathsFromDropEntries', () => {
|
||||
['/home/user/folder'],
|
||||
);
|
||||
});
|
||||
|
||||
it('uses native path-only entries from a folder scan', () => {
|
||||
assert.deepEqual(
|
||||
extractRootPathsFromDropEntries([{
|
||||
file: null,
|
||||
localPath: '/home/user/folder/src/main.ts',
|
||||
relativePath: 'folder/src/main.ts',
|
||||
isDirectory: false,
|
||||
}]),
|
||||
['/home/user/folder'],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,151 +2,11 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import xterm from "@xterm/xterm";
|
||||
import serializeMod from "@xterm/addon-serialize";
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
|
||||
import {
|
||||
applyHibernateWakeToTerminal,
|
||||
resolveHibernateWakeHistory,
|
||||
} from "./terminalHibernateRuntime.ts";
|
||||
import { applyHibernateWakeToTerminal } from "./terminalHibernateRuntime.ts";
|
||||
import { writeTerminalPayloadChunked } from "./terminalReplay.ts";
|
||||
|
||||
const { Terminal } = xterm;
|
||||
const { SerializeAddon } = serializeMod;
|
||||
|
||||
const readActiveBufferText = (term: XTerm): string => {
|
||||
const buffer = term.buffer.active;
|
||||
const lines: string[] = [];
|
||||
for (let index = 0; index < buffer.length; index += 1) {
|
||||
lines.push(buffer.getLine(index)?.translateToString(true) ?? "");
|
||||
}
|
||||
return lines.join("\n");
|
||||
};
|
||||
|
||||
const writeAndWait = (term: XTerm, data: string): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
term.write(data, () => resolve());
|
||||
});
|
||||
|
||||
test("hibernate wake pauses flow before replay and resumes only after reattach", () => {
|
||||
// #2762 / Codex: full-history wake must not race the capped pending buffer or
|
||||
// the 64 KiB preload backlog. Pause+wait drains into pending, then stop the
|
||||
// data listener, take pending once, replay, reattach, and only then resume.
|
||||
const mountSource = readFileSync(new URL("./terminalRuntimeMount.ts", import.meta.url), "utf8");
|
||||
const terminalSource = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(mountSource, /prepareWakeFlow\?:\s*\(\) => Promise<boolean>/);
|
||||
assert.match(mountSource, /restoreAfterFailedWake\?:\s*\(takenPending: string\) => void/);
|
||||
assert.match(mountSource, /resumeAfterReattach\?:\s*\(\) => void/);
|
||||
assert.match(mountSource, /takePendingBuffer: \(\) => string/);
|
||||
assert.match(mountSource, /stopHibernateDataListener: \(\) => void/);
|
||||
assert.match(mountSource, /const drainOk = \(await prepareWakeFlow\?\.\(\)\) \?\? true;/);
|
||||
assert.match(mountSource, /const takeAndTrackPending = \(\): string =>/);
|
||||
assert.match(mountSource, /const pendingAtApplyStart = takeAndTrackPending\(\);/);
|
||||
assert.match(mountSource, /const pendingTail = takeAndTrackPending\(\);/);
|
||||
assert.match(mountSource, /if \(!pendingTail\) break;/);
|
||||
assert.match(mountSource, /restoreAfterFailedWake\?\.\(takenPendingForRestore\)/);
|
||||
assert.doesNotMatch(mountSource, /pending\.slice\(replayedPendingLength\)/);
|
||||
assert.doesNotMatch(mountSource, /for \(let drainPass = 0;/);
|
||||
assert.doesNotMatch(mountSource, /finalPendingDelta/);
|
||||
assert.doesNotMatch(mountSource, /preTeardownTail/);
|
||||
|
||||
const prepareIndex = mountSource.indexOf("const drainOk = (await prepareWakeFlow?.()) ?? true;");
|
||||
const disableCapIndex = mountSource.indexOf("setHibernatePendingCapDisabled?.(true);");
|
||||
const stopDataBeforeReplay = mountSource.indexOf("if (drainOk) {\n stopHibernateDataListener();");
|
||||
const pendingIndex = mountSource.indexOf("const pendingAtApplyStart = takeAndTrackPending();");
|
||||
const applyIndex = mountSource.indexOf("await applyHibernateWakeToTerminal(");
|
||||
const stopDataBeforeTail = mountSource.indexOf(
|
||||
"stopHibernateDataListener();",
|
||||
applyIndex,
|
||||
);
|
||||
const pendingTailIndex = mountSource.indexOf("const pendingTail = takeAndTrackPending();");
|
||||
const emptyBreakIndex = mountSource.indexOf("if (!pendingTail) break;");
|
||||
const shouldReattachIndex = mountSource.indexOf(
|
||||
"const shouldReattach = sessionConnected && (getSessionConnected?.() ?? true);",
|
||||
);
|
||||
const stopAllIndex = mountSource.indexOf("stopHibernateListeners();", shouldReattachIndex);
|
||||
const reattachIndex = mountSource.indexOf("reattachSession(term);", shouldReattachIndex);
|
||||
const failedRestoreIndex = mountSource.indexOf("restoreAfterFailedWake?.(takenPendingForRestore);");
|
||||
const resumeIndex = mountSource.indexOf("resumeAfterReattach?.();");
|
||||
|
||||
assert.ok(prepareIndex >= 0, "wake must pause backend flow before history replay");
|
||||
assert.ok(
|
||||
disableCapIndex > prepareIndex,
|
||||
"drain failure must disable the pending cap before keeping the live listener",
|
||||
);
|
||||
assert.ok(stopDataBeforeReplay > prepareIndex, "successful drain stops the data listener before replay");
|
||||
assert.ok(pendingIndex > stopDataBeforeReplay, "pending take must run after drain handling");
|
||||
assert.ok(applyIndex > pendingIndex, "history replay follows the pending capture");
|
||||
assert.ok(
|
||||
stopDataBeforeTail > applyIndex,
|
||||
"data listener must stop again before residual pending drain",
|
||||
);
|
||||
assert.ok(
|
||||
pendingTailIndex > stopDataBeforeTail,
|
||||
"residual pending drain must run after history replay",
|
||||
);
|
||||
assert.ok(
|
||||
emptyBreakIndex > pendingTailIndex,
|
||||
"residual drain must end with an empty take before teardown",
|
||||
);
|
||||
assert.ok(
|
||||
shouldReattachIndex > emptyBreakIndex,
|
||||
"reattach decision must run after until-empty pending drain",
|
||||
);
|
||||
assert.ok(
|
||||
stopAllIndex > shouldReattachIndex,
|
||||
"full hibernate listener teardown must wait until after the reattach decision",
|
||||
);
|
||||
assert.ok(reattachIndex > stopAllIndex, "reattach runs after hibernate listeners are cleared");
|
||||
assert.ok(failedRestoreIndex >= 0, "failed wakes must restore take-and-cleared pending");
|
||||
assert.ok(resumeIndex > reattachIndex, "flow resume must wait until after reattach");
|
||||
assert.match(
|
||||
mountSource,
|
||||
/if \(!wakeSucceeded\) \{[\s\S]*?restoreAfterFailedWake\?\.\(takenPendingForRestore\);[\s\S]*?\} else if \(didReattach\) \{[\s\S]*?resumeAfterReattach\?\.\(\);/,
|
||||
);
|
||||
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/takePendingBuffer:\s*\(\)\s*=>\s*\{\s*const pending = hibernatePendingBufferRef\.current;\s*hibernatePendingBufferRef\.current = "";\s*return pending;\s*\}/,
|
||||
);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/setSessionFlowPausedAndWait\(backendId,\s*true\)/,
|
||||
);
|
||||
// Reconnect wakes (sessionConnected=false) must still pause when a backend
|
||||
// session exists; otherwise stopping the hibernate listener drops live output.
|
||||
assert.doesNotMatch(
|
||||
terminalSource,
|
||||
/prepareWakeFlow: async \(\) => \{\s*if \(!options\.sessionConnected\) return true;/,
|
||||
);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/stopHibernateListeners\(\{\s*keepPaused:\s*true\s*\}\)/,
|
||||
);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/restoreAfterFailedWake:\s*\(takenPending\)\s*=>\s*\{[\s\S]*?disposeRuntimeOnly\(\);[\s\S]*?beginHibernatedSessionListeners\(backendId\)/,
|
||||
);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/appendHibernatePendingBuffer\(\s*takenPending \|\| "",\s*pendingStillInRef,\s*\)/,
|
||||
);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/resumeAfterReattach:\s*\(\)\s*=>\s*\{[\s\S]*?setSessionFlowPaused\?\.\(backendId,\s*false\)/,
|
||||
);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/hibernatePendingCapDisabledRef\.current\s*\?\s*hibernatePendingBufferRef\.current \+ chunk/,
|
||||
);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/result\?\.success === true/,
|
||||
);
|
||||
});
|
||||
|
||||
test("writeTerminalPayloadChunked splits large buffers (shipped wake helper)", async () => {
|
||||
const writes: string[] = [];
|
||||
const term = {
|
||||
@@ -162,43 +22,7 @@ test("writeTerminalPayloadChunked splits large buffers (shipped wake helper)", a
|
||||
assert.equal(writes.join(""), payload);
|
||||
});
|
||||
|
||||
test("resolveHibernateWakeHistory prefers the coherent full snapshot", () => {
|
||||
assert.equal(
|
||||
resolveHibernateWakeHistory({
|
||||
snapshot: "FULL",
|
||||
viewportSnapshot: "VIEW",
|
||||
scrollbackSnapshot: "SCROLL",
|
||||
pendingBuffer: "",
|
||||
alternateScreen: false,
|
||||
}),
|
||||
"FULL",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveHibernateWakeHistory falls back to scrollback before viewport with a seam newline", () => {
|
||||
assert.equal(
|
||||
resolveHibernateWakeHistory({
|
||||
snapshot: "",
|
||||
viewportSnapshot: "VIEWPORT_END\r\n",
|
||||
scrollbackSnapshot: "SCROLLBACK_START",
|
||||
pendingBuffer: "",
|
||||
alternateScreen: false,
|
||||
}),
|
||||
"SCROLLBACK_START\r\nVIEWPORT_END\r\n",
|
||||
);
|
||||
assert.equal(
|
||||
resolveHibernateWakeHistory({
|
||||
snapshot: "",
|
||||
viewportSnapshot: "VIEWPORT_END\r\n",
|
||||
scrollbackSnapshot: "SCROLLBACK_START\r\n",
|
||||
pendingBuffer: "",
|
||||
alternateScreen: false,
|
||||
}),
|
||||
"SCROLLBACK_START\r\nVIEWPORT_END\r\n",
|
||||
);
|
||||
});
|
||||
|
||||
test("applyHibernateWakeToTerminal replays snapshot then pending without idle append", async () => {
|
||||
test("applyHibernateWakeToTerminal defers scrollback to idle and chunks viewport first", async () => {
|
||||
const writes: string[] = [];
|
||||
const term = {
|
||||
rows: 24,
|
||||
@@ -219,32 +43,38 @@ test("applyHibernateWakeToTerminal replays snapshot then pending without idle ap
|
||||
// @ts-expect-error test override
|
||||
globalThis.requestIdleCallback = (cb: () => void) => {
|
||||
idleScheduled = true;
|
||||
// Do not run immediately — proves scrollback is deferred off the wake path.
|
||||
setTimeout(cb, 0);
|
||||
return 1;
|
||||
};
|
||||
|
||||
try {
|
||||
const snapshot = "SCROLLBACK_START\r\nVIEWPORT_END\r\n";
|
||||
const pending = "PENDING_TAIL\r\n";
|
||||
const viewport = "VIEWPORT";
|
||||
const scrollback = "S".repeat(40_000);
|
||||
await applyHibernateWakeToTerminal(
|
||||
term,
|
||||
runtime as never,
|
||||
{
|
||||
snapshot,
|
||||
viewportSnapshot: "VIEWPORT_END\r\n",
|
||||
scrollbackSnapshot: "SCROLLBACK_START\r\n",
|
||||
pendingBuffer: pending,
|
||||
snapshot: viewport,
|
||||
viewportSnapshot: viewport,
|
||||
scrollbackSnapshot: scrollback,
|
||||
pendingBuffer: "",
|
||||
alternateScreen: false,
|
||||
},
|
||||
{ replayOptions: { chunkBytes: 8_192 } },
|
||||
);
|
||||
|
||||
assert.equal(writes.join(""), `${snapshot}${pending}`);
|
||||
assert.equal(
|
||||
idleScheduled,
|
||||
false,
|
||||
"scrollback must not be deferred to idle after viewport (append would evict the end)",
|
||||
// Viewport written on the wake path; scrollback scheduled idle.
|
||||
assert.ok(writes.join("").includes(viewport));
|
||||
assert.equal(idleScheduled, true);
|
||||
assert.ok(
|
||||
!writes.join("").includes(scrollback.slice(0, 1000)),
|
||||
"scrollback must not be written synchronously on wake",
|
||||
);
|
||||
|
||||
// Flush idle callback.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.ok(writes.join("").includes(scrollback.slice(0, 100)), "scrollback eventually applied");
|
||||
} finally {
|
||||
if (originalRic) {
|
||||
globalThis.requestIdleCallback = originalRic;
|
||||
@@ -255,115 +85,9 @@ test("applyHibernateWakeToTerminal replays snapshot then pending without idle ap
|
||||
}
|
||||
});
|
||||
|
||||
test("hibernate wake keeps newest rows from a SerializeAddon snapshot under a finite scrollback cap", async () => {
|
||||
// #2762: idle-appending older scrollback after viewport under scrollback=N
|
||||
// evicts the newest rows. Prefer the full SerializeAddon snapshot.
|
||||
const rows = 5;
|
||||
const scrollbackCap = 8;
|
||||
const source = new Terminal({
|
||||
cols: 40,
|
||||
rows,
|
||||
scrollback: 50,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
const serializeAddon = new SerializeAddon();
|
||||
source.loadAddon(serializeAddon);
|
||||
|
||||
const olderLines = Array.from({ length: 20 }, (_, index) => `old-${index}`);
|
||||
const newestLines = Array.from({ length: rows }, (_, index) => `new-${index}`);
|
||||
await writeAndWait(source, `${olderLines.join("\r\n")}\r\n${newestLines.join("\r\n")}\r\n`);
|
||||
|
||||
const snapshot = serializeAddon.serialize();
|
||||
const bufferLength = source.buffer.active.length;
|
||||
const viewportStart = Math.max(0, bufferLength - rows);
|
||||
const scrollbackSnapshot = serializeAddon.serialize({
|
||||
range: { start: Math.max(0, viewportStart - 20), end: viewportStart - 1 },
|
||||
});
|
||||
const viewportSnapshot = serializeAddon.serialize({
|
||||
range: { start: viewportStart, end: bufferLength - 1 },
|
||||
});
|
||||
source.dispose();
|
||||
|
||||
// Range concat is not byte-identical to the full snapshot (missing seam newline).
|
||||
assert.notEqual(scrollbackSnapshot + viewportSnapshot, snapshot);
|
||||
|
||||
const term = new Terminal({
|
||||
cols: 40,
|
||||
rows,
|
||||
scrollback: scrollbackCap,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
const runtime = {
|
||||
ensureWebglRenderer: () => {},
|
||||
clearTextureAtlas: () => {},
|
||||
};
|
||||
|
||||
try {
|
||||
await applyHibernateWakeToTerminal(
|
||||
term,
|
||||
runtime as never,
|
||||
{
|
||||
snapshot,
|
||||
viewportSnapshot,
|
||||
scrollbackSnapshot,
|
||||
pendingBuffer: "",
|
||||
alternateScreen: false,
|
||||
},
|
||||
{ replayOptions: { chunkBytes: 1024 } },
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
|
||||
const text = readActiveBufferText(term);
|
||||
for (const line of newestLines) {
|
||||
assert.match(text, new RegExp(line), `newest viewport line missing after wake: ${line}`);
|
||||
}
|
||||
assert.doesNotMatch(text, /new-0new-1/, "seam must not merge adjacent snapshot lines");
|
||||
} finally {
|
||||
term.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("wrong wake order (viewport then scrollback append) drops newest rows under scrollback cap", async () => {
|
||||
// Guardrail: documents why idle-append-after-viewport is unsafe.
|
||||
const rows = 5;
|
||||
const scrollbackCap = 10;
|
||||
const term = new Terminal({
|
||||
cols: 40,
|
||||
rows,
|
||||
scrollback: scrollbackCap,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const olderLines = Array.from({ length: scrollbackCap + rows }, (_, index) => `old-${index}`);
|
||||
const newestLines = Array.from({ length: rows }, (_, index) => `new-${index}`);
|
||||
await writeAndWait(term, `${newestLines.join("\r\n")}\r\n`);
|
||||
await writeAndWait(term, `${olderLines.join("\r\n")}\r\n`);
|
||||
|
||||
const text = readActiveBufferText(term);
|
||||
for (const line of newestLines) {
|
||||
assert.equal(
|
||||
text.includes(line),
|
||||
false,
|
||||
`viewport-first append must evict newest line under the cap: ${line}`,
|
||||
);
|
||||
}
|
||||
assert.match(text, /old-14/);
|
||||
} finally {
|
||||
term.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("hibernate runtime source prefers resolveHibernateWakeHistory on the wake path", () => {
|
||||
test("hibernate runtime source schedules scrollback via requestIdleCallback", () => {
|
||||
const source = readFileSync(new URL("./terminalHibernateRuntime.ts", import.meta.url), "utf8");
|
||||
assert.match(source, /export function resolveHibernateWakeHistory/);
|
||||
assert.match(
|
||||
source,
|
||||
/writeTerminalReplaySequence\(\s*term,\s*\[\s*history,\s*payload\.pendingBuffer\s*\]/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/scheduleIdle\(\(\)\s*=>\s*\{\s*void writeTerminalPayloadChunked\(term, scrollback/,
|
||||
);
|
||||
assert.match(source, /requestIdleCallback|scheduleIdle/);
|
||||
assert.match(source, /writeTerminalPayloadChunked\(term, scrollback/);
|
||||
assert.match(source, /writeTerminalReplaySequence\(term, \[viewport, payload\.pendingBuffer\]/);
|
||||
});
|
||||
@@ -53,9 +53,7 @@ export function useTerminalFilePaste({
|
||||
|
||||
const wantsImageUpload =
|
||||
autoUploadClipboardImage && !isLocalConnection && !!bridge?.readClipboardImage;
|
||||
const canHandleLocalPaste =
|
||||
isLocalConnection && !!(bridge?.readClipboardFiles || bridge?.hasClipboardImage);
|
||||
if (!wantsImageUpload && !canHandleLocalPaste) return;
|
||||
if (!wantsImageUpload && (!isLocalConnection || !bridge?.readClipboardFiles)) return;
|
||||
|
||||
// ⚡ Must call preventDefault SYNCHRONOUSLY — the event lifecycle
|
||||
// is synchronous; calling it after an await is too late and the
|
||||
|
||||
@@ -129,7 +129,6 @@ test("reconnect wakes a hibernated terminal before requiring a terminal instance
|
||||
const wakePromiseRefIndex = source.indexOf("const wakePromiseRef = useRef<Promise<boolean> | null>(null)");
|
||||
const wakeGuardRefIndex = source.indexOf("const reconnectWakeInFlightRef = useRef(false)");
|
||||
const wakeTokenRefIndex = source.indexOf("const reconnectWakeTokenRef = useRef<symbol | null>(null)");
|
||||
const wakeInvalidateModeIndex = source.indexOf('const reconnectWakeInvalidateModeRef = useRef<"dispose" | "keep">("dispose")');
|
||||
const wakeTokenCleanupIndex = source.indexOf("reconnectWakeTokenRef.current = null", wakeTokenRefIndex);
|
||||
const reconnectIndex = source.indexOf("const startReconnect = ");
|
||||
const hibernatedBranchIndex = source.indexOf('!termRef.current && hibernatedRef.current', reconnectIndex);
|
||||
@@ -141,15 +140,12 @@ test("reconnect wakes a hibernated terminal before requiring a terminal instance
|
||||
const wakeJoinIndex = source.indexOf("return wakePromiseRef.current ?? false", source.indexOf("const wakeFromHibernateRuntime"));
|
||||
const wakeTokenIndex = source.indexOf("const wakeToken = Symbol()", hibernatedBranchIndex);
|
||||
const staleWakeGuardIndex = source.indexOf("reconnectWakeTokenRef.current !== wakeToken", wakeInvocationIndex);
|
||||
const staleWakeDisposeGuardIndex = source.indexOf('reconnectWakeInvalidateModeRef.current === "dispose"', staleWakeGuardIndex);
|
||||
const staleWakeDisposeIndex = source.indexOf("disposeRuntimeOnly();", staleWakeDisposeGuardIndex);
|
||||
const disconnectKeepModeIndex = source.indexOf('reconnectWakeInvalidateModeRef.current = "keep"', source.indexOf("const handleDisconnect"));
|
||||
const staleWakeDisposeIndex = source.indexOf("disposeRuntimeOnly();", staleWakeGuardIndex);
|
||||
const missingTermReturnIndex = source.indexOf("if (!termRef.current) return;", reconnectIndex);
|
||||
|
||||
assert.notEqual(wakePromiseRefIndex, -1);
|
||||
assert.notEqual(wakeGuardRefIndex, -1);
|
||||
assert.notEqual(wakeTokenRefIndex, -1);
|
||||
assert.notEqual(wakeInvalidateModeIndex, -1);
|
||||
assert.notEqual(wakeTokenCleanupIndex, -1);
|
||||
assert.ok(wakeTokenCleanupIndex < reconnectIndex);
|
||||
assert.notEqual(reconnectIndex, -1);
|
||||
@@ -162,9 +158,7 @@ test("reconnect wakes a hibernated terminal before requiring a terminal instance
|
||||
assert.notEqual(wakeJoinIndex, -1);
|
||||
assert.notEqual(wakeTokenIndex, -1);
|
||||
assert.notEqual(staleWakeGuardIndex, -1);
|
||||
assert.notEqual(staleWakeDisposeGuardIndex, -1);
|
||||
assert.notEqual(staleWakeDisposeIndex, -1);
|
||||
assert.notEqual(disconnectKeepModeIndex, -1);
|
||||
assert.notEqual(missingTermReturnIndex, -1);
|
||||
assert.ok(
|
||||
hibernatedBranchIndex < missingTermReturnIndex && wakeCallIndex < missingTermReturnIndex,
|
||||
@@ -179,13 +173,9 @@ test("reconnect wakes a hibernated terminal before requiring a terminal instance
|
||||
"closing a terminal must be able to invalidate a pending hibernated reconnect",
|
||||
);
|
||||
assert.ok(
|
||||
staleWakeGuardIndex < staleWakeDisposeGuardIndex && staleWakeDisposeGuardIndex < staleWakeDisposeIndex,
|
||||
staleWakeGuardIndex < staleWakeDisposeIndex,
|
||||
"an invalidated hibernated wake must dispose any runtime created after unmount cleanup",
|
||||
);
|
||||
assert.ok(
|
||||
disconnectKeepModeIndex !== -1,
|
||||
"disconnect must keep a woken runtime so later reconnect still has a terminal instance",
|
||||
);
|
||||
});
|
||||
|
||||
test("dismissing the disconnected dialog returns focus to the terminal for enter reconnect", () => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from "./createTerminalSessionStarters";
|
||||
|
||||
const noop = () => undefined;
|
||||
const ENCRYPTED_CREDENTIAL_PLACEHOLDER = "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==";
|
||||
const ENCRYPTED_CREDENTIAL_PLACEHOLDER = "enc:v1:djEwAAAA";
|
||||
|
||||
const armSudoPrompt = (
|
||||
autofill: { armForCommand: (command: string) => void } | null,
|
||||
@@ -618,7 +618,7 @@ test("startMosh asks for credential re-entry when saved key material cannot be d
|
||||
keys: [{
|
||||
id: "key-1",
|
||||
label: "Deploy key",
|
||||
privateKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
privateKey: "enc:v1:djEwAAAA",
|
||||
}],
|
||||
resolvedChainHosts: [],
|
||||
sessionId: "session-1",
|
||||
@@ -699,7 +699,7 @@ test("startMosh does not use stale local key paths when selected key material is
|
||||
keys: [{
|
||||
id: "key-1",
|
||||
label: "Deploy key",
|
||||
privateKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
privateKey: "enc:v1:djEwAAAA",
|
||||
}],
|
||||
resolvedChainHosts: [],
|
||||
sessionId: "session-1",
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "./createTerminalSessionStarters";
|
||||
|
||||
const noop = () => undefined;
|
||||
const ENCRYPTED_CREDENTIAL_PLACEHOLDER = "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==";
|
||||
const ENCRYPTED_CREDENTIAL_PLACEHOLDER = "enc:v1:djEwAAAA";
|
||||
|
||||
test("startTelnet rejects missing saved proxy profiles", async () => {
|
||||
let started = false;
|
||||
|
||||
@@ -11,7 +11,7 @@ import { pasteTextIntoTerminal } from "./terminalUserPaste";
|
||||
import { shouldSuppressHostStartupCommandOnReconnect } from "../restoredSessionGate";
|
||||
|
||||
const noop = () => undefined;
|
||||
const ENCRYPTED_CREDENTIAL_PLACEHOLDER = "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==";
|
||||
const ENCRYPTED_CREDENTIAL_PLACEHOLDER = "enc:v1:djEwAAAA";
|
||||
|
||||
const armSudoPrompt = (
|
||||
autofill: { armForCommand: (command: string) => void } | null,
|
||||
@@ -569,465 +569,6 @@ test("startSSH rechecks the live automation policy before password fallback", as
|
||||
assert.equal(commitCount, 0);
|
||||
});
|
||||
|
||||
test("startSSH auth failure after disconnect does not reopen credential UI", async () => {
|
||||
let resolveStart: ((error: Error) => void) | null = null;
|
||||
let releaseStartEntered: (() => void) | null = null;
|
||||
const started = new Promise<void>((resolve) => { releaseStartEntered = resolve; });
|
||||
const needsAuthCalls: boolean[] = [];
|
||||
const statuses: string[] = [];
|
||||
const errors: Array<string | null> = [];
|
||||
const isBootActiveRef = { current: true };
|
||||
const bootEpochRef = { current: 1 };
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
startSSHSession: async () => {
|
||||
releaseStartEntered?.();
|
||||
return await new Promise<string>((_resolve, reject) => {
|
||||
resolveStart = reject;
|
||||
});
|
||||
},
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: noop,
|
||||
resizeSession: noop,
|
||||
};
|
||||
const ctx = createStarterContext({
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Target",
|
||||
hostname: "target.example.test",
|
||||
username: "alice",
|
||||
password: "secret",
|
||||
authMethod: "password",
|
||||
},
|
||||
isBootActiveRef,
|
||||
bootEpochRef,
|
||||
setNeedsAuth: (value: boolean) => { needsAuthCalls.push(value); },
|
||||
setStatus: (value: string) => { statuses.push(value); },
|
||||
setError: (value: string | null) => { errors.push(value); },
|
||||
updateStatus: (value: string) => { statuses.push(value); },
|
||||
terminalBackend,
|
||||
});
|
||||
|
||||
const startPromise = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
||||
await started;
|
||||
isBootActiveRef.current = false;
|
||||
bootEpochRef.current += 1;
|
||||
resolveStart?.(new Error("Authentication failed"));
|
||||
await startPromise;
|
||||
|
||||
assert.deepEqual(needsAuthCalls, []);
|
||||
assert.deepEqual(statuses, []);
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
|
||||
test("startSSH stale attempt cannot attach after disconnect then reconnect", async () => {
|
||||
let resolveStart: ((id: string) => void) | null = null;
|
||||
let releaseStartEntered: (() => void) | null = null;
|
||||
const started = new Promise<void>((resolve) => { releaseStartEntered = resolve; });
|
||||
const attached: string[] = [];
|
||||
const closed: string[] = [];
|
||||
const isBootActiveRef = { current: true };
|
||||
const bootEpochRef = { current: 1 };
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
startSSHSession: async () => {
|
||||
releaseStartEntered?.();
|
||||
return await new Promise<string>((resolve) => {
|
||||
resolveStart = resolve;
|
||||
});
|
||||
},
|
||||
closeSession: (id: string, opts?: { bootEpoch?: number }) => {
|
||||
if (opts?.bootEpoch !== undefined && opts.bootEpoch !== bootEpochRef.current) return;
|
||||
closed.push(id);
|
||||
},
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: noop,
|
||||
resizeSession: noop,
|
||||
};
|
||||
const ctx = createStarterContext({
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Target",
|
||||
hostname: "target.example.test",
|
||||
username: "alice",
|
||||
password: "secret",
|
||||
authMethod: "password",
|
||||
},
|
||||
isBootActiveRef,
|
||||
bootEpochRef,
|
||||
onSessionAttached: (id: string) => { attached.push(id); },
|
||||
terminalBackend,
|
||||
});
|
||||
|
||||
const startPromise = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
||||
await started;
|
||||
// Disconnect then immediately reconnect: boot becomes active again on a new epoch.
|
||||
bootEpochRef.current += 1;
|
||||
isBootActiveRef.current = false;
|
||||
bootEpochRef.current += 1;
|
||||
isBootActiveRef.current = true;
|
||||
resolveStart?.("stale-ssh-session");
|
||||
await startPromise;
|
||||
|
||||
assert.deepEqual(attached, []);
|
||||
// Shared sessionId must not be closed while the replacement reconnect owns it.
|
||||
assert.deepEqual(closed, []);
|
||||
});
|
||||
|
||||
test("stale startSSH closes orphan only when boot is fully inactive", async () => {
|
||||
let resolveStart: ((id: string) => void) | null = null;
|
||||
let releaseStartEntered: (() => void) | null = null;
|
||||
const started = new Promise<void>((resolve) => { releaseStartEntered = resolve; });
|
||||
const closed: string[] = [];
|
||||
const isBootActiveRef = { current: true };
|
||||
const bootEpochRef = { current: 1 };
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
startSSHSession: async () => {
|
||||
releaseStartEntered?.();
|
||||
return await new Promise<string>((resolve) => {
|
||||
resolveStart = resolve;
|
||||
});
|
||||
},
|
||||
// No newer registry owner exists; closing by the attempt's bootEpoch succeeds.
|
||||
closeSession: (id: string) => { closed.push(id); },
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: noop,
|
||||
resizeSession: noop,
|
||||
};
|
||||
const ctx = createStarterContext({
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Target",
|
||||
hostname: "target.example.test",
|
||||
username: "alice",
|
||||
password: "secret",
|
||||
authMethod: "password",
|
||||
},
|
||||
isBootActiveRef,
|
||||
bootEpochRef,
|
||||
terminalBackend,
|
||||
});
|
||||
|
||||
const startPromise = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
||||
await started;
|
||||
bootEpochRef.current += 1;
|
||||
isBootActiveRef.current = false;
|
||||
resolveStart?.("orphan-ssh-session");
|
||||
await startPromise;
|
||||
|
||||
assert.deepEqual(closed, ["orphan-ssh-session"]);
|
||||
});
|
||||
|
||||
test("stale startSSH success does not clear replacement MFA wait state", async () => {
|
||||
let resolveStart: ((id: string) => void) | null = null;
|
||||
let releaseStartEntered: (() => void) | null = null;
|
||||
const started = new Promise<void>((resolve) => { releaseStartEntered = resolve; });
|
||||
const awaitingUserInput: boolean[] = [];
|
||||
const closed: string[] = [];
|
||||
const isBootActiveRef = { current: true };
|
||||
const bootEpochRef = { current: 1 };
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
startSSHSession: async () => {
|
||||
releaseStartEntered?.();
|
||||
return await new Promise<string>((resolve) => {
|
||||
resolveStart = resolve;
|
||||
});
|
||||
},
|
||||
closeSession: (id: string, opts?: { bootEpoch?: number }) => {
|
||||
if (opts?.bootEpoch !== undefined && opts.bootEpoch !== bootEpochRef.current) return;
|
||||
closed.push(id);
|
||||
},
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: noop,
|
||||
resizeSession: noop,
|
||||
};
|
||||
const ctx = createStarterContext({
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Target",
|
||||
hostname: "target.example.test",
|
||||
username: "alice",
|
||||
password: "secret",
|
||||
authMethod: "password",
|
||||
},
|
||||
isBootActiveRef,
|
||||
bootEpochRef,
|
||||
setIsConnectionAwaitingUserInput: (value: boolean) => { awaitingUserInput.push(value); },
|
||||
terminalBackend,
|
||||
});
|
||||
|
||||
const startPromise = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
||||
await started;
|
||||
awaitingUserInput.length = 0;
|
||||
bootEpochRef.current += 1;
|
||||
isBootActiveRef.current = false;
|
||||
bootEpochRef.current += 1;
|
||||
isBootActiveRef.current = true;
|
||||
resolveStart?.("stale-ssh-session");
|
||||
await startPromise;
|
||||
|
||||
assert.deepEqual(awaitingUserInput, []);
|
||||
assert.deepEqual(closed, []);
|
||||
});
|
||||
|
||||
test("stale startSSH attach failure does not disconnect a newer reconnect", async () => {
|
||||
let resolveStart: ((id: string) => void) | null = null;
|
||||
let releaseStartEntered: (() => void) | null = null;
|
||||
const started = new Promise<void>((resolve) => { releaseStartEntered = resolve; });
|
||||
const statuses: string[] = [];
|
||||
const isBootActiveRef = { current: true };
|
||||
const bootEpochRef = { current: 1 };
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
startSSHSession: async () => {
|
||||
releaseStartEntered?.();
|
||||
return await new Promise<string>((resolve) => {
|
||||
resolveStart = resolve;
|
||||
});
|
||||
},
|
||||
closeSession: noop,
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: noop,
|
||||
resizeSession: noop,
|
||||
};
|
||||
const ctx = createStarterContext({
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Target",
|
||||
hostname: "target.example.test",
|
||||
username: "alice",
|
||||
password: "secret",
|
||||
authMethod: "password",
|
||||
},
|
||||
isBootActiveRef,
|
||||
bootEpochRef,
|
||||
updateStatus: (value: string) => { statuses.push(value); },
|
||||
setStatus: (value: string) => { statuses.push(value); },
|
||||
terminalBackend,
|
||||
});
|
||||
|
||||
const startPromise = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
||||
await started;
|
||||
bootEpochRef.current += 1;
|
||||
isBootActiveRef.current = false;
|
||||
bootEpochRef.current += 1;
|
||||
isBootActiveRef.current = true;
|
||||
statuses.length = 0;
|
||||
resolveStart?.("stale-ssh-session");
|
||||
await startPromise;
|
||||
|
||||
assert.deepEqual(statuses, []);
|
||||
});
|
||||
|
||||
test("stale startSSH failure does not reset replacement reconnect UI state", async () => {
|
||||
let rejectStart: ((error: Error) => void) | null = null;
|
||||
let releaseStartEntered: (() => void) | null = null;
|
||||
const started = new Promise<void>((resolve) => { releaseStartEntered = resolve; });
|
||||
const awaitingUserInput: boolean[] = [];
|
||||
const pastTcpDial: boolean[] = [];
|
||||
const chainProgress: Array<unknown> = [];
|
||||
let unsubscribed = false;
|
||||
const isBootActiveRef = { current: true };
|
||||
const bootEpochRef = { current: 1 };
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
startSSHSession: async () => {
|
||||
releaseStartEntered?.();
|
||||
return await new Promise<string>((_resolve, reject) => {
|
||||
rejectStart = reject;
|
||||
});
|
||||
},
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => () => { unsubscribed = true; },
|
||||
writeToSession: noop,
|
||||
resizeSession: noop,
|
||||
};
|
||||
const ctx = createStarterContext({
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Target",
|
||||
hostname: "target.example.test",
|
||||
username: "alice",
|
||||
password: "secret",
|
||||
authMethod: "password",
|
||||
},
|
||||
isBootActiveRef,
|
||||
bootEpochRef,
|
||||
setIsConnectionAwaitingUserInput: (value: boolean) => { awaitingUserInput.push(value); },
|
||||
setIsConnectionPastTcpDial: (value: boolean) => { pastTcpDial.push(value); },
|
||||
setChainProgress: (value: unknown) => { chainProgress.push(value); },
|
||||
terminalBackend,
|
||||
});
|
||||
|
||||
const startPromise = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
||||
await started;
|
||||
// Replacement reconnect is waiting for keyboard-interactive input.
|
||||
awaitingUserInput.length = 0;
|
||||
pastTcpDial.length = 0;
|
||||
chainProgress.length = 0;
|
||||
bootEpochRef.current += 1;
|
||||
isBootActiveRef.current = false;
|
||||
bootEpochRef.current += 1;
|
||||
isBootActiveRef.current = true;
|
||||
rejectStart?.(new Error("Authentication failed"));
|
||||
await startPromise;
|
||||
|
||||
assert.equal(unsubscribed, true);
|
||||
assert.deepEqual(awaitingUserInput, []);
|
||||
assert.deepEqual(pastTcpDial, []);
|
||||
assert.deepEqual(chainProgress, []);
|
||||
});
|
||||
|
||||
test("stale startSSH key auth failure does not launch password fallback", async () => {
|
||||
let rejectStart: ((error: Error) => void) | null = null;
|
||||
let releaseStartEntered: (() => void) | null = null;
|
||||
const started = new Promise<void>((resolve) => { releaseStartEntered = resolve; });
|
||||
const startCalls: Array<Record<string, unknown>> = [];
|
||||
const progressLogs: string[] = [];
|
||||
const isBootActiveRef = { current: true };
|
||||
const bootEpochRef = { current: 1 };
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
startSSHSession: async (options: Record<string, unknown>) => {
|
||||
startCalls.push(options);
|
||||
releaseStartEntered?.();
|
||||
return await new Promise<string>((_resolve, reject) => {
|
||||
rejectStart = reject;
|
||||
});
|
||||
},
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: noop,
|
||||
resizeSession: noop,
|
||||
};
|
||||
const ctx = createStarterContext({
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Target",
|
||||
hostname: "target.example.test",
|
||||
username: "alice",
|
||||
authMethod: "key",
|
||||
identityFileId: "key-1",
|
||||
password: "login-secret",
|
||||
},
|
||||
keys: [{
|
||||
id: "key-1",
|
||||
name: "Key",
|
||||
privateKey: "plain-private-key",
|
||||
publicKey: "",
|
||||
source: "embedded",
|
||||
}],
|
||||
isBootActiveRef,
|
||||
bootEpochRef,
|
||||
setProgressLogs: (updater: string[] | ((prev: string[]) => string[])) => {
|
||||
const next = typeof updater === "function" ? updater(progressLogs) : updater;
|
||||
progressLogs.splice(0, progressLogs.length, ...next);
|
||||
},
|
||||
terminalBackend,
|
||||
});
|
||||
|
||||
const startPromise = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
||||
await started;
|
||||
bootEpochRef.current += 1;
|
||||
isBootActiveRef.current = false;
|
||||
rejectStart?.(new Error("Authentication failed"));
|
||||
await startPromise;
|
||||
|
||||
assert.equal(startCalls.length, 1);
|
||||
assert.equal(startCalls[0].password, "login-secret");
|
||||
assert.equal(startCalls[0].bootEpoch, 1);
|
||||
assert.equal(
|
||||
progressLogs.includes("Key auth failed. Trying password..."),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("stale startSSH chain progress does not overwrite replacement reconnect UI", async () => {
|
||||
let chainProgressListener:
|
||||
| ((sessionId: string, hop: number, total: number, label: string, status: string, error?: string) => void)
|
||||
| null = null;
|
||||
let releaseStartEntered: (() => void) | null = null;
|
||||
const started = new Promise<void>((resolve) => { releaseStartEntered = resolve; });
|
||||
const awaitingUserInput: boolean[] = [];
|
||||
const chainProgress: unknown[] = [];
|
||||
const isBootActiveRef = { current: true };
|
||||
const bootEpochRef = { current: 1 };
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
startSSHSession: async () => {
|
||||
releaseStartEntered?.();
|
||||
return await new Promise<string>(() => {});
|
||||
},
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: (
|
||||
listener: (sessionId: string, hop: number, total: number, label: string, status: string, error?: string) => void,
|
||||
) => {
|
||||
chainProgressListener = listener;
|
||||
return noop;
|
||||
},
|
||||
writeToSession: noop,
|
||||
resizeSession: noop,
|
||||
};
|
||||
const ctx = createStarterContext({
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Target",
|
||||
hostname: "target.example.test",
|
||||
username: "alice",
|
||||
password: "secret",
|
||||
authMethod: "password",
|
||||
hostChain: { hostIds: ["jump-1"] },
|
||||
},
|
||||
resolvedChainHosts: [{
|
||||
id: "jump-1",
|
||||
label: "Jump",
|
||||
hostname: "jump.example.test",
|
||||
username: "jump",
|
||||
password: "jump-secret",
|
||||
}],
|
||||
isBootActiveRef,
|
||||
bootEpochRef,
|
||||
setIsConnectionAwaitingUserInput: (value: boolean) => { awaitingUserInput.push(value); },
|
||||
setChainProgress: (value: unknown) => { chainProgress.push(value); },
|
||||
terminalBackend,
|
||||
});
|
||||
|
||||
void createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
||||
await started;
|
||||
awaitingUserInput.length = 0;
|
||||
chainProgress.length = 0;
|
||||
bootEpochRef.current += 1;
|
||||
isBootActiveRef.current = false;
|
||||
bootEpochRef.current += 1;
|
||||
isBootActiveRef.current = true;
|
||||
chainProgressListener?.(
|
||||
"session-1",
|
||||
1,
|
||||
2,
|
||||
"jump.example.test",
|
||||
"auth-attempt",
|
||||
"waiting for user input...",
|
||||
);
|
||||
|
||||
assert.deepEqual(awaitingUserInput, []);
|
||||
assert.deepEqual(chainProgress, []);
|
||||
});
|
||||
|
||||
test("startSSH keeps interactive source auth retries off unrelated pooled transports", async () => {
|
||||
const captured: Record<string, unknown>[] = [];
|
||||
const terminalBackend = {
|
||||
@@ -1124,7 +665,7 @@ test("startSSH uses the system agent when a synced vault key cannot be decrypted
|
||||
label: "Synced key",
|
||||
type: "ED25519",
|
||||
publicKey: "ssh-ed25519 AAAASELECTED",
|
||||
privateKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
privateKey: "enc:v1:djEwAAAA",
|
||||
source: "imported",
|
||||
category: "key",
|
||||
created: 1,
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { ProviderValidationIssue } from "@netcatty/plugin-contract";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import type { Host, SSHKey } from "../../../types";
|
||||
import type { TerminalSessionExitEvent } from "../../../application/state/resolveTerminalSessionExitIntent";
|
||||
import { setTerminalBootEpoch } from "../../../domain/terminalBootEpoch";
|
||||
import type { TerminalSessionStartersContext } from "./createTerminalSessionStarters.types";
|
||||
export type {
|
||||
PendingAuth,
|
||||
@@ -17,8 +16,8 @@ import {
|
||||
attachSessionToTerminal,
|
||||
buildTermEnv,
|
||||
closeOrphanBackendSession,
|
||||
createBootAttemptGuard,
|
||||
getFlowController,
|
||||
isTerminalBootActive,
|
||||
notePendingOutputScrollIfEnabled,
|
||||
resetTerminalLineTimestampState,
|
||||
tryAttachSessionToTerminal,
|
||||
@@ -125,16 +124,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
ctx.setChainProgress(null);
|
||||
};
|
||||
|
||||
const createAttemptGuards = () => {
|
||||
const bootEpoch = ctx.bootEpochRef?.current ?? 0;
|
||||
const isCurrentAttempt = createBootAttemptGuard(ctx);
|
||||
return {
|
||||
bootEpoch,
|
||||
isCurrentAttempt,
|
||||
ignoreStaleAttemptUi: () => !isCurrentAttempt(),
|
||||
};
|
||||
};
|
||||
|
||||
const consumeRestoreCwdIntent = (term: XTerm, id: string): void => {
|
||||
const intent = ctx.restoreCwdIntentRef?.current;
|
||||
if (!intent) return;
|
||||
@@ -197,10 +186,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
};
|
||||
|
||||
const startSSH = async (term: XTerm) => {
|
||||
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards();
|
||||
// Correlate host-key prompts with this boot so a superseded start cannot
|
||||
// reopen approval UI after disconnect → reconnect.
|
||||
setTerminalBootEpoch(ctx.sessionId, bootEpoch);
|
||||
if (!ctx.terminalBackend.backendAvailable()) {
|
||||
ctx.setError("Native SSH bridge unavailable. Launch via Electron app.");
|
||||
writeTerminalLine(
|
||||
@@ -454,9 +439,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
const unsub = ctx.terminalBackend.onChainProgress((sid, hop, total, label, status, error) => {
|
||||
// P1: Only process events for this session
|
||||
if (sid !== ctx.sessionId) return;
|
||||
// Disconnect/reconnect can leave two SSH starts sharing this UI
|
||||
// sessionId; ignore progress from the superseded boot attempt.
|
||||
if (!isCurrentAttempt()) return;
|
||||
|
||||
// P3: Only show chain progress UI for multi-hop connections
|
||||
if (total > 1) {
|
||||
@@ -617,7 +599,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
sshTcpConnectTimeoutMs: connectionTimeouts.tcpConnectTimeoutSeconds * 1000,
|
||||
sshAuthReadyTimeoutMs: connectionTimeouts.authReadyTimeoutSeconds * 1000,
|
||||
verifyHostKeys: globalTerminalSettings.verifyHostKeys,
|
||||
bootEpoch,
|
||||
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
|
||||
sshDebugLogEnabled: ctx.sshDebugLogEnabled,
|
||||
identityFilePaths: attempt.useIdentityFiles ? targetIdentityFilePaths : undefined,
|
||||
@@ -705,9 +686,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
id = await startAttempt({ key, password: hasPassword ? effectivePassword : undefined, useIdentityFiles: true });
|
||||
} catch (err) {
|
||||
if (isAuthError(err) && hasPassword) {
|
||||
// Disconnect/reconnect may have invalidated this boot; do not
|
||||
// launch a password fallback that cannot be cleaned up yet.
|
||||
if (!isCurrentAttempt()) throw err;
|
||||
ctx.setProgressLogs((prev) => [
|
||||
...prev,
|
||||
"Key auth failed. Trying password...",
|
||||
@@ -722,27 +700,16 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
}
|
||||
|
||||
if (unsubscribeChainProgress) unsubscribeChainProgress();
|
||||
// A superseded start may still resolve after reconnect; do not clear the
|
||||
// replacement's MFA wait / connection-timeout state, and do not close the
|
||||
// shared sessionId while the newer boot is active.
|
||||
if (!isCurrentAttempt()) {
|
||||
closeOrphanBackendSession(ctx, id, { bootEpoch });
|
||||
return;
|
||||
}
|
||||
ctx.setIsConnectionAwaitingUserInput?.(false);
|
||||
|
||||
if (!tryAttachSessionToTerminal(ctx, term, id, {
|
||||
isCurrentAttempt,
|
||||
bootEpoch,
|
||||
onConnected: () => ctx.setChainProgress(null),
|
||||
onExitMessage: (evt) =>
|
||||
`\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
|
||||
sudoAutofillPassword: resolveSavedSudoAutofillPassword(),
|
||||
sudoAutofillCandidates: resolveSudoAutofillCandidates(),
|
||||
})) {
|
||||
// Only the current attempt may clear UI; a stale attach must not
|
||||
// disconnect a newer reconnect that already re-armed boot.
|
||||
if (isCurrentAttempt()) abortSessionStartAfterUnmount();
|
||||
abortSessionStartAfterUnmount();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -763,14 +730,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
}, 600);
|
||||
}
|
||||
} catch (err) {
|
||||
// Always drop this attempt's progress listener; only the current
|
||||
// boot may reset shared reconnect/UI state that a replacement owns.
|
||||
if (unsubscribeChainProgress) unsubscribeChainProgress();
|
||||
if (ignoreStaleAttemptUi()) return;
|
||||
ctx.setChainProgress(null);
|
||||
ctx.setIsConnectionAwaitingUserInput?.(false);
|
||||
ctx.setIsConnectionPastTcpDial?.(false);
|
||||
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const authError = isAuthError(err);
|
||||
|
||||
@@ -804,11 +763,15 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
writeTerminalLine(ctx, term, `\r\n[Failed to start SSH: ${message}]`);
|
||||
ctx.updateStatus("disconnected");
|
||||
}
|
||||
|
||||
ctx.setChainProgress(null);
|
||||
ctx.setIsConnectionAwaitingUserInput?.(false);
|
||||
ctx.setIsConnectionPastTcpDial?.(false);
|
||||
if (unsubscribeChainProgress) unsubscribeChainProgress();
|
||||
}
|
||||
};
|
||||
|
||||
const startTelnet = async (term: XTerm) => {
|
||||
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards();
|
||||
if (!ctx.terminalBackend.telnetAvailable()) {
|
||||
ctx.setError("Telnet bridge unavailable. Please run the desktop build.");
|
||||
writeTerminalLine(ctx, term, "\r\n[Telnet bridge unavailable. Please run the desktop build.]");
|
||||
@@ -916,14 +879,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
if (waitsForAutoLogin) {
|
||||
disposeAutoLoginComplete = ctx.terminalBackend.onTelnetAutoLoginComplete?.(
|
||||
ctx.sessionId,
|
||||
(evt) => {
|
||||
if (
|
||||
Number.isFinite(bootEpoch)
|
||||
&& Number.isFinite(evt?.bootEpoch)
|
||||
&& evt.bootEpoch !== bootEpoch
|
||||
) {
|
||||
return;
|
||||
}
|
||||
() => {
|
||||
disposeAutoLoginListener();
|
||||
cancelPendingStartupCommand = scheduleStartupCommand(ctx, term, telnetSessionId, () => {
|
||||
cancelPendingStartupCommand = undefined;
|
||||
@@ -933,16 +889,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
);
|
||||
disposeAutoLoginCancelled = ctx.terminalBackend.onTelnetAutoLoginCancelled?.(
|
||||
ctx.sessionId,
|
||||
(evt) => {
|
||||
if (
|
||||
Number.isFinite(bootEpoch)
|
||||
&& Number.isFinite(evt?.bootEpoch)
|
||||
&& evt.bootEpoch !== bootEpoch
|
||||
) {
|
||||
return;
|
||||
}
|
||||
cleanupTelnetStartupWait();
|
||||
},
|
||||
cleanupTelnetStartupWait,
|
||||
);
|
||||
}
|
||||
attachTelnetEchoMode(ctx.sessionId);
|
||||
@@ -958,7 +905,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
charset: ctx.host.charset,
|
||||
env: telnetEnv,
|
||||
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
|
||||
bootEpoch,
|
||||
});
|
||||
telnetSessionId = id;
|
||||
if (id !== ctx.sessionId) {
|
||||
@@ -966,14 +912,12 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
}
|
||||
|
||||
if (!tryAttachSessionToTerminal(ctx, term, id, {
|
||||
isCurrentAttempt,
|
||||
bootEpoch,
|
||||
onExitMessage: (evt) =>
|
||||
`\r\n[Telnet session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
|
||||
onExit: cleanupTelnetSession,
|
||||
})) {
|
||||
cleanupTelnetSession();
|
||||
if (isCurrentAttempt()) abortSessionStartAfterUnmount();
|
||||
abortSessionStartAfterUnmount();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -994,7 +938,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
return;
|
||||
}
|
||||
cleanupTelnetSession();
|
||||
if (ignoreStaleAttemptUi()) return;
|
||||
ctx.setError(message);
|
||||
writeTerminalLine(ctx, term, `\r\n[Failed to start Telnet: ${message}]`);
|
||||
ctx.updateStatus("disconnected");
|
||||
@@ -1002,7 +945,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
};
|
||||
|
||||
const startMosh = async (term: XTerm) => {
|
||||
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards();
|
||||
if (!ctx.terminalBackend.moshAvailable()) {
|
||||
ctx.setError("Mosh bridge unavailable. Please run the desktop build.");
|
||||
writeTerminalLine(ctx, term, "\r\n[Mosh bridge unavailable. Please run the desktop build.]");
|
||||
@@ -1141,14 +1083,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
cancelPendingStartupCommand = undefined;
|
||||
});
|
||||
};
|
||||
const onMoshReady = (evt?: { sessionId: string; bootEpoch?: number }) => {
|
||||
if (
|
||||
Number.isFinite(bootEpoch)
|
||||
&& Number.isFinite(evt?.bootEpoch)
|
||||
&& evt.bootEpoch !== bootEpoch
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const onMoshReady = () => {
|
||||
moshReadyFired = true;
|
||||
if (sessionAttached) {
|
||||
runMoshStartup();
|
||||
@@ -1194,13 +1129,10 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
charset: ctx.host.charset,
|
||||
env: moshEnv,
|
||||
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
|
||||
bootEpoch,
|
||||
});
|
||||
attachedSessionId = id;
|
||||
|
||||
if (!tryAttachSessionToTerminal(ctx, term, id, {
|
||||
isCurrentAttempt,
|
||||
bootEpoch,
|
||||
onExitMessage: (evt) =>
|
||||
`\r\n[Mosh session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
|
||||
// Real backend exit only — do not chain onto disposeExitRef, because
|
||||
@@ -1211,7 +1143,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
sudoAutofillCandidates: resolveSudoAutofillCandidates(),
|
||||
})) {
|
||||
cleanupMoshStartupWait();
|
||||
if (isCurrentAttempt()) abortSessionStartAfterUnmount();
|
||||
abortSessionStartAfterUnmount();
|
||||
return;
|
||||
}
|
||||
sessionAttached = true;
|
||||
@@ -1230,7 +1162,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
disposeMoshReady = undefined;
|
||||
cancelPendingStartupCommand?.();
|
||||
cancelPendingStartupCommand = undefined;
|
||||
if (ignoreStaleAttemptUi()) return;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
ctx.setError(message);
|
||||
writeTerminalLine(ctx, term, `\r\n[Failed to start Mosh: ${message}]`);
|
||||
@@ -1239,7 +1170,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
};
|
||||
|
||||
const startEt = async (term: XTerm) => {
|
||||
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards();
|
||||
if (!ctx.terminalBackend.etAvailable()) {
|
||||
ctx.setError("EternalTerminal bridge unavailable. Please run the desktop build.");
|
||||
writeTerminalLine(ctx, term, "\r\n[EternalTerminal bridge unavailable. Please run the desktop build.]");
|
||||
@@ -1509,20 +1439,15 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
charset: ctx.host.charset,
|
||||
env: etEnv,
|
||||
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
|
||||
bootEpoch,
|
||||
});
|
||||
|
||||
if (!tryAttachSessionToTerminal(ctx, term, id, {
|
||||
isCurrentAttempt,
|
||||
bootEpoch,
|
||||
onExitMessage: (evt) =>
|
||||
`\r\n[EternalTerminal session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
|
||||
sudoAutofillPassword: resolveSavedSudoAutofillPassword(),
|
||||
sudoAutofillCandidates: resolveSudoAutofillCandidates(),
|
||||
})) {
|
||||
// Only the current attempt may clear UI; a stale attach must not
|
||||
// disconnect a newer reconnect that already re-armed boot.
|
||||
if (isCurrentAttempt()) abortSessionStartAfterUnmount();
|
||||
abortSessionStartAfterUnmount();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1538,7 +1463,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
}, 600);
|
||||
}
|
||||
} catch (err) {
|
||||
if (ignoreStaleAttemptUi()) return;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
ctx.setError(message);
|
||||
writeTerminalLine(ctx, term, `\r\n[Failed to start EternalTerminal: ${message}]`);
|
||||
@@ -1547,7 +1471,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
};
|
||||
|
||||
const startPluginConnection = async (term: XTerm) => {
|
||||
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards();
|
||||
if (!ctx.terminalBackend.pluginConnectionAvailable()) {
|
||||
ctx.setError("Plugin connection bridge unavailable. Please run the desktop build with Plugin Development enabled.");
|
||||
writeTerminalLine(ctx, term, "\r\n[Plugin connection bridge unavailable.]");
|
||||
@@ -1585,12 +1508,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
startController.abort(new DOMException("Plugin connection request was cancelled", "AbortError"));
|
||||
if (ctx.terminalBackend.cancelPluginExtensionRequest) {
|
||||
try {
|
||||
const cancelPromise = Promise.resolve(
|
||||
ctx.terminalBackend.cancelPluginExtensionRequest(requestId),
|
||||
).catch((err) => {
|
||||
void Promise.resolve(ctx.terminalBackend.cancelPluginExtensionRequest(requestId)).catch((err) => {
|
||||
logger.warn("Failed to cancel pending plugin connection request", err);
|
||||
});
|
||||
ctx.trackSessionCleanup?.(cancelPromise);
|
||||
} catch (err) {
|
||||
logger.warn("Failed to cancel pending plugin connection request", err);
|
||||
}
|
||||
@@ -1601,7 +1521,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
bootMonitorTimer = setTimeout(() => {
|
||||
bootMonitorTimer = null;
|
||||
if (pendingCancelReleased) return;
|
||||
if (!isCurrentAttempt()) {
|
||||
if (!isTerminalBootActive(ctx)) {
|
||||
cancelPendingStart();
|
||||
return;
|
||||
}
|
||||
@@ -1618,7 +1538,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
ctx.disposeExitRef.current = cancelPendingStart;
|
||||
scheduleBootMonitor();
|
||||
try {
|
||||
const startPromise = ctx.terminalBackend.startPluginConnection({
|
||||
const opened = await ctx.terminalBackend.startPluginConnection({
|
||||
requestId,
|
||||
sessionId: ctx.sessionId,
|
||||
protocol: ctx.host.protocol,
|
||||
@@ -1637,17 +1557,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
: {}),
|
||||
signal: startController.signal,
|
||||
});
|
||||
// Disconnect may cancel before this settles; reconnect must wait until
|
||||
// finishExternalSession cleared the shared sessionId registration.
|
||||
ctx.trackSessionCleanup?.(startPromise);
|
||||
const opened = await startPromise;
|
||||
releasePendingStartCancellation();
|
||||
if (!isCurrentAttempt()) {
|
||||
closeOrphanBackendSession(ctx, opened.sessionId, { bootEpoch });
|
||||
return;
|
||||
}
|
||||
if (startController.signal.aborted) {
|
||||
closeOrphanBackendSession(ctx, opened.sessionId, { bootEpoch });
|
||||
if (startController.signal.aborted || !isTerminalBootActive(ctx)) {
|
||||
closeOrphanBackendSession(ctx, opened.sessionId);
|
||||
abortSessionStartAfterUnmount();
|
||||
return;
|
||||
}
|
||||
@@ -1665,17 +1577,13 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
scheduleStartupCommand(ctx, term, id);
|
||||
};
|
||||
if (!tryAttachSessionToTerminal(ctx, term, id, {
|
||||
isCurrentAttempt,
|
||||
bootEpoch,
|
||||
onExitMessage: formatPluginConnectionExitMessage,
|
||||
requireExplicitConnectionReady: true,
|
||||
onConnected: (meta) => {
|
||||
if (meta?.pluginConnectionReady === true) schedulePluginStartup();
|
||||
},
|
||||
})) {
|
||||
// Only the current attempt may clear UI; a stale attach must not
|
||||
// disconnect a newer reconnect that already re-armed boot.
|
||||
if (isCurrentAttempt()) abortSessionStartAfterUnmount();
|
||||
abortSessionStartAfterUnmount();
|
||||
return;
|
||||
}
|
||||
if (opened.status === "connected") {
|
||||
@@ -1684,7 +1592,10 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
}
|
||||
} catch (error) {
|
||||
releasePendingStartCancellation();
|
||||
if (ignoreStaleAttemptUi()) return;
|
||||
if (!isTerminalBootActive(ctx)) {
|
||||
abortSessionStartAfterUnmount();
|
||||
return;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
ctx.setError(message);
|
||||
writeTerminalLine(ctx, term, "\r\n[Failed to start plugin connection. See connection details.]");
|
||||
@@ -1693,7 +1604,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
};
|
||||
|
||||
const startLocal = async (term: XTerm) => {
|
||||
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards();
|
||||
if (!ctx.terminalBackend.localAvailable()) {
|
||||
ctx.setError("Local shell bridge unavailable. Please run the desktop build.");
|
||||
writeTerminalLine(
|
||||
@@ -1726,11 +1636,10 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
TERM: ctx.terminalSettings?.terminalEmulationType ?? "xterm-256color",
|
||||
},
|
||||
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
|
||||
bootEpoch,
|
||||
});
|
||||
|
||||
if (!isCurrentAttempt()) {
|
||||
closeOrphanBackendSession(ctx, id, { bootEpoch });
|
||||
if (!isTerminalBootActive(ctx)) {
|
||||
closeOrphanBackendSession(ctx, id);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1805,7 +1714,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
consumeRestoreCwdIntent(term, id);
|
||||
scheduleStartupCommand(ctx, term, id);
|
||||
} catch (err) {
|
||||
if (ignoreStaleAttemptUi()) return;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
ctx.setError(message);
|
||||
writeTerminalLine(ctx, term, `\r\n[Failed to start local shell: ${message}]`);
|
||||
@@ -1815,7 +1723,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
|
||||
// Start Serial session
|
||||
const startSerial = async (term: XTerm) => {
|
||||
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards();
|
||||
if (!ctx.serialConfig) {
|
||||
ctx.setError("No serial configuration provided");
|
||||
writeTerminalLine(ctx, term, "\r\n[Error: No serial configuration provided]");
|
||||
@@ -1839,20 +1746,15 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
flowControl: ctx.serialConfig.flowControl,
|
||||
charset: ctx.host.charset,
|
||||
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
|
||||
bootEpoch,
|
||||
});
|
||||
|
||||
if (!tryAttachSessionToTerminal(ctx, term, id, {
|
||||
isCurrentAttempt,
|
||||
bootEpoch,
|
||||
onExitMessage: (evt) =>
|
||||
`\r\n[serial port closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
|
||||
// Convert lone LF to CRLF to prevent "staircase effect" in serial terminals
|
||||
convertLfToCrlf: true,
|
||||
})) {
|
||||
// Only the current attempt may clear UI; a stale attach must not
|
||||
// disconnect a newer reconnect that already re-armed boot.
|
||||
if (isCurrentAttempt()) abortSessionStartAfterUnmount();
|
||||
abortSessionStartAfterUnmount();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1861,7 +1763,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
ctx.setProgressValue(100);
|
||||
writeTerminalLine(ctx, term, `[Connected to ${ctx.serialConfig.path} at ${ctx.serialConfig.baudRate} baud]`);
|
||||
} catch (err) {
|
||||
if (ignoreStaleAttemptUi()) return;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
ctx.setError(message);
|
||||
writeTerminalLine(ctx, term, `\r\n[Failed to connect to serial port: ${message}]`);
|
||||
|
||||
@@ -73,15 +73,15 @@ export type TerminalBackendApi = {
|
||||
) => () => void;
|
||||
onTelnetAutoLoginComplete?: (
|
||||
sessionId: string,
|
||||
cb: (evt: { sessionId: string; bootEpoch?: number }) => void,
|
||||
cb: (evt: { sessionId: string }) => void,
|
||||
) => (() => void) | undefined;
|
||||
onTelnetAutoLoginCancelled?: (
|
||||
sessionId: string,
|
||||
cb: (evt: { sessionId: string; bootEpoch?: number }) => void,
|
||||
cb: (evt: { sessionId: string }) => void,
|
||||
) => (() => void) | undefined;
|
||||
onMoshSessionReady?: (
|
||||
sessionId: string,
|
||||
cb: (evt: { sessionId: string; bootEpoch?: number }) => void,
|
||||
cb: (evt: { sessionId: string }) => void,
|
||||
) => (() => void) | undefined;
|
||||
onTelnetEchoMode?: (
|
||||
sessionId: string,
|
||||
@@ -103,7 +103,7 @@ export type TerminalBackendApi = {
|
||||
writeToSession: (sessionId: string, data: string, options?: { automated?: boolean; sensitive?: boolean; lineDelayMs?: number; logRewrite?: ProgrammaticCommandLogRewrite }) => void;
|
||||
interruptSession?: (sessionId: string, trace?: NetcattyTerminalInterruptTrace) => void;
|
||||
resizeSession: (sessionId: string, cols: number, rows: number) => void;
|
||||
closeSession: (sessionId: string, options?: { bootEpoch?: number }) => void | Promise<void>;
|
||||
closeSession: (sessionId: string) => void | Promise<void>;
|
||||
/** Pause/resume the source stream for output back-pressure (optional). */
|
||||
setSessionFlowPaused?: (sessionId: string, paused: boolean) => void;
|
||||
/** Acknowledge rendered terminal output bytes for main-process IPC back-pressure. */
|
||||
@@ -189,11 +189,6 @@ export type TerminalSessionStartersContext = {
|
||||
isVisibleRef?: RefObject<boolean>;
|
||||
/** False after unmount/teardown so in-flight session starts skip attach. */
|
||||
isBootActiveRef?: RefObject<boolean>;
|
||||
/**
|
||||
* Monotonic boot epoch. Disconnect / a newer reconnect bumps this so an
|
||||
* older in-flight start cannot become current again when boot is re-armed.
|
||||
*/
|
||||
bootEpochRef?: RefObject<number>;
|
||||
pendingOutputScrollRef?: RefObject<boolean>;
|
||||
|
||||
sessionRef: RefObject<string | null>;
|
||||
@@ -201,11 +196,6 @@ export type TerminalSessionStartersContext = {
|
||||
hasRunStartupCommandRef: RefObject<boolean>;
|
||||
disposeDataRef: RefObject<(() => void) | null>;
|
||||
disposeExitRef: RefObject<(() => void) | null>;
|
||||
/**
|
||||
* Track an async cleanup (e.g. cancelled plugin start → finishExternalSession)
|
||||
* so Disconnect/Reconnect can await it before starting a replacement boot.
|
||||
*/
|
||||
trackSessionCleanup?: (promise: Promise<unknown>) => void;
|
||||
disposeTelnetEchoModeRef?: RefObject<(() => void) | null>;
|
||||
fitAddonRef: RefObject<FitAddon | null>;
|
||||
serializeAddonRef: RefObject<SerializeAddon | null>;
|
||||
|
||||
@@ -1476,8 +1476,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// No xterm selection: pass Ctrl+C through for SIGINT, and Cmd+C for
|
||||
// Kitty Super+C (nested TUIs). Other copy chords stay a safe no-op.
|
||||
// When copy is bound specifically to Ctrl+C and there is no text
|
||||
// selected, pass the event through so xterm can send SIGINT.
|
||||
const shouldForwardCopyToTerminal =
|
||||
shouldPassThroughCopyShortcut(action, term.hasSelection(), e);
|
||||
if (shouldForwardCopyToTerminal && !kittySequenceForKeyDown) return true;
|
||||
@@ -1494,10 +1494,24 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
break;
|
||||
}
|
||||
case "paste": {
|
||||
// Always share the context-menu paste path so local image-only
|
||||
// clipboard can forward Ctrl+V, and remote auto-upload stays
|
||||
// gated inside handleTerminalClipboardPaste.
|
||||
void ctx.terminalContextActionsRef?.current?.onPaste?.();
|
||||
const autoImagePaste =
|
||||
ctx.terminalSettingsRef.current?.autoUploadClipboardImageOnPaste === true;
|
||||
if (autoImagePaste) {
|
||||
// onPaste (shared with the context menu) auto-uploads a
|
||||
// clipboard image via SFTP for remote sessions and falls
|
||||
// back to a regular text paste when there is no image.
|
||||
void ctx.terminalContextActionsRef?.current?.onPaste?.();
|
||||
break;
|
||||
}
|
||||
navigator.clipboard.readText().then((text) => {
|
||||
const id = ctx.sessionRef.current;
|
||||
if (id) {
|
||||
pasteTextIntoTerminal(term, text, {
|
||||
scrollOnPaste: shouldScrollOnTerminalPaste(ctx.terminalSettingsRef.current),
|
||||
onPasteData: broadcastUserPasteData,
|
||||
});
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "pasteSelection": {
|
||||
|
||||
@@ -3,7 +3,6 @@ import test from "node:test";
|
||||
|
||||
import {
|
||||
isPlainCtrlCInterruptChord,
|
||||
isPlainMetaCCopyChord,
|
||||
shouldPassThroughCopyShortcut,
|
||||
} from "./terminalCopyShortcut.ts";
|
||||
|
||||
@@ -28,20 +27,10 @@ test("plain Ctrl+C copy with no selection passes through for SIGINT", () => {
|
||||
assert.equal(shouldPassThroughCopyShortcut("copy", false, event), true);
|
||||
});
|
||||
|
||||
test("plain Cmd+C copy with no selection passes through for Kitty Super+C", () => {
|
||||
const event = keyboardEvent("c", "KeyC", { metaKey: true });
|
||||
|
||||
assert.equal(isPlainMetaCCopyChord(event), true);
|
||||
assert.equal(isPlainCtrlCInterruptChord(event), false);
|
||||
assert.equal(shouldPassThroughCopyShortcut("copy", false, event), true);
|
||||
});
|
||||
|
||||
test("copy shortcut does not pass through when text is selected", () => {
|
||||
const ctrlC = keyboardEvent("c", "KeyC", { ctrlKey: true });
|
||||
const cmdC = keyboardEvent("c", "KeyC", { metaKey: true });
|
||||
const event = keyboardEvent("c", "KeyC", { ctrlKey: true });
|
||||
|
||||
assert.equal(shouldPassThroughCopyShortcut("copy", true, ctrlC), false);
|
||||
assert.equal(shouldPassThroughCopyShortcut("copy", true, cmdC), false);
|
||||
assert.equal(shouldPassThroughCopyShortcut("copy", true, event), false);
|
||||
});
|
||||
|
||||
test("copy shortcut does not pass through for shifted or alternate chords", () => {
|
||||
@@ -49,26 +38,14 @@ test("copy shortcut does not pass through for shifted or alternate chords", () =
|
||||
shouldPassThroughCopyShortcut("copy", false, keyboardEvent("C", "KeyC", { ctrlKey: true, shiftKey: true })),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldPassThroughCopyShortcut("copy", false, keyboardEvent("C", "KeyC", { metaKey: true, shiftKey: true })),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldPassThroughCopyShortcut("copy", false, keyboardEvent("l", "KeyL", { ctrlKey: true })),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldPassThroughCopyShortcut("copy", false, keyboardEvent("c", "KeyC", { metaKey: true, ctrlKey: true })),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldPassThroughCopyShortcut("paste", false, keyboardEvent("c", "KeyC", { ctrlKey: true })),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldPassThroughCopyShortcut("paste", false, keyboardEvent("c", "KeyC", { metaKey: true })),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("plain Ctrl+C copy passthrough follows the physical C key on non-Latin layouts", () => {
|
||||
@@ -77,10 +54,3 @@ test("plain Ctrl+C copy passthrough follows the physical C key on non-Latin layo
|
||||
assert.equal(isPlainCtrlCInterruptChord(event), true);
|
||||
assert.equal(shouldPassThroughCopyShortcut("copy", false, event), true);
|
||||
});
|
||||
|
||||
test("plain Cmd+C copy passthrough follows the physical C key on non-Latin layouts", () => {
|
||||
const event = keyboardEvent("\u0441", "KeyC", { metaKey: true });
|
||||
|
||||
assert.equal(isPlainMetaCCopyChord(event), true);
|
||||
assert.equal(shouldPassThroughCopyShortcut("copy", false, event), true);
|
||||
});
|
||||
@@ -3,43 +3,18 @@ type CopyShortcutKeyEvent = Pick<
|
||||
"key" | "code" | "ctrlKey" | "shiftKey" | "altKey" | "metaKey"
|
||||
>;
|
||||
|
||||
function isPhysicalCKey(e: CopyShortcutKeyEvent): boolean {
|
||||
return e.key.toLowerCase() === "c" || e.code === "KeyC";
|
||||
}
|
||||
|
||||
export function isPlainCtrlCInterruptChord(e: CopyShortcutKeyEvent): boolean {
|
||||
return e.ctrlKey
|
||||
&& !e.shiftKey
|
||||
&& !e.altKey
|
||||
&& !e.metaKey
|
||||
&& isPhysicalCKey(e);
|
||||
&& (e.key.toLowerCase() === "c" || e.code === "KeyC");
|
||||
}
|
||||
|
||||
/** macOS Cmd+C / Super+C — forward when there is no xterm selection. */
|
||||
export function isPlainMetaCCopyChord(e: CopyShortcutKeyEvent): boolean {
|
||||
return e.metaKey
|
||||
&& !e.ctrlKey
|
||||
&& !e.shiftKey
|
||||
&& !e.altKey
|
||||
&& isPhysicalCKey(e);
|
||||
}
|
||||
|
||||
/**
|
||||
* When copy matches with no xterm selection, pass the chord through instead of
|
||||
* consuming an empty clipboard write.
|
||||
*
|
||||
* - Ctrl+C → SIGINT (or Kitty Ctrl+C)
|
||||
* - Cmd+C → Kitty Super+C for nested TUIs (e.g. Herdr)
|
||||
*
|
||||
* Other no-selection copy bindings stay consumed as a safe no-op so keys like
|
||||
* F5 / Ctrl+L are not forwarded to the remote (#1461).
|
||||
*/
|
||||
export function shouldPassThroughCopyShortcut(
|
||||
action: string,
|
||||
hasSelection: boolean,
|
||||
e: CopyShortcutKeyEvent,
|
||||
): boolean {
|
||||
return action === "copy"
|
||||
&& !hasSelection
|
||||
&& (isPlainCtrlCInterruptChord(e) || isPlainMetaCCopyChord(e));
|
||||
return action === "copy" && !hasSelection && isPlainCtrlCInterruptChord(e);
|
||||
}
|
||||
Loaded 100 of 231 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user