Compare commits

..
Author SHA1 Message Date
bincxz fe7f5029af fix(ai): use neutral MCP text for silent terminal_execute success
Codex P2 on #2724: ok:true empty serial/raw PTY results no longer fall
back to "Error: Operation failed". Prefer Catty's "Command completed
(no output)" and keep operational failures / non-zero exits unchanged.
Also prefer non-empty OpenCode tool error/output when formatting tool
results.
2026-08-05 11:10:48 +08:00
bincxz 1d0762d250 fix(ai): stop OpenCode turns dying on terminal tool failures
OpenCode MCP terminal_execute treated non-zero exits as bare
"Error: Operation failed" (dropping stdout), and the OpenCode driver
aborted the whole turn on tool status=error. Preserve command output
and surface tool errors as tool results so multi-step agent runs can
continue.

Closes #2718
2026-08-05 10:58:39 +08:00
491 changed files with 5309 additions and 44826 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 }}
+72 -436
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}`,
});
}
@@ -261,25 +254,6 @@ jobs:
context,
issueNumber: issue.number,
});
const relatedPull = pull || await auto.findOpenPullForIssue({
github,
context,
issueNumber: issue.number,
includeRelated: true,
});
const refined = auto.refineIssueCommentRoute(decision, {
hasOpenBotPull: Boolean(pull),
hasOpenRelatedPull: Boolean(relatedPull),
body: comment.body,
labels,
});
if (refined.kind === 'issue_classify') {
return set('issue_classify', {
issue_number: String(issue.number),
trigger_comment_id: String(comment.id),
reason: refined.reason,
});
}
return set('issue_followup', {
issue_number: String(issue.number),
pull_number: pull ? String(pull.number) : '',
@@ -300,8 +274,9 @@ jobs:
repository: `${context.repo.owner}/${context.repo.repo}`,
});
if (
sameRepo &&
context.payload.action === 'closed' &&
auto.shouldCleanupSourceIssueAfterPull(pr, {
auto.shouldGatePullOnSourceIssueFollowups(pr, {
ownActors,
repository: `${context.repo.owner}/${context.repo.repo}`,
})
@@ -309,7 +284,7 @@ jobs:
return set('source_issue_cleanup', {
issue_number: String(auto.extractSourceIssueNumber(pr)),
pull_number: String(pr.number),
reason: pr.merged ? 'merged PR closed source issue' : 'automation PR closed',
reason: pr.merged ? 'automation PR merged' : 'automation PR closed',
});
}
if (sameRepo) {
@@ -357,41 +332,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
@@ -430,16 +370,16 @@ jobs:
core.warning(`PR #${pullNumber} is no longer closed; skipping source cleanup.`);
return;
}
if (!auto.shouldCleanupSourceIssueAfterPull(pull, {
if (!auto.shouldGatePullOnSourceIssueFollowups(pull, {
ownActors: process.env.OWN_ACTORS,
repository: `${context.repo.owner}/${context.repo.repo}`,
})) {
core.warning(`PR #${pullNumber} is not eligible for source cleanup; skipping.`);
core.warning(`PR #${pullNumber} is not a trusted automation pull request; skipping source cleanup.`);
return;
}
const issueNumbers = auto.extractSourceIssueNumbers(pull);
const issueNumber = auto.extractSourceIssueNumber(pull);
const expectedIssueNumber = Number(process.env.SOURCE_ISSUE_NUMBER);
if (!issueNumbers.includes(expectedIssueNumber)) {
if (!issueNumber || issueNumber !== expectedIssueNumber) {
core.warning(`PR #${pullNumber} source issue changed while queued; skipping cleanup.`);
return;
}
@@ -454,161 +394,20 @@ jobs:
if (error.status !== 404) throw error;
}
};
for (const issueNumber of issueNumbers) {
const { data: sourceIssue } = await github.rest.issues.get({
for (const name of ['ready-for-agent', 'needs-info', 'ready-for-human']) {
await removeLabel(issueNumber, name);
}
if (!pull.merged) {
await github.rest.issues.addLabels({
...context.repo,
issue_number: issueNumber,
labels: ['ready-for-human'],
});
if (sourceIssue.pull_request) {
core.warning(`#${issueNumber} is a pull request, not a source issue; skipping cleanup.`);
continue;
}
if (pull.merged && sourceIssue.state !== 'closed') {
core.warning(`Merged PR #${pullNumber} does not currently close issue #${issueNumber}; skipping cleanup.`);
continue;
}
for (const name of ['ready-for-agent', 'needs-info', 'ready-for-human']) {
await removeLabel(issueNumber, name);
}
if (!pull.merged) {
await github.rest.issues.addLabels({
...context.repo,
issue_number: issueNumber,
labels: ['ready-for-human'],
});
}
}
for (const name of ['automation:codex-loop', 'ready-for-human']) {
await removeLabel(pullNumber, name);
}
reconcile_closed_handoffs:
name: Reconcile handoffs
if: github.event_name == 'schedule' && github.event.schedule == '17 3 * * *'
runs-on: ubuntu-latest
permissions:
actions: write
contents: read
issues: write
pull-requests: write
steps:
- name: Checkout automation helpers
uses: actions/checkout@v7
with:
ref: ${{ github.event.repository.default_branch }}
- name: Remove stale ready-for-human labels
uses: actions/github-script@v9
with:
github-token: ${{ secrets.TRIAGE_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const auto = require(`${process.env.GITHUB_WORKSPACE}/scripts/cursor-automation.cjs`);
const queries = [
`repo:${context.repo.owner}/${context.repo.repo} is:issue is:closed label:"ready-for-human" label:triage`,
`repo:${context.repo.owner}/${context.repo.repo} is:pr is:merged label:"ready-for-human" label:"automation:bot-pr"`,
];
let cleaned = 0;
for (const q of queries) {
const items = await github.paginate(
github.rest.search.issuesAndPullRequests,
{ q, per_page: 100 },
(response) => auto.extractPaginatedItems(response),
);
for (const item of items) {
try {
await github.rest.issues.removeLabel({
...context.repo,
issue_number: item.number,
name: 'ready-for-human',
});
cleaned += 1;
} catch (error) {
if (error.status !== 404) throw error;
}
}
}
core.summary.addHeading('Closed handoff reconciliation');
core.summary.addRaw(`Removed stale ready-for-human from ${cleaned} closed items.`);
await core.summary.write();
- name: Retry handoffs fixed by this hardening pass
uses: actions/github-script@v9
env:
ISSUE_BOT_LOGINS: ${{ vars.AUTOMATION_ISSUE_BOT_LOGINS || '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`);
const recoveryVersion = 'protected-tests-and-media-v1';
const auditedIssueNumbers = new Set([2679, 2697, 2704, 2705, 2708, 2709]);
const repository = `${context.repo.owner}/${context.repo.repo}`;
const issues = await github.paginate(
github.rest.search.issuesAndPullRequests,
{
q: `repo:${repository} is:issue is:open label:"ready-for-human" label:triage`,
per_page: 100,
},
(response) => auto.extractPaginatedItems(response),
);
const pulls = await github.paginate(github.rest.pulls.list, {
...context.repo,
state: 'open',
per_page: 100,
sort: 'updated',
direction: 'desc',
});
let dispatched = 0;
for (const issue of issues) {
if (!auditedIssueNumbers.has(Number(issue.number))) continue;
const comments = await github.paginate(
github.rest.issues.listComments,
{ ...context.repo, issue_number: issue.number, per_page: 100 },
);
if (!auto.shouldRetryIssueHandoff(comments, {
trustedActors: process.env.ISSUE_BOT_LOGINS,
recoveryVersion,
notBefore: '2026-07-31T12:54:37Z',
notAfter: '2026-08-04T08:27:14Z',
})) continue;
if (pulls.some((pull) => auto.isTrustedOpenPullForIssue(pull, issue.number, {
repository,
includeRelated: true,
}))) continue;
const { data: marker } = await github.rest.issues.createComment({
...context.repo,
issue_number: issue.number,
body: [
'<!-- cursor-automation -->',
`<!-- cursor-handoff-recovery:version=${recoveryVersion} -->`,
'',
'此前导致自动处理停止的问题已经修复,现重新进入自动检查。',
].join('\n'),
});
try {
await github.rest.actions.createWorkflowDispatch({
...context.repo,
workflow_id: 'cursor-automation.yml',
ref: context.payload.repository.default_branch,
inputs: { issue_number: String(issue.number) },
});
dispatched += 1;
} catch (error) {
try {
await github.rest.issues.deleteComment({
...context.repo,
comment_id: marker.id,
});
} catch (cleanupError) {
core.warning(`Issue #${issue.number}: could not remove retry marker (${cleanupError.message}).`);
}
throw error;
}
}
core.summary.addHeading('Retry recovered handoffs');
core.summary.addRaw(`Re-dispatched ${dispatched} retryable issues without open related PRs.`);
await core.summary.write();
classify:
name: Classify issue
needs: route
@@ -894,7 +693,6 @@ jobs:
fi
- name: Research external context for classification
id: research
if: steps.prepare.outputs.should_run == 'true'
env:
GITHUB_TOKEN: ''
@@ -1004,7 +802,6 @@ jobs:
run: *stage_cursor_api_key
- name: Classify with Cursor CLI
id: classify_agent
if: steps.prepare.outputs.should_run == 'true'
env:
GITHUB_TOKEN: ''
@@ -1060,15 +857,6 @@ jobs:
);
'
- name: Validate classification result
id: validate_classification
if: steps.prepare.outputs.should_run == 'true'
run: |
node -e '
const auto = require(process.env.RUNNER_TEMP + "/cursor-automation.cjs");
auto.parseClassificationFile(".cursor-runtime/classification.json");
'
- name: Apply classification
id: apply
if: steps.prepare.outputs.should_run == 'true'
@@ -1102,20 +890,6 @@ jobs:
with:
github-token: ${{ secrets.TRIAGE_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const fs = require('node:fs');
let auto = null;
for (const helper of [
`${process.env.RUNNER_TEMP}/cursor-automation.cjs`,
`${process.env.GITHUB_WORKSPACE}/scripts/cursor-automation.cjs`,
]) {
if (!fs.existsSync(helper)) continue;
try {
auto = require(helper);
break;
} catch (error) {
core.warning(`Could not load automation helper ${helper}: ${error.message}`);
}
}
const issueNumber = Number(process.env.ISSUE_NUMBER);
const commentId = String(process.env.TRIGGER_COMMENT_ID || '');
const { data: issue } = await github.rest.issues.get({
@@ -1159,42 +933,22 @@ jobs:
if (issue.state === 'closed') update.state = 'open';
await github.rest.issues.update(update);
if (!processed) {
const isChinese = /[\u3400-\u9fff]/u.test(
`${issue.title || ''}\n${issue.body || ''}`,
);
const marker = /^[A-Za-z0-9_-]+$/.test(commentId)
? `<!-- cursor-followup:comment-id=${commentId};result=blocked -->`
: '';
const kind = '${{ steps.research.outcome }}' === 'failure'
? 'research_failed'
: '${{ steps.classify_agent.outcome }}' === 'failure'
? 'classification_failed'
: '${{ steps.validate_classification.outcome }}' === 'failure'
? 'classification_failed'
: '${{ steps.apply.outcome }}' === 'failure'
? 'apply_failed'
: 'processing_failed';
const chinese = /[\u3400-\u9fff]/u.test(
`${issue.title || ''}\n${issue.body || ''}`,
);
const failureMessage = auto
? auto.buildClassificationFailureMessage(issue, {
kind,
isFollowup: Boolean(commentId),
workflowUrl: `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
})
: [
chinese
? '自动分类流程没有正常完成,Issue 已保留并转给维护者继续处理。'
: 'The automatic classification process did not finish normally. The issue was preserved for maintainer review.',
`${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
].join('\n\n');
await github.rest.issues.createComment({
...context.repo,
issue_number: issueNumber,
body: [
'<!-- cursor-automation -->',
`<!-- cursor-classification-failure:kind=${kind};run=${context.runId} -->`,
marker,
'',
failureMessage,
isChinese
? '收到这条补充了,但自动复核没有安全完成,已经转给维护者继续处理。'
: 'We received the additional information, but the automatic review could not finish safely. A maintainer has been notified.',
].filter(Boolean).join('\n'),
});
}
@@ -2006,7 +1760,6 @@ jobs:
changedFiles: process.argv[2].split("\n").filter(Boolean),
nameStatusText: process.argv[3],
});
auto.writeProtectedPathReport(process.env.RUNNER_TEMP + "/protected-paths.json", hits);
if (hits.length) {
console.error("Protected paths modified:", hits.join(", "));
process.exit(1);
@@ -2102,7 +1855,6 @@ jobs:
const hits = auto.hasProtectedChangesInSources({
nameStatusText: process.argv[1],
});
auto.writeProtectedPathReport(process.env.RUNNER_TEMP + "/protected-paths.json", hits);
if (hits.length) {
console.error("Protected paths modified after verification:", hits.join(", "));
process.exit(1);
@@ -2136,7 +1888,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 +1909,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
@@ -2334,19 +2084,10 @@ jobs:
...context.repo,
issue_number: issueNumber,
});
const protectedPaths = auto.readProtectedPathReport(
process.env.RUNNER_TEMP + '/protected-paths.json',
const fallback = auto.buildIssueFollowupFallbackReply(
issue,
'processing_failed',
);
const fallback = protectedPaths.length
? auto.buildImplementationFailureMessage(issue, {
kind: 'protected_path',
protectedPaths,
workflowUrl: `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
})
: auto.buildIssueFollowupFallbackReply(
issue,
'processing_failed',
);
const comments = await github.paginate(
github.rest.issues.listComments,
{ ...context.repo, issue_number: issueNumber, per_page: 100 },
@@ -3072,32 +2813,29 @@ jobs:
cp scripts/cursor-automation.cjs "$RUNNER_TEMP/cursor-automation.cjs"
cp scripts/compare-ci-test-baseline.cjs "$RUNNER_TEMP/compare-ci-test-baseline.cjs"
- name: Skip if trusted related PR already open
- name: Skip if bot PR already open
id: existing
uses: actions/github-script@v9
with:
script: |
const auto = require(`${process.env.RUNNER_TEMP}/cursor-automation.cjs`);
const botPull = await auto.findOpenBotPrForIssue({
const existing = await auto.findOpenBotPrForIssue({
github,
context,
issueNumber: process.env.ISSUE_NUMBER,
});
const existing = botPull || await auto.findOpenPullForIssue({
github,
context,
issueNumber: process.env.ISSUE_NUMBER,
includeRelated: true,
});
core.setOutput('exists', existing ? 'true' : 'false');
core.setOutput('url', existing ? existing.html_url : '');
if (existing) {
await auto.markNeedsHuman({
github,
context,
issueNumber: Number(process.env.ISSUE_NUMBER),
message: `A trusted related pull request is already open: ${existing.html_url}`,
dedupeMarker: `<!-- cursor-existing-related-pr:${existing.number} -->`,
await github.rest.issues.createComment({
...context.repo,
issue_number: Number(process.env.ISSUE_NUMBER),
body: [
auto.TRIAGE_MARKER,
auto.DISCLAIMER,
'',
`An automation pull request is already open or was already filed: ${existing.html_url}`,
].join('\n'),
});
}
@@ -3254,7 +2992,6 @@ jobs:
changedFiles: process.argv[2].split("\n").filter(Boolean),
nameStatusText: process.argv[3],
});
auto.writeProtectedPathReport(process.env.RUNNER_TEMP + "/protected-paths.json", hits);
if (hits.length) {
console.error("Protected paths modified:", hits.join(", "));
process.exit(1);
@@ -3377,7 +3114,6 @@ jobs:
node -e '
const auto = require(process.env.RUNNER_TEMP + "/cursor-automation.cjs");
const hits = auto.hasProtectedChangesInSources({ nameStatusText: process.argv[1] });
auto.writeProtectedPathReport(process.env.RUNNER_TEMP + "/protected-paths.json", hits);
if (hits.length) {
console.error("Protected paths modified:", hits.join(", "));
process.exit(1);
@@ -3424,7 +3160,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",
@@ -3435,7 +3170,6 @@ jobs:
'
- name: Upload implement patch
id: upload_patch
if: steps.patch.outputs.artifact_ready == 'true'
uses: actions/upload-artifact@v7
with:
@@ -3446,7 +3180,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 +3189,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
@@ -3478,17 +3203,14 @@ jobs:
: `${process.env.GITHUB_WORKSPACE}/scripts/cursor-automation.cjs`;
const auto = require(helper);
const issueNumber = Number(process.env.ISSUE_NUMBER);
const protectedPaths = auto.readProtectedPathReport(
process.env.RUNNER_TEMP + '/protected-paths.json',
);
const { data: issue } = await github.rest.issues.get({
...context.repo,
issue_number: issueNumber,
});
const protectedFailure = '${{ steps.check_protected.outcome }}' === 'failure';
const artifactReady = '${{ steps.patch.outputs.artifact_ready }}' === 'true';
const artifactUploaded = '${{ steps.upload_patch.outcome }}' === 'success';
const verifyFailed = '${{ steps.verify.outputs.passed }}' === 'false';
const kind = protectedPaths.length
const kind = protectedFailure
? 'protected_path'
: artifactReady && verifyFailed
? 'verification_failed'
@@ -3500,10 +3222,7 @@ jobs:
message: auto.buildImplementationFailureMessage(issue, {
kind,
workflowUrl: `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
artifactName: artifactReady && artifactUploaded
? `implement-patch-${context.runId}`
: '',
protectedPaths,
artifactName: artifactReady ? `implement-patch-${context.runId}` : '',
}),
dedupeMarker: `<!-- cursor-implement-failure:base=${{ steps.branch.outputs.base_sha }};kind=${kind} -->`,
});
@@ -3539,60 +3258,7 @@ jobs:
name: implement-patch-${{ github.run_id }}
path: patch-in
- name: Checkout trusted helper for publish guard
uses: actions/checkout@v7
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
path: helpers
- name: Ensure automation helper present
working-directory: helpers
env:
BOOTSTRAP_SHA: ${{ github.sha }}
run: |
set -euo pipefail
if [[ -f scripts/cursor-automation.cjs ]]; then
cp scripts/cursor-automation.cjs "$RUNNER_TEMP/cursor-automation.cjs"
exit 0
fi
git fetch --depth=1 origin "${BOOTSTRAP_SHA}"
git show "FETCH_HEAD:scripts/cursor-automation.cjs" > "$RUNNER_TEMP/cursor-automation.cjs"
test -s "$RUNNER_TEMP/cursor-automation.cjs"
- name: Skip publish if trusted related PR opened during implementation
id: existing
uses: actions/github-script@v9
with:
github-token: ${{ secrets.TRIAGE_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const auto = require(`${process.env.RUNNER_TEMP}/cursor-automation.cjs`);
const issueNumber = Number(process.env.ISSUE_NUMBER);
const botPull = await auto.findOpenBotPrForIssue({
github,
context,
issueNumber,
});
const existing = botPull || await auto.findOpenPullForIssue({
github,
context,
issueNumber,
includeRelated: true,
});
core.setOutput('exists', existing ? 'true' : 'false');
if (existing) {
await auto.markNeedsHuman({
github,
context,
issueNumber,
message: `A trusted related pull request is already open: ${existing.html_url}`,
dedupeMarker: `<!-- cursor-existing-related-pr:${existing.number} -->`,
});
core.notice(`Trusted related pull request ${existing.html_url} opened during implementation; skip publishing a duplicate branch.`);
}
- name: Publish branch from fresh runner
if: steps.existing.outputs.exists != 'true'
id: publish
run: |
set -euo pipefail
@@ -3635,7 +3301,7 @@ jobs:
exit 1
- name: Mark needs human on publish failure
if: failure() && steps.existing.outputs.exists != 'true' && steps.publish.outputs.handoff == 'true'
if: failure() && steps.publish.outputs.handoff == 'true'
uses: actions/github-script@v9
env:
ISSUE_NUMBER: ${{ needs.implement.outputs.issue_number }}
@@ -3663,6 +3329,32 @@ jobs:
});
}
- name: Checkout helper for PR body helpers
if: steps.publish.outputs.published == 'true'
uses: actions/checkout@v7
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
path: helpers
- name: Ensure automation helper present
if: steps.publish.outputs.published == 'true'
working-directory: helpers
env:
BOOTSTRAP_SHA: ${{ github.sha }}
EVENT_NAME: ${{ github.event_name }}
HEAD_REPO: ''
BASE_REPO: ${{ github.repository }}
run: |
set -euo pipefail
if [[ -f scripts/cursor-automation.cjs ]]; then
cp scripts/cursor-automation.cjs "$RUNNER_TEMP/cursor-automation.cjs"
exit 0
fi
git fetch --depth=1 origin "${BOOTSTRAP_SHA}"
git show "FETCH_HEAD:scripts/cursor-automation.cjs" > "$RUNNER_TEMP/cursor-automation.cjs"
test -s "$RUNNER_TEMP/cursor-automation.cjs"
- name: Open draft PR
if: steps.publish.outputs.published == 'true'
id: openpr
@@ -3681,29 +3373,6 @@ jobs:
script: |
const auto = require(`${process.env.RUNNER_TEMP}/cursor-automation.cjs`);
const fs = require('node:fs');
const issueNumber = Number(process.env.ISSUE_NUMBER);
const botPull = await auto.findOpenBotPrForIssue({
github,
context,
issueNumber,
});
const existing = botPull || await auto.findOpenPullForIssue({
github,
context,
issueNumber,
includeRelated: true,
});
if (existing) {
await auto.markNeedsHuman({
github,
context,
issueNumber,
message: `A trusted related pull request is already open: ${existing.html_url}`,
dedupeMarker: `<!-- cursor-existing-related-pr:${existing.number} -->`,
});
core.notice(`Trusted related pull request ${existing.html_url} opened before PR creation; skip the duplicate.`);
return;
}
let statusText = '';
let agentBody = '';
try {
@@ -4731,7 +4400,6 @@ jobs:
changedFiles: process.argv[2].split("\n").filter(Boolean),
nameStatusText: process.argv[3],
});
auto.writeProtectedPathReport(process.env.RUNNER_TEMP + "/protected-paths.json", hits);
if (hits.length) {
console.error("Protected paths modified:", hits.join(", "));
process.exit(1);
@@ -4830,7 +4498,6 @@ jobs:
node -e '
const auto = require(process.env.RUNNER_TEMP + "/cursor-automation.cjs");
const hits = auto.hasProtectedChangesInSources({ nameStatusText: process.argv[1] });
auto.writeProtectedPathReport(process.env.RUNNER_TEMP + "/protected-paths.json", hits);
if (hits.length) {
console.error("Protected paths modified:", hits.join(", "));
process.exit(1);
@@ -4867,7 +4534,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",
@@ -4878,7 +4544,6 @@ jobs:
'
- name: Upload fix patch
id: upload_fixpatch
if: steps.codex.outputs.action == 'fix' && steps.fixpatch.outputs.artifact_ready == 'true'
uses: actions/upload-artifact@v7
with:
@@ -4887,7 +4552,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 +4561,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
@@ -4922,12 +4578,9 @@ jobs:
issue_number: pull_number,
body: [
auto.TRIAGE_MARKER,
`<!-- cursor-codex-fix-failure:kind=no_changes;run=${context.runId} -->`,
auto.DISCLAIMER,
'',
auto.buildCodexFixFailureMessage({
kind: 'no_changes',
workflowUrl: `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
}),
'Automatic fix did not produce additional code changes for the latest Codex findings. Marking for human review.',
].join('\n'),
});
await auto.applyCodexTerminalLabels({
@@ -4951,32 +4604,14 @@ jobs:
: `${process.env.GITHUB_WORKSPACE}/scripts/cursor-automation.cjs`;
const auto = require(helper);
const pull_number = Number(process.env.PULL_NUMBER);
const protectedPaths = auto.readProtectedPathReport(
process.env.RUNNER_TEMP + '/protected-paths.json',
);
const artifactReady = '${{ steps.fixpatch.outputs.artifact_ready }}' === 'true';
const artifactUploaded = '${{ steps.upload_fixpatch.outcome }}' === 'success';
const verifyFailed = '${{ steps.fixverify.outputs.passed }}' === 'false';
const kind = protectedPaths.length
? 'protected_path'
: artifactReady && verifyFailed
? 'verification_failed'
: 'processing_failed';
await github.rest.issues.createComment({
...context.repo,
issue_number: pull_number,
body: [
auto.TRIAGE_MARKER,
`<!-- cursor-codex-fix-failure:kind=${kind};run=${context.runId} -->`,
auto.DISCLAIMER,
'',
auto.buildCodexFixFailureMessage({
kind,
protectedPaths,
artifactName: artifactReady && artifactUploaded
? `codex-fix-patch-${context.runId}`
: '',
workflowUrl: `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
}),
'Automatic Codex fix did not finish cleanly (agent, protected paths, or verification failed). Marking for human review.',
].join('\n'),
});
await auto.applyCodexTerminalLabels({
@@ -5361,7 +4996,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,
+24 -116
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,
@@ -51,7 +50,7 @@ import { TERMINAL_THEMES } from './infrastructure/config/terminalThemes';
import { useThemeRuntime, useTerminalAppearanceInjection } from './application/state/useThemeRuntime';
import { useCustomThemes } from './application/state/customThemeStore';
import type { SyncPayload } from './domain/sync';
import { applySyncPayload, buildLocalVaultPayloadAsync, hasMeaningfulSyncData } from './application/syncPayload';
import { applySyncPayload, buildLocalVaultPayload, hasMeaningfulSyncData } from './application/syncPayload';
import {
applyProtectedSyncPayload,
ensureVersionChangeBackup,
@@ -249,7 +248,6 @@ function App({ settings }: { settings: SettingsState }) {
updateKnownHosts,
updateManagedSources,
addShellHistoryEntry,
removeShellHistoryEntry,
addConnectionLog,
updateConnectionLog,
toggleConnectionLogSaved,
@@ -291,11 +289,15 @@ function App({ settings }: { settings: SettingsState }) {
draggingSessionId,
setDraggingSessionId,
sessionRenameTarget,
sessionRenameValue,
setSessionRenameValue,
startSessionRename,
renameSessionInline,
submitSessionRename,
resetSessionRename,
workspaceRenameTarget,
workspaceRenameValue,
setWorkspaceRenameValue,
startWorkspaceRename,
submitWorkspaceRename,
resetWorkspaceRename,
@@ -487,7 +489,7 @@ function App({ settings }: { settings: SettingsState }) {
[portForwardingRules],
);
const buildCurrentSyncPayload = useCallback(async () => {
const buildCurrentSyncPayload = useCallback(() => {
let effectivePortForwardingRules = portForwardingRulesForSync;
if (effectivePortForwardingRules.length === 0) {
const stored = localStorageAdapter.read<typeof portForwardingRulesForSync>(
@@ -503,7 +505,7 @@ function App({ settings }: { settings: SettingsState }) {
}
}
return buildLocalVaultPayloadAsync(
return buildLocalVaultPayload(
{
hosts,
keys,
@@ -546,10 +548,6 @@ function App({ settings }: { settings: SettingsState }) {
}, [buildCurrentSyncPayload]);
const versionBackupAttemptedRef = useRef(false);
const versionBackupInFlightRef = useRef(false);
// Bumps when plugin contributions settle or the sidecar-host grace timer
// fires so version-change backups can wait for live sidecar collect.
const [pluginSidecarReadyTick, setPluginSidecarReadyTick] = useState(0);
// Two-stage gate: once the vault has initialized we open the auto-sync
// gate immediately — the hook's own hasMeaningfulSyncData guard and
// the cross-window restore barrier prevent an empty-but-not-yet-
@@ -561,25 +559,6 @@ function App({ settings }: { settings: SettingsState }) {
}
}, [isVaultInitialized, startupSyncSafetyReady]);
useEffect(() => {
if (isPeerSessionWindow) return;
const bridge = netcattyBridge.get() as {
onPluginContributionsChanged?: (callback: () => void) => () => void;
} | null | undefined;
const unsubscribe = bridge?.onPluginContributionsChanged?.(() => {
setPluginSidecarReadyTick((v) => v + 1);
});
// Plugins stay gated off for many installs; still allow a version
// backup after a short grace so we do not wait forever for host ready.
const graceTimer = window.setTimeout(() => {
setPluginSidecarReadyTick((v) => v + 1);
}, 5_000);
return () => {
unsubscribe?.();
window.clearTimeout(graceTimer);
};
}, [isPeerSessionWindow]);
// Retry the version-change backup as hosts/keys/snippets become
// available. ensureVersionChangeBackup refuses to advance the stored
// version stamp when the observed payload is empty, so running this
@@ -588,47 +567,30 @@ function App({ settings }: { settings: SettingsState }) {
// in which case the effect continues to no-op).
useEffect(() => {
if (isPeerSessionWindow || !isVaultInitialized || versionBackupAttemptedRef.current) return;
// Always wait for contributions or the grace tick — host-ready alone is
// not enough because collect can still return an empty authoritative
// bundle before plugin settings are declared.
if (pluginSidecarReadyTick === 0) return;
if (versionBackupInFlightRef.current) return;
const payload = buildCurrentSyncPayloadRef.current();
if (!hasMeaningfulSyncData(payload)) return;
versionBackupAttemptedRef.current = true;
let cancelled = false;
versionBackupInFlightRef.current = true;
void (async () => {
try {
const payload = await buildCurrentSyncPayloadRef.current();
if (cancelled) return;
if (!hasMeaningfulSyncData(payload)) return;
const info = await netcattyBridge.get()?.getAppInfo?.();
if (cancelled) return;
await ensureVersionChangeBackup(payload, info?.version ?? null);
if (cancelled) return;
// Latch only after a completed (non-cancelled) attempt so effect
// cleanup cannot permanently suppress retries when the in-flight
// call later throws.
versionBackupAttemptedRef.current = true;
} catch (error) {
if (!cancelled) {
console.error('[App] Failed to create version-change backup:', error);
}
} finally {
versionBackupInFlightRef.current = false;
// If hydration re-ran the effect mid-flight, nudge another attempt
// now that the in-flight guard is clear.
if (cancelled && !versionBackupAttemptedRef.current) {
queueMicrotask(() => {
setPluginSidecarReadyTick((v) => v + 1);
});
// Reset the latch so a later data change (or the next mount)
// can retry. ensureVersionChangeBackup already leaves the
// version stamp untouched on failure, so retrying is safe.
versionBackupAttemptedRef.current = false;
}
console.error('[App] Failed to create version-change backup:', error);
}
})();
return () => {
cancelled = true;
};
}, [isPeerSessionWindow, isVaultInitialized, hosts, keys, identities, proxyProfiles, snippets, customGroups, snippetPackages, notes, noteGroups, knownHosts, pluginSidecarReadyTick]);
}, [isPeerSessionWindow, isVaultInitialized, hosts, keys, identities, proxyProfiles, snippets, customGroups, snippetPackages, notes, noteGroups, knownHosts]);
// Memoized "apply a remote payload safely" callback. Stable identity
// across renders so useAutoSync's `syncNow` useCallback doesn't rebuild
@@ -661,8 +623,7 @@ function App({ settings }: { settings: SettingsState }) {
applyProtectedSyncPayload({
buildPreApplyPayload: () => buildCurrentSyncPayload(),
applyPayload: async () => {
const portable = stripSyncPayloadEncryptedCredentials(payload);
await applySyncPayload(portable, {
await applySyncPayload(payload, {
importVaultData: importDataFromString,
importPortForwardingRules,
onSettingsApplied: settings.rehydrateAllFromStorage,
@@ -824,28 +785,12 @@ function App({ settings }: { settings: SettingsState }) {
const unsubscribe = bridge.onPassphraseRequest(async (request) => {
console.log('[App] Passphrase request received:', request);
const isRequestCurrent = () => (
typeof request.sessionId !== "string"
|| isTerminalBootEpochCurrent(request.sessionId, request.bootEpoch)
);
// Disconnect/reconnect may leave a late passphrase prompt for a
// superseded boot; reject epoch-tagged requests that are no longer current.
if (!isRequestCurrent()) {
void bridge.respondPassphrase?.(request.requestId, "", true);
return;
}
// If the bridge already tried a passphrase and it was wrong, skip auto-respond
if (!request.passphraseInvalid) {
// Check if a reference key exists for this path — use its passphrase
const currentKeys = keysRef.current;
const refKey = currentKeys.find((k: SSHKey) => k.source === 'reference' && k.filePath === request.keyPath);
if (refKey?.passphrase && refKey.savePassphrase !== false && !isEncryptedCredentialPlaceholder(refKey.passphrase)) {
if (!isRequestCurrent()) {
void bridge.respondPassphrase?.(request.requestId, "", true);
return;
}
console.log('[App] Auto-responding with reference key passphrase for:', request.keyPath);
void bridge.respondPassphrase?.(request.requestId, refKey.passphrase, false);
return;
@@ -853,10 +798,6 @@ function App({ settings }: { settings: SettingsState }) {
// Fallback: try old storage for passphrase
const saved = await loadDefaultKeyPassphrase(request.keyPath);
if (!isRequestCurrent()) {
void bridge.respondPassphrase?.(request.requestId, "", true);
return;
}
if (saved) {
console.log('[App] Auto-responding with saved passphrase for:', request.keyPath);
// Migrate to reference key if one exists
@@ -874,29 +815,18 @@ function App({ settings }: { settings: SettingsState }) {
} catch (err) {
console.warn('[App] Failed to migrate passphrase to reference key:', err);
}
if (!isRequestCurrent()) {
void bridge.respondPassphrase?.(request.requestId, "", true);
return;
}
}
void bridge.respondPassphrase?.(request.requestId, saved, false);
return;
}
}
if (!isRequestCurrent()) {
void bridge.respondPassphrase?.(request.requestId, "", true);
return;
}
// No saved passphrase or it was invalid, show modal
setPassphraseQueue(prev => [...prev, {
requestId: request.requestId,
keyPath: request.keyPath,
keyName: request.keyName,
hostname: request.hostname,
sessionId: request.sessionId,
bootEpoch: request.bootEpoch,
}]);
});
@@ -905,31 +835,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]);
@@ -1792,7 +1697,6 @@ function App({ settings }: { settings: SettingsState }) {
const appVaultDomain = useMemo(() => ({
addShellHistoryEntry,
removeShellHistoryEntry,
commitPluginImporterData,
commitVaultImportTransaction,
connectionLogs,
@@ -1838,7 +1742,7 @@ function App({ settings }: { settings: SettingsState }) {
updateSnippetPackages,
updateSnippets,
vaultFocusRequest,
}), [addShellHistoryEntry, commitPluginImporterData, commitVaultImportTransaction, connectionLogs, convertKnownHostToHost, customGroups, deepLinkHostDraft, effectiveKnownHosts, groupConfigs, handleAddKnownHost, handleDeleteHost, handleOpenHostFromVaultNote, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, hosts, identities, importOrReuseKey, keys, managedSources, navigateToSection, noteGroups, notes, proxyProfiles, readPersistedHosts, readPersistedManagedSources, removeShellHistoryEntry, setDeepLinkHostDraft, setNavigateToSection, setVaultFocusRequest, snippetPackages, snippets, unmanageSource, updateCustomGroups, updateGroupConfigs, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, vaultFocusRequest]);
}), [addShellHistoryEntry, commitPluginImporterData, commitVaultImportTransaction, connectionLogs, convertKnownHostToHost, customGroups, deepLinkHostDraft, effectiveKnownHosts, groupConfigs, handleAddKnownHost, handleDeleteHost, handleOpenHostFromVaultNote, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, hosts, identities, importOrReuseKey, keys, managedSources, navigateToSection, noteGroups, notes, proxyProfiles, readPersistedHosts, readPersistedManagedSources, setDeepLinkHostDraft, setNavigateToSection, setVaultFocusRequest, snippetPackages, snippets, unmanageSource, updateCustomGroups, updateGroupConfigs, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, vaultFocusRequest]);
const appTerminalDomain = useMemo(() => ({
addSessionToWorkspace,
@@ -1941,7 +1845,10 @@ function App({ settings }: { settings: SettingsState }) {
resetWorkspaceRename,
resolvedTheme,
sessionRenameTarget,
sessionRenameValue,
setActiveTabId,
setSessionRenameValue,
setWorkspaceRenameValue,
settings,
startSessionRename,
renameSessionInline,
@@ -1953,7 +1860,8 @@ function App({ settings }: { settings: SettingsState }) {
toggleConnectionLogSaved,
updateConnectionLog,
workspaceRenameTarget,
}), [accentMode, clearUnsavedConnectionLogs, closeLogView, customAccent, deleteConnectionLog, handleEndSessionDrag, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handleSyncNowManual, isMacClient, logViews, openLogView, orderedTabsWithEditors, orphanSessionsForShell, reorderWorkTabs, resetSessionRename, resetWorkspaceRename, resolvedTheme, sessionRenameTarget, setActiveTabId, settings, startSessionRename, renameSessionInline, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, themeById, toggleConnectionLogSaved, updateConnectionLog, workspaceRenameTarget]);
workspaceRenameValue,
}), [accentMode, clearUnsavedConnectionLogs, closeLogView, customAccent, deleteConnectionLog, handleEndSessionDrag, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handleSyncNowManual, isMacClient, logViews, openLogView, orderedTabsWithEditors, orphanSessionsForShell, reorderWorkTabs, resetSessionRename, resetWorkspaceRename, resolvedTheme, sessionRenameTarget, sessionRenameValue, setActiveTabId, setSessionRenameValue, setWorkspaceRenameValue, settings, startSessionRename, renameSessionInline, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, themeById, toggleConnectionLogSaved, updateConnectionLog, workspaceRenameTarget, workspaceRenameValue]);
const appDialogsDomain = useMemo(() => ({
addToWorkspaceDialog,
-1
View File
@@ -3,7 +3,6 @@
## [Unreleased] - 2026-03-11
### 功能
- 终端新增"粘贴时自动上传剪贴板图片"选项:剪贴板含图片时,在远程会话中粘贴(快捷键/右键/中键)自动通过 SFTP 上传图片到远端 `.netcatty-paste-images/` 目录并输入远端路径,否则保持原有文本粘贴行为(设置 > 终端 > 行为,默认关闭)
- 修复自动更新 IPC 事件仅发送到单个窗口的问题,改为广播所有窗口(主窗口 + 设置窗口均可收到)
- 统一手动检查更新与自动更新的状态机,消除三套并行状态
- 手动"检查更新"通过 GitHub API 检测版本,发现更新后异步触发 electron-updater 下载
-3
View File
@@ -92,7 +92,6 @@ interface AppHostEditorLayerProps {
onCreateGroup: (groupPath: string) => void;
onImportOrReuseKey: (draft: Partial<SSHKey>) => SSHKey;
onUpdateSnippets: (snippets: Snippet[]) => void;
onUpdateHosts?: (hosts: Host[] | ((prev: Host[]) => Host[])) => void;
}
export const AppHostEditorLayer: React.FC<AppHostEditorLayerProps> = ({
@@ -118,7 +117,6 @@ export const AppHostEditorLayer: React.FC<AppHostEditorLayerProps> = ({
onCreateGroup,
onImportOrReuseKey,
onUpdateSnippets,
onUpdateHosts,
}) => {
const { t } = useI18n();
const derivedSurfaceVisible = useWorkSurfaceVisible({
@@ -191,7 +189,6 @@ export const AppHostEditorLayer: React.FC<AppHostEditorLayerProps> = ({
groupConfigs={groupConfigs}
snippets={snippets}
onSnippetsChange={onUpdateSnippets}
onHostsChange={onUpdateHosts}
onImportKey={onImportOrReuseKey}
onSave={onSave}
onCancel={onCancel}
-5
View File
@@ -53,8 +53,3 @@ test('shared host tree forwards work-surface host management callbacks', () => {
assert.match(hostTreeLayerSource, /onNewHost=\{onNewHost\}/);
assert.match(hostTreeLayerSource, /onEditHost=\{onEditHost\}/);
});
test('shared host tree layer is memoized with a custom areEqual', () => {
assert.match(hostTreeLayerSource, /memo\(AppHostTreeLayerInner,\s*appHostTreeLayerAreEqual\)/);
});
+2 -31
View File
@@ -1,4 +1,4 @@
import React, { memo, useMemo } from 'react';
import React, { useMemo } from 'react';
import { useActiveTabId } from '../state/activeTabStore';
import type { EditorTabChrome } from '../state/editorTabStore';
@@ -47,33 +47,7 @@ export function getAppHostTreeLayerStyle(surfaceVisible: boolean): React.CSSProp
};
}
function appHostTreeLayerAreEqual(
prev: AppHostTreeLayerProps,
next: AppHostTreeLayerProps,
): boolean {
return prev.enabled === next.enabled
&& prev.hosts === next.hosts
&& prev.customGroups === next.customGroups
&& prev.groupConfigs === next.groupConfigs
&& prev.sessions === next.sessions
&& prev.workspaces === next.workspaces
&& prev.editorTabs === next.editorTabs
&& prev.logViews === next.logViews
&& prev.orderedTabs === next.orderedTabs
&& prev.accentMode === next.accentMode
&& prev.currentTerminalTheme === next.currentTerminalTheme
&& prev.customAccent === next.customAccent
&& prev.followAppTerminalTheme === next.followAppTerminalTheme
&& prev.hostById === next.hostById
&& prev.themeById === next.themeById
&& prev.resolveSessionAppearance === next.resolveSessionAppearance
&& prev.onConnect === next.onConnect
&& prev.onNewHost === next.onNewHost
&& prev.onEditHost === next.onEditHost
&& prev.onCreateLocalTerminal === next.onCreateLocalTerminal;
}
const AppHostTreeLayerInner: React.FC<AppHostTreeLayerProps> = ({
export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
enabled,
hosts,
customGroups,
@@ -180,6 +154,3 @@ const AppHostTreeLayerInner: React.FC<AppHostTreeLayerProps> = ({
</div>
);
};
export const AppHostTreeLayer = memo(AppHostTreeLayerInner, appHostTreeLayerAreEqual);
AppHostTreeLayer.displayName = 'AppHostTreeLayer';
+113 -184
View File
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo } from 'react';
import { AlertTriangle, Download, Trash2 } from 'lucide-react';
import { activeTabStore, toEditorTabId, useIsEditorTabActive } from '../state/activeTabStore';
import { editorTabStore } from '../state/editorTabStore';
@@ -12,7 +12,7 @@ import { QuickScriptEditorDialog } from '../../components/scripts/QuickScriptEdi
import { AddToWorkspaceDialog } from '../../components/workspace/AddToWorkspaceDialog';
import { KeyboardInteractiveModal } from '../../components/KeyboardInteractiveModal';
import { PassphraseModal } from '../../components/PassphraseModal';
import { UnsavedChangesProvider, promptUnsavedChanges } from '../../components/editor/UnsavedChangesDialog';
import { UnsavedChangesProvider } from '../../components/editor/UnsavedChangesDialog';
import { SnippetExecutionProvider } from '../../components/SnippetExecutionProvider';
import { Button } from '../../components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../../components/ui/dialog';
@@ -23,7 +23,6 @@ import { toast } from '../../components/ui/toast';
import { AppHostTreeLayer } from './AppHostTreeLayer';
import { AppHostEditorLayer } from './AppHostEditorLayer';
import { AppPluginKeybindingHost } from './AppPluginKeybindingHost';
import { shouldOpenHostEditOnWorkSurface } from './workTabSurface';
import { getUiThemeById } from '../../infrastructure/config/uiThemes';
import { buildAppThemeCssVars } from '../state/settingsStateDefaults';
import { useMainWindowInputFocusRecovery } from '../state/useMainWindowInputFocusRecovery';
@@ -66,68 +65,6 @@ const TextEditorTabFallback = ({ tabId }: { tabId: string }) => {
);
};
/** Local draft so keystrokes do not rebuild App chrome domain every character. */
function RenameDraftDialog({
open,
inputId,
initialName,
title,
nameLabel,
placeholder,
cancelLabel,
saveLabel,
onCancel,
onSave,
}: {
open: boolean;
inputId: string;
initialName: string;
title: string;
nameLabel: string;
placeholder: string;
cancelLabel: string;
saveLabel: string;
onCancel: () => void;
onSave: (name: string) => void;
}) {
const [draft, setDraft] = useState(initialName);
useEffect(() => {
if (open) setDraft(initialName);
}, [open, initialName]);
return (
<Dialog open={open} onOpenChange={(nextOpen) => {
if (!nextOpen) onCancel();
}}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="space-y-2 py-2">
<Label htmlFor={inputId}>{nameLabel}</Label>
<Input
id={inputId}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key !== 'Enter') return;
if (!draft.trim()) return;
onSave(draft);
}}
autoFocus
placeholder={placeholder}
/>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onCancel}>{cancelLabel}</Button>
<Button onClick={() => onSave(draft)} disabled={!draft.trim()}>{saveLabel}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export type AppViewProps = {
domains: AppViewDomains;
};
@@ -176,7 +113,7 @@ function AppViewInner({ domains }: AppViewProps) {
useMainWindowInputFocusRecovery({ onPageHidden: dismissTransientOverlays });
const {
accentMode, addShellHistoryEntry, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, copyWorkspaceWithCurrentShell,
connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent,
customGroups, currentTerminalTheme, deepLinkHostDraft, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
@@ -189,14 +126,14 @@ function AppViewInner({ domains }: AppViewProps) {
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
resolveEmptyVaultConflict, resolvedTheme, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
setNavigateToSection, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
setWorkspaceFocusedSession, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
sessionRenameValue, sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
setNavigateToSection, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
sftpShowHiddenFiles, sftpUseCompressedUpload, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById,
toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog,
readPersistedHosts, readPersistedManagedSources, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources,
updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaces,
updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaceRenameValue, workspaces,
VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper,
} = ctx;
@@ -245,21 +182,6 @@ function AppViewInner({ domains }: AppViewProps) {
onUpdateHosts: updateHosts,
onSaved: handleWorkSurfaceHostSaved,
});
const openWorkSurfaceHostEdit = workSurfaceHostEditor.openEdit;
const handleEditHostFromOverlay = useCallback((host: (typeof hosts)[number]) => {
if (shouldOpenHostEditOnWorkSurface(activeTabStore.getActiveTabId())) {
openWorkSurfaceHostEdit(host);
return;
}
setDeepLinkHostDraft(host);
setNavigateToSection('hosts');
setActiveTabId('vault');
}, [
openWorkSurfaceHostEdit,
setActiveTabId,
setDeepLinkHostDraft,
setNavigateToSection,
]);
const handleCreateWorkSurfaceHostGroup = useCallback((groupPath: string) => {
updateCustomGroups(Array.from(new Set([...customGroups, groupPath])));
}, [customGroups, updateCustomGroups]);
@@ -273,70 +195,6 @@ function AppViewInner({ domains }: AppViewProps) {
pluginViewTabStore.close(tabId);
}, [orderedTabsWithEditors]);
const orderedTabsWithEditorsRef = useRef(orderedTabsWithEditors);
orderedTabsWithEditorsRef.current = orderedTabsWithEditors;
// Stable for TopTabs memo: read ordered tabs via ref; prompt via module singleton.
const handleRequestCloseEditorTab = useCallback(async (id: string): Promise<boolean> => {
const tab = editorTabStore.getTab(id);
if (!tab) return false;
const closeEditorAndActivateNeighbor = () => {
const closingTabId = toEditorTabId(id);
const list = orderedTabsWithEditorsRef.current;
const idx = list.indexOf(closingTabId);
releaseEditorTabSaveCoordinator(id);
editorTabStore.close(id);
if (activeTabStore.getActiveTabId() !== closingTabId) return;
const next = list[idx - 1] ?? list[idx + 1] ?? 'vault';
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
};
const dirty = tab.content !== tab.baselineContent;
if (!dirty) {
closeEditorAndActivateNeighbor();
return true;
}
const choice = await promptUnsavedChanges(tab.fileName);
if (choice === 'cancel') return false;
if (choice === 'discard') {
closeEditorAndActivateNeighbor();
return true;
}
if (choice === 'save') {
const ok = await saveEditorTab(id);
if (!ok) {
const msg = editorTabStore.getTab(id)?.saveError ?? 'Save failed';
toast.error(msg, 'SFTP');
return false;
}
const latest = editorTabStore.getTab(id);
if (!latest || latest.content !== latest.baselineContent) return false;
closeEditorAndActivateNeighbor();
return true;
}
return false;
}, []);
// Keep the hotkey ref current during render so Cmd/Ctrl+W never sees the
// App.tsx stub `() => false` between commit and useEffect.
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
const handleSaveSessionRename = useCallback((name: string) => {
if (!sessionRenameTarget) return;
if (!name.trim()) return;
submitSessionRename(sessionRenameTarget.id, name);
resetSessionRename();
}, [resetSessionRename, sessionRenameTarget, submitSessionRename]);
const handleSaveWorkspaceRename = useCallback((name: string) => {
if (!workspaceRenameTarget) return;
if (!name.trim()) return;
submitWorkspaceRename(workspaceRenameTarget.id, name);
resetWorkspaceRename();
}, [resetWorkspaceRename, submitWorkspaceRename, workspaceRenameTarget]);
useEffect(() => {
const catalog = buildPluginSettingScopeCatalog({
hosts,
@@ -350,7 +208,54 @@ function AppViewInner({ domains }: AppViewProps) {
return (
<SnippetExecutionProvider>
<UnsavedChangesProvider>
{() => (
{({ prompt }) => {
// Helper: close an editor tab and activate the neighbor (left-preference), or vault.
const closeEditorAndActivateNeighbor = (id: string) => {
const closingTabId = toEditorTabId(id);
const list = orderedTabsWithEditors;
const idx = list.indexOf(closingTabId);
releaseEditorTabSaveCoordinator(id);
editorTabStore.close(id);
if (activeTabStore.getActiveTabId() !== closingTabId) return;
const next = list[idx - 1] ?? list[idx + 1] ?? 'vault';
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
};
// Real dirty-confirm close handler.
const handleRequestCloseEditorTab = async (id: string): Promise<boolean> => {
const tab = editorTabStore.getTab(id);
if (!tab) return false;
const dirty = tab.content !== tab.baselineContent;
if (!dirty) {
closeEditorAndActivateNeighbor(id);
return true;
}
const choice = await prompt(tab.fileName);
if (choice === 'cancel') return false;
if (choice === 'discard') {
closeEditorAndActivateNeighbor(id);
return true;
}
if (choice === 'save') {
const ok = await saveEditorTab(id);
if (!ok) {
const msg = editorTabStore.getTab(id)?.saveError ?? 'Save failed';
toast.error(msg, 'SFTP');
return false;
}
const latest = editorTabStore.getTab(id);
if (!latest || latest.content !== latest.baselineContent) return false;
closeEditorAndActivateNeighbor(id);
return true;
}
return false;
};
// Expose to the hotkey dispatcher (Cmd/Ctrl+W).
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
return (
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" data-terminal-appearance-root onContextMenu={handleRootContextMenu}>
<TopTabs
theme={resolvedTheme}
@@ -367,7 +272,6 @@ function AppViewInner({ domains }: AppViewProps) {
onRenameSession={startSessionRename}
onCopySession={copySessionWithCurrentShell}
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
onEditHost={handleEditHostFromOverlay}
onRenameWorkspace={startWorkspaceRename}
onCopyWorkspace={copyWorkspaceWithCurrentShell}
onCloseWorkspace={closeWorkspace}
@@ -441,7 +345,6 @@ function AppViewInner({ domains }: AppViewProps) {
onCreateGroup={handleCreateWorkSurfaceHostGroup}
onImportOrReuseKey={importOrReuseKey}
onUpdateSnippets={updateSnippets}
onUpdateHosts={updateHosts}
/>
<VaultViewContainer appThemeStyle={appThemeStyle}>
@@ -543,6 +446,8 @@ function AppViewInner({ domains }: AppViewProps) {
identities={identities}
snippets={snippets}
snippetPackages={snippetPackages}
notes={notes}
noteGroups={noteGroups}
sessions={sessions}
workspaces={workspaces}
knownHosts={effectiveKnownHosts}
@@ -580,7 +485,6 @@ function AppViewInner({ domains }: AppViewProps) {
onUpdateHost={handleUpdateHostFromTerminal}
onAddKnownHost={handleAddKnownHost}
onCommandExecuted={handleTerminalCommandExecuted}
onDeleteShellHistoryEntry={removeShellHistoryEntry}
onTerminalDataCapture={handleTerminalDataCapture}
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
onAddSessionToWorkspace={addSessionToWorkspace}
@@ -606,6 +510,8 @@ function AppViewInner({ domains }: AppViewProps) {
updateHosts={updateTerminalHosts}
updateSnippets={updateSnippets}
updateSnippetPackages={updateSnippetPackages}
updateNotes={updateNotes}
updateNoteGroups={updateNoteGroups}
sftpDefaultViewMode={sftpDefaultViewMode}
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
sftpAutoSync={sftpAutoSync}
@@ -759,11 +665,6 @@ function AppViewInner({ domains }: AppViewProps) {
showSftpTab={settings.showSftpTab}
onQueryChange={setQuickSearch}
onSelect={handleHostConnectWithProtocolCheck}
onEditHost={(host) => {
setIsQuickSwitcherOpen(false);
setQuickSearch('');
handleEditHostFromOverlay(host);
}}
onSelectTab={(tabId) => {
setActiveTabId(tabId);
setIsQuickSwitcherOpen(false);
@@ -790,32 +691,59 @@ function AppViewInner({ domains }: AppViewProps) {
</LazyLoadBoundary>
)}
<RenameDraftDialog
open={!!sessionRenameTarget}
inputId="session-name"
initialName={sessionRenameTarget ? (sessionRenameTarget.customName || sessionRenameTarget.hostLabel) : ''}
title={t('dialog.renameSession.title')}
nameLabel={t('field.name')}
placeholder={t('placeholder.sessionName')}
cancelLabel={t('common.cancel')}
saveLabel={t('common.save')}
onCancel={resetSessionRename}
onSave={handleSaveSessionRename}
/>
<RenameDraftDialog
open={!!workspaceRenameTarget}
inputId="workspace-name"
initialName={workspaceRenameTarget ? workspaceRenameTarget.title : ''}
title={t('dialog.renameWorkspace.title')}
nameLabel={t('field.name')}
placeholder={t('placeholder.workspaceName')}
cancelLabel={t('common.cancel')}
saveLabel={t('common.save')}
onCancel={resetWorkspaceRename}
onSave={handleSaveWorkspaceRename}
/>
<Dialog open={!!sessionRenameTarget} onOpenChange={(open) => {
if (!open) {
resetSessionRename();
}
}}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t('dialog.renameSession.title')}</DialogTitle>
</DialogHeader>
<div className="space-y-2 py-2">
<Label htmlFor="session-name">{t('field.name')}</Label>
<Input
id="session-name"
value={sessionRenameValue}
onChange={(e) => setSessionRenameValue(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') submitSessionRename(); }}
autoFocus
placeholder={t('placeholder.sessionName')}
/>
</div>
<DialogFooter>
<Button variant="ghost" onClick={resetSessionRename}>{t('common.cancel')}</Button>
<Button onClick={submitSessionRename} disabled={!sessionRenameValue.trim()}>{t('common.save')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={!!workspaceRenameTarget} onOpenChange={(open) => {
if (!open) {
resetWorkspaceRename();
}
}}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t('dialog.renameWorkspace.title')}</DialogTitle>
</DialogHeader>
<div className="space-y-2 py-2">
<Label htmlFor="workspace-name">{t('field.name')}</Label>
<Input
id="workspace-name"
value={workspaceRenameValue}
onChange={(e) => setWorkspaceRenameValue(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') submitWorkspaceRename(); }}
autoFocus
placeholder={t('placeholder.workspaceName')}
/>
</div>
<DialogFooter>
<Button variant="ghost" onClick={resetWorkspaceRename}>{t('common.cancel')}</Button>
<Button onClick={submitWorkspaceRename} disabled={!workspaceRenameValue.trim()}>{t('common.save')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{isCreateWorkspaceOpen && (
<LazyLoadBoundary name="Create workspace" resetKey="create-workspace">
@@ -919,7 +847,8 @@ function AppViewInner({ domains }: AppViewProps) {
</DialogContent>
</Dialog>
</div>
)}
);
}}
</UnsavedChangesProvider>
</SnippetExecutionProvider>
);
@@ -5,10 +5,6 @@ import {
removeKeyboardInteractiveRequest,
shouldQueueKeyboardInteractiveRequest,
} from "./useAppStartupEffects.ts";
import {
clearTerminalBootEpoch,
setTerminalBootEpoch,
} from "../../domain/terminalBootEpoch.ts";
const sessions = [{ id: "terminal-1" }, { id: "terminal-2" }];
@@ -23,42 +19,6 @@ test("terminal-scoped keyboard-interactive requests are limited to owned session
);
});
test("disconnected terminal sessions do not queue keyboard-interactive prompts", () => {
assert.equal(
shouldQueueKeyboardInteractiveRequest(
{ scope: "terminal", sessionId: "terminal-1" },
[{ id: "terminal-1", status: "disconnected" }],
),
false,
);
assert.equal(
shouldQueueKeyboardInteractiveRequest(
{ scope: "terminal", sessionId: "terminal-1" },
[{ id: "terminal-1", status: "connecting" }],
),
true,
);
});
test("superseded terminal boot epochs do not queue keyboard-interactive prompts", () => {
setTerminalBootEpoch("terminal-1", 3);
assert.equal(
shouldQueueKeyboardInteractiveRequest(
{ scope: "terminal", sessionId: "terminal-1", bootEpoch: 1 },
[{ id: "terminal-1", status: "connecting" }],
),
false,
);
assert.equal(
shouldQueueKeyboardInteractiveRequest(
{ scope: "terminal", sessionId: "terminal-1", bootEpoch: 3 },
[{ id: "terminal-1", status: "connecting" }],
),
true,
);
clearTerminalBootEpoch("terminal-1");
});
test("external keyboard-interactive requests are not filtered by terminal session ids", () => {
assert.equal(
shouldQueueKeyboardInteractiveRequest({ scope: "external", sessionId: "sftp-conn-1" }, sessions),
+6 -63
View File
@@ -9,12 +9,8 @@ import { sftpTransferCenterStore } from '../state/sftpTransferCenterStore';
import { resumeTransferWithDedicatedSession } from '../state/sftp/dedicatedTransferResume';
import { getSftpTransferResourceKeys, globalSftpTransferScheduler } from '../state/sftp/globalTransferScheduler';
import { hasNewSourceFingerprint } from '../state/sftp/transferProgressMetadata';
import {
STORAGE_KEY_AUTO_UPDATE_ENABLED,
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
} from '../../infrastructure/config/storageKeys';
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from '../../infrastructure/config/storageKeys';
import type { TransferTask } from '../../domain/models';
import { isTerminalBootEpochCurrent } from '../../domain/terminalBootEpoch';
import {
canApplyDedicatedResumeProgress,
createDedicatedResumeChildUpdateBatcher,
@@ -28,10 +24,8 @@ type KeyboardInteractiveRequestLike = {
scope?: KeyboardInteractiveScope;
sessionId?: string;
hostId?: string;
requestId?: string;
bootEpoch?: number;
};
type SessionIdLike = { id: string; hostId?: string; hostname?: string; status?: string };
type SessionIdLike = { id: string; hostId?: string; hostname?: string };
type KeyboardInteractiveQueueItem = { requestId: string };
export function shouldQueueKeyboardInteractiveRequest(
@@ -40,14 +34,7 @@ export function shouldQueueKeyboardInteractiveRequest(
): boolean {
if (request.scope !== "terminal") return true;
if (!request.sessionId) return false;
const session = sessions.find((entry) => entry.id === request.sessionId);
if (!session) return false;
// Status-bar disconnect keeps the tab; do not queue MFA for aborted panes.
if (session.status === "disconnected") return false;
// After disconnect → reconnect the tab is connecting again; reject MFA from
// a superseded SSH start that still shares this sessionId.
if (!isTerminalBootEpochCurrent(request.sessionId, request.bootEpoch)) return false;
return true;
return sessions.some((session) => session.id === request.sessionId);
}
export function removeKeyboardInteractiveRequest<T extends KeyboardInteractiveQueueItem>(
@@ -221,7 +208,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
// Skip "update available" toast if auto-download has already started or completed
if (updateState.autoDownloadStatus !== 'idle') return;
// Don't show automatic notification when auto-update is disabled
if (localStorageAdapter.readString(STORAGE_KEY_AUTO_UPDATE_ENABLED) === 'false') return;
if (localStorageAdapter.readString('netcatty_auto_update_enabled_v1') === 'false') return;
if (updateState.hasUpdate && updateState.latestRelease) {
const version = updateState.latestRelease.version;
toast.info(
@@ -422,9 +409,6 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
targetType: task.direction === "download" ? "local" : "sftp",
sourceSftpId: task.direction === "download" ? sftpId : undefined,
targetSftpId: task.direction === "upload" ? sftpId : undefined,
// Keep host-scoped path gates across session reopen (Codex P1).
sourceHostId: task.sourceHostId,
targetHostId: task.targetHostId,
totalBytes: task.totalBytes,
resumable: task.resumable !== false,
checkpointBytes,
@@ -436,30 +420,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
});
},
);
// Same-id retry stole ownership; wait for the live owner's terminal
// status instead of treating this invoke as completed (Codex P2).
if (result?.superseded === true) {
// Wait for live owner terminal status only (no fixed deadline).
for (;;) {
const latest = sftpTransferCenterStore.getSnapshot().tasks.find((candidate) => candidate.id === task.id);
const status = latest?.status;
if (status === "completed" || status === "cancelled" || status === "failed") {
if (status === "failed") {
throw new Error(latest?.error || "Transfer failed");
}
if (status === "cancelled") {
sftpTransferCenterStore.ingestBackgroundEvent({
type: "cancelled",
transferId: task.id,
endedAt: Date.now(),
});
}
// completed: events already applied; cancelled handled above.
break;
}
await new Promise((resolve) => setTimeout(resolve, 200));
}
} else if (result?.cancelled || result?.error === "Transfer cancelled") {
if (result?.cancelled || result?.error === "Transfer cancelled") {
sftpTransferCenterStore.ingestBackgroundEvent({ type: "cancelled", transferId: task.id, endedAt: Date.now() });
} else if (result?.error) {
throw new Error(result.error);
@@ -532,12 +493,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
if (!bridge?.onKeyboardInteractive) return;
const unsubscribe = bridge.onKeyboardInteractive((request) => {
if (!shouldQueueKeyboardInteractiveRequest(request, sessionsRef.current)) {
if (request.scope === "terminal" && request.requestId) {
void bridge.respondKeyboardInteractive?.(request.requestId, [], true);
}
return;
}
if (!shouldQueueKeyboardInteractiveRequest(request, sessionsRef.current)) return;
console.log('[App] Keyboard-interactive request received:', request);
// Add to queue instead of replacing - supports multiple concurrent sessions
setKeyboardInteractiveQueue(prev => [...prev, {
@@ -555,23 +511,10 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
const unsubscribeCancelled = bridge.onKeyboardInteractiveCancelled?.((event) => {
setKeyboardInteractiveQueue(prev => removeKeyboardInteractiveRequest(prev, event.requestId));
});
const onTerminalDisconnected = (event: Event) => {
const sessionId = (event as CustomEvent<{ sessionId?: string }>).detail?.sessionId;
if (!sessionId) return;
setKeyboardInteractiveQueue((prev) => {
const doomed = prev.filter((request) => request.sessionId === sessionId);
for (const request of doomed) {
void bridge.respondKeyboardInteractive?.(request.requestId, [], true);
}
return prev.filter((request) => request.sessionId !== sessionId);
});
};
window.addEventListener("netcatty:terminal-session-disconnected", onTerminalDisconnected);
return () => {
unsubscribe?.();
unsubscribeCancelled?.();
window.removeEventListener("netcatty:terminal-session-disconnected", onTerminalDisconnected);
};
}, [enabled, setKeyboardInteractiveQueue]);
-10
View File
@@ -9,7 +9,6 @@ import {
reorderWorkTabIds,
resolveWorkTabActiveHostId,
resolveWorkTabHostTreeTheme,
shouldOpenHostEditOnWorkSurface,
} from './workTabSurface';
import type { EditorTab } from '../state/editorTabStore';
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
@@ -78,15 +77,6 @@ test('root pages are not work tab surfaces', () => {
assert.equal(isRootPageTabId('session-1'), false);
});
test('host edit overlay prefers work-surface editor except on vault/sftp/plugin tabs', () => {
assert.equal(shouldOpenHostEditOnWorkSurface('session-1'), true);
assert.equal(shouldOpenHostEditOnWorkSurface('workspace-1'), true);
assert.equal(shouldOpenHostEditOnWorkSurface('editor:file-1'), true);
assert.equal(shouldOpenHostEditOnWorkSurface('vault'), false);
assert.equal(shouldOpenHostEditOnWorkSurface('sftp'), false);
assert.equal(shouldOpenHostEditOnWorkSurface('plugin-view:demo'), false);
});
test('shared host tree is visible for editor, log, session, and workspace tabs', () => {
const sessionIds = new Set(['session-1']);
const workspaceIds = new Set(['workspace-1']);
-8
View File
@@ -23,14 +23,6 @@ export function isRootPageTabId(activeTabId: string): boolean {
return activeTabId === 'vault' || activeTabId === 'sftp';
}
/**
* Host edit from overlays (Quick Switcher): use the terminal work-surface
* HostDetailsPanel when a work tab is active; otherwise deep-link into Vault.
*/
export function shouldOpenHostEditOnWorkSurface(activeTabId: string): boolean {
return !isRootPageTabId(activeTabId) && !isPluginViewTabId(activeTabId);
}
export function buildOrderedWorkTabIds(
tabOrder: readonly string[],
allTabIds: readonly string[],
-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',
-10
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',
@@ -577,9 +574,6 @@ Highlight the focused split pane:
'settings.terminal.behavior.bracketedPaste': 'Bracketed paste mode',
'settings.terminal.behavior.bracketedPaste.desc':
'Wrap pasted text with escape sequences so the shell can distinguish paste from typed input. Disable if you see ^[[200~ artifacts.',
'settings.terminal.behavior.autoUploadClipboardImage': 'Auto-upload clipboard images on paste',
'settings.terminal.behavior.autoUploadClipboardImage.desc':
'When the clipboard holds an image, pasting in a remote session uploads it via SFTP to .netcatty-paste-images/ under the remote working directory and inserts the remote path instead of pasting text.',
'settings.terminal.behavior.shiftEnterNewline': 'Shift+Enter text',
'settings.terminal.behavior.shiftEnterNewline.desc':
'Send configured text instead of normal Enter when pressing Shift+Enter in the terminal.',
@@ -686,10 +680,6 @@ Highlight the focused split pane:
'settings.terminal.section.serverStats': 'Server Stats (Linux)',
'settings.terminal.hostInfoBar.show': 'Show host information bar',
'settings.terminal.hostInfoBar.show.desc': 'Show the host address and server information above the terminal. Action buttons remain available when hidden.',
'settings.terminal.hostInfoBar.titleMode': 'Host information title',
'settings.terminal.hostInfoBar.titleMode.desc': 'Choose whether the bar shows the connection address (user@host) or the vault host label.',
'settings.terminal.hostInfoBar.titleMode.address': 'Connection address',
'settings.terminal.hostInfoBar.titleMode.label': 'Host label',
'settings.terminal.section.systemManager': 'System Manager',
'settings.terminal.systemManager.processRefreshInterval': 'Process list refresh',
'settings.terminal.systemManager.processRefreshInterval.desc': 'How often to refresh the process list in the system manager side panel.',
@@ -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',
-12
View File
@@ -34,7 +34,6 @@ export const enTerminalMessages: Messages = {
'history.action.paste': 'Paste to terminal',
'history.action.run': 'Run in terminal',
'history.action.saveAsSnippet': 'Save as snippet',
'history.action.delete': 'Delete from history',
'terminal.toolbar.library': 'Library',
'terminal.toolbar.noSnippets': 'No snippets available',
'terminal.toolbar.terminalSettings': 'Terminal settings',
@@ -82,10 +81,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',
@@ -337,13 +332,6 @@ export const enTerminalMessages: Messages = {
'cloudSync.connect.webdav.failedTitle': 'WebDAV connection failed',
'cloudSync.connect.s3.success': 'S3 connected successfully',
'cloudSync.connect.s3.failedTitle': 'S3 connection failed',
'cloudSync.connect.plugin.success': 'Plugin sync provider connected successfully',
'cloudSync.connect.plugin.failedTitle': 'Plugin sync connection failed',
'cloudSync.pluginConfig.title': 'Configure {name}',
'cloudSync.pluginConfig.desc': 'Enter the JSON configuration required by this plugin sync provider.',
'cloudSync.pluginConfig.label': 'Provider configuration (JSON)',
'cloudSync.pluginConfig.invalidJson': 'Configuration must be valid JSON.',
'cloudSync.pluginConfig.schemaInvalid': 'Configuration does not match the provider schema.',
'cloudSync.lastSync.never': 'Never',
'cloudSync.lastSync.justNow': 'Just now',
'cloudSync.lastSync.minutesAgo': '{minutes} min ago',
+32 -6
View File
@@ -113,6 +113,8 @@ export const enVaultMessages: Messages = {
'knownHosts.empty.title': 'No Known Hosts',
'knownHosts.empty.desc':
"Known hosts are SSH servers you've connected to before. Import from your system's known_hosts file to get started.",
'knownHosts.results.showingLimited':
'Showing {shown} of {total} hosts. Use search to find specific hosts.',
'knownHosts.toast.scanUnavailable': 'System scan is unavailable on this platform.',
'knownHosts.toast.scanNoFile': 'No system known_hosts file found.',
'knownHosts.toast.scanNoEntries': 'No usable entries found in known_hosts.',
@@ -266,8 +268,6 @@ export const enVaultMessages: Messages = {
'sftp.transfers.calculatingTotal': 'Calculating total size...',
'sftp.transfers.filesCount': '{count} files',
'sftp.transfers.filesProgress': '{current}/{total} files',
// Progressive folder walk: total is "found so far", not a fixed grand total.
'sftp.transfers.filesDiscoveredProgress': '{completed} done · {discovered} found',
'sftp.transfers.expandChildren': 'Show files',
'sftp.transfers.collapseChildren': 'Hide files',
'sftp.transfers.expandChildList': 'Show detail',
@@ -448,6 +448,13 @@ export const enVaultMessages: Messages = {
'settings.tab.sftpFileAssociations': 'SFTP',
'settings.sftp.transferConcurrency': 'Transfer Concurrency',
'settings.sftp.transferConcurrency.desc': 'Maximum number of files transferred in parallel for the same endpoint, including folder contents and separately selected files. Higher values may improve speed but can overwhelm some servers.',
'settings.sftp.transferPoolIdleTtl': 'Transfer connection keep-alive',
'settings.sftp.transferPoolIdleTtl.desc': 'How long dedicated transfer SFTP connections stay warm after idle. Longer values reduce cold-start delay before the next upload or download. Busy transfers are never closed.',
'settings.sftp.transferPoolIdleTtl.1m': '1 minute',
'settings.sftp.transferPoolIdleTtl.5m': '5 minutes',
'settings.sftp.transferPoolIdleTtl.15m': '15 minutes',
'settings.sftp.transferPoolIdleTtl.30m': '30 minutes',
'settings.sftp.transferPoolIdleTtl.never': 'Until app quit',
'settings.ssh.transportIdleTtl': 'SSH connection keep-alive',
'settings.ssh.transportIdleTtl.desc': 'How long a shared SSH connection stays warm after the last terminal, SFTP browse, bulk transfer, or port-forward lease is released. While warm, reconnecting to the same host can skip re-authentication (including MFA). Active sessions and transfers are never closed.',
'settings.ssh.transportIdleTtl.1m': '1 minute',
@@ -553,9 +560,6 @@ export const enVaultMessages: Messages = {
'settings.sftp.compressedUpload.enable': 'Enable folder compression',
'settings.sftp.compressedUpload.enableDesc': 'Automatically compress folders using tar before transfer. Requires tar support on the server. Falls back to regular transfer if not available.',
'settings.sftp.skipUnchanged.enable': 'Skip unchanged files',
'settings.sftp.skipUnchanged.enableDesc': 'Skip files whose size and modification time already match the destination (rsync-style). Disabled when replacing a folder into a staging path.',
// Quick Switcher
'qs.search.placeholder': 'Search hosts or tabs',
'qs.jumpTo': 'Jump To',
@@ -904,8 +908,30 @@ export const enVaultMessages: Messages = {
'hostDetails.plugin.unavailable': 'The configured provider is unavailable. Configuration is preserved until the plugin is restored.',
'hostDetails.plugin.useSsh': 'Use SSH instead',
// Host form (legacy keys still referenced by TerminalAuthDialog)
// Host form (legacy modal)
'hostForm.title.edit': 'Edit Host',
'hostForm.title.new': 'New Host',
'hostForm.desc.edit': 'Update connection details for this host',
'hostForm.desc.new': 'Create a new SSH host entry',
'hostForm.field.label': 'Label',
'hostForm.placeholder.label': 'My Production Server',
'hostForm.field.hostname': 'Hostname / IP',
'hostForm.placeholder.hostname': '192.168.1.1',
'hostForm.field.port': 'Port',
'hostForm.field.username': 'Username',
'hostForm.field.osType': 'OS Type',
'hostForm.placeholder.selectOs': 'Select OS',
'hostForm.field.group': 'Group',
'hostForm.placeholder.group': 'e.g. AWS, DigitalOcean',
'hostForm.field.tags': 'Tags',
'hostForm.placeholder.addTag': 'Add a tag...',
'hostForm.auth.method': 'Authentication Method',
'hostForm.auth.password': 'Password',
'hostForm.auth.sshKey': 'SSH Key',
'hostForm.auth.selectKey': 'Select an SSH Key',
'hostForm.auth.noKeys': 'No keys available',
'hostForm.auth.noKeysHint': 'No SSH keys found in Keychain. Please create one first.',
'hostForm.saveHost': 'Save Host',
// Connection logs
'logs.table.date': 'Date',
-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': 'Предыдущих сессий нет',
-10
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',
@@ -538,9 +535,6 @@ export const ruCoreMessages: Messages = {
'settings.terminal.behavior.bracketedPaste': 'Режим bracketed paste',
'settings.terminal.behavior.bracketedPaste.desc':
'Оборачивать вставляемый текст escape-последовательностями, чтобы оболочка отличала вставку от обычного ввода. Отключите, если видите артефакты вида ^[[200~.',
'settings.terminal.behavior.autoUploadClipboardImage': 'Авто-загрузка изображений буфера обмена при вставке',
'settings.terminal.behavior.autoUploadClipboardImage.desc':
'Когда в буфере обмена есть изображение, вставка в удалённой сессии загружает его по SFTP в каталог .netcatty-paste-images/ в текущем удалённом каталоге и вводит удалённый путь вместо вставки текста.',
'settings.terminal.behavior.shiftEnterNewline': 'Текст для Shift+Enter',
'settings.terminal.behavior.shiftEnterNewline.desc':
'Отправлять настроенный текст вместо обычного Enter при нажатии Shift+Enter в терминале.',
@@ -643,10 +637,6 @@ export const ruCoreMessages: Messages = {
'settings.terminal.section.serverStats': 'Статистика сервера (Linux)',
'settings.terminal.hostInfoBar.show': 'Показывать сведения о хосте',
'settings.terminal.hostInfoBar.show.desc': 'Показывать адрес хоста и сведения о сервере над терминалом. Кнопки действий остаются доступными после скрытия.',
'settings.terminal.hostInfoBar.titleMode': 'Заголовок сведений о хосте',
'settings.terminal.hostInfoBar.titleMode.desc': 'Показывать в панели адрес подключения (user@host) или метку хоста из хранилища.',
'settings.terminal.hostInfoBar.titleMode.address': 'Адрес подключения',
'settings.terminal.hostInfoBar.titleMode.label': 'Метка хоста',
'settings.terminal.section.systemManager': 'Системный менеджер',
'settings.terminal.systemManager.processRefreshInterval': 'Обновление списка процессов',
'settings.terminal.systemManager.processRefreshInterval.desc': 'Как часто обновлять список процессов в боковой панели системного менеджера.',
@@ -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': 'Создан',
-12
View File
@@ -55,7 +55,6 @@ export const ruTerminalMessages: Messages = {
'history.action.paste': 'Вставить в терминал',
'history.action.run': 'Выполнить в терминале',
'history.action.saveAsSnippet': 'Сохранить как сниппет',
'history.action.delete': 'Удалить из истории',
'terminal.toolbar.library': 'Библиотека',
'terminal.toolbar.noSnippets': 'Нет доступных сниппетов',
'terminal.toolbar.terminalSettings': 'Настройки терминала',
@@ -103,10 +102,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': 'Использование памяти',
@@ -353,13 +348,6 @@ export const ruTerminalMessages: Messages = {
'cloudSync.connect.webdav.failedTitle': 'Ошибка подключения WebDAV',
'cloudSync.connect.s3.success': 'S3 успешно подключён',
'cloudSync.connect.s3.failedTitle': 'Ошибка подключения S3',
'cloudSync.connect.plugin.success': 'Провайдер синхронизации плагина подключен',
'cloudSync.connect.plugin.failedTitle': 'Ошибка подключения синхронизации плагина',
'cloudSync.pluginConfig.title': 'Настройка {name}',
'cloudSync.pluginConfig.desc': 'Введите JSON-конфигурацию, требуемую этим провайдером синхронизации.',
'cloudSync.pluginConfig.label': 'Конфигурация провайдера (JSON)',
'cloudSync.pluginConfig.invalidJson': 'Конфигурация должна быть допустимым JSON.',
'cloudSync.pluginConfig.schemaInvalid': 'Конфигурация не соответствует схеме провайдера.',
'cloudSync.lastSync.never': 'Никогда',
'cloudSync.lastSync.justNow': 'Только что',
'cloudSync.lastSync.minutesAgo': '{minutes} мин назад',
+32 -5
View File
@@ -150,6 +150,8 @@ export const ruVaultMessages: Messages = {
'knownHosts.empty.title': 'Нет известных хостов',
'knownHosts.empty.desc':
'Известные хосты — это SSH-серверы, к которым вы подключались раньше. Импортируйте системный файл known_hosts, чтобы начать.',
'knownHosts.results.showingLimited':
'Показано {shown} из {total} хостов. Используйте поиск, чтобы найти нужные хосты.',
'knownHosts.toast.scanUnavailable': 'Сканирование системы недоступно на этой платформе.',
'knownHosts.toast.scanNoFile': 'Системный файл known_hosts не найден.',
'knownHosts.toast.scanNoEntries': 'В known_hosts не найдено пригодных записей.',
@@ -303,7 +305,6 @@ export const ruVaultMessages: Messages = {
'sftp.transfers.calculatingTotal': 'Вычисление общего размера...',
'sftp.transfers.filesCount': '{count} файл(ов)',
'sftp.transfers.filesProgress': '{current}/{total} файл(ов)',
'sftp.transfers.filesDiscoveredProgress': '{completed} готово · {discovered} найдено',
'sftp.transfers.expandChildren': 'Показать файлы',
'sftp.transfers.collapseChildren': 'Скрыть файлы',
'sftp.transfers.expandChildList': 'Показать детали',
@@ -484,6 +485,13 @@ export const ruVaultMessages: Messages = {
'settings.tab.sftpFileAssociations': 'SFTP',
'settings.sftp.transferConcurrency': 'Параллелизм передачи',
'settings.sftp.transferConcurrency.desc': 'Максимальное число файлов, одновременно передаваемых для одной цели, включая содержимое папок и отдельно выбранные файлы. Большие значения могут ускорить передачу, но перегрузить некоторые серверы.',
'settings.sftp.transferPoolIdleTtl': 'Удержание transfer-соединений',
'settings.sftp.transferPoolIdleTtl.desc': 'Как долго простаивающие SFTP-соединения пула передачи остаются открытыми. Большее время снижает задержку перед следующей передачей. Активные передачи не закрываются.',
'settings.sftp.transferPoolIdleTtl.1m': '1 минута',
'settings.sftp.transferPoolIdleTtl.5m': '5 минут',
'settings.sftp.transferPoolIdleTtl.15m': '15 минут',
'settings.sftp.transferPoolIdleTtl.30m': '30 минут',
'settings.sftp.transferPoolIdleTtl.never': 'До выхода из приложения',
'settings.ssh.transportIdleTtl': 'Удержание SSH-соединений',
'settings.ssh.transportIdleTtl.desc': 'Как долго общее SSH-соединение остаётся открытым после освобождения последнего lease терминала, SFTP-браузера, пакетной передачи или проброса портов. Пока оно тёплое, повторное подключение к тому же хосту может обойти повторную аутентификацию (включая MFA). Активные сессии и передачи не закрываются.',
'settings.ssh.transportIdleTtl.1m': '1 минута',
@@ -589,9 +597,6 @@ export const ruVaultMessages: Messages = {
'settings.sftp.compressedUpload.enable': 'Включить сжатие папок',
'settings.sftp.compressedUpload.enableDesc': 'Автоматически сжимать папки с помощью tar перед передачей. Требует поддержки tar на сервере. Если она недоступна, будет использована обычная передача.',
'settings.sftp.skipUnchanged.enable': 'Пропускать неизменённые файлы',
'settings.sftp.skipUnchanged.enableDesc': 'Пропускать файлы, у которых размер и время изменения уже совпадают с назначением (в стиле rsync). Отключается при замене папки во временный путь.',
// Quick Switcher
'qs.search.placeholder': 'Поиск хостов или вкладок',
'qs.jumpTo': 'Перейти к',
@@ -936,8 +941,30 @@ export const ruVaultMessages: Messages = {
'hostDetails.plugin.unavailable': 'Настроенный провайдер недоступен. Конфигурация сохранится до восстановления плагина.',
'hostDetails.plugin.useSsh': 'Использовать SSH',
// Host form (legacy keys still referenced by TerminalAuthDialog)
// Host form (legacy modal)
'hostForm.title.edit': 'Редактировать хост',
'hostForm.title.new': 'Новый хост',
'hostForm.desc.edit': 'Обновите параметры подключения для этого хоста',
'hostForm.desc.new': 'Создайте новую запись SSH-хоста',
'hostForm.field.label': 'Метка',
'hostForm.placeholder.label': 'Мой production-сервер',
'hostForm.field.hostname': 'Имя хоста / IP',
'hostForm.placeholder.hostname': '192.168.1.1',
'hostForm.field.port': 'Порт',
'hostForm.field.username': 'Имя пользователя',
'hostForm.field.osType': 'Тип ОС',
'hostForm.placeholder.selectOs': 'Выберите ОС',
'hostForm.field.group': 'Группа',
'hostForm.placeholder.group': 'например, AWS, DigitalOcean',
'hostForm.field.tags': 'Теги',
'hostForm.placeholder.addTag': 'Добавить тег...',
'hostForm.auth.method': 'Метод аутентификации',
'hostForm.auth.password': 'Пароль',
'hostForm.auth.sshKey': 'SSH-ключ',
'hostForm.auth.selectKey': 'Выберите SSH-ключ',
'hostForm.auth.noKeys': 'Нет доступных ключей',
'hostForm.auth.noKeysHint': 'В связке ключей не найдено SSH-ключей. Сначала создайте один.',
'hostForm.saveHost': 'Сохранить хост',
'vault.import.plugins.title': 'Импортёры плагинов',
'vault.import.plugins.loading': 'Создание безопасного предварительного просмотра…',
'vault.import.plugins.progress': 'Обработано {completed} из {total} {message}',
-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': '没有历史会话',
+1 -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 调试日志',
@@ -732,6 +729,7 @@ export const zhCNCoreMessages: Messages = {
'knownHosts.empty.title': '暂无已知主机',
'knownHosts.empty.desc':
'Known Hosts 是你之前连接过的 SSH server。导入系统的 known_hosts 文件以开始。',
'knownHosts.results.showingLimited': '显示 {shown}/{total} 个主机。使用搜索查找特定主机。',
'knownHosts.toast.scanUnavailable': '当前平台无法扫描系统 known_hosts。',
'knownHosts.toast.scanNoFile': '未找到系统 known_hosts 文件。',
'knownHosts.toast.scanNoEntries': 'known_hosts 中没有可用条目。',
@@ -862,7 +860,6 @@ export const zhCNCoreMessages: Messages = {
'sftp.transfers.calculatingTotal': '正在统计总大小...',
'sftp.transfers.filesCount': '{count} 个文件',
'sftp.transfers.filesProgress': '{current}/{total} 个文件',
'sftp.transfers.filesDiscoveredProgress': '已完成 {completed} · 已发现 {discovered}',
'sftp.transfers.expandChildren': '展开文件',
'sftp.transfers.collapseChildren': '收起文件',
'sftp.transfers.expandChildList': '展开详情',
@@ -10,7 +10,6 @@ export const zhCnSystemManagerMessages: Messages = {
'systemManager.tabs.processes': '进程',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.tabs.gpu': 'GPU',
'systemManager.popup.loading': '正在打开终端…',
'systemManager.popup.startupFailed': '启动命令未成功。请确认目标仍然可用后重试。',
@@ -24,7 +23,6 @@ export const zhCnSystemManagerMessages: Messages = {
'systemManager.errors.loadDockerStats': '加载容器性能数据失败',
'systemManager.errors.loadDockerImages': '加载镜像列表失败',
'systemManager.errors.loadOverview': '加载系统概览失败',
'systemManager.errors.loadGpu': '加载 GPU / NPU 状态失败',
'systemManager.errors.sshChannelUnavailable': '服务器拒绝打开新的执行通道。请稍后重试,或重新连接当前主机。',
'systemManager.overview.empty': '暂无系统概览数据。',
@@ -189,23 +187,6 @@ export const zhCnSystemManagerMessages: Messages = {
'systemManager.docker.start': '启动',
'systemManager.docker.stop': '停止',
'systemManager.gpu.unavailable': '此主机未检测到 NVIDIA GPU 或昇腾 NPU 工具。',
'systemManager.gpu.loading': '正在加载加速卡状态…',
'systemManager.gpu.empty': '已检测到加速卡工具,但未返回设备信息。',
'systemManager.gpu.meta': '{{devices}} 张卡 · {{processes}} 个进程',
'systemManager.gpu.devices': '设备',
'systemManager.gpu.processes': '计算进程',
'systemManager.gpu.noProcesses': '当前没有计算进程。',
'systemManager.gpu.vendor.nvidia': 'NVIDIA',
'systemManager.gpu.vendor.ascend': '昇腾',
'systemManager.gpu.util': '利用率',
'systemManager.gpu.memory': '显存',
'systemManager.gpu.hbm': 'HBM',
'systemManager.gpu.temperature': '温度',
'systemManager.gpu.power': '功耗',
'systemManager.gpu.fan': '风扇 {{value}}%',
'systemManager.gpu.driver': '驱动 {{version}}',
'systemManager.inspect.status': '状态',
'systemManager.inspect.image': '镜像',
'systemManager.inspect.created': '创建时间',
+7 -11
View File
@@ -52,7 +52,6 @@ export const zhCNTerminalMessages: Messages = {
'history.action.paste': '粘贴到终端',
'history.action.run': '在终端执行',
'history.action.saveAsSnippet': '保存为代码片段',
'history.action.delete': '从历史记录中删除',
// SFTP File Opener
'sftp.context.copyPath': '复制文件路径',
'sftp.context.openWith': '打开方式...',
@@ -93,6 +92,13 @@ export const zhCNTerminalMessages: Messages = {
'settings.tab.sftpFileAssociations': 'SFTP',
'settings.sftp.transferConcurrency': '传输并发数',
'settings.sftp.transferConcurrency.desc': '同一目标同时传输的文件数量上限,包括文件夹内文件和单独多选的文件。较高的值可能提高速度,但可能导致某些服务器过载。',
'settings.sftp.transferPoolIdleTtl': '传输连接保活时间',
'settings.sftp.transferPoolIdleTtl.desc': '专用传输 SFTP 连接空闲多久后关闭。时间更长可减少下次上传/下载的冷启动等待;进行中的传输不会被关闭。',
'settings.sftp.transferPoolIdleTtl.1m': '1 分钟',
'settings.sftp.transferPoolIdleTtl.5m': '5 分钟',
'settings.sftp.transferPoolIdleTtl.15m': '15 分钟',
'settings.sftp.transferPoolIdleTtl.30m': '30 分钟',
'settings.sftp.transferPoolIdleTtl.never': '直到退出应用',
'settings.ssh.transportIdleTtl': 'SSH 连接保活时间',
'settings.ssh.transportIdleTtl.desc': '最后一个终端、SFTP 浏览、批量传输或端口转发释放后,共享 SSH 连接再保留多久。保留期间再次打开同主机可跳过重新认证(含 MFA);进行中的会话与传输不会被关闭。',
'settings.ssh.transportIdleTtl.1m': '1 分钟',
@@ -199,9 +205,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': '选择主题',
@@ -307,9 +310,6 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.behavior.bracketedPaste': '括号粘贴模式',
'settings.terminal.behavior.bracketedPaste.desc':
'粘贴文本时使用转义序列包裹,以便终端区分粘贴和键入。如果出现 ^[[200~ 字样请关闭此选项。',
'settings.terminal.behavior.autoUploadClipboardImage': '粘贴时自动上传剪贴板图片',
'settings.terminal.behavior.autoUploadClipboardImage.desc':
'当剪贴板包含图片时,在远程会话中粘贴会通过 SFTP 将图片上传到远端当前目录的 .netcatty-paste-images/ 文件夹,并在终端输入远端路径,而不是粘贴文本。',
'settings.terminal.behavior.shiftEnterNewline': 'Shift+Enter 发送文本',
'settings.terminal.behavior.shiftEnterNewline.desc':
'在终端中按 Shift+Enter 时发送配置的文本,而不是普通回车。',
@@ -410,10 +410,6 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.section.serverStats': '服务器状态(Linux)',
'settings.terminal.hostInfoBar.show': '显示主机信息栏',
'settings.terminal.hostInfoBar.show.desc': '在终端上方显示主机地址和服务器信息。隐藏后仍可使用操作按钮。',
'settings.terminal.hostInfoBar.titleMode': '主机信息标题',
'settings.terminal.hostInfoBar.titleMode.desc': '选择信息栏显示连接地址(user@host)还是主机名称(Vault 标签)。',
'settings.terminal.hostInfoBar.titleMode.address': '连接地址',
'settings.terminal.hostInfoBar.titleMode.label': '主机名称',
'settings.terminal.serverStats.show': '显示服务器状态',
'settings.terminal.serverStats.show.desc': '在终端状态栏显示 CPU、内存和磁盘使用情况(仅限 Linux 服务器)。',
'settings.terminal.serverStats.refreshInterval': '刷新间隔',
+22 -12
View File
@@ -331,8 +331,29 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.plugin.useSsh': '改用 SSH',
// Host form (legacy modal)
// Host form (legacy keys still referenced by TerminalAuthDialog)
'hostForm.title.edit': '编辑主机',
'hostForm.title.new': '新建主机',
'hostForm.desc.edit': '更新该主机的连接信息',
'hostForm.desc.new': '创建一个新的 SSH 主机条目',
'hostForm.field.label': '名称',
'hostForm.placeholder.label': 'My Production Server',
'hostForm.field.hostname': 'Hostname / IP',
'hostForm.placeholder.hostname': '192.168.1.1',
'hostForm.field.port': '端口',
'hostForm.field.username': '用户名',
'hostForm.field.osType': '操作系统类型',
'hostForm.placeholder.selectOs': '选择操作系统',
'hostForm.field.group': '分组',
'hostForm.placeholder.group': '例如:AWS、DigitalOcean',
'hostForm.field.tags': '标签',
'hostForm.placeholder.addTag': '添加标签…',
'hostForm.auth.method': '认证方式',
'hostForm.auth.password': '密码',
'hostForm.auth.sshKey': 'SSH密钥',
'hostForm.auth.selectKey': '选择 SSH密钥',
'hostForm.auth.noKeys': '暂无密钥',
'hostForm.auth.noKeysHint': '钥匙串中未找到 SSH密钥,请先创建一个。',
'hostForm.saveHost': '保存主机',
// Connection logs
'logs.table.date': '日期',
@@ -406,10 +427,6 @@ export const zhCNVaultMessages: Messages = {
'terminal.statusbar.copyHostname.tooltip': '复制主机地址({hostname})',
'terminal.statusbar.copyHostname.toast': '已复制主机地址:{hostname}',
'terminal.statusbar.copyHostname.error': '复制主机地址失败',
'terminal.statusbar.disconnect.label': '断开连接',
'terminal.statusbar.disconnect.tooltip': '断开当前会话,但保留标签页',
'terminal.statusbar.reconnect.label': '重新连接',
'terminal.statusbar.reconnect.tooltip': '重新连接当前会话',
'terminal.serverStats.cpu': 'CPU 使用率',
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
'terminal.serverStats.memory': '内存使用',
@@ -642,13 +659,6 @@ export const zhCNVaultMessages: Messages = {
'cloudSync.connect.webdav.failedTitle': 'WebDAV 连接失败',
'cloudSync.connect.s3.success': 'S3 已连接',
'cloudSync.connect.s3.failedTitle': 'S3 连接失败',
'cloudSync.connect.plugin.success': '插件同步提供方已连接',
'cloudSync.connect.plugin.failedTitle': '插件同步连接失败',
'cloudSync.pluginConfig.title': '配置 {name}',
'cloudSync.pluginConfig.desc': '请输入该插件同步提供方所需的 JSON 配置。',
'cloudSync.pluginConfig.label': '提供方配置(JSON)',
'cloudSync.pluginConfig.invalidJson': '配置必须是有效的 JSON。',
'cloudSync.pluginConfig.schemaInvalid': '配置不符合提供方声明的结构。',
'cloudSync.lastSync.never': '从未',
'cloudSync.lastSync.justNow': '刚刚',
'cloudSync.lastSync.minutesAgo': '{minutes} 分钟前',
-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': '沒有歷史工作階段',
+1 -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 除錯日誌',
@@ -732,6 +729,7 @@ export const zhTWCoreMessages: Messages = {
'knownHosts.empty.title': '暫無已知主機',
'knownHosts.empty.desc':
'Known Hosts 是你之前連線過的 SSH server。匯入系統的 known_hosts 檔案以開始。',
'knownHosts.results.showingLimited': '顯示 {shown}/{total} 個主機。使用搜索查詢特定主機。',
'knownHosts.toast.scanUnavailable': '目前平台無法掃描系統 known_hosts。',
'knownHosts.toast.scanNoFile': '未找到系統 known_hosts 檔案。',
'knownHosts.toast.scanNoEntries': 'known_hosts 中沒有可用條目。',
@@ -862,7 +860,6 @@ export const zhTWCoreMessages: Messages = {
'sftp.transfers.calculatingTotal': '正在統計總大小...',
'sftp.transfers.filesCount': '{count} 個檔案',
'sftp.transfers.filesProgress': '{current}/{total} 個檔案',
'sftp.transfers.filesDiscoveredProgress': '已完成 {completed} · 已發現 {discovered}',
'sftp.transfers.expandChildren': '展開檔案',
'sftp.transfers.collapseChildren': '收起檔案',
'sftp.transfers.expandChildList': '展開詳情',
@@ -10,7 +10,6 @@ export const zhTwSystemManagerMessages: Messages = {
'systemManager.tabs.processes': '程序',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.tabs.gpu': 'GPU',
'systemManager.popup.loading': '正在開啟終端…',
'systemManager.popup.startupFailed': '啟動指令未順利完成。請確認目標仍可使用後再試一次。',
@@ -24,7 +23,6 @@ export const zhTwSystemManagerMessages: Messages = {
'systemManager.errors.loadDockerStats': '載入容器效能資料失敗',
'systemManager.errors.loadDockerImages': '載入映像列表失敗',
'systemManager.errors.loadOverview': '載入系統概覽失敗',
'systemManager.errors.loadGpu': '載入 GPU / NPU 狀態失敗',
'systemManager.errors.sshChannelUnavailable': '伺服器拒絕開啟新的執行通道。請稍後重試,或重新連線目前主機。',
'systemManager.overview.empty': '暫無系統概覽資料。',
@@ -189,23 +187,6 @@ export const zhTwSystemManagerMessages: Messages = {
'systemManager.docker.start': '啟動',
'systemManager.docker.stop': '停止',
'systemManager.gpu.unavailable': '此主機未偵測到 NVIDIA GPU 或昇騰 NPU 工具。',
'systemManager.gpu.loading': '正在載入加速卡狀態…',
'systemManager.gpu.empty': '已偵測到加速卡工具,但未回傳裝置資訊。',
'systemManager.gpu.meta': '{{devices}} 張卡 · {{processes}} 個程序',
'systemManager.gpu.devices': '裝置',
'systemManager.gpu.processes': '計算程序',
'systemManager.gpu.noProcesses': '目前沒有計算程序。',
'systemManager.gpu.vendor.nvidia': 'NVIDIA',
'systemManager.gpu.vendor.ascend': '昇騰',
'systemManager.gpu.util': '使用率',
'systemManager.gpu.memory': '顯示記憶體',
'systemManager.gpu.hbm': 'HBM',
'systemManager.gpu.temperature': '溫度',
'systemManager.gpu.power': '功耗',
'systemManager.gpu.fan': '風扇 {{value}}%',
'systemManager.gpu.driver': '驅動 {{version}}',
'systemManager.inspect.status': '狀態',
'systemManager.inspect.image': '映像',
'systemManager.inspect.created': '建立時間',
+7 -11
View File
@@ -52,7 +52,6 @@ export const zhTWTerminalMessages: Messages = {
'history.action.paste': '貼上到終端',
'history.action.run': '在終端執行',
'history.action.saveAsSnippet': '儲存為程式碼片段',
'history.action.delete': '從歷史記錄中刪除',
// SFTP File Opener
'sftp.context.copyPath': '複製檔案路徑',
'sftp.context.openWith': '開啟方式...',
@@ -93,6 +92,13 @@ export const zhTWTerminalMessages: Messages = {
'settings.tab.sftpFileAssociations': 'SFTP',
'settings.sftp.transferConcurrency': '傳輸併發數',
'settings.sftp.transferConcurrency.desc': '同一目標同時傳輸的檔案數量上限,包括資料夾內檔案和單獨多選的檔案。較高的值可能提高速度,但可能導致某些伺服器過載。',
'settings.sftp.transferPoolIdleTtl': '傳輸連線保活時間',
'settings.sftp.transferPoolIdleTtl.desc': '專用傳輸 SFTP 連線閒置多久後關閉。時間更長可減少下次上傳/下載的冷啟動等待;進行中的傳輸不會被關閉。',
'settings.sftp.transferPoolIdleTtl.1m': '1 分鐘',
'settings.sftp.transferPoolIdleTtl.5m': '5 分鐘',
'settings.sftp.transferPoolIdleTtl.15m': '15 分鐘',
'settings.sftp.transferPoolIdleTtl.30m': '30 分鐘',
'settings.sftp.transferPoolIdleTtl.never': '直到結束應用',
'settings.ssh.transportIdleTtl': 'SSH 連線保活時間',
'settings.ssh.transportIdleTtl.desc': '最後一個終端、SFTP 瀏覽、批次傳輸或連接埠轉發釋放後,共享 SSH 連線再保留多久。保留期間再次開啟同主機可略過重新驗證(含 MFA);進行中的工作階段與傳輸不會被關閉。',
'settings.ssh.transportIdleTtl.1m': '1 分鐘',
@@ -199,9 +205,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': '選擇主題',
@@ -307,9 +310,6 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.behavior.bracketedPaste': '括號貼上模式',
'settings.terminal.behavior.bracketedPaste.desc':
'貼上文字時使用轉義序列包裹,以便終端區分貼上和鍵入。如果出現 ^[[200~ 字樣請關閉此選項。',
'settings.terminal.behavior.autoUploadClipboardImage': '貼上時自動上傳剪貼簿圖片',
'settings.terminal.behavior.autoUploadClipboardImage.desc':
'當剪貼簿包含圖片時,在遠端工作階段中貼上會透過 SFTP 將圖片上傳到遠端目前目錄的 .netcatty-paste-images/ 資料夾,並在終端輸入遠端路徑,而不是貼上文字。',
'settings.terminal.behavior.shiftEnterNewline': 'Shift+Enter 傳送文字',
'settings.terminal.behavior.shiftEnterNewline.desc':
'在終端中按 Shift+Enter 時傳送設定的文字,而不是普通 Enter。',
@@ -410,10 +410,6 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.section.serverStats': '伺服器狀態(Linux)',
'settings.terminal.hostInfoBar.show': '顯示主機資訊列',
'settings.terminal.hostInfoBar.show.desc': '在終端上方顯示主機地址和伺服器資訊。隱藏後仍可使用操作按鈕。',
'settings.terminal.hostInfoBar.titleMode': '主機資訊標題',
'settings.terminal.hostInfoBar.titleMode.desc': '選擇資訊列顯示連線地址(user@host)還是主機名稱(Vault 標籤)。',
'settings.terminal.hostInfoBar.titleMode.address': '連線地址',
'settings.terminal.hostInfoBar.titleMode.label': '主機名稱',
'settings.terminal.serverStats.show': '顯示伺服器狀態',
'settings.terminal.serverStats.show.desc': '在終端狀態列顯示 CPU、記憶體和磁碟使用情況(僅限 Linux 伺服器)。',
'settings.terminal.serverStats.refreshInterval': '重新整理間隔',
+22 -12
View File
@@ -331,8 +331,29 @@ export const zhTWVaultMessages: Messages = {
'hostDetails.plugin.useSsh': '改用 SSH',
// Host form (legacy modal)
// Host form (legacy keys still referenced by TerminalAuthDialog)
'hostForm.title.edit': '編輯主機',
'hostForm.title.new': '新建主機',
'hostForm.desc.edit': '更新該主機的連線資訊',
'hostForm.desc.new': '建立一個新的 SSH 主機條目',
'hostForm.field.label': '名稱',
'hostForm.placeholder.label': 'My Production Server',
'hostForm.field.hostname': 'Hostname / IP',
'hostForm.placeholder.hostname': '192.168.1.1',
'hostForm.field.port': '埠',
'hostForm.field.username': '使用者名稱',
'hostForm.field.osType': '作業系統型別',
'hostForm.placeholder.selectOs': '選擇作業系統',
'hostForm.field.group': '分組',
'hostForm.placeholder.group': '例如:AWS、DigitalOcean',
'hostForm.field.tags': '標籤',
'hostForm.placeholder.addTag': '新增標籤…',
'hostForm.auth.method': '認證方式',
'hostForm.auth.password': '密碼',
'hostForm.auth.sshKey': 'SSH金鑰',
'hostForm.auth.selectKey': '選擇 SSH金鑰',
'hostForm.auth.noKeys': '暫無金鑰',
'hostForm.auth.noKeysHint': '鑰匙串中未找到 SSH金鑰,請先建立一個。',
'hostForm.saveHost': '儲存主機',
// Connection logs
'logs.table.date': '日期',
@@ -406,10 +427,6 @@ export const zhTWVaultMessages: Messages = {
'terminal.statusbar.copyHostname.tooltip': '複製主機地址({hostname})',
'terminal.statusbar.copyHostname.toast': '已複製主機地址:{hostname}',
'terminal.statusbar.copyHostname.error': '複製主機地址失敗',
'terminal.statusbar.disconnect.label': '中斷連線',
'terminal.statusbar.disconnect.tooltip': '中斷目前工作階段,但保留標籤頁',
'terminal.statusbar.reconnect.label': '重新連線',
'terminal.statusbar.reconnect.tooltip': '重新連線目前工作階段',
'terminal.serverStats.cpu': 'CPU 使用率',
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
'terminal.serverStats.memory': '記憶體使用',
@@ -642,13 +659,6 @@ export const zhTWVaultMessages: Messages = {
'cloudSync.connect.webdav.failedTitle': 'WebDAV 連線失敗',
'cloudSync.connect.s3.success': 'S3 已連線',
'cloudSync.connect.s3.failedTitle': 'S3 連線失敗',
'cloudSync.connect.plugin.success': '外掛同步提供者已連線',
'cloudSync.connect.plugin.failedTitle': '外掛同步連線失敗',
'cloudSync.pluginConfig.title': '設定 {name}',
'cloudSync.pluginConfig.desc': '請輸入此外掛同步提供者所需的 JSON 設定。',
'cloudSync.pluginConfig.label': '提供者設定(JSON)',
'cloudSync.pluginConfig.invalidJson': '設定必須是有效的 JSON。',
'cloudSync.pluginConfig.schemaInvalid': '設定不符合提供者宣告的結構。',
'cloudSync.lastSync.never': '從未',
'cloudSync.lastSync.justNow': '剛剛',
'cloudSync.lastSync.minutesAgo': '{minutes} 分鐘前',
+2 -2
View File
@@ -400,7 +400,7 @@ function writeApplyInProgressSentinel(record: VaultApplyInProgressRecord): void
type ProtectedApplyCallback = () => void | Promise<void>;
export type ApplyProtectedSyncPayloadOptions = {
buildPreApplyPayload: () => SyncPayload | Promise<SyncPayload>;
buildPreApplyPayload: () => SyncPayload;
translateProtectiveBackupFailure: (message: string) => string;
} & (
| { applyPayload: ProtectedApplyCallback; prepareApply?: never }
@@ -415,7 +415,7 @@ export function applyProtectedSyncPayload(
const applyPayload = options.prepareApply
? await options.prepareApply()
: options.applyPayload;
const pre = await buildPreApplyPayload();
const pre = buildPreApplyPayload();
let protectiveBackupId: string | null = null;
try {
const backup = await createRequiredProtectiveLocalVaultBackup(pre);
@@ -1,137 +0,0 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { storePluginSyncSecretsThenConnect } from './pluginSyncConnectWithSecrets.ts';
describe('storePluginSyncSecretsThenConnect', () => {
it('deletes just-created secret keys when connect rejects', async () => {
const putCalls: Array<{ key: string; value: string }> = [];
const deletedKeys: string[][] = [];
let connectCalls = 0;
await assert.rejects(
() => storePluginSyncSecretsThenConnect({
providerId: 'plugin:sync.example',
secrets: [
{ secretKey: 'sync-credential', value: 'bad-password' },
{ secretKey: 'sync-credential:token', value: 'bad-token' },
],
putSecret: async ({ key, value }) => {
putCalls.push({ key, value });
return { kind: 'secret', id: `ref-${key}`, key };
},
deleteSecrets: async ({ keys }) => {
deletedKeys.push([...keys]);
return { deleted: keys.length };
},
connect: async () => {
connectCalls += 1;
throw new Error('auth failed');
},
}),
/auth failed/,
);
assert.equal(connectCalls, 1);
assert.deepEqual(putCalls, [
{ key: 'sync-credential', value: 'bad-password' },
{ key: 'sync-credential:token', value: 'bad-token' },
]);
assert.deepEqual(deletedKeys, [['sync-credential', 'sync-credential:token']]);
});
it('does not delete secrets when connect succeeds', async () => {
const deletedKeys: string[][] = [];
await storePluginSyncSecretsThenConnect({
providerId: 'plugin:sync.example',
secrets: [{ secretKey: 'sync-credential', value: 'ok' }],
putSecret: async ({ key }) => ({ kind: 'secret', id: 'ref-1', key }),
deleteSecrets: async ({ keys }) => {
deletedKeys.push([...keys]);
return { deleted: keys.length };
},
connect: async () => {},
});
assert.deepEqual(deletedKeys, []);
});
it('does not delete when reusing an existing credential without new secrets', async () => {
const deletedKeys: string[][] = [];
const existing = { kind: 'secret' as const, id: 'existing-ref', key: 'sync-credential' };
await assert.rejects(
() => storePluginSyncSecretsThenConnect({
providerId: 'plugin:sync.example',
secrets: [],
existingCredential: existing,
putSecret: async () => {
throw new Error('put should not run');
},
deleteSecrets: async ({ keys }) => {
deletedKeys.push([...keys]);
return { deleted: keys.length };
},
connect: async () => {
throw new Error('network failed');
},
}),
/network failed/,
);
assert.deepEqual(deletedKeys, []);
});
it('deletes secrets that were put before a later put fails', async () => {
const deletedKeys: string[][] = [];
await assert.rejects(
() => storePluginSyncSecretsThenConnect({
providerId: 'plugin:sync.example',
secrets: [
{ secretKey: 'sync-credential', value: 'first' },
{ secretKey: 'sync-credential:token', value: 'second' },
],
putSecret: async ({ key }) => {
if (key === 'sync-credential:token') throw new Error('put failed');
return { kind: 'secret', id: 'ref-1', key };
},
deleteSecrets: async ({ keys }) => {
deletedKeys.push([...keys]);
return { deleted: keys.length };
},
connect: async () => {
throw new Error('connect should not run');
},
}),
/put failed/,
);
assert.deepEqual(deletedKeys, [['sync-credential']]);
});
it('keeps overwritten secrets when reconnect connect rejects', async () => {
const deletedKeys: string[][] = [];
const restoredKeys: string[][] = [];
await assert.rejects(
() => storePluginSyncSecretsThenConnect({
providerId: 'plugin:sync.example',
secrets: [{ secretKey: 'sync-credential', value: 'bad-password' }],
putSecret: async ({ key }) => ({
kind: 'secret',
id: 'ref-overwritten',
key,
created: false,
}),
deleteSecrets: async ({ keys }) => {
deletedKeys.push([...keys]);
return { deleted: keys.length };
},
restoreSecrets: async ({ keys }) => {
restoredKeys.push([...keys]);
return { restored: keys.length };
},
connect: async () => {
throw new Error('auth failed');
},
}),
/auth failed/,
);
assert.deepEqual(deletedKeys, []);
assert.deepEqual(restoredKeys, [['sync-credential']]);
});
});
-107
View File
@@ -1,107 +0,0 @@
/**
* Persist plugin sync secrets then connect; roll back just-created secrets if
* connect (or a later put) fails so bad passwords are not left in OS storage.
* Overwritten keys are restored from the host-side overwrite stash so a
* rejected reconnect does not leave a broken SecretRef / rejected credential.
*/
export type PluginSyncSecretRef = { kind: 'secret'; id: string; key: string };
export interface PluginSyncConnectSecretInput {
secretKey: string;
value: string;
}
export interface PluginSyncPutSecretResult extends PluginSyncSecretRef {
/** False when the durable (plugin,key) row already existed and was overwritten. */
created?: boolean;
}
export interface StorePluginSyncSecretsThenConnectParams {
providerId: string;
secrets: readonly PluginSyncConnectSecretInput[];
/** Reused when `secrets` is empty (edit non-secret fields / reconnect). */
existingCredential?: PluginSyncSecretRef;
putSecret: (params: {
providerId: string;
key: string;
value: string;
}) => Promise<PluginSyncPutSecretResult>;
deleteSecrets: (params: {
providerId: string;
keys: string[];
}) => Promise<unknown>;
/**
* Restore host-stashed plaintext for overwritten keys (`discard: false`),
* or drop the stash after a successful connect (`discard: true`).
*/
restoreSecrets?: (params: {
providerId: string;
keys: string[];
discard?: boolean;
}) => Promise<unknown>;
connect: (credential: PluginSyncSecretRef | undefined) => Promise<void>;
}
export async function storePluginSyncSecretsThenConnect(
params: StorePluginSyncSecretsThenConnectParams,
): Promise<void> {
const createdKeys: string[] = [];
const overwrittenKeys: string[] = [];
let credential: PluginSyncSecretRef | undefined = params.existingCredential;
try {
if (params.secrets.length > 0) {
credential = undefined;
for (const secret of params.secrets) {
const ref = await params.putSecret({
providerId: params.providerId,
key: secret.secretKey,
value: secret.value,
});
if (ref.created === false) {
overwrittenKeys.push(secret.secretKey);
} else {
createdKeys.push(secret.secretKey);
}
// SyncConnectPayload.credential carries the primary (first) secret;
// additional secrets remain addressable via secrets.get(key).
if (!credential) credential = ref;
}
}
await params.connect(credential);
if (overwrittenKeys.length > 0 && params.restoreSecrets) {
try {
await params.restoreSecrets({
providerId: params.providerId,
keys: [...overwrittenKeys],
discard: true,
});
} catch {
/* best-effort stash cleanup */
}
}
} catch (error) {
if (createdKeys.length > 0) {
try {
await params.deleteSecrets({
providerId: params.providerId,
keys: [...createdKeys],
});
} catch {
/* best-effort; surface the original connect/put error */
}
}
if (overwrittenKeys.length > 0 && params.restoreSecrets) {
try {
await params.restoreSecrets({
providerId: params.providerId,
keys: [...overwrittenKeys],
});
} catch {
/* best-effort; surface the original connect/put error */
}
}
throw error;
}
}
-209
View File
@@ -1,209 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
type LocalStorageMock = {
clear(): void;
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
};
function installLocalStorage(): LocalStorageMock {
const store = new Map<string, string>();
const localStorage: LocalStorageMock = {
clear() { store.clear(); },
getItem(key) { return store.has(key) ? store.get(key)! : null; },
setItem(key, value) { store.set(key, String(value)); },
removeItem(key) { store.delete(key); },
};
Object.defineProperty(globalThis, 'localStorage', {
value: localStorage,
configurable: true,
});
return localStorage;
}
const localStorage = installLocalStorage();
const { SYNC_STORAGE_KEYS } = await import('../domain/sync.ts');
const { localStorageAdapter } = await import('../infrastructure/persistence/localStorageAdapter.ts');
const {
collectPluginSyncSidecarsFromHost,
commitPluginSidecarsLastKnown,
isPluginSidecarHostReady,
} = await import('./pluginSyncSidecarBridge.ts');
const { hasMeaningfulCloudSyncData } = await import('./syncPayload.ts');
test.beforeEach(() => {
localStorage.clear();
delete (globalThis as { window?: unknown }).window;
});
test('isPluginSidecarHostReady follows pluginHostReady probe when present', () => {
(globalThis as { window: unknown }).window = {
netcatty: {
pluginHostReady: () => true,
async collectPluginSyncSidecars() {
return { version: 1, entries: [] };
},
},
};
assert.equal(isPluginSidecarHostReady(), true);
(globalThis as { window: unknown }).window = {
netcatty: {
pluginHostReady: () => false,
async collectPluginSyncSidecars() {
return null;
},
},
};
assert.equal(isPluginSidecarHostReady(), false);
});
test('collect defers empty last-known until commit (empty-vault guard ignores last-known alone)', async () => {
localStorageAdapter.write(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN, {
version: 1,
entries: [{
pluginId: 'com.example.p',
kind: 'settings',
key: 'k',
value: 1,
updatedAt: 1,
}],
});
(globalThis as { window: unknown }).window = {
netcatty: {
async collectPluginSyncSidecars() {
return { version: 1, entries: [] };
},
},
};
const collected = await collectPluginSyncSidecarsFromHost();
assert.deepEqual(collected, { version: 1, entries: [] });
// Last-known still holds prior entries until commit, but that alone must not
// bypass the empty-vault upload guard.
const lastKnown = localStorageAdapter.read<{ entries: unknown[] }>(
SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN,
);
assert.equal(lastKnown?.entries?.length, 1);
assert.equal(
hasMeaningfulCloudSyncData({
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
pluginSidecars: { version: 1, entries: [] },
}),
false,
);
commitPluginSidecarsLastKnown({ version: 1, entries: [] });
const committed = localStorageAdapter.read<{ entries: unknown[] }>(
SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN,
);
assert.deepEqual(committed?.entries, []);
});
test('liveOnly collect returns null instead of last-known when host is gated off', async () => {
localStorageAdapter.write(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN, {
version: 1,
entries: [{
pluginId: 'com.example.p',
kind: 'settings',
key: 'k',
value: 1,
updatedAt: 1,
}],
});
(globalThis as { window: unknown }).window = {
netcatty: {
async collectPluginSyncSidecars() {
return null;
},
},
};
assert.equal(await collectPluginSyncSidecarsFromHost({ liveOnly: true }), null);
const fallback = await collectPluginSyncSidecarsFromHost();
assert.equal(fallback?.entries?.length, 1);
});
test('liveOnly collect omits pending remote when replay cannot apply', async () => {
localStorageAdapter.write(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_PENDING_REMOTE, {
version: 1,
entries: [{
pluginId: 'com.example.remote',
kind: 'settings',
key: 'k',
value: 'remote',
updatedAt: 2,
}],
});
(globalThis as { window: unknown }).window = {
netcatty: {
async collectPluginSyncSidecars() {
return { version: 1, entries: [] };
},
// no applyPluginSyncSidecars → pending cannot replay
},
};
assert.equal(await collectPluginSyncSidecarsFromHost({ liveOnly: true }), null);
const uploadPath = await collectPluginSyncSidecarsFromHost();
assert.equal(uploadPath?.entries?.[0]?.value, 'remote');
});
test('commitPluginSidecarsAfterSuccessfulSync prefers merged payload sidecars', async () => {
const { commitPluginSidecarsAfterSuccessfulSync } = await import('./pluginSyncSidecarBridge.ts');
localStorageAdapter.write(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN, {
version: 1,
entries: [{
pluginId: 'com.example.old',
kind: 'settings',
key: 'k',
value: 'old',
updatedAt: 1,
}],
});
commitPluginSidecarsAfterSuccessfulSync(
{
pluginSidecars: {
version: 1,
entries: [{
pluginId: 'com.example.local',
kind: 'settings',
key: 'k',
value: 'local',
updatedAt: 2,
}],
},
},
[{
success: true,
mergedPayload: {
pluginSidecars: {
version: 1,
entries: [{
pluginId: 'com.example.merged',
kind: 'settings',
key: 'k',
value: 'merged',
updatedAt: 3,
}],
},
},
}],
);
const committed = localStorageAdapter.read<{ entries: Array<{ value: string }> }>(
SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN,
);
assert.equal(committed?.entries?.[0]?.value, 'merged');
});
-343
View File
@@ -1,343 +0,0 @@
/**
* Renderer bridge for plugin encrypted-sync sidecars.
* Collects/applies sidecars through the main-process non-cascade store when
* the plugin host is available. When the host is offline, last-known sidecars
* are retained so cloud uploads cannot wipe previously synced plugin data.
*/
import type { PluginSyncSidecarBundle } from '../domain/pluginSyncSidecar';
import { SYNC_STORAGE_KEYS } from '../domain/sync';
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
/** Ordinary upload fallback when collect cannot reach the host. */
const LAST_KNOWN_SIDECARS_KEY = SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN;
/**
* Remote apply that could not reach the host DB. Distinct from last-known so
* a later collect does not re-apply a stale post-collect snapshot over newer
* local plugin settings.
*/
const PENDING_REMOTE_SIDECARS_KEY = SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_PENDING_REMOTE;
const HOST_UNAVAILABLE_MARKER = 'PLUGIN_SIDECAR_HOST_UNAVAILABLE';
export class PluginSidecarHostUnavailableError extends Error {
readonly code = HOST_UNAVAILABLE_MARKER;
constructor(message = 'Plugin sidecar host is unavailable') {
super(message);
this.name = 'PluginSidecarHostUnavailableError';
}
}
export function isPluginSidecarHostUnavailableError(error: unknown): boolean {
if (error instanceof PluginSidecarHostUnavailableError) return true;
if (!error || typeof error !== 'object') return false;
const maybe = error as { code?: unknown; message?: unknown };
if (maybe.code === HOST_UNAVAILABLE_MARKER) return true;
return typeof maybe.message === 'string'
&& maybe.message.includes('Plugin sidecar host is unavailable');
}
type ElectronSidecarApi = {
collectPluginSyncSidecars?: () => Promise<PluginSyncSidecarBundle | null | undefined>;
applyPluginSyncSidecars?: (
bundle: PluginSyncSidecarBundle | null | undefined,
) => Promise<unknown>;
/** True only when main process wired PluginSyncSidecarService. */
pluginHostReady?: () => boolean;
};
function getSidecarApi(): ElectronSidecarApi | null {
if (typeof window === 'undefined') return null;
// Preload exposes the production bridge as window.netcatty only.
const bridge = (window as Window & {
netcatty?: ElectronSidecarApi;
electron?: ElectronSidecarApi;
}).netcatty
?? (window as Window & { electron?: ElectronSidecarApi }).electron;
return bridge ?? null;
}
/**
* Whether the main-process sidecar service is wired. Version-change backups
* should wait for this (or a grace timeout) before latching, otherwise an
* early empty collect permanently skips plugin settings in the upgrade snapshot.
*/
export function isPluginSidecarHostReady(): boolean {
const api = getSidecarApi();
if (!api) return false;
if (typeof api.pluginHostReady === 'function') {
try {
return api.pluginHostReady() === true;
} catch {
return false;
}
}
// Older / test bridges without the probe: collect presence implies ready.
return typeof api.collectPluginSyncSidecars === 'function';
}
function readBundle(key: string): PluginSyncSidecarBundle | null {
const raw = localStorageAdapter.read<PluginSyncSidecarBundle>(key);
if (!raw || !Array.isArray(raw.entries)) return null;
return { version: 1, entries: raw.entries };
}
function writeBundle(key: string, bundle: PluginSyncSidecarBundle | null | undefined): void {
if (!bundle || !Array.isArray(bundle.entries)) {
localStorageAdapter.remove(key);
return;
}
const ok = localStorageAdapter.write(key, {
version: 1,
entries: bundle.entries,
});
if (ok === false) {
throw new Error(
`Failed to persist plugin sidecars (${key}); local storage write was rejected`,
);
}
}
function readLastKnownSidecars(): PluginSyncSidecarBundle | null {
return readBundle(LAST_KNOWN_SIDECARS_KEY);
}
function writeLastKnownSidecars(bundle: PluginSyncSidecarBundle | null | undefined): void {
writeBundle(LAST_KNOWN_SIDECARS_KEY, bundle);
}
/** null means no pending remote apply. Empty entries is a valid pending reset. */
function readPendingRemoteSidecars(): PluginSyncSidecarBundle | null {
const raw = localStorageAdapter.read<PluginSyncSidecarBundle | null>(PENDING_REMOTE_SIDECARS_KEY);
if (raw == null) return null;
if (!Array.isArray(raw.entries)) return null;
return { version: 1, entries: raw.entries };
}
function writePendingRemoteSidecars(bundle: PluginSyncSidecarBundle): void {
const ok = localStorageAdapter.write(PENDING_REMOTE_SIDECARS_KEY, {
version: 1,
entries: bundle.entries,
});
if (ok === false) {
// Operational failure — must not look like host-unavailable (which apply
// swallows). Sync must abort so the remote bundle is not lost.
throw new Error(
'Failed to queue pending remote plugin sidecars; local storage write was rejected',
);
}
}
function clearPendingRemoteSidecars(): void {
localStorageAdapter.remove(PENDING_REMOTE_SIDECARS_KEY);
}
function isAuthoritativeBundle(
value: unknown,
): value is PluginSyncSidecarBundle {
return Boolean(
value
&& typeof value === 'object'
&& Array.isArray((value as PluginSyncSidecarBundle).entries),
);
}
function isSuccessfulApplyResult(result: unknown): boolean {
if (result == null) return false;
if (
typeof result === 'object'
&& result !== null
&& 'applied' in result
&& (result as { applied?: unknown }).applied === false
) {
return false;
}
return true;
}
/**
* Returns:
* - a real bundle (possibly empty entries) when the host collected successfully
* - last-known bundle when the host is unavailable (protect remote from wipe)
* Throws on operational host failure (DB/runtime error) so sync aborts.
*
* Pending remote applies (host was offline during download) are replayed into
* the DB before collection. Ordinary last-known collect snapshots are never
* re-applied, so local settings edits made after the last collect are kept.
*/
export async function collectPluginSyncSidecarsFromHost(options?: {
/**
* When true, never fall back to last-known cache. Returns null if the host is
* gated off / non-authoritative so callers can omit `pluginSidecars` instead
* of applying a stale cache snapshot (e.g. convergent conflict materialize).
*/
liveOnly?: boolean;
} = {}): Promise<PluginSyncSidecarBundle | null> {
const liveOnly = options.liveOnly === true;
const api = getSidecarApi();
if (typeof api?.collectPluginSyncSidecars !== 'function') {
return liveOnly ? null : readLastKnownSidecars();
}
const pendingRemote = readPendingRemoteSidecars();
if (pendingRemote && typeof api.applyPluginSyncSidecars === 'function') {
try {
const replayResult = await api.applyPluginSyncSidecars(pendingRemote);
if (!isSuccessfulApplyResult(replayResult)) {
// Upload path: keep returning pending so we do not push stale local
// over an authoritative remote still waiting to apply.
// liveOnly (conflict materialize): omit instead of attaching the queue.
return liveOnly ? null : pendingRemote;
}
clearPendingRemoteSidecars();
} catch (error) {
// Operational failure: keep pending and surface so upload aborts.
// Host-unavailable during replay: upload keeps pending; liveOnly omits.
if (isPluginSidecarHostUnavailableError(error)) {
return liveOnly ? null : pendingRemote;
}
throw error;
}
} else if (pendingRemote && typeof api.applyPluginSyncSidecars !== 'function') {
return liveOnly ? null : pendingRemote;
}
const bundle = await api.collectPluginSyncSidecars();
// Passive/null means the plugin host is gated off or manager resolution failed.
// Do not treat that as an authoritative empty bundle (would wipe last-known).
if (bundle == null || !isAuthoritativeBundle(bundle)) {
return liveOnly ? null : readLastKnownSidecars();
}
const normalized: PluginSyncSidecarBundle = {
version: 1,
entries: bundle.entries,
};
// Defer overwriting a non-empty last-known with an authoritative empty collect.
// useAutoSync runs hasMeaningfulCloudSyncData() after buildPayload/collect; if
// we erased last-known here, a deliberate plugin-only reset (entries → [])
// would look like an empty vault and be blocked. Commit the empty cache after
// a successful upload via commitPluginSidecarsLastKnown().
const previous = readLastKnownSidecars();
const previousHadEntries = Array.isArray(previous?.entries) && previous.entries.length > 0;
if (!(previousHadEntries && normalized.entries.length === 0)) {
writeLastKnownSidecars(normalized);
}
return normalized;
}
/**
* Persist the sidecar bundle that was just successfully synced (or applied).
* Clears a deferred non-empty last-known after an empty reset upload.
*/
export function commitPluginSidecarsLastKnown(
bundle: PluginSyncSidecarBundle | null | undefined,
): void {
if (!bundle || !Array.isArray(bundle.entries)) {
writeLastKnownSidecars({ version: 1, entries: [] });
return;
}
writeLastKnownSidecars({ version: 1, entries: bundle.entries });
}
/**
* After a successful cloud upload/download round-trip, finalize the deferred
* last-known sidecar cache. Prefer merged/downloaded payload sidecars when
* present so we do not overwrite a remote apply with the pre-sync local collect.
*/
export function commitPluginSidecarsAfterSuccessfulSync(
payload: { pluginSidecars?: PluginSyncSidecarBundle | null },
results: Iterable<{
success?: boolean;
mergedPayload?: { pluginSidecars?: PluginSyncSidecarBundle | null } | null;
}>,
): void {
const resultList = Array.from(results);
if (!resultList.some((result) => result.success === true)) return;
let commitSidecars: PluginSyncSidecarBundle | undefined;
if (Object.prototype.hasOwnProperty.call(payload, 'pluginSidecars')) {
commitSidecars = {
version: 1,
entries: Array.isArray(payload.pluginSidecars?.entries) ? payload.pluginSidecars.entries : [],
};
}
for (const result of resultList) {
if (
result.mergedPayload
&& Object.prototype.hasOwnProperty.call(result.mergedPayload, 'pluginSidecars')
) {
commitSidecars = {
version: 1,
entries: Array.isArray(result.mergedPayload.pluginSidecars?.entries)
? result.mergedPayload.pluginSidecars.entries
: [],
};
break;
}
}
if (commitSidecars) commitPluginSidecarsLastKnown(commitSidecars);
}
export async function applyPluginSyncSidecarsFromHost(
bundle: PluginSyncSidecarBundle | null | undefined,
): Promise<void> {
// Empty bundle is authoritative (cloud deleted all sidecars) — still apply.
const normalized: PluginSyncSidecarBundle = {
version: 1,
entries: Array.isArray(bundle?.entries) ? bundle.entries : [],
};
const api = getSidecarApi();
if (typeof api?.applyPluginSyncSidecars !== 'function') {
// Host offline: queue remote apply for later DB replay, and keep last-known
// for upload protection so cloud is not wiped with empty collect.
writePendingRemoteSidecars(normalized);
writeLastKnownSidecars(normalized);
throw new PluginSidecarHostUnavailableError(
'Plugin sidecar host is unavailable; cannot apply downloaded sidecars',
);
}
const result = await api.applyPluginSyncSidecars(normalized);
// Passive IPC returns null when the manager is unavailable, or
// { applied: false } when the sidecar service was not wired.
if (!isSuccessfulApplyResult(result)) {
writePendingRemoteSidecars(normalized);
writeLastKnownSidecars(normalized);
throw new PluginSidecarHostUnavailableError(
'Plugin sidecar host is unavailable; cannot apply downloaded sidecars',
);
}
clearPendingRemoteSidecars();
// Prefer merged entries returned by apply (includes missing-plugin rows the
// remote omitted) when refreshing last-known without a successful collect.
const appliedMerged: PluginSyncSidecarBundle | null = Array.isArray(
(result as { entries?: unknown }).entries,
)
? { version: 1, entries: (result as { entries: PluginSyncSidecarBundle['entries'] }).entries }
: null;
const fallbackLastKnown = appliedMerged ?? normalized;
// Prefer a fresh host collect so last-known reflects post-apply host state.
if (typeof api.collectPluginSyncSidecars === 'function') {
try {
const collected = await api.collectPluginSyncSidecars();
if (isAuthoritativeBundle(collected)) {
writeLastKnownSidecars({ version: 1, entries: collected.entries });
return;
}
// Apply already committed; collect shape was non-authoritative. Prefer the
// merged apply result over the raw remote so preserved local rows survive.
writeLastKnownSidecars(fallbackLastKnown);
return;
} catch {
// Apply already committed. Do not fall back to the raw remote bundle —
// that would drop missing-plugin rows applyFromSync preserved.
try {
writeLastKnownSidecars(fallbackLastKnown);
} catch {
// last-known write failure is secondary; apply already succeeded.
}
return;
}
}
// No collect API: use merged apply result when available, else remote.
writeLastKnownSidecars(fallbackLastKnown);
}
-85
View File
@@ -1,85 +0,0 @@
import type React from 'react';
import {
STORAGE_KEY_AI_PANEL_DIAGNOSTIC_HIDE,
STORAGE_KEY_AI_PANEL_DIAGNOSTIC_PROFILE,
} from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
export const AI_PANEL_DIAGNOSTIC_HIDE_KEY = STORAGE_KEY_AI_PANEL_DIAGNOSTIC_HIDE;
export const AI_PANEL_DIAGNOSTIC_PROFILE_KEY = STORAGE_KEY_AI_PANEL_DIAGNOSTIC_PROFILE;
export const AI_PANEL_FORCE_HIDE_ALL_CONTENT = false;
export const AI_PANEL_FORCE_HIDE_SHELL = false;
export type AIPanelDiagnosticPart =
| 'all'
| 'attachments'
| 'header'
| 'history'
| 'input'
| 'markdown'
| 'messages'
| 'recent'
| 'toolcalls';
function readLocalStorageValue(key: string): string {
try {
return localStorageAdapter.readString(key) ?? '';
} catch {
return '';
}
}
export function getAIPanelDiagnosticHiddenParts(): ReadonlySet<string> {
if (AI_PANEL_FORCE_HIDE_ALL_CONTENT) {
return new Set(['all']);
}
const raw = readLocalStorageValue(AI_PANEL_DIAGNOSTIC_HIDE_KEY);
return new Set(
raw
.split(',')
.map((part) => part.trim().toLowerCase())
.filter(Boolean),
);
}
export function isAIPanelDiagnosticPartHidden(
part: AIPanelDiagnosticPart,
hiddenParts = getAIPanelDiagnosticHiddenParts(),
): boolean {
return hiddenParts.has('all') || hiddenParts.has(part);
}
export function isAIPanelDiagnosticsProfilingEnabled(): boolean {
const raw = readLocalStorageValue(AI_PANEL_DIAGNOSTIC_PROFILE_KEY).trim().toLowerCase();
return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
}
export function logAIPanelProfiler(
id: string,
phase: 'mount' | 'update' | 'nested-update',
actualDuration: number,
baseDuration: number,
): void {
if (!isAIPanelDiagnosticsProfilingEnabled()) return;
console.info(
`[AI panel profile] ${id} ${phase}: actual=${actualDuration.toFixed(1)}ms base=${baseDuration.toFixed(1)}ms`,
);
}
export function profileAIPanelCalculation<T>(label: string, calculate: () => T): T {
if (!isAIPanelDiagnosticsProfilingEnabled()) return calculate();
const startedAt = performance.now();
try {
return calculate();
} finally {
const elapsed = performance.now() - startedAt;
console.info(`[AI panel profile] ${label}: ${elapsed.toFixed(1)}ms`);
}
}
export function getAIPanelProfilerProps(id: string): Pick<React.ProfilerProps, 'id' | 'onRender'> {
return {
id,
onRender: logAIPanelProfiler,
};
}
@@ -1,31 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getAppearanceChromeSnapshot,
publishAppearanceChromeSnapshot,
subscribeAppearanceChrome,
} from './appearanceChromeStore.ts';
test('appearanceChromeStore notifies subscribers only when accent fields change', () => {
const events: string[] = [];
const unsubscribe = subscribeAppearanceChrome(() => {
const snap = getAppearanceChromeSnapshot();
events.push(`${snap.accentMode}:${snap.customAccent}`);
});
publishAppearanceChromeSnapshot({ accentMode: 'custom', customAccent: '#ff0000' });
assert.equal(events.at(-1), 'custom:#ff0000');
assert.equal(getAppearanceChromeSnapshot().customAccent, '#ff0000');
publishAppearanceChromeSnapshot({ accentMode: 'custom', customAccent: '#ff0000' });
assert.equal(events.length, 1);
publishAppearanceChromeSnapshot({ accentMode: 'custom', customAccent: '#00ff00' });
assert.equal(events.at(-1), 'custom:#00ff00');
publishAppearanceChromeSnapshot({ accentMode: 'theme', customAccent: '#00ff00' });
assert.equal(events.at(-1), 'theme:#00ff00');
unsubscribe();
});
@@ -1,68 +0,0 @@
import { useSyncExternalStore } from 'react';
type Listener = () => void;
export type AppearanceChromeSnapshot = {
accentMode: 'theme' | 'custom';
customAccent: string;
};
const DEFAULT_SNAPSHOT: AppearanceChromeSnapshot = Object.freeze({
accentMode: 'theme',
customAccent: '',
});
/**
* External store for app accent chrome so Terminal leaves can apply custom
* accent without TerminalLayer rebuilding on every color-picker drag tick.
*/
class AppearanceChromeStore {
private snapshot: AppearanceChromeSnapshot = DEFAULT_SNAPSHOT;
private listeners = new Set<Listener>();
getSnapshot = (): AppearanceChromeSnapshot => this.snapshot;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
setSnapshot(next: AppearanceChromeSnapshot): void {
if (
this.snapshot.accentMode === next.accentMode
&& this.snapshot.customAccent === next.customAccent
) {
return;
}
this.snapshot = next;
for (const listener of this.listeners) {
listener();
}
}
}
export const appearanceChromeStore = new AppearanceChromeStore();
export function publishAppearanceChromeSnapshot(
snapshot: AppearanceChromeSnapshot,
): void {
appearanceChromeStore.setSnapshot(snapshot);
}
export function getAppearanceChromeSnapshot(): AppearanceChromeSnapshot {
return appearanceChromeStore.getSnapshot();
}
export function subscribeAppearanceChrome(listener: Listener): () => void {
return appearanceChromeStore.subscribe(listener);
}
export function useAppearanceChromeStore(): AppearanceChromeSnapshot {
return useSyncExternalStore(
subscribeAppearanceChrome,
getAppearanceChromeSnapshot,
getAppearanceChromeSnapshot,
);
}
+1 -46
View File
@@ -27,7 +27,6 @@ const systemLight: AppearanceRenderSnapshot = {
darkUiThemeId: "midnight",
accentMode: "theme",
customAccent: "208 100% 50%",
customAccentVersion: 0,
};
test("an OS color event cannot persist a stale System choice over a newer Dark choice", () => {
@@ -98,7 +97,6 @@ test("a non-theme appearance IPC value wins over stale local storage for that ke
darkUiThemeId: "midnight",
accentMode: "theme",
customAccent: "208 100% 50%",
customAccentVersion: 0,
};
// Storage still lags for every non-theme field (the race the review covers).
const staleStored: StoredAppearanceValues = {
@@ -153,22 +151,6 @@ test("a non-theme appearance IPC value wins over stale local storage for that ke
}, customAccentSelection);
assert.equal(nextCustomAccent.customAccent, "221.2 83.2% 53.3%");
// Stale lower-version accent echoes must not clobber a newer local drag sample.
const afterDrag = {
...current,
customAccent: "199 89% 48%",
customAccentVersion: 2,
};
const staleAccentEcho = resolveAppearanceSyncState(afterDrag, {
...staleStored,
customAccent: "0 84% 60%",
}, {
key: STORAGE_KEY_COLOR,
value: { color: "0 84% 60%", version: 1 },
});
assert.equal(staleAccentEcho.customAccent, "199 89% 48%");
assert.equal(staleAccentEcho.customAccentVersion, 2);
// Without an announced key, full rehydrate still reads storage.
const noIncoming = resolveAppearanceSyncState(current, {
...staleStored,
@@ -186,7 +168,6 @@ test("sequential keyed appearance IPC updates compose without stale-storage clob
darkUiThemeId: "midnight",
accentMode: "theme",
customAccent: "208 100% 50%",
customAccentVersion: 0,
};
const staleStored: StoredAppearanceValues = { ...initial };
@@ -215,7 +196,6 @@ test("System on a light OS changes to Dark in every open follow-app terminal", (
darkUiThemeId: systemLight.darkUiThemeId,
accentMode: systemLight.accentMode,
customAccent: systemLight.customAccent,
customAccentVersion: systemLight.customAccentVersion,
};
const terminalAppearance = (appearance: AppearanceState, resolvedTheme: "light" | "dark") => (
resolveGlobalTerminalAppearance({
@@ -312,7 +292,7 @@ test("the race guards are wired into the real settings paths", () => {
const lightNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId)", writeIndex);
const darkNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId)", writeIndex);
const accentModeNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_ACCENT_MODE, accentMode)", writeIndex);
const colorNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_COLOR, customAccentRecord)", writeIndex);
const colorNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_COLOR, customAccent)", writeIndex);
assert.ok(guardIndex >= 0, "the settings effect must compare persisted appearance fields");
assert.ok(returnIndex > guardIndex && returnIndex < writeIndex, "the stale render must stop before storage is written");
@@ -321,8 +301,6 @@ test("the race guards are wired into the real settings paths", () => {
assert.ok(darkNotifyIndex > writeIndex, "dark UI theme changes must be announced over IPC with the new value");
assert.ok(accentModeNotifyIndex > writeIndex, "accent mode changes must be announced over IPC with the new value");
assert.ok(colorNotifyIndex > writeIndex, "custom accent changes must be announced over IPC with the new value");
assert.match(stateSource, /shouldBroadcastCustomAccentChange/);
assert.match(stateSource, /serializeCustomAccentRecord\(customAccentRecord\)/);
// Source still only notifies fields that actually changed (keyed, not a single theme-only broadcast).
assert.match(
stateSource,
@@ -343,26 +321,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",
);
});
+15 -41
View File
@@ -6,14 +6,10 @@ import {
STORAGE_KEY_UI_THEME_LIGHT,
} from '../../infrastructure/config/storageKeys';
import {
isValidHslToken,
isValidTheme,
isValidUiThemeId,
} from './settingsStateDefaults';
import {
parseCustomAccentRecord,
shouldApplyCustomAccentRecord,
type CustomAccentRecord,
} from './customAccentSync';
export type AppearanceState = {
theme: "light" | "dark" | "system";
@@ -21,8 +17,6 @@ export type AppearanceState = {
darkUiThemeId: string;
accentMode: "theme" | "custom";
customAccent: string;
/** Sync revision for custom accent; gates stale IPC/storage echoes during color-picker drag. */
customAccentVersion: number;
};
export type AppearanceRenderSnapshot = {
@@ -56,8 +50,7 @@ export function hasPersistedAppearanceChanged(
|| previous.lightUiThemeId !== current.lightUiThemeId
|| previous.darkUiThemeId !== current.darkUiThemeId
|| previous.accentMode !== current.accentMode
|| previous.customAccent !== current.customAccent
|| previous.customAccentVersion !== current.customAccentVersion;
|| previous.customAccent !== current.customAccent;
}
export function resolveIncomingAppearanceValue<T>(
@@ -113,20 +106,13 @@ export function resolveAppearanceSyncState(
current.accentMode,
(value): value is AppearanceState['accentMode'] => value === 'theme' || value === 'custom',
);
const currentAccentRecord: CustomAccentRecord = {
color: current.customAccent,
version: current.customAccentVersion,
};
const incomingAccentRaw = incoming?.key === STORAGE_KEY_COLOR ? incoming.value : undefined;
const resolvedAccentRaw = incomingAccentRaw !== undefined
? incomingAccentRaw
: (incoming ? currentAccentRecord : stored.customAccent);
const resolvedAccentRecord = incomingAccentRaw !== undefined || !incoming
? parseCustomAccentRecord(resolvedAccentRaw)
: currentAccentRecord;
const nextAccentRecord = shouldApplyCustomAccentRecord(currentAccentRecord, resolvedAccentRecord)
? resolvedAccentRecord
: currentAccentRecord;
const customAccent = resolveIncomingAppearanceValue(
incoming,
STORAGE_KEY_COLOR,
stored.customAccent,
current.customAccent,
(value): value is string => typeof value === 'string' && isValidHslToken(value),
);
return {
theme: isValidTheme(theme) ? theme : current.theme,
@@ -137,8 +123,9 @@ export function resolveAppearanceSyncState(
? darkUiThemeId
: current.darkUiThemeId,
accentMode: accentMode === 'theme' || accentMode === 'custom' ? accentMode : current.accentMode,
customAccent: nextAccentRecord.color,
customAccentVersion: nextAccentRecord.version,
customAccent: typeof customAccent === 'string' && isValidHslToken(customAccent)
? customAccent.trim()
: current.customAccent,
};
}
@@ -178,24 +165,11 @@ export function resolveAppearanceStorageEvent(
};
}
if (key === STORAGE_KEY_COLOR) {
if (newValue == null) {
return { handled: true, next: current };
}
const incoming = parseCustomAccentRecord(newValue);
const currentRecord: CustomAccentRecord = {
color: current.customAccent,
version: current.customAccentVersion,
};
if (!shouldApplyCustomAccentRecord(currentRecord, incoming)) {
return { handled: true, next: current };
}
return {
handled: true,
next: {
...current,
customAccent: incoming.color,
customAccentVersion: incoming.version,
},
next: newValue && isValidHslToken(newValue)
? { ...current, customAccent: newValue.trim() }
: current,
};
}
return { handled: false, next: current };
-204
View File
@@ -1,204 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
parseCustomAccentRecord,
serializeCustomAccentRecord,
shouldApplyCustomAccentRecord,
shouldBroadcastCustomAccentChange,
type CustomAccentMutationSource,
type CustomAccentRecord,
} from './customAccentSync.ts';
/**
* Minimal model of the settings ↔ main custom-accent sync loop that caused
* #2743. Models both IPC rebroadcast and stale localStorage overwrites while
* the native color picker fires rapid onChange events.
*/
function simulateAccentDrag(options: {
shouldBroadcast: (
source: CustomAccentMutationSource,
persistMounted: boolean,
) => { shouldBroadcast: boolean; nextSource: CustomAccentMutationSource };
shouldApply: (current: CustomAccentRecord, incoming: CustomAccentRecord) => boolean;
versioned: boolean;
}): { settingsValues: string[]; mainValues: string[]; storageWrites: string[] } {
let settings: CustomAccentRecord = { color: '221.2 83.2% 53.3%', version: 0 };
let main: CustomAccentRecord = { color: '221.2 83.2% 53.3%', version: 0 };
let settingsSource: CustomAccentMutationSource = 'local';
let mainSource: CustomAccentMutationSource = 'local';
let storage = options.versioned
? serializeCustomAccentRecord(settings)
: settings.color;
const settingsValues: string[] = [];
const mainValues: string[] = [];
const storageWrites: string[] = [];
const pendingIpc: Array<{ to: 'settings' | 'main'; record: CustomAccentRecord }> = [];
const writeStorage = (record: CustomAccentRecord) => {
const next = options.versioned
? serializeCustomAccentRecord(record)
: record.color;
if (next === storage) return;
storage = next;
storageWrites.push(next);
};
const applyLocal = (window: 'settings' | 'main', color: string) => {
const bump = (prev: CustomAccentRecord): CustomAccentRecord => (
options.versioned
? { color, version: prev.version + 1 }
: { color, version: 0 }
);
if (window === 'settings') {
settingsSource = 'local';
settings = bump(settings);
settingsValues.push(settings.color);
writeStorage(settings);
const decision = options.shouldBroadcast(settingsSource, true);
settingsSource = decision.nextSource;
if (decision.shouldBroadcast) pendingIpc.push({ to: 'main', record: { ...settings } });
return;
}
mainSource = 'local';
main = bump(main);
mainValues.push(main.color);
writeStorage(main);
const decision = options.shouldBroadcast(mainSource, true);
mainSource = decision.nextSource;
if (decision.shouldBroadcast) pendingIpc.push({ to: 'settings', record: { ...main } });
};
const applyIncoming = (window: 'settings' | 'main', record: CustomAccentRecord) => {
if (window === 'settings') {
if (!options.shouldApply(settings, record)) return;
settingsSource = 'incoming';
settings = { ...record };
settingsValues.push(settings.color);
writeStorage(settings);
const decision = options.shouldBroadcast(settingsSource, true);
settingsSource = decision.nextSource;
if (decision.shouldBroadcast) pendingIpc.push({ to: 'main', record: { ...settings } });
return;
}
if (!options.shouldApply(main, record)) return;
mainSource = 'incoming';
main = { ...record };
mainValues.push(main.color);
writeStorage(main);
const decision = options.shouldBroadcast(mainSource, true);
mainSource = decision.nextSource;
if (decision.shouldBroadcast) pendingIpc.push({ to: 'settings', record: { ...main } });
};
const flushIpc = () => {
while (pendingIpc.length > 0) {
const next = pendingIpc.shift()!;
applyIncoming(next.to, next.record);
if (settingsValues.length > 40) break;
}
};
const deliverStorageTo = (window: 'settings' | 'main') => {
const record = options.versioned
? parseCustomAccentRecord(JSON.parse(storage))
: parseCustomAccentRecord(storage);
applyIncoming(window, record);
};
applyLocal('settings', '0 84% 60%');
deliverStorageTo('main');
applyLocal('settings', '199 89% 48%');
// Delayed IPC for the older color arrives after the newer local drag sample.
const delayed = pendingIpc.shift();
flushIpc();
if (delayed) applyIncoming(delayed.to, delayed.record);
deliverStorageTo('settings');
flushIpc();
return { settingsValues, mainValues, storageWrites };
}
test('parseCustomAccentRecord accepts legacy HSL tokens and versioned JSON', () => {
assert.deepEqual(parseCustomAccentRecord('199 89% 48%'), { color: '199 89% 48%', version: 0 });
assert.deepEqual(
parseCustomAccentRecord({ color: '0 84% 60%', version: 3 }),
{ color: '0 84% 60%', version: 3 },
);
assert.deepEqual(
parseCustomAccentRecord('{"color":"262.1 83.3% 57.8%","version":9}'),
{ color: '262.1 83.3% 57.8%', version: 9 },
);
assert.equal(parseCustomAccentRecord('bad').color, '221.2 83.2% 53.3%');
});
test('shouldApplyCustomAccentRecord ignores stale revisions', () => {
const current = { color: '199 89% 48%', version: 2 };
assert.equal(shouldApplyCustomAccentRecord(current, { color: '0 84% 60%', version: 1 }), false);
assert.equal(shouldApplyCustomAccentRecord(current, { color: '330 81% 60%', version: 3 }), true);
assert.equal(shouldApplyCustomAccentRecord(current, { color: '199 89% 48%', version: 2 }), false);
});
test('shouldBroadcastCustomAccentChange suppresses incoming rebroadcasts', () => {
assert.deepEqual(
shouldBroadcastCustomAccentChange('incoming', true),
{ shouldBroadcast: false, nextSource: 'local' },
);
assert.deepEqual(
shouldBroadcastCustomAccentChange('local', true),
{ shouldBroadcast: true, nextSource: 'local' },
);
assert.deepEqual(
shouldBroadcastCustomAccentChange('local', false),
{ shouldBroadcast: false, nextSource: 'local' },
);
});
test('legacy unversioned always-broadcast accent sync oscillates during a fast drag', () => {
const alwaysBroadcast = (
_source: CustomAccentMutationSource,
persistMounted: boolean,
) => ({
shouldBroadcast: persistMounted,
nextSource: 'local' as const,
});
const alwaysApply = () => true;
const { settingsValues } = simulateAccentDrag({
shouldBroadcast: alwaysBroadcast,
shouldApply: alwaysApply,
versioned: false,
});
const unique = new Set(settingsValues);
assert.ok(
unique.has('0 84% 60%') && unique.has('199 89% 48%') && settingsValues.length > 2,
`expected oscillation between drag samples, got ${settingsValues.join(' | ')}`,
);
});
test('versioned accent sync ignores stale peer echoes during a fast drag', () => {
const { settingsValues, mainValues } = simulateAccentDrag({
shouldBroadcast: shouldBroadcastCustomAccentChange,
shouldApply: shouldApplyCustomAccentRecord,
versioned: true,
});
assert.deepEqual(settingsValues, ['0 84% 60%', '199 89% 48%']);
assert.ok(mainValues.includes('199 89% 48%'));
assert.equal(
mainValues.includes('0 84% 60%') && mainValues[mainValues.length - 1] === '0 84% 60%',
false,
);
});
test('serializeCustomAccentRecord round-trips through parse', () => {
const raw = serializeCustomAccentRecord({ color: '262.1 83.3% 57.8%', version: 9 });
assert.deepEqual(parseCustomAccentRecord(JSON.parse(raw)), {
color: '262.1 83.3% 57.8%',
version: 9,
});
});
-93
View File
@@ -1,93 +0,0 @@
import { DEFAULT_CUSTOM_ACCENT, isValidHslToken } from './settingsStateDefaults';
export type CustomAccentMutationSource = 'local' | 'incoming';
export type CustomAccentRecord = {
color: string;
version: number;
};
const FALLBACK_RECORD: CustomAccentRecord = {
color: DEFAULT_CUSTOM_ACCENT,
version: 0,
};
function normalizeAccentColor(raw: unknown): string | null {
if (typeof raw !== 'string') return null;
const trimmed = raw.trim();
return isValidHslToken(trimmed) ? trimmed : null;
}
/**
* Parse persisted / IPC custom-accent payloads.
* Accepts legacy plain HSL tokens ("221.2 83.2% 53.3%") and versioned records.
*/
export function parseCustomAccentRecord(raw: unknown): CustomAccentRecord {
if (typeof raw === 'string') {
const trimmed = raw.trim();
if (trimmed.startsWith('{')) {
try {
return parseCustomAccentRecord(JSON.parse(trimmed));
} catch {
// fall through to plain HSL
}
}
const color = normalizeAccentColor(trimmed);
if (color) return { color, version: 0 };
}
if (raw && typeof raw === 'object') {
const record = raw as { color?: unknown; version?: unknown };
const color = normalizeAccentColor(record.color) ?? FALLBACK_RECORD.color;
const version = Number(record.version);
return {
color,
version: Number.isFinite(version) && version > 0 ? Math.floor(version) : 0,
};
}
return { ...FALLBACK_RECORD };
}
export function serializeCustomAccentRecord(record: CustomAccentRecord): string {
const color = normalizeAccentColor(record.color) ?? FALLBACK_RECORD.color;
return JSON.stringify({
color,
version: Math.max(0, Math.floor(record.version) || 0),
});
}
/**
* Incoming peer updates must not clobber a newer local/drag revision.
* Equal versions are treated as already-applied (no state thrash).
*/
export function shouldApplyCustomAccentRecord(
current: CustomAccentRecord,
incoming: CustomAccentRecord,
): boolean {
if (incoming.version > current.version) return true;
if (incoming.version < current.version) return false;
// Same version: only apply when the color itself differs and both are
// legacy/unversioned (version 0), so first-load plain strings still sync.
if (incoming.version === 0 && current.version === 0) {
return incoming.color !== current.color;
}
return false;
}
/**
* Decide whether a custom-accent state change should be rebroadcast to peer
* 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).
*/
export function shouldBroadcastCustomAccentChange(
mutationSource: CustomAccentMutationSource,
persistMounted: boolean,
): { shouldBroadcast: boolean; nextSource: CustomAccentMutationSource } {
if (mutationSource === 'incoming') {
return { shouldBroadcast: false, nextSource: 'local' };
}
return { shouldBroadcast: persistMounted, nextSource: 'local' };
}
@@ -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,
};
}
+1 -1
View File
@@ -19,7 +19,7 @@ const publishCredentialCatalogIds = (ids: ReadonlyArray<string>) => {
};
export const pluginExtensionBridge = Object.freeze({
async listProviders(kind: "connection" | "authentication" | "importer" | "sync") {
async listProviders(kind: "connection" | "authentication" | "importer") {
return requireBridge().listPluginExtensionProviders?.({ kind }) ?? [];
},
async updateCredentialCatalog(entries: ReadonlyArray<{ id: string; ciphertext: string }>) {
+11 -30
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,
@@ -67,7 +66,6 @@ interface UseSettingsStorageSyncParams {
darkUiThemeId: string;
accentMode: 'theme' | 'custom';
customAccent: string;
customAccentVersion: number;
customCSS: string;
uiFontFamilyId: string;
hotkeyScheme: HotkeyScheme;
@@ -80,7 +78,6 @@ interface UseSettingsStorageSyncParams {
sftpAutoSync: boolean;
sftpShowHiddenFiles: boolean;
sftpUseCompressedUpload: boolean;
sftpSkipUnchanged: boolean;
sftpAutoOpenSidebar: boolean;
sftpFollowTerminalCwd: boolean;
sftpDefaultViewMode: 'list' | 'tree';
@@ -112,7 +109,7 @@ interface UseSettingsStorageSyncParams {
setLightUiThemeId: Dispatch<SetStateAction<string>>;
setDarkUiThemeId: Dispatch<SetStateAction<string>>;
setAccentMode: Dispatch<SetStateAction<'theme' | 'custom'>>;
applyIncomingCustomAccent: (raw: unknown) => void;
setCustomAccent: Dispatch<SetStateAction<string>>;
setCustomCSS: Dispatch<SetStateAction<string>>;
setUiFontFamilyId: Dispatch<SetStateAction<string>>;
setHotkeyScheme: Dispatch<SetStateAction<HotkeyScheme>>;
@@ -127,7 +124,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'>>;
@@ -164,20 +160,20 @@ interface UseSettingsStorageSyncParams {
export function useSettingsStorageSync({
enabled = true,
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentVersion,
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
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,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
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,
@@ -188,21 +184,21 @@ export function useSettingsStorageSync({
// can compare without capturing 25+ state variables in its closure / dep array.
// This avoids constant listener detach/reattach on every state change.
const settingsSnapshotRef = useRef({
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentVersion,
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
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,
});
settingsSnapshotRef.current = {
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentVersion,
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
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,
@@ -219,15 +215,7 @@ export function useSettingsStorageSync({
if (appearance.next.lightUiThemeId !== s.lightUiThemeId) setLightUiThemeId(appearance.next.lightUiThemeId);
if (appearance.next.darkUiThemeId !== s.darkUiThemeId) setDarkUiThemeId(appearance.next.darkUiThemeId);
if (appearance.next.accentMode !== s.accentMode) setAccentMode(appearance.next.accentMode);
if (
appearance.next.customAccent !== s.customAccent
|| appearance.next.customAccentVersion !== s.customAccentVersion
) {
applyIncomingCustomAccent({
color: appearance.next.customAccent,
version: appearance.next.customAccentVersion,
});
}
if (appearance.next.customAccent !== s.customAccent) setCustomAccent(appearance.next.customAccent);
return;
}
if (e.key === STORAGE_KEY_CUSTOM_CSS && e.newValue !== null) {
@@ -382,12 +370,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';
@@ -523,7 +505,7 @@ export function useSettingsStorageSync({
mergeIncomingTerminalSettings,
setAccentMode,
setAutoUpdateEnabled,
applyIncomingCustomAccent,
setCustomAccent,
setCustomCSS,
setDarkUiThemeId,
setEditorWordWrapState,
@@ -551,7 +533,6 @@ export function useSettingsStorageSync({
setSftpTransferConcurrencyState,
setSshTransportIdleTtlMsState,
setSftpUseCompressedUpload,
setSftpSkipUnchanged,
setShowOnlyUngroupedHostsInRootState,
setShowHostTreeSidebarState,
setTerminalSidePanelAutoOpenState,
@@ -50,26 +50,6 @@ test("parks browse only when the interactive surface hides and not already parke
}), false);
});
test("keeps a hidden SFTP owner live while an external editor temp file is open", () => {
const interactive = isBrowseSessionInteractive({
surfaceVisible: false,
hasOwnedEditorTab: false,
hasActiveExternalEdit: true,
});
assert.equal(interactive, true);
assert.equal(shouldParkBrowseSessions({
interactive: false,
browseParked: false,
activeExternalEditCount: 1,
}), false);
assert.equal(shouldParkBrowseSessions({
interactive: false,
browseParked: false,
activeExternalEditCount: 0,
}), true);
});
test("restores browse when the surface becomes interactive again after park", () => {
assert.equal(shouldRestoreBrowseSessions({ interactive: true, browseParked: true }), true);
assert.equal(shouldRestoreBrowseSessions({ interactive: true, browseParked: false }), false);
@@ -9,12 +9,8 @@
export function isBrowseSessionInteractive(params: {
surfaceVisible: boolean;
hasOwnedEditorTab: boolean;
/** External editor temp files (Notepad++ etc.) still need the browse session. */
hasActiveExternalEdit?: boolean;
}): boolean {
return params.surfaceVisible
|| params.hasOwnedEditorTab
|| !!params.hasActiveExternalEdit;
return params.surfaceVisible || params.hasOwnedEditorTab;
}
export function listRemoteBrowseConnectionIds(
@@ -53,14 +49,8 @@ export function shouldParkBrowseSessions(params: {
browseParked: boolean;
/** Defer park while unfinished transfers may still use browse sessions pre-lease. */
activeTransfersCount?: number;
/**
* Defer park while an external editor still holds a downloaded temp file.
* Parking calls closeSftp, which deletes those temps and breaks save/sync.
*/
activeExternalEditCount?: number;
}): boolean {
if (params.activeTransfersCount && params.activeTransfersCount > 0) return false;
if (params.activeExternalEditCount && params.activeExternalEditCount > 0) return false;
return !params.interactive && !params.browseParked;
}
-32
View File
@@ -1,32 +0,0 @@
export type SortField = "name" | "size" | "modified" | "type";
export type SortOrder = "asc" | "desc";
export interface ColumnWidths {
name: number;
modified: number;
size: number;
type: number;
}
export type SftpColumnVisibility = Record<keyof ColumnWidths, boolean>;
export const DEFAULT_SFTP_COLUMN_VISIBILITY: SftpColumnVisibility = {
name: true,
modified: true,
size: true,
type: true,
};
export const normalizeSftpColumnVisibility = (value: unknown): SftpColumnVisibility => {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return DEFAULT_SFTP_COLUMN_VISIBILITY;
}
const stored = value as Partial<Record<keyof ColumnWidths, unknown>>;
return {
name: true,
modified: stored.modified !== false,
size: stored.size !== false,
type: stored.type !== false,
};
};
@@ -0,0 +1,31 @@
import assert from "node:assert/strict";
import test from "node:test";
import { resumeCompressedUploadSafely } from "./compressedUploadControl.ts";
test("live compressed resume failure stays paused instead of starting a second transfer", async () => {
const outcome = await resumeCompressedUploadSafely({
transferId: "compressed-live",
reconnectRequired: false,
resume: async () => ({ success: false, reason: "Upload resume is unavailable" }),
});
assert.deepEqual(outcome, { kind: "failed", reason: "Upload resume is unavailable" });
});
test("restored compressed task may restart when its old worker no longer exists", async () => {
const outcome = await resumeCompressedUploadSafely({
transferId: "compressed-restored",
reconnectRequired: true,
resume: async () => ({ success: false, reason: "Compression is not active" }),
});
assert.deepEqual(outcome, { kind: "restart", reason: "Compression is not active" });
});
test("successful compressed resume rejoins the existing job", async () => {
const outcome = await resumeCompressedUploadSafely({
transferId: "compressed-live",
reconnectRequired: false,
resume: async () => ({ success: true }),
});
assert.deepEqual(outcome, { kind: "resumed" });
});
@@ -0,0 +1,16 @@
export type CompressedUploadResumeOutcome =
| { kind: "resumed" }
| { kind: "restart"; reason?: string }
| { kind: "failed"; reason: string };
export async function resumeCompressedUploadSafely(params: {
transferId: string;
reconnectRequired: boolean;
resume?: (transferId: string) => Promise<{ success: boolean; reason?: string }>;
}): Promise<CompressedUploadResumeOutcome> {
const result = await (params.resume?.(params.transferId)
?? { success: false, reason: "Resume unavailable" });
if (result.success) return { kind: "resumed" };
if (params.reconnectRequired) return { kind: "restart", reason: result.reason };
return { kind: "failed", reason: result.reason ?? "Could not resume the compressed upload." };
}
@@ -29,7 +29,6 @@ import {
isMissingDirectoryReplacePathError,
promoteDirectoryReplaceStage as promoteDirectoryReplacePaths,
} from "./directoryReplacePromotion";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
export interface DedicatedResumeDeps {
hosts: readonly Host[];
@@ -709,19 +708,8 @@ async function resumeSingleFileWithDedicatedSession(
skipAdmission: true,
});
if (streamResult?.superseded === true) {
// Live same-id owner still running; wait for terminal events only.
for (;;) {
if (shouldAbort?.()) throw new Error("Transfer cancelled");
const latest = sftpTransferCenterStore.getTask(task.id);
const status = latest?.status;
if (status === "completed") break;
if (status === "failed") throw new Error(latest?.error || "Transfer failed");
if (status === "cancelled") throw new Error("Transfer cancelled");
await new Promise((resolve) => setTimeout(resolve, 200));
}
} else if (streamResult?.error || streamResult?.cancelled) {
throw new Error(streamResult.error || "Transfer cancelled");
if (streamResult?.error) {
throw new Error(streamResult.error);
}
}, { retries: 1, delayMs: 600 });
return { transferId: task.id };
@@ -1270,17 +1258,7 @@ async function resumeDirectoryWithDedicatedSession(
skipAdmission: true,
});
if (streamResult?.superseded === true) {
for (;;) {
if (options?.shouldAbort?.()) throw new Error("Transfer cancelled");
const latest = sftpTransferCenterStore.getTask(childBase.id);
const status = latest?.status;
if (status === "completed") break;
if (status === "failed") throw new Error(latest?.error || "Transfer failed");
if (status === "cancelled") throw new Error("Transfer cancelled");
await new Promise((resolve) => setTimeout(resolve, 200));
}
} else if (streamResult?.error || streamResult?.cancelled) {
if (streamResult?.error || streamResult?.cancelled) {
throw new Error(streamResult.error || "Transfer cancelled");
}
@@ -1,186 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { TransferTask } from "../../../domain/models";
import {
isExternalDragDropFileUpload,
retryExternalDragDropFileUpload,
} from "./externalDragDropRetry";
const baseTask = (overrides: Partial<TransferTask> = {}): TransferTask => ({
id: "child-1",
fileName: "a.txt",
sourcePath: "/tmp/docs/a.txt",
targetPath: "/remote/docs/a.txt",
sourceConnectionId: "external",
targetConnectionId: "conn-1",
targetHostId: "host-1",
direction: "upload",
status: "failed",
totalBytes: 12,
transferredBytes: 0,
speed: 0,
startTime: 1,
isDirectory: false,
origin: "drag-drop",
parentTaskId: "folder-1",
error: "Network error",
...overrides,
});
test("detects progressive drag-drop file children as retryable", () => {
assert.equal(isExternalDragDropFileUpload(baseTask()), true);
assert.equal(isExternalDragDropFileUpload(baseTask({ isDirectory: true })), false);
assert.equal(isExternalDragDropFileUpload(baseTask({ origin: "manual" })), false);
assert.equal(isExternalDragDropFileUpload(baseTask({ sourceConnectionId: "local" })), false);
assert.equal(isExternalDragDropFileUpload(baseTask({ status: "completed" })), false);
assert.equal(isExternalDragDropFileUpload(baseTask({ retryable: false })), false);
});
test("retry reuses the same transfer id and starts a stream upload", async () => {
const patches: Array<{ id: string; updates: Partial<TransferTask> }> = [];
const streams: Array<Record<string, unknown>> = [];
// No pool acquire → falls back to browse sftp id.
const result = await retryExternalDragDropFileUpload(baseTask(), {
getBrowseSftpId: () => "sftp-live",
startStreamTransfer: async (options) => {
streams.push(options as unknown as Record<string, unknown>);
return { transferId: options.transferId };
},
onPatch: (taskId, updates) => patches.push({ id: taskId, updates }),
});
assert.equal(result.success, true);
assert.equal(streams.length, 1);
assert.equal(streams[0].transferId, "child-1");
assert.equal(streams[0].sourcePath, "/tmp/docs/a.txt");
assert.equal(streams[0].targetPath, "/remote/docs/a.txt");
assert.equal(streams[0].targetSftpId, "sftp-live");
assert.equal(patches[0].updates.status, "transferring");
assert.equal(patches.at(-1)?.updates.status, "completed");
});
test("retry prefers a dedicated pool session over the browse sftp id", async () => {
let released = false;
let acquired = false;
const result = await retryExternalDragDropFileUpload(baseTask(), {
getBrowseSftpId: () => "sftp-live",
acquireTransferSession: async () => {
acquired = true;
return {
sftpId: "sftp-pool",
poolKey: "host-1",
release: () => { released = true; },
discard: () => {},
};
},
startStreamTransfer: async (options) => {
assert.equal(options.targetSftpId, "sftp-pool");
return {};
},
onPatch: () => {},
});
assert.equal(result.success, true);
assert.equal(acquired, true);
assert.equal(released, true);
});
test("retry falls back to browse when pool acquire is unavailable", async () => {
const result = await retryExternalDragDropFileUpload(baseTask(), {
getBrowseSftpId: () => "sftp-live",
startStreamTransfer: async (options) => {
assert.equal(options.targetSftpId, "sftp-live");
return {};
},
onPatch: () => {},
});
assert.equal(result.success, true);
});
test("retry rolls parent to completed when all siblings succeed", async () => {
const patches: Array<{ id: string; updates: Partial<TransferTask> }> = [];
const result = await retryExternalDragDropFileUpload(baseTask(), {
getBrowseSftpId: () => "sftp-live",
startStreamTransfer: async () => ({}),
getTask: (id) => (
id === "folder-1"
? baseTask({
id: "folder-1",
isDirectory: true,
status: "failed",
sourcePath: "/tmp/docs",
targetPath: "/remote/docs",
error: "1 of 2 files failed",
totalBytes: 2,
transferredBytes: 1,
parentTaskId: undefined,
})
: undefined
),
getChildTasks: () => [
baseTask({ id: "child-1", status: "transferring" }),
baseTask({ id: "child-2", status: "completed", sourcePath: "/tmp/docs/b.txt" }),
],
onPatch: (taskId, updates) => patches.push({ id: taskId, updates }),
});
assert.equal(result.success, true);
const parentPatch = patches.find((p) => p.id === "folder-1");
assert.ok(parentPatch);
assert.equal(parentPatch?.updates.status, "completed");
});
test("retry does not rollup a still-transferring progressive parent", async () => {
const patches: Array<{ id: string; updates: Partial<TransferTask> }> = [];
await retryExternalDragDropFileUpload(baseTask(), {
getBrowseSftpId: () => "sftp-live",
startStreamTransfer: async () => ({}),
getTask: (id) => (
id === "folder-1"
? baseTask({
id: "folder-1",
isDirectory: true,
status: "transferring",
sourcePath: "/tmp/docs",
parentTaskId: undefined,
})
: undefined
),
getChildTasks: () => [
baseTask({ id: "child-1", status: "transferring" }),
baseTask({ id: "child-2", status: "completed", sourcePath: "/tmp/docs/b.txt" }),
],
onPatch: (taskId, updates) => patches.push({ id: taskId, updates }),
});
assert.equal(patches.some((p) => p.id === "folder-1"), false);
});
test("retry fails clearly when no sftp session can be opened", async () => {
const patches: Array<Partial<TransferTask>> = [];
const result = await retryExternalDragDropFileUpload(baseTask(), {
getBrowseSftpId: () => undefined,
startStreamTransfer: async () => {
throw new Error("should not start");
},
onPatch: (_id, updates) => patches.push(updates),
});
assert.equal(result.success, false);
assert.match(result.error || "", /No SFTP session/);
assert.equal(patches.at(-1)?.status, "failed");
});
test("stream error keeps the row failed with the backend message", async () => {
const patches: Array<Partial<TransferTask>> = [];
const result = await retryExternalDragDropFileUpload(baseTask(), {
getBrowseSftpId: () => "sftp-live",
startStreamTransfer: async () => ({ error: "Permission denied" }),
onPatch: (_id, updates) => patches.push(updates),
});
assert.equal(result.success, false);
assert.equal(result.error, "Permission denied");
assert.equal(patches.at(-1)?.status, "failed");
assert.equal(patches.at(-1)?.error, "Permission denied");
});
@@ -1,267 +0,0 @@
/**
* Low-cost retry for progressive / external drag-drop file uploads.
*
* These rows use sourceConnectionId "external" and never have dual-pane
* endpoints, so the generic processTransfer retry path silently no-ops.
* Retry re-opens a single startStreamTransfer with the stored local path.
*/
import type { TransferTask } from "../../../domain/models";
import type { TransferConnectionLease } from "./transferConnectionPool";
import { isTransferCancelledFlag } from "./transferCancelLatch";
export function isExternalDragDropFileUpload(
task: Pick<
TransferTask,
| "origin"
| "direction"
| "sourceConnectionId"
| "isDirectory"
| "sourcePath"
| "targetPath"
| "retryable"
| "status"
>,
): boolean {
if (task.retryable === false) return false;
if (task.origin !== "drag-drop") return false;
if (task.direction !== "upload") return false;
if (task.sourceConnectionId !== "external") return false;
if (task.isDirectory) return false;
if (!task.sourcePath || task.sourcePath === "local") return false;
if (!task.targetPath) return false;
return task.status === "failed" || task.status === "cancelled" || task.status === "attention";
}
export type ExternalDragDropRetryDeps = {
getBrowseSftpId: (connectionId: string) => string | undefined;
acquireTransferSession?: (
hostId: string,
transferId: string,
) => Promise<TransferConnectionLease>;
startStreamTransfer: (options: {
transferId: string;
sourcePath: string;
targetPath: string;
sourceType: "local";
targetType: "local" | "sftp";
targetSftpId?: string;
targetHostId?: string;
totalBytes?: number;
resumable?: boolean;
checkpointBytes?: number;
}) => Promise<{ error?: string; cancelled?: boolean } | undefined>;
clearPendingCancel?: (transferId: string) => Promise<unknown>;
cleanupArtifacts?: (task: TransferTask) => Promise<void>;
onPatch: (taskId: string, updates: Partial<TransferTask>) => void;
/** Live store lookup for terminal cancel races and completion bytes. */
getTask?: (taskId: string) => TransferTask | undefined;
/** Children of a progressive parent (for rollup after child success). */
getChildTasks?: (parentTaskId: string) => TransferTask[];
};
/**
* Restart a failed/cancelled external drag-drop file upload in place (same id).
* Returns true when the stream completed successfully.
*/
export async function retryExternalDragDropFileUpload(
task: TransferTask,
deps: ExternalDragDropRetryDeps,
): Promise<{ success: boolean; error?: string }> {
if (!isExternalDragDropFileUpload(task)) {
return { success: false, error: "Not an external drag-drop file upload" };
}
try {
await deps.clearPendingCancel?.(task.id);
} catch {
// best-effort
}
try {
await deps.cleanupArtifacts?.(task);
} catch {
// best-effort
}
const targetIsLocal = task.targetConnectionId === "local" || !task.targetHostId;
let lease: TransferConnectionLease | null = null;
let targetSftpId: string | undefined;
try {
if (!targetIsLocal) {
// Prefer a dedicated pool session when hostId is known — same policy as
// progressive upload (do not pin the browse/tab session).
if (task.targetHostId && deps.acquireTransferSession) {
lease = await deps.acquireTransferSession(task.targetHostId, `${task.id}:retry`);
targetSftpId = lease.sftpId;
}
if (!targetSftpId) {
targetSftpId = deps.getBrowseSftpId(task.targetConnectionId);
}
if (!targetSftpId) {
const error = "No SFTP session available to retry this upload. Reconnect and try again.";
deps.onPatch(task.id, {
status: "failed",
error,
endTime: Date.now(),
speed: 0,
});
return { success: false, error };
}
}
deps.onPatch(task.id, {
status: "transferring",
error: undefined,
transferredBytes: 0,
checkpointBytes: 0,
speed: 0,
endTime: undefined,
phase: "transferring",
reconnectRequired: false,
pauseUnavailableReason: undefined,
startTime: Date.now(),
});
const result = await deps.startStreamTransfer({
transferId: task.id,
sourcePath: task.sourcePath,
targetPath: task.targetPath,
sourceType: "local",
targetType: targetIsLocal ? "local" : "sftp",
targetSftpId: targetIsLocal ? undefined : targetSftpId,
targetHostId: targetIsLocal ? undefined : task.targetHostId,
totalBytes: task.totalBytes > 0 ? task.totalBytes : undefined,
resumable: true,
checkpointBytes: 0,
});
// Late cancel can settle the row before this return; never resurrect it.
if (isTransferCancelledFlag(task.id) || result?.cancelled) {
deps.onPatch(task.id, {
status: "cancelled",
error: undefined,
endTime: Date.now(),
speed: 0,
phase: undefined,
});
return { success: false, error: "Transfer cancelled" };
}
if (result?.error) {
deps.onPatch(task.id, {
status: "failed",
error: result.error,
endTime: Date.now(),
speed: 0,
phase: undefined,
});
return { success: false, error: result.error };
}
const live = deps.getTask?.(task.id);
const completedBytes = Math.max(
live?.totalBytes ?? 0,
live?.transferredBytes ?? 0,
task.totalBytes,
0,
);
deps.onPatch(task.id, {
status: "completed",
error: undefined,
transferredBytes: completedBytes,
totalBytes: Math.max(live?.totalBytes ?? 0, task.totalBytes, completedBytes),
endTime: Date.now(),
speed: 0,
phase: undefined,
});
// Progressive parents finalize once with "N of M failed". After a child
// retry succeeds, re-roll the parent when no failed children remain.
if (task.parentTaskId && deps.getChildTasks) {
rollupParentAfterChildSuccess(task.parentTaskId, task.id, deps);
}
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (isTransferCancelledFlag(task.id) || /cancel/i.test(message)) {
deps.onPatch(task.id, {
status: "cancelled",
error: undefined,
endTime: Date.now(),
speed: 0,
phase: undefined,
});
return { success: false, error: "Transfer cancelled" };
}
deps.onPatch(task.id, {
status: "failed",
error: message,
endTime: Date.now(),
speed: 0,
phase: undefined,
});
if (lease && /session|sftp|disconnect|not found/i.test(message)) {
try { lease.discard(); } catch { /* best-effort */ }
lease = null;
}
return { success: false, error: message };
} finally {
try { lease?.release(); } catch { /* best-effort */ }
}
}
function rollupParentAfterChildSuccess(
parentTaskId: string,
completedChildId: string,
deps: ExternalDragDropRetryDeps,
): void {
// Only re-roll parents that already finished the progressive walk as failed.
// Never promote a still-scanning/transferring parent mid-walk.
const parent = deps.getTask?.(parentTaskId);
if (
parent
&& parent.status !== "failed"
&& parent.status !== "attention"
&& parent.status !== "cancelled"
) {
return;
}
const children = deps.getChildTasks?.(parentTaskId) ?? [];
if (children.length === 0) return;
const stillFailed = children.some(
(child) =>
child.id !== completedChildId
&& (child.status === "failed" || child.status === "attention"),
);
if (stillFailed) return;
const active = children.some(
(child) =>
child.id !== completedChildId
&& (
child.status === "transferring"
|| child.status === "pending"
|| child.status === "queued"
|| child.status === "pausing"
|| child.status === "paused"
),
);
if (active) return;
const completedCount = children.filter(
(child) => child.id === completedChildId || child.status === "completed",
).length;
const total = Math.max(
Number(parent?.totalBytes) || 0,
children.length,
completedCount,
);
deps.onPatch(parentTaskId, {
status: "completed",
error: undefined,
transferredBytes: completedCount,
totalBytes: total,
speed: 0,
endTime: Date.now(),
phase: undefined,
});
}
@@ -1,28 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createExternalEditTempRetention } from "./externalEditTempRetention";
test("remembers distinct temps per sftp session and forgets one session without touching others", () => {
const retention = createExternalEditTempRetention();
assert.equal(retention.remember("sftp-a", "/tmp/a.txt"), true);
assert.equal(retention.remember("sftp-a", "/tmp/a.txt"), false);
assert.equal(retention.remember("sftp-b", "/tmp/b.txt"), true);
assert.equal(retention.size, 2);
assert.equal(retention.forgetSftp("sftp-a"), true);
assert.equal(retention.size, 1);
assert.equal(retention.forgetSftp("sftp-a"), false);
assert.equal(retention.forgetPath("/tmp/b.txt"), true);
assert.equal(retention.size, 0);
});
test("clear drops every retainer after session-wide cleanup", () => {
const retention = createExternalEditTempRetention();
retention.remember("sftp-1", "/tmp/one.txt");
retention.remember("sftp-2", "/tmp/two.txt");
assert.equal(retention.clear(), true);
assert.equal(retention.size, 0);
assert.equal(retention.clear(), false);
});
@@ -1,59 +0,0 @@
/**
* Renderer-side retainers for remote temps opened in external editors.
* closeSftp deletes those files; forget matching retainers so park/auto-connect
* checks do not keep treating a deleted temp as active work.
*/
export type ExternalEditTempRetention = {
remember(sftpId: string, localPath: string): boolean;
forgetPath(localPath: string): boolean;
forgetSftp(sftpId: string): boolean;
clear(): boolean;
readonly size: number;
};
export function createExternalEditTempRetention(): ExternalEditTempRetention {
const bySftp = new Map<string, Set<string>>();
const recount = (): number => {
let total = 0;
for (const paths of bySftp.values()) total += paths.size;
return total;
};
return {
remember(sftpId, localPath) {
if (!sftpId || !localPath) return false;
let paths = bySftp.get(sftpId);
if (!paths) {
paths = new Set();
bySftp.set(sftpId, paths);
}
if (paths.has(localPath)) return false;
paths.add(localPath);
return true;
},
forgetPath(localPath) {
if (!localPath) return false;
let changed = false;
for (const [sftpId, paths] of bySftp) {
if (!paths.delete(localPath)) continue;
changed = true;
if (paths.size === 0) bySftp.delete(sftpId);
}
return changed;
},
forgetSftp(sftpId) {
if (!sftpId || !bySftp.has(sftpId)) return false;
bySftp.delete(sftpId);
return true;
},
clear() {
if (bySftp.size === 0) return false;
bySftp.clear();
return true;
},
get size() {
return recount();
},
};
}
@@ -138,13 +138,9 @@ test("release invalidates pending watch starts while a new generation remains us
test("backend session cleanup removes a stopped watch from renderer ownership", async () => {
const stopped: Array<{ watchId: string; cleanupTempFile: boolean }> = [];
let onBackendStopped: ((payload: {
watchId: string;
localPath?: string;
}) => void) | null = null;
let onBackendStopped: ((payload: { watchId: string }) => void) | null = null;
let lifecycle: ExternalFileWatchLifecycle | null = null;
let renderer: ReactTestRenderer | null = null;
const forgottenPaths: string[] = [];
function Probe() {
lifecycle = useExternalFileWatchLifecycle(
@@ -155,7 +151,6 @@ test("backend session cleanup removes a stopped watch from renderer ownership",
onBackendStopped = callback;
return () => { onBackendStopped = null; };
},
(localPath) => { forgottenPaths.push(localPath); },
);
return null;
}
@@ -167,13 +162,9 @@ test("backend session cleanup removes a stopped watch from renderer ownership",
assert.equal(lifecycle!.activeCountRef.current, 1);
await act(async () => {
onBackendStopped?.({
watchId: "watch-closed-with-session",
localPath: "/tmp/externally-deleted.txt",
});
onBackendStopped?.({ watchId: "watch-closed-with-session" });
});
assert.equal(lifecycle!.activeCountRef.current, 0);
assert.deepEqual(forgottenPaths, ["/tmp/externally-deleted.txt"]);
await act(async () => renderer!.unmount());
await new Promise((resolve) => setImmediate(resolve));
@@ -19,12 +19,7 @@ export type StopExternalFileWatch = (
) => void | Promise<unknown>;
export type SubscribeExternalFileWatchStopped = (
callback: (payload: {
watchId: string;
localPath?: string;
remotePath?: string;
sftpId?: string;
}) => void,
callback: (payload: { watchId: string }) => void,
) => (() => void) | void;
export interface ExternalFileWatchLifecycle {
@@ -37,19 +32,16 @@ export interface ExternalFileWatchLifecycle {
export function useExternalFileWatchLifecycle(
stopWatch: StopExternalFileWatch,
subscribeStopped?: SubscribeExternalFileWatchStopped,
onStoppedLocalPath?: (localPath: string) => void,
): ExternalFileWatchLifecycle {
const watchIdsRef = useRef<Set<string>>(new Set());
const activeCountRef = useRef(0);
const stopWatchRef = useRef(stopWatch);
const subscribeStoppedRef = useRef(subscribeStopped);
const onStoppedLocalPathRef = useRef(onStoppedLocalPath);
const disposedRef = useRef(false);
const generationRef = useRef(0);
const invalidatedCleanupTempFilesRef = useRef(false);
stopWatchRef.current = stopWatch;
subscribeStoppedRef.current = subscribeStopped;
onStoppedLocalPathRef.current = onStoppedLocalPath;
const captureGeneration = useCallback(() => generationRef.current, []);
@@ -94,10 +86,9 @@ export function useExternalFileWatchLifecycle(
};
}, [releaseAll]);
useEffect(() => subscribeStoppedRef.current?.(({ watchId, localPath }) => {
useEffect(() => subscribeStoppedRef.current?.(({ watchId }) => {
if (!watchId || !watchIdsRef.current.delete(watchId)) return;
activeCountRef.current = watchIdsRef.current.size;
if (localPath) onStoppedLocalPathRef.current?.(localPath);
}), []);
return { activeCountRef, captureGeneration, remember, releaseAll };
@@ -1,83 +0,0 @@
import { useCallback, useMemo, useSyncExternalStore } from "react";
import type { SftpBookmark } from "../../../domain/models";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { STORAGE_KEY_SFTP_LOCAL_BOOKMARKS } from "../../../infrastructure/config/storageKeys";
import { createSftpBookmark } from "./bookmarkHelpers";
type Listener = () => void;
const listeners = new Set<Listener>();
let snapshot: SftpBookmark[] =
localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS) ?? [];
export function subscribeLocalSftpBookmarks(listener: Listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function getLocalSftpBookmarksSnapshot() {
return snapshot;
}
export function rehydrateLocalSftpBookmarks() {
snapshot = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS) ?? [];
for (const listener of listeners) listener();
}
export function setLocalSftpBookmarks(
next: SftpBookmark[] | ((prev: SftpBookmark[]) => SftpBookmark[]),
) {
snapshot = typeof next === "function" ? next(snapshot) : next;
localStorageAdapter.write(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS, snapshot);
for (const listener of listeners) listener();
}
if (typeof window !== "undefined") {
window.addEventListener("storage", (event) => {
if (event.key === STORAGE_KEY_SFTP_LOCAL_BOOKMARKS) {
rehydrateLocalSftpBookmarks();
}
});
}
interface UseLocalSftpBookmarksParams {
currentPath: string | undefined;
}
export const useLocalSftpBookmarks = ({
currentPath,
}: UseLocalSftpBookmarksParams) => {
const bookmarks = useSyncExternalStore(
subscribeLocalSftpBookmarks,
getLocalSftpBookmarksSnapshot,
getLocalSftpBookmarksSnapshot,
);
const isCurrentPathBookmarked = useMemo(
() => !!currentPath && bookmarks.some((b) => b.path === currentPath),
[currentPath, bookmarks],
);
const toggleBookmark = useCallback(() => {
if (!currentPath) return;
if (isCurrentPathBookmarked) {
setLocalSftpBookmarks((prev) => prev.filter((b) => b.path !== currentPath));
} else {
setLocalSftpBookmarks((prev) => [...prev, createSftpBookmark(currentPath)]);
}
}, [currentPath, isCurrentPathBookmarked]);
const deleteBookmark = useCallback((id: string) => {
setLocalSftpBookmarks((prev) => prev.filter((b) => b.id !== id));
}, []);
return {
bookmarks,
isCurrentPathBookmarked,
toggleBookmark,
deleteBookmark,
};
};
@@ -2,13 +2,8 @@ import test from "node:test";
import assert from "node:assert/strict";
import {
DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY,
DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY,
DEFAULT_SFTP_SKIP_UNCHANGED,
resolveSftpDirectoryListingConcurrency,
resolveSftpSkipUnchangedEnabled,
resolveSftpTransferConcurrency,
runBoundedConcurrency,
runSftpTransferWorkers,
} from "./transferConcurrency";
@@ -17,20 +12,6 @@ test("defaults folder file transfers to two concurrent files", () => {
assert.equal(DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY, 2);
});
test("defaults directory listing fanout to four concurrent readdirs", () => {
assert.equal(
resolveSftpDirectoryListingConcurrency(() => null),
DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY,
);
assert.equal(DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY, 4);
});
test("defaults skip-unchanged to enabled", () => {
assert.equal(resolveSftpSkipUnchangedEnabled(() => null), DEFAULT_SFTP_SKIP_UNCHANGED);
assert.equal(DEFAULT_SFTP_SKIP_UNCHANGED, true);
assert.equal(resolveSftpSkipUnchangedEnabled(() => false), false);
});
test("keeps explicit folder transfer concurrency within the supported range", () => {
assert.equal(resolveSftpTransferConcurrency(() => 1), 1);
assert.equal(resolveSftpTransferConcurrency(() => 16), 16);
@@ -38,19 +19,6 @@ test("keeps explicit folder transfer concurrency within the supported range", ()
assert.equal(resolveSftpTransferConcurrency(() => 17), DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY);
});
test("keeps directory listing concurrency within the supported range", () => {
assert.equal(resolveSftpDirectoryListingConcurrency(() => 1), 1);
assert.equal(resolveSftpDirectoryListingConcurrency(() => 8), 8);
assert.equal(
resolveSftpDirectoryListingConcurrency(() => 0),
DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY,
);
assert.equal(
resolveSftpDirectoryListingConcurrency(() => 9),
DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY,
);
});
test("limits default multi-file transfer scheduling to two concurrent workers", async () => {
let active = 0;
let maxActive = 0;
@@ -65,106 +33,6 @@ test("limits default multi-file transfer scheduling to two concurrent workers",
assert.equal(maxActive, 2);
});
test("runBoundedConcurrency respects an explicit limit", async () => {
let active = 0;
let maxActive = 0;
await runBoundedConcurrency([1, 2, 3, 4, 5, 6], 3, async () => {
active += 1;
maxActive = Math.max(maxActive, active);
await new Promise((resolve) => setTimeout(resolve, 5));
active -= 1;
});
assert.equal(maxActive, 3);
});
test("runBoundedConcurrency drains siblings and stops new claims after an error", async () => {
const started: number[] = [];
const finished: number[] = [];
let releaseSlow!: () => void;
const slowGate = new Promise<void>((resolve) => {
releaseSlow = resolve;
});
const run = runBoundedConcurrency([0, 1, 2, 3, 4], 2, async (item) => {
started.push(item);
if (item === 0) {
await new Promise((resolve) => setTimeout(resolve, 10));
throw new Error("boom");
}
if (item === 1) {
await slowGate;
}
finished.push(item);
});
await new Promise((resolve) => setTimeout(resolve, 30));
let settledWhileSiblingRunning = false;
await Promise.race([
run.then(
() => {
settledWhileSiblingRunning = true;
},
() => {
settledWhileSiblingRunning = true;
},
),
new Promise((resolve) => setTimeout(resolve, 5)),
]);
assert.equal(
settledWhileSiblingRunning,
false,
"must wait for in-flight siblings before propagating the error",
);
assert.ok(started.includes(1));
assert.equal(finished.includes(1), false);
releaseSlow();
await assert.rejects(run, /boom/);
assert.ok(finished.includes(1), "in-flight sibling must finish before reject");
assert.deepEqual(
[...started].sort((a, b) => a - b),
[0, 1],
"must not claim additional queue items after the first error",
);
});
test("runSftpTransferWorkers drains siblings and stops new claims after an error", async () => {
const started: number[] = [];
const finished: number[] = [];
let releaseSlow!: () => void;
const slowGate = new Promise<void>((resolve) => {
releaseSlow = resolve;
});
const run = runSftpTransferWorkers([0, 1, 2, 3, 4], () => 2, async (item) => {
started.push(item);
if (item === 0) {
await new Promise((resolve) => setTimeout(resolve, 10));
throw new Error("session lost");
}
if (item === 1) {
await slowGate;
}
finished.push(item);
});
await new Promise((resolve) => setTimeout(resolve, 30));
let settledEarly = false;
await Promise.race([
run.then(
() => { settledEarly = true; },
() => { settledEarly = true; },
),
new Promise((resolve) => setTimeout(resolve, 5)),
]);
assert.equal(settledEarly, false);
releaseSlow();
await assert.rejects(run, /session lost/);
assert.ok(finished.includes(1));
assert.deepEqual([...started].sort((a, b) => a - b), [0, 1]);
});
test("beforeClaim runs before claiming the next queue index", async () => {
const events: string[] = [];
let paused = true;
+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(),
}
@@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
DEFAULT_SFTP_TRANSFER_POOL_IDLE_TTL_MS,
isTransferPoolIdleReclaimDisabled,
resolveSftpTransferPoolIdleTtlMs,
} from "../../../infrastructure/config/sftpTransferPool.ts";
test("default pool idle TTL is five minutes", () => {
assert.equal(DEFAULT_SFTP_TRANSFER_POOL_IDLE_TTL_MS, 5 * 60_000);
assert.equal(resolveSftpTransferPoolIdleTtlMs(() => null), DEFAULT_SFTP_TRANSFER_POOL_IDLE_TTL_MS);
});
test("accepts preset values including never-reclaim zero", () => {
assert.equal(resolveSftpTransferPoolIdleTtlMs(() => 60_000), 60_000);
assert.equal(resolveSftpTransferPoolIdleTtlMs(() => 0), 0);
assert.equal(resolveSftpTransferPoolIdleTtlMs(() => 123), DEFAULT_SFTP_TRANSFER_POOL_IDLE_TTL_MS);
});
test("transfer pool idle reclaim helper is deprecated and always reclaim-enabled", () => {
// Channels close when idle; SSH park is separate. The helper always returns false.
assert.equal(isTransferPoolIdleReclaimDisabled(0), false);
assert.equal(isTransferPoolIdleReclaimDisabled(300_000), false);
});
+5
View File
@@ -111,4 +111,9 @@ export interface SftpStateOptions {
* This keeps side-panel tab switches on the already-authenticated SSH transport.
*/
resolveBrowseSourceSessionId?: (hostId: string, host?: Host) => string | undefined;
/**
* @deprecated Transfer channels no longer park independently. SSH keep-alive
* is controlled by sshTransportIdleTtlMs in settings.
*/
transferPoolIdleTtlMs?: number;
}
@@ -59,96 +59,3 @@ test("upload task callbacks write through the transfer store without page callba
assert.equal(patches[1].updates.transferredBytes, 100);
assert.deepEqual(dismissed, []);
});
test("progress promotes pending scanning folder rows into transferring", () => {
const patches: Array<{ taskId: string; updates: Partial<TransferTask> }> = [];
const liveTask: TransferTask = {
id: "folder-1",
fileName: "docs",
sourcePath: "local",
targetPath: "/remote/docs",
sourceConnectionId: "external",
targetConnectionId: "connection-1",
direction: "upload",
status: "pending",
totalBytes: 0,
transferredBytes: 0,
speed: 0,
startTime: 1,
isDirectory: true,
progressMode: "files",
phase: "scanning",
};
const store = {
upsertTasks: () => {},
patchTask: (taskId: string, updates: Partial<TransferTask>) => patches.push({ taskId, updates }),
dismiss: () => {},
getTask: (taskId: string) => (taskId === "folder-1" ? liveTask : undefined),
};
const callbacks = createUploadTaskCallbacks({
ownerId: "owner-1",
connectionId: "connection-1",
targetPath: "/remote",
store,
});
callbacks.onTaskProgress?.("folder-1", {
transferred: 12,
total: 400,
speed: 0,
percent: 3,
phase: "transferring",
});
assert.equal(patches[0].taskId, "folder-1");
assert.equal(patches[0].updates.status, "transferring");
assert.equal(patches[0].updates.transferredBytes, 12);
assert.equal(patches[0].updates.totalBytes, 400);
assert.equal(patches[0].updates.phase, "transferring");
});
test("scanning callbacks expose live file counts in files progress mode", () => {
const upserts: TransferTask[][] = [];
const patches: Array<{ taskId: string; updates: Partial<TransferTask> }> = [];
const store = {
upsertTasks: (tasks: readonly TransferTask[]) => upserts.push([...tasks]),
patchTask: (taskId: string, updates: Partial<TransferTask>) => patches.push({ taskId, updates }),
dismiss: () => {},
};
const callbacks = createUploadTaskCallbacks({
ownerId: "owner-1",
connectionId: "connection-1",
targetPath: "/remote",
store,
});
callbacks.onScanningStart?.("scan-1", { label: "docs" });
callbacks.onScanningProgress?.("scan-1", {
fileCount: 1284,
directoryCount: 40,
entryCount: 1324,
label: "docs",
});
assert.equal(upserts[0][0].fileName, "docs");
assert.equal(upserts[0][0].phase, "scanning");
assert.equal(upserts[0][0].progressMode, "files");
assert.equal(upserts[0][0].status, "pending");
assert.deepEqual(patches[0], {
taskId: "scan-1",
updates: {
// Found files live in totalBytes; completed stays 0 during scan.
transferredBytes: 0,
totalBytes: 1284,
progressMode: "files",
phase: "scanning",
fileName: "docs",
},
});
// Display contract: 0 done · N found (not N done · N found).
const discovered = Math.max(patches[0].updates.totalBytes ?? 0, patches[0].updates.transferredBytes ?? 0);
const completed = patches[0].updates.transferredBytes ?? 0;
assert.equal(completed, 0);
assert.equal(discovered, 1284);
});
+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,
});
},
});
@@ -157,23 +157,6 @@ test("release metadata closes the backend before a failed connection is retained
assert.equal(cacheKeys.size, 0);
});
test("releaseSftpConnectionMetadata notifies after a remote session closes", async () => {
const sessions = new Map([["connection-1", "sftp-1"]]);
const cacheKeys = new Map([["connection-1", "cache-1"]]);
const notified: string[] = [];
await releaseSftpConnectionMetadata({
connectionId: "connection-1",
sftpSessions: sessions,
connectionCacheKeys: cacheKeys,
clearCacheForConnection: () => {},
closeSftp: async () => {},
onRemoteSessionClosed: (sftpId) => { notified.push(sftpId); },
});
assert.deepEqual(notified, ["sftp-1"]);
});
test("resolvePinnedReconnectSide follows a tab moved to the other side", () => {
assert.equal(
resolvePinnedReconnectSide("left", "tab-1", [], [{ id: "tab-1" }]),
+9 -15
View File
@@ -2,7 +2,6 @@ import React, { useCallback, useEffect, useRef, useState } from "react";
import type { MutableRefObject } from "react";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import type { Host, Identity, KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
import { createKnownHostFromHostKeyInfo } from "../../../domain/knownHosts";
import type { SftpHostKeyInfo, SftpHostKeyVerificationState, SftpPane } from "./types";
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
import { useSftpHostCredentials } from "./useSftpHostCredentials";
@@ -42,8 +41,6 @@ interface UseSftpConnectionsParams {
clearCacheForConnection: (connectionId: string) => void;
createEmptyPane: (id?: string, showHiddenFiles?: boolean) => SftpPane;
autoConnectLocalOnMount?: boolean;
/** Fired after a browse SFTP id is closed so renderer retainers can drop. */
onRemoteSessionClosed?: (sftpId: string) => void;
}
export interface SftpConnectOptions {
@@ -85,7 +82,6 @@ export async function releaseSftpConnectionMetadata(params: {
connectionCacheKeys: Map<string, string>;
clearCacheForConnection: (connectionId: string) => void;
closeSftp: (sftpId: string) => Promise<unknown>;
onRemoteSessionClosed?: (sftpId: string) => void;
}): Promise<void> {
const sftpId = takeSftpConnectionMetadataForClose(params);
if (params.isLocal || !sftpId) return;
@@ -94,7 +90,6 @@ export async function releaseSftpConnectionMetadata(params: {
} catch {
// Best-effort: backend owner cleanup remains the final safety net.
}
params.onRemoteSessionClosed?.(sftpId);
}
export function createSftpConnectionId(
@@ -207,7 +202,15 @@ const createKnownHostFromSftpHostKeyInfo = (
hostKeyInfo: SftpHostKeyInfo,
now = Date.now(),
idSuffix = Math.random().toString(36).slice(2, 11),
): KnownHost => createKnownHostFromHostKeyInfo(hostKeyInfo, { now, idSuffix });
): KnownHost => ({
id: hostKeyInfo.knownHostId || `kh-${now}-${idSuffix}`,
hostname: hostKeyInfo.hostname,
port: hostKeyInfo.port || 22,
keyType: hostKeyInfo.keyType,
publicKey: hostKeyInfo.publicKey || `SHA256:${hostKeyInfo.fingerprint}`,
fingerprint: hostKeyInfo.fingerprint,
discoveredAt: now,
});
export const useSftpConnections = ({
hosts,
@@ -237,13 +240,7 @@ export const useSftpConnections = ({
clearCacheForConnection,
createEmptyPane,
autoConnectLocalOnMount = true,
onRemoteSessionClosed,
}: UseSftpConnectionsParams): UseSftpConnectionsResult => {
const onRemoteSessionClosedRef = useRef(onRemoteSessionClosed);
onRemoteSessionClosedRef.current = onRemoteSessionClosed;
const notifyRemoteSessionClosed = useCallback((sftpId: string | undefined) => {
if (sftpId) onRemoteSessionClosedRef.current?.(sftpId);
}, []);
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities, knownHosts, terminalSettings });
const { listLocalFiles, listRemoteFiles } = useSftpDirectoryListing();
const [hostKeyVerification, setHostKeyVerification] = useState<SftpHostKeyVerificationState | null>(null);
@@ -453,7 +450,6 @@ export const useSftpConnections = ({
connectionCacheKeys: connectionCacheKeyMapRef.current,
clearCacheForConnection,
closeSftp: async (sftpId) => netcattyBridge.get()?.closeSftp(sftpId),
onRemoteSessionClosed: (sftpId) => notifyRemoteSessionClosed(sftpId),
});
};
@@ -495,7 +491,6 @@ export const useSftpConnections = ({
} catch {
// Ignore errors when closing stale SFTP sessions
}
notifyRemoteSessionClosed(oldSftpId);
}
}
}
@@ -971,7 +966,6 @@ export const useSftpConnections = ({
} catch {
// Ignore errors when closing SFTP session during disconnect
}
notifyRemoteSessionClosed(sftpId);
}
}
@@ -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";
@@ -93,17 +41,12 @@ import {
cleanupFailedExternalOpenTemp,
useExternalFileWatchLifecycle,
} from "./externalFileWatchLifecycle";
import { createExternalEditTempRetention } from "./externalEditTempRetention";
import {
cancelExternalUploadRuntime,
getExternalUploadController,
registerExternalUploadController,
unregisterExternalUploadController,
} from "./externalUploadRuntime";
import {
isTransferPauseLatched,
waitWhileTransferOrRootPaused,
} from "./transferPauseLatch";
type UploadConflictResolver = {
resolve: (action: FileConflictAction) => void;
@@ -207,12 +150,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);
@@ -223,49 +163,23 @@ export const useSftpExternalOperations = (
},
}), [registerUploadController]);
// Temp files downloaded for external editors (even without auto-sync watches).
// Parking / disconnect / reconnect call closeSftp, which deletes these paths —
// forget matching retainers so hasActiveWork does not stick after cleanup.
const externalEditTempsRef = useRef(createExternalEditTempRetention());
const [activeExternalEditCount, setActiveExternalEditCount] = useState(0);
const rememberExternalEditTemp = useCallback((sftpId: string, localPath: string) => {
if (!externalEditTempsRef.current.remember(sftpId, localPath)) return;
setActiveExternalEditCount(externalEditTempsRef.current.size);
}, []);
const forgetExternalEditTemp = useCallback((localPath: string) => {
if (!externalEditTempsRef.current.forgetPath(localPath)) return;
setActiveExternalEditCount(externalEditTempsRef.current.size);
}, []);
const forgetExternalEditTempsForSftp = useCallback((sftpId: string) => {
if (!externalEditTempsRef.current.forgetSftp(sftpId)) return;
setActiveExternalEditCount(externalEditTempsRef.current.size);
}, []);
// Track every renderer-owned watch id so duplicate opens stay deduplicated
// and panel/window teardown releases the worker-side polling resources.
const stopExternalFileWatch = useCallback(async (watchId: string, cleanupTempFile: boolean) => {
await netcattyBridge.get()?.stopFileWatch?.(watchId, cleanupTempFile);
}, []);
const subscribeExternalFileWatchStopped = useCallback((
callback: (payload: { watchId: string; localPath?: string }) => void,
callback: (payload: { watchId: string }) => void,
) => netcattyBridge.get()?.onFileWatchStopped?.(callback), []);
const {
activeCountRef: activeFileWatchCountRef,
captureGeneration: captureExternalFileWatchGeneration,
remember: rememberExternalFileWatch,
releaseAll: releaseExternalFileWatchesBase,
releaseAll: releaseExternalFileWatches,
} = useExternalFileWatchLifecycle(
stopExternalFileWatch,
subscribeExternalFileWatchStopped,
// Force-stop (temp deleted / session cleanup) includes localPath — drop retainer.
forgetExternalEditTemp,
);
const releaseExternalFileWatches = useCallback(async (cleanupTempFiles = false) => {
await releaseExternalFileWatchesBase(cleanupTempFiles);
if (externalEditTempsRef.current.clear()) {
setActiveExternalEditCount(0);
}
}, [releaseExternalFileWatchesBase]);
const [uploadConflicts, setUploadConflicts] = useState<FileConflict[]>([]);
const uploadConflictResolversRef = useRef<Map<string, UploadConflictResolver>>(new Map());
/** Maps conflict id → owning UploadController so cancel A never stops B's prompts. */
@@ -569,7 +483,6 @@ export const useSftpExternalOperations = (
if (bridge.registerTempFile) {
try {
await bridge.registerTempFile(sftpId, localTempPath);
rememberExternalEditTemp(sftpId, localTempPath);
} catch (err) {
console.warn("[SFTP] Failed to register temp file for cleanup:", err);
}
@@ -577,7 +490,7 @@ export const useSftpExternalOperations = (
return { localTempPath, sftpId, externalTransferId };
},
[getActivePane, isTransferCancelled, ownerId, rememberExternalEditTemp, sftpSessionsRef],
[getActivePane, isTransferCancelled, ownerId, sftpSessionsRef],
);
const downloadToTempAndOpen = useCallback(
@@ -612,7 +525,6 @@ export const useSftpExternalOperations = (
await bridge.openWithApplication(localTempPath, appPath);
} catch (err) {
await cleanupFailedExternalOpenTemp(bridge, sftpId, localTempPath).catch(() => {});
forgetExternalEditTemp(localTempPath);
if (externalTransferId) {
sftpTransferCenterStore.patchTask(externalTransferId, {
status: "failed" as TransferStatus,
@@ -643,13 +555,7 @@ export const useSftpExternalOperations = (
return { localTempPath, watchId };
},
[
captureExternalFileWatchGeneration,
downloadToTemp,
forgetExternalEditTemp,
getActivePane,
rememberExternalFileWatch,
],
[captureExternalFileWatchGeneration, downloadToTemp, getActivePane, rememberExternalFileWatch],
);
const openWithSystemDefault = useCallback(
@@ -684,14 +590,12 @@ export const useSftpExternalOperations = (
} catch (error) {
if (!pane.connection.isLocal) {
await cleanupFailedExternalOpenTemp(bridgeMethods, sftpId, localTempPath).catch(() => {});
forgetExternalEditTemp(localTempPath);
}
throw error;
}
if (!result.success) {
if (!pane.connection.isLocal) {
await cleanupFailedExternalOpenTemp(bridgeMethods, sftpId, localTempPath).catch(() => {});
forgetExternalEditTemp(localTempPath);
}
if (externalTransferId) {
sftpTransferCenterStore.patchTask(externalTransferId, {
@@ -723,13 +627,7 @@ export const useSftpExternalOperations = (
notify.error(err instanceof Error ? err.message : String(err), "SFTP");
}
},
[
captureExternalFileWatchGeneration,
downloadToTemp,
forgetExternalEditTemp,
getActivePane,
rememberExternalFileWatch,
],
[captureExternalFileWatchGeneration, downloadToTemp, getActivePane, rememberExternalFileWatch],
);
// Create upload callbacks that translate to TransferTask updates
@@ -823,12 +721,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 +845,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 +908,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 +923,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 +940,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 +1103,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 +1215,7 @@ export const useSftpExternalOperations = (
: undefined,
shouldDiscard: isSessionError,
run: async (uploadSftpId) => uploadEntriesDirect(
capturedEntries,
entries,
{
targetPath: uploadTargetPath,
sftpId: uploadSftpId,
@@ -1587,7 +1223,7 @@ export const useSftpExternalOperations = (
isLocal: livePane.connection!.isLocal,
bridge: uploadBridge,
joinPath,
callbacks: transferCallbacks,
callbacks,
useCompressedUpload,
resolveConflict: createUploadConflictResolver(controller),
},
@@ -1603,8 +1239,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 +1259,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,
@@ -1867,8 +1491,6 @@ export const useSftpExternalOperations = (
cancelExternalUpload,
selectApplication,
activeFileWatchCountRef,
activeExternalEditCount,
forgetExternalEditTempsForSftp,
releaseExternalFileWatches,
uploadConflicts,
resolveUploadConflict,
@@ -68,10 +68,6 @@ export interface SftpExternalOperationsResult {
) => Promise<{ localTempPath: string; watchId?: string }>;
openWithSystemDefault: (side: "left" | "right", remotePath: string, fileName: string, options?: { enableWatch?: boolean }) => Promise<void>;
activeFileWatchCountRef: React.MutableRefObject<number>;
/** Remote temps opened in an external editor; keeps browse sessions from parking. */
activeExternalEditCount: number;
/** Drop retainers after closeSftp deletes that session's registered temps. */
forgetExternalEditTempsForSftp: (sftpId: string) => void;
releaseExternalFileWatches: (cleanupTempFiles?: boolean) => Promise<void>;
uploadExternalFiles: (
side: "left" | "right",
@@ -232,7 +232,7 @@ test("buildSftpHostCredentials rejects proxy identities with blank usernames eve
label: "Proxy login",
username: " ",
authMethod: "password",
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
password: "enc:v1:djEwAAAA",
created: 1,
}],
}),
@@ -285,7 +285,7 @@ test("buildSftpHostCredentials rejects incomplete proxy identities on jump hosts
label: "Proxy login",
username: "",
authMethod: "password",
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
password: "enc:v1:djEwAAAA",
created: 1,
}],
}),
@@ -368,7 +368,7 @@ test("buildSftpHostCredentials rejects undecryptable proxy identity passwords",
label: "Proxy login",
username: "proxy-user",
authMethod: "password",
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
password: "enc:v1:djEwAAAA",
created: 1,
}],
}),
@@ -434,7 +434,7 @@ test("buildSftpHostCredentials rejects undecryptable jump host proxy identity pa
label: "Proxy login",
username: "proxy-user",
authMethod: "password",
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
password: "enc:v1:djEwAAAA",
created: 1,
}],
}),
@@ -539,7 +539,7 @@ test("buildSftpHostCredentials rejects undecryptable saved password credentials"
() => buildSftpHostCredentials({
host: host({
authMethod: "password",
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
password: "enc:v1:djEwAAAA",
}),
hosts: [],
keys: [],
@@ -553,7 +553,7 @@ test("buildSftpHostCredentials keeps automatic target discovery available with a
const credentials = buildSftpHostCredentials({
host: host({
authMethod: "auto",
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
password: "enc:v1:djEwAAAA",
}),
hosts: [],
keys: [],
@@ -569,7 +569,7 @@ test("buildSftpHostCredentials keeps automatic jump discovery available with an
id: "jump-1",
label: "Jump",
authMethod: "auto",
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
password: "enc:v1:djEwAAAA",
});
const credentials = buildSftpHostCredentials({
host: host({ hostChain: { hostIds: ["jump-1"] } }),
@@ -604,7 +604,7 @@ test("buildSftpHostCredentials rejects undecryptable saved key material without
id: "key-1",
label: "Imported key",
type: "ED25519",
privateKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
privateKey: "enc:v1:djEwAAAA",
source: "imported",
category: "key",
created: 1,
@@ -626,7 +626,7 @@ test("buildSftpHostCredentials does not use stale local key paths when a selecte
id: "key-1",
label: "Imported key",
type: "ED25519",
privateKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
privateKey: "enc:v1:djEwAAAA",
source: "imported",
category: "key",
created: 1,
@@ -653,7 +653,7 @@ test("buildSftpHostCredentials uses the system agent when a synced key cannot be
label: "Synced key",
type: "ED25519",
publicKey: "ssh-ed25519 AAAASELECTED",
privateKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
privateKey: "enc:v1:djEwAAAA",
source: "imported",
category: "key",
created: 1,
@@ -1,10 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const source = readFileSync(new URL("./useSftpPaneSorting.ts", import.meta.url), "utf8");
test("useSftpPaneSorting imports column layout from application state", () => {
assert.doesNotMatch(source, /from ["'].*components\//);
assert.match(source, /from ["']\.\/columnLayout["']/);
});
@@ -2,6 +2,25 @@ import { useEffect, useLayoutEffect, useRef } from "react";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
export function useWarmSftpTransferPool(params: {
hostIds: readonly string[];
activeHostId?: string;
warmTransferPoolForHost?: (hostId: string) => void | Promise<void>;
}) {
const warmRef = useRef(params.warmTransferPoolForHost);
warmRef.current = params.warmTransferPoolForHost;
const hostIdsKey = [...new Set([
...params.hostIds,
...(params.activeHostId ? [params.activeHostId] : []),
])].sort().join("\u0000");
useEffect(() => {
for (const hostId of hostIdsKey.split("\u0000").filter(Boolean)) {
void warmRef.current?.(hostId);
}
}, [hostIdsKey]);
}
export function useReportSftpTransferOwnerActivity(params: {
ownerId: string;
activeTransfersCount: number;
+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(() => {
+5 -7
View File
@@ -1,13 +1,11 @@
import { SftpFileEntry, TransferTask } from "../../../domain/models";
import { formatBytes } from "../../../domain/formatBytes";
export const formatFileSize = (bytes: number): string => {
return formatBytes(bytes, {
zeroDisplay: "--",
includeTB: true,
fractionDigits: 2,
byteUnit: "Bytes",
});
if (bytes === 0) return "--";
const units = ["Bytes", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const size = bytes / Math.pow(1024, i);
return `${size.toFixed(i === 0 ? 0 : 2)} ${units[i]}`;
};
/**
+1 -1
View File
@@ -2318,7 +2318,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
const browserPersistence: StorePersistence | undefined = typeof globalThis.localStorage === "undefined"
? undefined
: {
read: () => localStorageAdapter.readString(STORAGE_KEY_SFTP_TRANSFER_CENTER),
read: () => globalThis.localStorage.getItem(STORAGE_KEY_SFTP_TRANSFER_CENTER),
write: (value) => { localStorageAdapter.writeString(STORAGE_KEY_SFTP_TRANSFER_CENTER, value); },
};
+9
View File
@@ -172,6 +172,15 @@ export const useUIFontsLoaded = (): boolean => {
);
};
/**
* Check if a font ID is valid
*/
export const isValidUiFontId = (fontId: string): boolean => {
// Local fonts are always considered valid (they start with 'local-')
if (fontId.startsWith('local-')) return true;
return uiFontStore.getAvailableFonts().some(f => f.id === fontId);
};
/**
* Initialize UI fonts eagerly
*/
@@ -1,10 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const source = readFileSync(new URL("./useAIChatStreaming.ts", import.meta.url), "utf8");
test("useAIChatStreaming imports streaming support from infrastructure", () => {
assert.match(source, /from ['"]\.\.\/\.\.\/infrastructure\/ai\/aiChatStreamingSupport['"]/);
assert.doesNotMatch(source, /from ['"].*components\//);
});
-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",
);
});
+33 -117
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';
@@ -33,7 +31,6 @@ import {
sanitizePortForwardingRulesForSync,
shouldPromptCloudVaultRecovery,
} from '../syncPayload';
import { commitPluginSidecarsAfterSuccessfulSync } from '../pluginSyncSidecarBridge';
import { readInterruptedVaultApply } from '../localVaultBackups';
import {
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
@@ -79,21 +76,6 @@ interface AutoSyncConfig {
// Get manager singleton for direct state access
const manager = getCloudSyncManager();
const AUTO_SYNC_PROVIDER_ORDER: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
/** Prefer built-in order, then any connected namespaced plugin provider. */
function pickConnectedAutoSyncProvider(
providers: Record<string, { status?: string; tokens?: unknown; config?: unknown }>,
): CloudProvider | undefined {
for (const id of AUTO_SYNC_PROVIDER_ORDER) {
const conn = providers[id];
if (conn && isProviderReadyForSync(conn as never)) return id;
}
for (const [id, conn] of Object.entries(providers)) {
if (AUTO_SYNC_PROVIDER_ORDER.includes(id as CloudProvider)) continue;
if (conn && isProviderReadyForSync(conn as never)) return id as CloudProvider;
}
return undefined;
}
const SYNCABLE_SETTING_STORAGE_KEY_SET = new Set<string>(SYNCABLE_SETTING_STORAGE_KEYS);
// Cross-window restore barrier: stored as an epoch-ms deadline. Any value
@@ -143,7 +125,6 @@ const getSyncPayloadDataHash = (payload: SyncPayload): string => {
portForwardingRules: sanitizePortForwardingRulesForSync(payload.portForwardingRules),
groupConfigs: payload.groupConfigs,
settings: payload.settings,
pluginSidecars: payload.pluginSidecars,
});
};
@@ -207,21 +188,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
return () => window.removeEventListener('sftp-bookmarks-changed', handler);
}, []);
// Plugin settings live in the main-process DB; contribution change events
// are the renderer signal that sidecar-backed values may have changed.
const [pluginSidecarsVersion, setPluginSidecarsVersion] = useState(0);
useEffect(() => {
const bridge = netcattyBridge.get() as {
onPluginContributionsChanged?: (callback: () => void) => () => void;
} | null | undefined;
const unsubscribe = bridge?.onPluginContributionsChanged?.(() => {
setPluginSidecarsVersion((v) => v + 1);
});
return () => {
unsubscribe?.();
};
}, []);
const [syncableSettingsStorageVersion, setSyncableSettingsStorageVersion] = useState(0);
useEffect(() => {
const bumpIfSyncableSetting = (key: string | null | undefined) => {
@@ -273,24 +239,19 @@ export const useAutoSync = (config: AutoSyncConfig) => {
config.groupConfigs,
]);
// Build sync payload (includes plugin sidecars when host is available)
// Build sync payload
const buildPayload = useCallback(async (): Promise<SyncPayload> => {
const { withPluginSyncSidecars } = await import('../syncPayload');
const { collectPluginSyncSidecarsFromHost } = await import('../pluginSyncSidecarBridge');
const base: SyncPayload = {
return {
...getSyncSnapshot(),
settings: await collectCloudSyncableSettings(),
syncedAt: Date.now(),
};
const sidecars = await collectPluginSyncSidecarsFromHost();
return withPluginSyncSidecars(base, sidecars);
}, [getSyncSnapshot]);
// Create a hash of current data for comparison (includes settings + sidecars)
// Create a hash of current data for comparison (includes settings)
const getDataHash = useCallback(async () => {
const payload = await buildPayload();
return getSyncPayloadDataHash(payload);
}, [buildPayload]);
return JSON.stringify({ ...getSyncSnapshot(), settings: await collectCloudSyncableSettings() });
}, [getSyncSnapshot]);
// Sync now handler - get fresh state directly from manager
const syncNow = useCallback(async (options?: SyncNowOptions): Promise<boolean> => {
@@ -385,8 +346,8 @@ export const useAutoSync = (config: AutoSyncConfig) => {
throw new Error(t('sync.autoSync.vaultLocked'));
}
const dataHash = await getDataHash();
const payload = await buildPayload();
const dataHash = getSyncPayloadDataHash(payload);
const encryptedCredentialPaths = findSyncPayloadEncryptedCredentialPaths(payload);
if (
encryptedCredentialPaths.length > 0
@@ -437,14 +398,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.');
}
@@ -461,10 +421,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
}
}
// Commit sidecar last-known after a successful sync (also done inside
// useCloudSync.syncNow; keep here as defense for this path's merge logic).
commitPluginSidecarsAfterSuccessfulSync(payload, resultList);
lastSyncedDataRef.current = dataHash;
manager.setPendingLocalSync(false);
@@ -495,7 +451,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(() => {
@@ -561,19 +517,13 @@ export const useAutoSync = (config: AutoSyncConfig) => {
if (!remoteCheckDoneRef.current) {
return;
}
try {
const currentHash = await getDataHashRef.current();
const hashDecision = resolveAutoSyncHashDecision({
currentHash,
lastSyncedHash: lastSyncedDataRef.current,
appliedSkipHash: skipNextSyncHashRef.current,
});
manager.setPendingLocalSync(hashDecision === 'sync');
} catch (error) {
// Sidecar collect can throw on host DB/runtime errors; leave the
// pending indicator unchanged rather than surfacing an unhandled rejection.
console.warn('[AutoSync] Failed to refresh pending local sync:', error);
}
const currentHash = await getDataHashRef.current();
const hashDecision = resolveAutoSyncHashDecision({
currentHash,
lastSyncedHash: lastSyncedDataRef.current,
appliedSkipHash: skipNextSyncHashRef.current,
});
manager.setPendingLocalSync(hashDecision === 'sync');
}, [convergentSyncPaused, enabled, sync.hasAnyConnectedProvider, sync.isUnlocked]);
const refreshPendingLocalSyncRef = useRef(refreshPendingLocalSync);
@@ -617,7 +567,9 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// "nothing to check" early return doesn't leak the lock and wedge
// the retry timer. Any path that takes the lock MUST reach the
// finally-release below.
const connectedProvider = pickConnectedAutoSyncProvider(state.providers) ?? null;
const connectedProvider = AUTO_SYNC_PROVIDER_ORDER.find((provider) =>
isProviderReadyForSync(state.providers[provider]),
) ?? null;
if (!connectedProvider) {
// Nothing to check — mark as done so the auto-sync gate opens.
@@ -710,11 +662,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
@@ -767,7 +715,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
conflictActionOverride: 'upload-local',
});
const roundTripResultList = Array.from(roundTripResults.values());
commitPluginSidecarsAfterSuccessfulSync(remotePayload, roundTripResultList);
const wasShrinkBlocked = roundTripResultList.some((result) => result.shrinkBlocked === true);
const roundTripFullySynced = roundTripResultList.length > 0
&& roundTripResultList.every((result) => result.success);
@@ -785,17 +732,8 @@ 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);
const allProvidersSynced = results.length > 0
&& results.every((result) => result.success);
const wasShrinkBlocked = results.some((result) => result.shrinkBlocked === true);
@@ -812,20 +750,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 +779,8 @@ 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);
const wasShrinkBlocked = roundTripResultList.some((r) => r.shrinkBlocked === true);
const roundTripFullySynced = roundTripResultList.length > 0
&& roundTripResultList.every((result) => result.success);
@@ -896,11 +828,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
isInitializedRef.current = true;
}
if (markCurrentDataSynced && (!hadInitialBaseline || inspectedRemoteChange)) {
try {
lastSyncedDataRef.current = await getDataHashRef.current();
} catch (error) {
console.warn('[AutoSync] Failed to capture post-inspect baseline hash:', error);
}
lastSyncedDataRef.current = await getDataHashRef.current();
} else if (!markCurrentDataSynced) {
lastSyncedDataRef.current = '';
}
@@ -945,13 +873,9 @@ export const useAutoSync = (config: AutoSyncConfig) => {
const establishInitialBaseline = () => {
isInitializedRef.current = true;
void (async () => {
try {
const currentHash = await getDataHash();
if (cancelled) return;
lastSyncedDataRef.current = currentHash;
} catch (error) {
console.warn('[AutoSync] Failed to establish sync baseline hash:', error);
}
const currentHash = await getDataHash();
if (cancelled) return;
lastSyncedDataRef.current = currentHash;
})();
};
@@ -992,13 +916,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
syncTimeoutRef.current = setTimeout(() => {
syncTimeoutRef.current = null;
void (async () => {
let currentHash: string;
try {
currentHash = await getDataHash();
} catch (error) {
console.warn('[AutoSync] Failed to hash local vault before auto-sync:', error);
return;
}
const currentHash = await getDataHash();
if (cancelled) return;
// After a remote apply or merge, skip only that exact applied data.
@@ -1068,7 +986,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
config.settingsVersion,
enabled,
bookmarksVersion,
pluginSidecarsVersion,
convergentSyncPaused,
syncableSettingsStorageVersion,
]);
@@ -1085,7 +1002,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
config.settingsVersion,
enabled,
bookmarksVersion,
pluginSidecarsVersion,
syncableSettingsStorageVersion,
]);
+5 -112
View File
@@ -59,7 +59,6 @@ interface CloudSyncRunOptions {
overrideShrink?: boolean;
conflictActionOverride?: CloudSyncConflictAction;
applyConvergentPayload?: ConvergentPayloadApplier;
signal?: AbortSignal;
}
export interface CloudSyncHook {
@@ -108,12 +107,6 @@ export interface CloudSyncHook {
authAttemptId?: number
) => Promise<void>;
connectGoogle: () => Promise<string>;
/** Connect a namespaced plugin sync Provider (encrypted-object storage only). */
connectPluginProvider: (
providerId: string,
configuration?: unknown,
credential?: unknown,
) => Promise<void>;
connectOneDrive: () => Promise<string>;
connectWebDAV: (config: WebDAVConfig) => Promise<void>;
connectS3: (config: S3Config) => Promise<void>;
@@ -131,7 +124,7 @@ export interface CloudSyncHook {
syncToProvider: (
provider: CloudProvider,
payload: SyncPayload,
opts?: Pick<CloudSyncRunOptions, 'overrideShrink' | 'applyConvergentPayload' | 'signal'>,
opts?: Pick<CloudSyncRunOptions, 'overrideShrink' | 'applyConvergentPayload'>,
) => Promise<SyncResult>;
downloadFromProvider: (provider: CloudProvider) => Promise<RemoteSyncPayload | null>;
commitRemoteInspection: (provider: CloudProvider, remoteFile: SyncedFile, payload: SyncPayload, opts?: { recordDownload?: boolean }) => Promise<void>;
@@ -310,39 +303,6 @@ export const useCloudSync = (): CloudSyncHook => {
// A missing/corrupt replica is surfaced by the next explicit sync.
});
}, [state.securityState]);
// Keep plugin sync provider availability aligned with live contributions so
// disabled/uninstalled providers leave the auto-sync ready set after restart.
useEffect(() => {
let cancelled = false;
const refreshAvailable = async () => {
try {
const { pluginExtensionBridge } = await import('./pluginExtensionBridge');
const listed = await pluginExtensionBridge.listProviders('sync');
if (cancelled) return;
const ids = (listed ?? []).map((entry) => {
const nested = (entry as { provider?: { id?: string } }).provider;
return typeof nested?.id === 'string' ? nested.id : '';
}).filter((id) => id.length > 0);
manager.setAvailablePluginSyncProviders(ids);
} catch {
// Transient IPC / host startup failures must not wipe the availability
// catalog; leave the previous set until a successful discovery.
}
};
void refreshAvailable();
let unsubscribe = () => {};
void import('./pluginExtensionBridge').then(({ pluginExtensionBridge }) => {
if (cancelled) return;
unsubscribe = pluginExtensionBridge.onContributionsChanged(() => {
void refreshAvailable();
});
});
return () => {
cancelled = true;
unsubscribe();
};
}, []);
// ========== Computed Values ==========
@@ -716,18 +676,6 @@ export const useCloudSync = (): CloudSyncHook => {
const connectS3 = useCallback(async (config: S3Config): Promise<void> => {
await manager.connectConfigProvider('s3', config);
}, []);
/**
* Connect a namespaced plugin sync Provider (encrypted-object storage only).
* Configuration is opaque plugin-owned JSON; encryption stays host-owned.
*/
const connectPluginProvider = useCallback(async (
providerId: string,
configuration: unknown = {},
credential?: unknown,
): Promise<void> => {
await manager.connectPluginProvider(providerId, configuration, credential);
}, []);
const cancelOAuthConnect = useCallback(() => {
const githubAbort = activeGitHubAuthAbortRef.current;
@@ -786,48 +734,16 @@ export const useCloudSync = (): CloudSyncHook => {
const syncNowWithUnlock = useCallback(async (payload: SyncPayload, opts?: CloudSyncRunOptions) => {
await ensureUnlocked();
const controller = new AbortController();
let onAbort: (() => void) | undefined;
if (opts?.signal) {
if (opts.signal.aborted) controller.abort();
else {
onAbort = () => controller.abort();
opts.signal.addEventListener('abort', onAbort, { once: true });
}
}
try {
const results = await manager.syncAllProviders(payload, { ...opts, signal: controller.signal });
const { commitPluginSidecarsAfterSuccessfulSync } = await import('../pluginSyncSidecarBridge');
commitPluginSidecarsAfterSuccessfulSync(payload, results.values());
return results;
} finally {
if (opts?.signal && onAbort) opts.signal.removeEventListener('abort', onAbort);
}
return await manager.syncAllProviders(payload, opts);
}, [ensureUnlocked]);
const syncToProviderWithUnlock = useCallback(async (
provider: CloudProvider,
payload: SyncPayload,
opts?: Pick<CloudSyncRunOptions, 'overrideShrink' | 'applyConvergentPayload' | 'signal'>,
opts?: Pick<CloudSyncRunOptions, 'overrideShrink' | 'applyConvergentPayload'>,
) => {
await ensureUnlocked();
const controller = new AbortController();
let onAbort: (() => void) | undefined;
if (opts?.signal) {
if (opts.signal.aborted) controller.abort();
else {
onAbort = () => controller.abort();
opts.signal.addEventListener('abort', onAbort, { once: true });
}
}
try {
const result = await manager.syncToProvider(provider, payload, { ...opts, signal: controller.signal });
const { commitPluginSidecarsAfterSuccessfulSync } = await import('../pluginSyncSidecarBridge');
commitPluginSidecarsAfterSuccessfulSync(payload, [result]);
return result;
} finally {
if (opts?.signal && onAbort) opts.signal.removeEventListener('abort', onAbort);
}
return await manager.syncToProvider(provider, payload, opts);
}, [ensureUnlocked]);
const downloadFromProviderWithUnlock = useCallback(async (provider: CloudProvider) => {
@@ -869,29 +785,7 @@ export const useCloudSync = (): CloudSyncHook => {
) => Promise<void>,
) => {
await ensureUnlocked();
// Collect live sidecars so conflict apply/upload does not revert plugin
// settings that changed after the last provider baseline. Operational
// collection failures must abort (not proceed as empty). Host gated off /
// non-authoritative collect must omit the field (not last-known cache) so
// apply preserves local DB sidecars.
const {
collectPluginSyncSidecarsFromHost,
isPluginSidecarHostUnavailableError,
} = await import('../pluginSyncSidecarBridge');
let pluginSidecars: SyncPayload['pluginSidecars'] | undefined;
try {
const collected = await collectPluginSyncSidecarsFromHost({ liveOnly: true });
if (collected) pluginSidecars = collected;
} catch (error) {
if (isPluginSidecarHostUnavailableError(error)) {
// Host gated off: omit field so apply preserves local sidecars.
} else {
throw error;
}
}
return manager.resolveConvergentConflict(addressKey, candidateDot, applyPayload, {
pluginSidecars,
});
return manager.resolveConvergentConflict(addressKey, candidateDot, applyPayload);
}, [ensureUnlocked]);
const refreshConvergentSyncConfig = useCallback(() => {
@@ -960,7 +854,6 @@ export const useCloudSync = (): CloudSyncHook => {
connectOneDrive,
connectWebDAV,
connectS3,
connectPluginProvider,
completePKCEAuth,
cancelOAuthConnect,
disconnectProvider,
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { buildAITerminalSessionInfo } from '../../domain/buildAITerminalSessionInfo';
import { buildAITerminalSessionInfo } from '../../components/terminalLayer/TerminalLayerSupport';
import { detectLocalOs } from '../../lib/localShell';
import type { Host, PortForwardingRule, TerminalSession } from '../../types';
import { STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED } from '../../infrastructure/config/storageKeys';
@@ -144,26 +144,12 @@ export function readExternalMcpMode(): ExternalMcpMode {
return normalizeExternalMcpMode(localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE));
}
export function writeExternalMcpMode(mode: ExternalMcpMode): ExternalMcpMode {
const normalized = normalizeExternalMcpMode(mode);
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, normalized);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_MODE);
return normalized;
}
export function readExternalMcpIdleTimeoutMinutes(): number {
return normalizeExternalMcpIdleTimeoutMinutes(
localStorageAdapter.readNumber(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES),
);
}
export function writeExternalMcpIdleTimeoutMinutes(minutes: number): number {
const normalized = normalizeExternalMcpIdleTimeoutMinutes(minutes);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES, normalized);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES);
return normalized;
}
/** Whether host_open should surface/focus the main window. Defaults to true (existing behavior). */
export function readExternalMcpFocusOnHostOpen(): boolean {
return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN) ?? true;
@@ -201,13 +187,6 @@ export function readSessionIdleTimeoutMinutes(): number {
);
}
export function writeSessionIdleTimeoutMinutes(minutes: number): number {
const normalized = normalizeSessionIdleTimeoutMinutes(minutes);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES, normalized);
emitAIStateChanged(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES);
return normalized;
}
export function shouldStartExternalMcpOnStartup({
enabled,
mode,
-3
View File
@@ -782,9 +782,6 @@ export function useOutputTriggers({
resetTriggerScanState();
},
schedule: (callback) => {
// Prefer setTimeout over rAF: terminal output can continue while the
// Electron renderer is hidden/minimized, when rAF is throttled or paused.
// Pending scan buffers are capped, so delayed flushes can drop matches.
const timer = globalThis.setTimeout(callback, OUTPUT_TRIGGER_SCAN_DELAY_MS);
return () => globalThis.clearTimeout(timer);
},
@@ -1,10 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const source = readFileSync(new URL("./usePortForwardHostKeyVerification.ts", import.meta.url), "utf8");
test("usePortForwardHostKeyVerification stays below the UI layer", () => {
assert.doesNotMatch(source, /from ["'].*components\//);
assert.match(source, /from ["']\.\.\/\.\.\/domain\/portForwardHostKey["']/);
});
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import type { KnownHost } from "../../domain/models";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
import type { HostKeyInfo } from "../../domain/hostKey";
import type { HostKeyInfo } from "../../components/terminal/TerminalHostKeyVerification";
import {
createKnownHostFromPortForwardHostKeyInfo,
enqueuePortForwardHostKeyVerification,
@@ -9,7 +9,7 @@ import {
toPendingPortForwardHostKeyVerification,
type PendingPortForwardHostKeyVerification,
type PortForwardHostKeyRequest,
} from "../../domain/portForwardHostKey";
} from "../../components/port-forwarding/hostKeyVerification";
export interface PortForwardHostKeyVerificationState {
hostKeyInfo: HostKeyInfo;
+6 -15
View File
@@ -619,26 +619,17 @@ export const useSessionState = ({
});
}, []);
const submitWorkspaceRename = useCallback((workspaceId?: string, name?: string) => {
if (workspaceId !== undefined && name !== undefined) {
const trimmed = name.trim();
if (!trimmed) return;
setWorkspaces(prev => prev.map(w => (
w.id === workspaceId ? { ...w, title: trimmed, autoTitle: false } : w
)));
return;
}
const submitWorkspaceRename = useCallback(() => {
setWorkspaceRenameValue(prevValue => {
const trimmed = prevValue.trim();
if (!trimmed) return prevValue;
const name = prevValue.trim();
if (!name) return prevValue;
setWorkspaceRenameTarget(prevTarget => {
if (!prevTarget) return prevTarget;
setWorkspaces(prev => prev.map(w => w.id === prevTarget.id ? { ...w, title: trimmed, autoTitle: false } : w));
setWorkspaces(prev => prev.map(w => w.id === prevTarget.id ? { ...w, title: name, autoTitle: false } : w));
return null;
});
return '';
});
}, []);
+22 -126
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,13 +84,11 @@ 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,
DEFAULT_CUSTOM_ACCENT,
DEFAULT_DARK_UI_THEME,
DEFAULT_EDITOR_WORD_WRAP,
DEFAULT_HOTKEY_SCHEME,
@@ -158,14 +154,6 @@ import {
type WindowOpacityMutationSource,
type WindowOpacityRecord,
} from './windowOpacitySync';
import {
parseCustomAccentRecord,
serializeCustomAccentRecord,
shouldApplyCustomAccentRecord,
shouldBroadcastCustomAccentChange,
type CustomAccentMutationSource,
type CustomAccentRecord,
} from './customAccentSync';
import {
createLocalTerminalFontSizeRecord,
createTerminalFontSizeSyncOrigin,
@@ -195,7 +183,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);
@@ -205,47 +193,15 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
const stored = readStoredString(STORAGE_KEY_UI_THEME_DARK);
return stored && isValidUiThemeId('dark', stored) ? stored : DEFAULT_DARK_UI_THEME;
});
const [customAccentRecord, setCustomAccentRecord] = useState<CustomAccentRecord>(() => {
return parseCustomAccentRecord(readStoredString(STORAGE_KEY_COLOR));
const [customAccent, setCustomAccent] = useState<string>(() => {
const stored = readStoredString(STORAGE_KEY_COLOR);
return stored && isValidHslToken(stored) ? stored.trim() : DEFAULT_CUSTOM_ACCENT;
});
const customAccent = customAccentRecord.color;
const customAccentMutationSourceRef = useRef<CustomAccentMutationSource>('local');
const setCustomAccent = useCallback((nextValue: SetStateAction<string>) => {
customAccentMutationSourceRef.current = 'local';
setCustomAccentRecord((prev) => {
const candidate = typeof nextValue === 'function'
? (nextValue as (prevState: string) => string)(prev.color)
: nextValue;
const next = parseCustomAccentRecord(candidate);
if (next.color === prev.color) return prev;
return { color: next.color, version: prev.version + 1 };
});
}, []);
const applyIncomingCustomAccent = useCallback((raw: unknown) => {
const incoming = parseCustomAccentRecord(raw);
setCustomAccentRecord((prev) => {
if (!shouldApplyCustomAccentRecord(prev, incoming)) {
return prev;
}
customAccentMutationSourceRef.current = 'incoming';
return incoming;
});
}, []);
const [accentMode, setAccentMode] = useState<'theme' | 'custom'>(() => {
const stored = readStoredString(STORAGE_KEY_ACCENT_MODE);
if (stored === 'theme' || stored === 'custom') return stored;
const raw = readStoredString(STORAGE_KEY_COLOR);
if (!raw) return DEFAULT_ACCENT_MODE;
if (isValidHslToken(raw.trim())) return 'custom';
if (raw.trim().startsWith('{')) {
try {
const obj = JSON.parse(raw.trim()) as { color?: unknown };
return typeof obj.color === 'string' && isValidHslToken(obj.color) ? 'custom' : DEFAULT_ACCENT_MODE;
} catch {
return DEFAULT_ACCENT_MODE;
}
}
return DEFAULT_ACCENT_MODE;
const legacyColor = readStoredString(STORAGE_KEY_COLOR);
return legacyColor && isValidHslToken(legacyColor) ? 'custom' : DEFAULT_ACCENT_MODE;
});
const [uiFontFamilyId, setUiFontFamilyId] = useState<string>(() => {
const stored = readStoredString(STORAGE_KEY_UI_FONT_FAMILY);
@@ -423,11 +379,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 +543,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');
@@ -603,7 +556,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
darkUiThemeId,
accentMode,
customAccent,
customAccentVersion: customAccentRecord.version,
});
appearanceStateRef.current = {
theme,
@@ -611,7 +563,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
darkUiThemeId,
accentMode,
customAccent,
customAccentVersion: customAccentRecord.version,
};
const setTerminalSettings = useCallback((nextValue: SetStateAction<TerminalSettings>) => {
@@ -734,7 +685,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]);
@@ -781,7 +732,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
darkUiThemeId: nextDarkId,
accentMode: nextAccentMode,
customAccent: nextAccent,
customAccentVersion: nextAccentVersion,
} = nextAppearance;
// Fix 2: Skip expensive DOM operations if nothing actually changed
@@ -790,27 +740,18 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
nextLightId === current.lightUiThemeId &&
nextDarkId === current.darkUiThemeId &&
nextAccentMode === current.accentMode &&
nextAccent === current.customAccent &&
nextAccentVersion === current.customAccentVersion
nextAccent === current.customAccent
) {
return;
}
// Publish synchronously so a later keyed IPC in the same turn composes on top.
appearanceStateRef.current = nextAppearance;
if (
nextAccent !== current.customAccent
|| nextAccentVersion !== current.customAccentVersion
) {
// Mark incoming so the persist effect does not rebroadcast to the peer that
// originated this update (color-picker drag ping-pong; see #2743).
customAccentMutationSourceRef.current = 'incoming';
setCustomAccentRecord({ color: nextAccent, version: nextAccentVersion });
}
setTheme(nextTheme);
setLightUiThemeId(nextLightId);
setDarkUiThemeId(nextDarkId);
setAccentMode(nextAccentMode);
setCustomAccent(nextAccent);
const effective = nextTheme === 'system' ? getSystemPreference() : nextTheme;
const tokens = getUiThemeById(effective, effective === 'dark' ? nextDarkId : nextLightId).tokens;
@@ -841,13 +782,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 +832,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);
@@ -960,7 +892,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
darkUiThemeId,
accentMode,
customAccent,
customAccentVersion: customAccentRecord.version,
};
// Capture previous snapshot before overwrite so we can notify only the
// appearance fields that actually changed on this render.
@@ -968,8 +899,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;
@@ -984,19 +913,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId);
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
localStorageAdapter.writeString(STORAGE_KEY_ACCENT_MODE, accentMode);
// Never let a stale effect overwrite a newer revision already on disk.
const storedAccent = parseCustomAccentRecord(
localStorageAdapter.readString(STORAGE_KEY_COLOR),
);
if (
shouldApplyCustomAccentRecord(storedAccent, customAccentRecord)
|| storedAccent.version === customAccentRecord.version
) {
localStorageAdapter.writeString(
STORAGE_KEY_COLOR,
serializeCustomAccentRecord(customAccentRecord),
);
}
localStorageAdapter.writeString(STORAGE_KEY_COLOR, customAccent);
// Fix 1: Skip IPC broadcast on initial mount (values already match localStorage)
if (!persistMountedRef.current) return;
// Emit a keyed IPC notification for each changed appearance field so the
@@ -1015,21 +932,10 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
if (!previousAppearance || previousAppearance.accentMode !== accentMode) {
notifySettingsChanged(STORAGE_KEY_ACCENT_MODE, accentMode);
}
if (
!previousAppearance
|| previousAppearance.customAccent !== customAccent
|| previousAppearance.customAccentVersion !== customAccentRecord.version
) {
const decision = shouldBroadcastCustomAccentChange(
customAccentMutationSourceRef.current,
true,
);
customAccentMutationSourceRef.current = decision.nextSource;
if (decision.shouldBroadcast) {
notifySettingsChanged(STORAGE_KEY_COLOR, customAccentRecord);
}
if (!previousAppearance || previousAppearance.customAccent !== customAccent) {
notifySettingsChanged(STORAGE_KEY_COLOR, customAccent);
}
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentRecord, notifySettingsChanged]);
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, notifySettingsChanged]);
// Listen for OS color scheme changes to keep systemPreference in sync
useEffect(() => {
@@ -1129,21 +1035,19 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
useSettingsStorageSync({
enabled: enableSettingsSync,
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
customAccentVersion: customAccentRecord.version,
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,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode,
applyIncomingCustomAccent,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
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 +1275,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 +1660,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setSftpShowHiddenFiles,
sftpUseCompressedUpload,
setSftpUseCompressedUpload,
sftpSkipUnchanged,
setSftpSkipUnchanged,
sftpAutoOpenSidebar,
setSftpAutoOpenSidebar,
sftpFollowTerminalCwd,
@@ -1844,11 +1740,11 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
// Opaque version that changes when any synced setting changes, used by useAutoSync.
// eslint-disable-next-line react-hooks/exhaustive-deps
settingsVersion: useMemo(() => Math.random(), [
theme, lightUiThemeId, darkUiThemeId, accentMode,
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
uiFontFamilyId, uiLanguage, customCSS,
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
]),
+11 -13
View File
@@ -55,7 +55,6 @@ export async function releaseSftpTabConnection(params: {
connectionCacheKeys: Map<string, string>;
clearCacheForConnection: (connectionId: string) => void;
closeSftp: (sftpId: string) => Promise<unknown>;
onRemoteSessionClosed?: (sftpId: string) => void;
}): Promise<void> {
await releaseSftpConnectionMetadata(params);
}
@@ -67,7 +66,6 @@ export const useSftpState = (
options?: SftpStateOptions
) => {
const transferOwnerIdRef = useRef(options?.transferOwnerId ?? crypto.randomUUID());
const forgetExternalEditTempsForSftpRef = useRef<(sftpId: string) => void>(() => {});
const createPane = useCallback(
(id?: string, showHiddenFiles = options?.defaultShowHiddenFiles ?? false) =>
createEmptyPane(id, showHiddenFiles),
@@ -243,7 +241,6 @@ export const useSftpState = (
connectionCacheKeys: connectionCacheKeyMapRef.current,
clearCacheForConnection,
closeSftp: async (sftpId) => netcattyBridge.get()?.closeSftp(sftpId),
onRemoteSessionClosed: (sftpId) => forgetExternalEditTempsForSftpRef.current(sftpId),
});
}
closeTab(side, tabId);
@@ -256,7 +253,6 @@ export const useSftpState = (
connectionCacheKeys: connectionCacheKeyMapRef.current,
clearCacheForConnection,
closeSftp: async (sftpId) => netcattyBridge.get()?.closeSftp(sftpId),
onRemoteSessionClosed: (sftpId) => forgetExternalEditTempsForSftpRef.current(sftpId),
});
}, [clearCacheForConnection]);
@@ -399,6 +395,14 @@ export const useSftpState = (
[hosts, identities, keys, openPoolSftpSession, transferKnownHosts, transferPoolKeyCache, transferTerminalSettings],
);
/**
* @deprecated No-op. SSH transport idle park keeps connections warm; opening
* a background transfer channel is unnecessary and could re-trigger MFA.
*/
const warmTransferPoolForHost = useCallback(async (_hostId: string) => {
// Intentionally empty — unified transport registry owns keep-alive.
}, []);
/** True after browse channels were soft-closed while this owner stayed mounted. */
const browseParkedRef = useRef(false);
const browseLifecycleGenRef = useRef(0);
@@ -440,7 +444,6 @@ export const useSftpState = (
clearCacheForConnection,
createEmptyPane: createPane,
autoConnectLocalOnMount: options?.autoConnectLocalOnMount,
onRemoteSessionClosed: (sftpId) => forgetExternalEditTempsForSftpRef.current(sftpId),
});
const {
@@ -564,8 +567,6 @@ export const useSftpState = (
cancelExternalUpload,
selectApplication,
activeFileWatchCountRef,
activeExternalEditCount,
forgetExternalEditTempsForSftp,
releaseExternalFileWatches,
uploadConflicts,
resolveUploadConflict,
@@ -626,7 +627,6 @@ export const useSftpState = (
useCompressedUpload: options?.useCompressedUpload,
isTransferCancelled,
});
forgetExternalEditTempsForSftpRef.current = forgetExternalEditTempsForSftp;
const conflicts = useMemo(
() => [...transferConflicts, ...uploadConflicts],
@@ -666,7 +666,6 @@ export const useSftpState = (
interactive,
browseParked: browseParkedRef.current,
activeTransfersCount: Math.max(activeTransfersCount, centerActive ? 1 : 0),
activeExternalEditCount,
})) {
return;
}
@@ -685,7 +684,6 @@ export const useSftpState = (
} catch {
// best-effort — session may already be gone
}
forgetExternalEditTempsForSftpRef.current(sftpId);
}));
};
@@ -739,7 +737,6 @@ export const useSftpState = (
}, [
interactive,
activeTransfersCount,
activeExternalEditCount,
clearCacheForConnection,
connect,
getActivePane,
@@ -816,6 +813,7 @@ export const useSftpState = (
rejectHostKeyVerification,
acceptHostKeyVerification,
acceptAndSaveHostKeyVerification,
warmTransferPoolForHost,
};
const methodsRef = useRef(currentMethods);
methodsRef.current = currentMethods;
@@ -898,6 +896,8 @@ export const useSftpState = (
rejectHostKeyVerification: () => methodsRef.current.rejectHostKeyVerification(),
acceptHostKeyVerification: () => methodsRef.current.acceptHostKeyVerification(),
acceptAndSaveHostKeyVerification: () => methodsRef.current.acceptAndSaveHostKeyVerification(),
warmTransferPoolForHost: (...args: Parameters<typeof warmTransferPoolForHost>) =>
methodsRef.current.warmTransferPoolForHost(...args),
activeFileWatchCountRef,
}), [activeFileWatchCountRef]); // activeFileWatchCountRef is a stable ref
@@ -911,7 +911,6 @@ export const useSftpState = (
rightTabs,
transfers,
activeTransfersCount,
activeExternalEditCount,
conflicts,
hostKeyVerification,
@@ -933,7 +932,6 @@ export const useSftpState = (
rightTabs,
transfers,
activeTransfersCount,
activeExternalEditCount,
conflicts,
hostKeyVerification,
stableMethods,
@@ -112,14 +112,6 @@ export function useSystemManagerBackend() {
return bridge.getDockerStats(options);
}, []);
const listAccelerators = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.listAccelerators) {
return { success: false as const, error: 'listAccelerators unavailable' };
}
return bridge.listAccelerators(sessionId);
}, []);
const dockerInspect = useCallback(async (options: { sessionId: string; containerId: string }) => {
const bridge = netcattyBridge.get();
if (!bridge?.dockerInspect) {
@@ -180,7 +172,6 @@ export function useSystemManagerBackend() {
listDockerContainers,
listDockerImages,
getDockerStats,
listAccelerators,
dockerInspect,
dockerImageInspect,
dockerAction,
@@ -199,7 +190,6 @@ export function useSystemManagerBackend() {
listDockerContainers,
listDockerImages,
getDockerStats,
listAccelerators,
dockerInspect,
dockerImageInspect,
dockerAction,
@@ -1,10 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const source = readFileSync(new URL("./useTerminalAiContexts.ts", import.meta.url), "utf8");
test("useTerminalAiContexts imports AI context primitives from domain", () => {
assert.match(source, /from ['"]\.\.\/\.\.\/domain\/buildAITerminalSessionInfo['"]/);
assert.doesNotMatch(source, /from ['"].*components\//);
});
+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);
}, []);
+7 -22
View File
@@ -1,4 +1,4 @@
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import type { TerminalTheme } from '../../domain/models';
import {
@@ -42,9 +42,7 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
const [userIntent, setUserIntent] = useState<ThemeUserIntent>(idleThemeUserIntent());
// Theme-id settings without accent — keeps resolveFocusedAppearance identity
// stable during color-picker drag (TerminalLayer memo depends on it).
const appearanceSettingsBase = useMemo((): Omit<TerminalAppearanceSettings, 'accentMode' | 'customAccent'> => ({
const appearanceSettings = useMemo((): TerminalAppearanceSettings => ({
terminalThemeId,
terminalThemeDarkId,
terminalThemeLightId,
@@ -52,6 +50,8 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
resolvedTheme,
lightUiThemeId,
darkUiThemeId,
accentMode,
customAccent,
}), [
terminalThemeId,
terminalThemeDarkId,
@@ -60,17 +60,9 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
resolvedTheme,
lightUiThemeId,
darkUiThemeId,
]);
const accentRef = useRef({ accentMode, customAccent });
accentRef.current = { accentMode, customAccent };
// Chrome / injection consumers need live accent on every settings change.
const appearanceSettings = useMemo((): TerminalAppearanceSettings => ({
...appearanceSettingsBase,
accentMode,
customAccent,
}), [appearanceSettingsBase, accentMode, customAccent]);
]);
const globalAppearance = useMemo(() => resolveGlobalTerminalAppearance({
userIntent,
@@ -78,21 +70,14 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
customThemes,
}), [userIntent, appearanceSettings, customThemes]);
// Read accent from a ref so this callback identity does not churn on drag.
// Callers that invoke it after an accent-only update still get the latest
// accent; Terminal panes also re-apply appearanceChromeStore at the leaf.
const resolveFocusedAppearance = useCallback((hostScope: TerminalAppearanceHostScope): ResolvedAppearance => (
resolveTerminalAppearance({
userIntent,
settings: {
...appearanceSettingsBase,
accentMode: accentRef.current.accentMode,
customAccent: accentRef.current.customAccent,
},
settings: appearanceSettings,
hostScope,
customThemes,
})
), [userIntent, appearanceSettingsBase, customThemes]);
), [userIntent, appearanceSettings, customThemes]);
const applyFollowAppSettingsForPick = useCallback((themeId: string) => {
const update = getFollowAppTerminalThemeSelectionUpdate(themeId);
@@ -1,12 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const source = readFileSync(new URL("./useVaultImportHandlers.ts", import.meta.url), "utf8");
test("useVaultImportHandlers stays below the UI layer", () => {
assert.doesNotMatch(source, /from ["'].*components\//);
assert.doesNotMatch(source, /from ["'].*\/toast["']/);
assert.match(source, /notify: VaultImportNotifier/);
assert.match(source, /from ["']\.\/vaultImportOptions["']/);
});
+5 -58
View File
@@ -51,14 +51,9 @@ import {
STORAGE_KEY_TERM_SETTINGS,
} from "../../infrastructure/config/storageKeys";
import { localStorageAdapter, LOCAL_STORAGE_ADAPTER_CHANGED_EVENT } from "../../infrastructure/persistence/localStorageAdapter";
import {
mergeGlobalHistoryOnAppend,
removeGlobalHistoryEntry,
sanitizeGlobalHistoryEntries,
} from "../../domain/globalHistory";
import { mergeGlobalHistoryOnAppend, sanitizeGlobalHistoryEntries } from "../../domain/globalHistory";
import {
buildTerminalDataMapFromLogs,
connectionLogTerminalDataMapsEqual,
mergeConnectionLogsFromStorage,
mergeTerminalDataIntoLogs,
mergeTerminalDataMapsForStorage,
@@ -66,10 +61,6 @@ import {
} from "../../domain/connectionLogTerminalData";
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
import { loadSanitizedShellHistory } from "./shellHistoryPersistence";
import {
publishNotesSnapshot,
registerNotesActions,
} from "./notesStore";
import { publishShellHistorySnapshot } from "./shellHistoryStore";
import { setVaultInitialized } from "./vaultInitStore";
import {
@@ -262,7 +253,7 @@ const writeConnectionLogTerminalDataMap = (
localMaps,
buildPersistedLogIds(logs, usePersistedLogIdsFromDisk),
);
if (connectionLogTerminalDataMapsEqual(existing, pruned)) return true;
if (JSON.stringify(existing) === JSON.stringify(pruned)) return true;
return localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA, pruned);
};
@@ -285,13 +276,9 @@ export const useVaultState = () => {
const customGroupsRef = useRef<string[]>([]);
const managedSourcesRef = useRef<ManagedSource[]>([]);
const hostsRef = useRef<Host[]>([]);
const notesRef = useRef<VaultNote[]>([]);
const noteGroupsRef = useRef<string[]>([]);
customGroupsRef.current = customGroups;
managedSourcesRef.current = managedSources;
hostsRef.current = hosts;
notesRef.current = notes;
noteGroupsRef.current = noteGroups;
// Write-version counters prevent out-of-order async writes from overwriting
// newer data. Each update bumps the counter; the .then() callback only
@@ -600,20 +587,14 @@ export const useVaultState = () => {
const updateNotes = useCallback((data: Partial<VaultNote>[]) => {
const cleaned = normalizeVaultNotes(data);
notesRef.current = cleaned;
setNotes(cleaned);
localStorageAdapter.write(STORAGE_KEY_NOTES, cleaned);
// Publish synchronously so Notes→AI handoff does not read a stale catalog
// while React state is still committing (useLayoutEffect republishes too).
publishNotesSnapshot({ notes: cleaned, noteGroups: noteGroupsRef.current });
}, []);
const updateNoteGroups = useCallback((data: unknown) => {
const cleaned = normalizeNoteGroups(data);
noteGroupsRef.current = cleaned;
setNoteGroups(cleaned);
localStorageAdapter.write(STORAGE_KEY_NOTE_GROUPS, cleaned);
publishNotesSnapshot({ notes: notesRef.current, noteGroups: cleaned });
}, []);
const updateCustomGroups = useCallback((
@@ -843,18 +824,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) => {
@@ -875,16 +844,6 @@ export const useVaultState = () => {
publishShellHistorySnapshot([]);
}, []);
const removeShellHistoryEntry = useCallback((entryId: string) => {
setShellHistory((prev) => {
const updated = removeGlobalHistoryEntry(prev, entryId);
if (updated === prev) return prev;
localStorageAdapter.write(STORAGE_KEY_SHELL_HISTORY, updated);
publishShellHistorySnapshot(updated);
return updated;
});
}, []);
// Connection logs management
const addConnectionLog = useCallback(
(log: Omit<ConnectionLog, "id">) => {
@@ -1016,10 +975,6 @@ export const useVaultState = () => {
),
);
setHosts(sanitized);
// Always re-encrypt the batch. Stale enc:v1 placeholders are left
// unchanged by encryptCredentialValue (no double-wrap); plaintext
// siblings still need encryption when safeStorage was previously
// unavailable for some records.
encryptHosts(sanitized).then((enc) => {
if (ver === hostsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
@@ -1402,12 +1357,9 @@ export const useVaultState = () => {
const updateHostLastConnected = useCallback((hostId: string) => {
setHosts((prev) => {
const idx = prev.findIndex((h) => h.id === hostId);
if (idx < 0) return prev;
const now = Date.now();
if (prev[idx].lastConnectedAt === now) return prev;
const next = prev.slice();
next[idx] = { ...prev[idx], lastConnectedAt: now };
const next = prev.map((h) =>
h.id === hostId ? { ...h, lastConnectedAt: Date.now() } : h,
);
const ver = ++hostsWriteVersion.current;
const encryptPromise = encryptHosts(next);
hostsEncryptPendingRef.current = encryptPromise.then(() => undefined);
@@ -1554,10 +1506,6 @@ export const useVaultState = () => {
latestHosts.length,
destination,
storedGroups,
{
identities: latestIdentities.length,
keys: latestKeys.length,
},
);
const nextHosts = normalizeVaultOrder(committed.hosts.map((host) => sanitizeHost(host)));
const nextKeys = normalizeVaultOrder(committed.keys);
@@ -1671,7 +1619,6 @@ export const useVaultState = () => {
updateGroupConfigs,
addShellHistoryEntry,
clearShellHistory,
removeShellHistoryEntry,
addConnectionLog,
updateConnectionLog,
toggleConnectionLogSaved,
@@ -134,20 +134,15 @@ export function useWorkSurfaceHostEditor({
const save = useCallback((draft: Host) => {
if (!target) return;
let saved = false;
onUpdateHosts((prevHosts) => {
const nextHosts = saveWorkSurfaceHostDraft(prevHosts, target, draft);
if (!nextHosts) return prevHosts;
saved = true;
return nextHosts;
});
if (!saved) {
const nextHosts = saveWorkSurfaceHostDraft(hosts, target, draft);
if (!nextHosts) {
close();
return;
}
onUpdateHosts(nextHosts);
onSaved?.(target.mode);
close();
}, [close, onSaved, onUpdateHosts, target]);
}, [close, hosts, onSaved, onUpdateHosts, target]);
useEffect(() => {
if (shouldCloseDeletedWorkSurfaceHost(hosts, target)) {
Loaded 100 of 491 files, more files were not shown because too many files have changed in this diff. Show more