Compare commits

..
Author SHA1 Message Date
Cursor Agentand陈大猫 c849bf6a73 fix(plugins): preserve overwrite stash across retries and prefix deletes
Do not replace an existing restore stash on later puts, and clear matching
stash entries when deleteByKeyPrefix wipes sync credentials on disconnect.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 11:36:59 +00:00
Cursor Agentand陈大猫 0d9c4a6e02 fix(plugins): keep overwrite stash until restore write succeeds
Only drop the prior plaintext after encrypt+upsert succeeds so a failed
restore can still retry and recover the saved SecretRef.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 11:32:08 +00:00
Cursor Agentand陈大猫 acd5700284 fix(plugins): host-own sync provider bindings and clear failed stashes
Move provider→plugin bindings into a dedicated DB table outside
plugin-writable secrets, validate provider namespace ownership, and drop
overwrite stashes when replacement writes fail.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 11:27:26 +00:00
Cursor Agentand陈大猫 05b33b5521 fix(plugins): make sync secret overwrite stash opt-in
Only the syncPutSecret path stashes prior plaintext/SecretRef so ordinary
secrets.set overwrites do not leave credentials in process memory.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 11:18:26 +00:00
Cursor Agentand陈大猫 786e5b48ef fix(plugins): restore overwritten sync secrets on rejected reconnect
Stash prior plaintext and SecretRef id on overwrite, restore them when
connect fails, and discard the stash after a successful reconnect so
saved provider credentials keep resolving.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 11:12:47 +00:00
Cursor Agentand陈大猫 6414c9e177 fix(plugins): address residual Codex sync secret and backup gaps
Retain provider→plugin secret bindings so disconnect can wipe credentials
after a plugin is disabled, keep overwritten secrets on failed reconnect,
and snapshot live-empty sidecars in protective backups.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 11:05:39 +00:00
231 changed files with 2725 additions and 25253 deletions

No files matched your search

+3 -4
View File
@@ -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 }}
+5 -69
View File
@@ -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,
+2 -60
View File
@@ -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]);
+4
View File
@@ -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),
+4 -58
View File
@@ -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]);
-10
View File
@@ -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',
-3
View File
@@ -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',
-4
View File
@@ -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',
-5
View File
@@ -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',
-10
View File
@@ -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': 'Предыдущих сессий нет',
-3
View File
@@ -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': 'Создан',
-4
View File
@@ -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': 'Использование памяти',
-4
View File
@@ -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': 'Перейти к',
-10
View File
@@ -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': '没有历史会话',
-4
View File
@@ -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': '选择主题',
-4
View File
@@ -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': '内存使用',
-10
View File
@@ -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': '沒有歷史工作階段',
-4
View File
@@ -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': '選擇主題',
-4
View File
@@ -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,
);
}
-23
View File
@@ -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",
);
});
+1 -1
View File
@@ -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,
);
-101
View File
@@ -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);
});
-138
View File
@@ -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,
};
}
+4 -14
View File
@@ -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;
+8 -92
View File
@@ -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 () => {
+26 -258
View File
@@ -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);
});
+4 -48
View File
@@ -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,
+3 -67
View File
@@ -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(() => {
-45
View File
@@ -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",
);
});
+13 -33
View File
@@ -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);
+8 -35
View File
@@ -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,
+5 -5
View File
@@ -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);
}, []);
-20
View File
@@ -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
View File
@@ -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
View File
@@ -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 }),
);
+6 -10
View File
@@ -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,
});
}
+2 -26
View File
@@ -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({
+9 -52
View File
@@ -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,
}],
}),
-2
View File
@@ -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). */
+1 -1
View File
@@ -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/);
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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}
-26
View File
@@ -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",
+8
View File
@@ -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,
+3 -8
View File
@@ -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;
-149
View File
@@ -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/,
);
});
+39 -361
View File
@@ -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>
-142
View File
@@ -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;
-57
View File
@@ -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",
+5 -67
View File
@@ -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 -4
View File
@@ -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">
+3 -108
View File
@@ -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({
+10 -145
View File
@@ -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,
+1
View File
@@ -551,6 +551,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
handlePathKeyDown={handlePathKeyDown}
handlePathDoubleClick={handlePathDoubleClick}
handlePathSubmit={handlePathSubmit}
startTransition={startTransition}
getNextUntitledName={getNextUntitledName}
setNewFileName={setNewFileName}
setFileNameError={setFileNameError}
+52 -76
View File
@@ -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)
+26 -59
View File
@@ -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\}/);
});
-294
View File
@@ -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>
);
});
+4 -6
View File
@@ -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>
);
+1 -97
View File
@@ -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 -82
View File
@@ -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'],
);
});
});
+23 -299
View File
@@ -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