mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-01 03:12:33 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe7f5029af | ||
|
|
1d0762d250 |
No files matched your search
@@ -176,10 +176,9 @@ jobs:
|
||||
pack_script: pack:mac
|
||||
- name: windows
|
||||
os: windows-latest
|
||||
# Official Windows releases are x64-only for now. Bundled mosh/et
|
||||
# are win32-x64, and win.target must not hard-code arm64 or the
|
||||
# x64 job still emits broken win-arm64 / universal NSIS packages
|
||||
# (#2570). Use pack:win-x64 until a dedicated arm64 job exists.
|
||||
# The mosh binary workflow currently produces win32-x64 only.
|
||||
# Keep official packages aligned with bundled-mosh coverage
|
||||
# until Cygwin arm64 is stable enough to build win32-arm64.
|
||||
pack_script: pack:win-x64
|
||||
env:
|
||||
MOSH_BIN_RELEASE: ${{ needs.resolve-mosh.outputs.mosh_bin_release }}
|
||||
|
||||
@@ -196,16 +196,9 @@ jobs:
|
||||
}
|
||||
|
||||
if (event === 'issues') {
|
||||
const issue = context.payload.issue;
|
||||
const decision = auto.decideIssuesEventRoute({
|
||||
action: context.payload.action,
|
||||
labels: issue?.labels || [],
|
||||
actorLogin: context.payload.sender?.login,
|
||||
botLogins: process.env.ISSUE_BOT_LOGINS,
|
||||
});
|
||||
return set(decision.kind, {
|
||||
issue_number: String(issue.number),
|
||||
reason: decision.reason,
|
||||
return set('issue_classify', {
|
||||
issue_number: String(context.payload.issue.number),
|
||||
reason: `issues:${context.payload.action}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
@@ -491,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>(
|
||||
@@ -507,7 +505,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}
|
||||
}
|
||||
|
||||
return buildLocalVaultPayloadAsync(
|
||||
return buildLocalVaultPayload(
|
||||
{
|
||||
hosts,
|
||||
keys,
|
||||
@@ -550,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-
|
||||
@@ -565,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
|
||||
@@ -592,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
|
||||
@@ -665,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,
|
||||
@@ -828,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;
|
||||
@@ -857,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
|
||||
@@ -878,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,
|
||||
}]);
|
||||
});
|
||||
|
||||
@@ -909,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]);
|
||||
|
||||
@@ -1796,7 +1697,6 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
const appVaultDomain = useMemo(() => ({
|
||||
addShellHistoryEntry,
|
||||
removeShellHistoryEntry,
|
||||
commitPluginImporterData,
|
||||
commitVaultImportTransaction,
|
||||
connectionLogs,
|
||||
@@ -1842,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,
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
## [Unreleased] - 2026-03-11
|
||||
|
||||
### 功能
|
||||
- 终端新增"粘贴时自动上传剪贴板图片"选项:剪贴板含图片时,在远程会话中粘贴(快捷键/右键/中键)自动通过 SFTP 上传图片到远端 `.netcatty-paste-images/` 目录并输入远端路径,否则保持原有文本粘贴行为(设置 > 终端 > 行为,默认关闭)
|
||||
- 修复自动更新 IPC 事件仅发送到单个窗口的问题,改为广播所有窗口(主窗口 + 设置窗口均可收到)
|
||||
- 统一手动检查更新与自动更新的状态机,消除三套并行状态
|
||||
- 手动"检查更新"通过 GitHub API 检测版本,发现更新后异步触发 electron-updater 下载
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
@@ -114,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,
|
||||
@@ -183,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]);
|
||||
@@ -288,7 +272,6 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onRenameSession={startSessionRename}
|
||||
onCopySession={copySessionWithCurrentShell}
|
||||
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
|
||||
onEditHost={handleEditHostFromOverlay}
|
||||
onRenameWorkspace={startWorkspaceRename}
|
||||
onCopyWorkspace={copyWorkspaceWithCurrentShell}
|
||||
onCloseWorkspace={closeWorkspace}
|
||||
@@ -362,7 +345,6 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onCreateGroup={handleCreateWorkSurfaceHostGroup}
|
||||
onImportOrReuseKey={importOrReuseKey}
|
||||
onUpdateSnippets={updateSnippets}
|
||||
onUpdateHosts={updateHosts}
|
||||
/>
|
||||
|
||||
<VaultViewContainer appThemeStyle={appThemeStyle}>
|
||||
@@ -464,6 +446,8 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
identities={identities}
|
||||
snippets={snippets}
|
||||
snippetPackages={snippetPackages}
|
||||
notes={notes}
|
||||
noteGroups={noteGroups}
|
||||
sessions={sessions}
|
||||
workspaces={workspaces}
|
||||
knownHosts={effectiveKnownHosts}
|
||||
@@ -501,7 +485,6 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onUpdateHost={handleUpdateHostFromTerminal}
|
||||
onAddKnownHost={handleAddKnownHost}
|
||||
onCommandExecuted={handleTerminalCommandExecuted}
|
||||
onDeleteShellHistoryEntry={removeShellHistoryEntry}
|
||||
onTerminalDataCapture={handleTerminalDataCapture}
|
||||
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
|
||||
onAddSessionToWorkspace={addSessionToWorkspace}
|
||||
@@ -527,6 +510,8 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
updateHosts={updateTerminalHosts}
|
||||
updateSnippets={updateSnippets}
|
||||
updateSnippetPackages={updateSnippetPackages}
|
||||
updateNotes={updateNotes}
|
||||
updateNoteGroups={updateNoteGroups}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
||||
sftpAutoSync={sftpAutoSync}
|
||||
@@ -680,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);
|
||||
|
||||
@@ -5,10 +5,6 @@ import {
|
||||
removeKeyboardInteractiveRequest,
|
||||
shouldQueueKeyboardInteractiveRequest,
|
||||
} from "./useAppStartupEffects.ts";
|
||||
import {
|
||||
clearTerminalBootEpoch,
|
||||
setTerminalBootEpoch,
|
||||
} from "../../domain/terminalBootEpoch.ts";
|
||||
|
||||
const sessions = [{ id: "terminal-1" }, { id: "terminal-2" }];
|
||||
|
||||
@@ -23,42 +19,6 @@ test("terminal-scoped keyboard-interactive requests are limited to owned session
|
||||
);
|
||||
});
|
||||
|
||||
test("disconnected terminal sessions do not queue keyboard-interactive prompts", () => {
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest(
|
||||
{ scope: "terminal", sessionId: "terminal-1" },
|
||||
[{ id: "terminal-1", status: "disconnected" }],
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest(
|
||||
{ scope: "terminal", sessionId: "terminal-1" },
|
||||
[{ id: "terminal-1", status: "connecting" }],
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("superseded terminal boot epochs do not queue keyboard-interactive prompts", () => {
|
||||
setTerminalBootEpoch("terminal-1", 3);
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest(
|
||||
{ scope: "terminal", sessionId: "terminal-1", bootEpoch: 1 },
|
||||
[{ id: "terminal-1", status: "connecting" }],
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest(
|
||||
{ scope: "terminal", sessionId: "terminal-1", bootEpoch: 3 },
|
||||
[{ id: "terminal-1", status: "connecting" }],
|
||||
),
|
||||
true,
|
||||
);
|
||||
clearTerminalBootEpoch("terminal-1");
|
||||
});
|
||||
|
||||
test("external keyboard-interactive requests are not filtered by terminal session ids", () => {
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest({ scope: "external", sessionId: "sftp-conn-1" }, sessions),
|
||||
|
||||
@@ -11,7 +11,6 @@ import { getSftpTransferResourceKeys, globalSftpTransferScheduler } from '../sta
|
||||
import { hasNewSourceFingerprint } from '../state/sftp/transferProgressMetadata';
|
||||
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from '../../infrastructure/config/storageKeys';
|
||||
import type { TransferTask } from '../../domain/models';
|
||||
import { isTerminalBootEpochCurrent } from '../../domain/terminalBootEpoch';
|
||||
import {
|
||||
canApplyDedicatedResumeProgress,
|
||||
createDedicatedResumeChildUpdateBatcher,
|
||||
@@ -25,10 +24,8 @@ type KeyboardInteractiveRequestLike = {
|
||||
scope?: KeyboardInteractiveScope;
|
||||
sessionId?: string;
|
||||
hostId?: string;
|
||||
requestId?: string;
|
||||
bootEpoch?: number;
|
||||
};
|
||||
type SessionIdLike = { id: string; hostId?: string; hostname?: string; status?: string };
|
||||
type SessionIdLike = { id: string; hostId?: string; hostname?: string };
|
||||
type KeyboardInteractiveQueueItem = { requestId: string };
|
||||
|
||||
export function shouldQueueKeyboardInteractiveRequest(
|
||||
@@ -37,14 +34,7 @@ export function shouldQueueKeyboardInteractiveRequest(
|
||||
): boolean {
|
||||
if (request.scope !== "terminal") return true;
|
||||
if (!request.sessionId) return false;
|
||||
const session = sessions.find((entry) => entry.id === request.sessionId);
|
||||
if (!session) return false;
|
||||
// Status-bar disconnect keeps the tab; do not queue MFA for aborted panes.
|
||||
if (session.status === "disconnected") return false;
|
||||
// After disconnect → reconnect the tab is connecting again; reject MFA from
|
||||
// a superseded SSH start that still shares this sessionId.
|
||||
if (!isTerminalBootEpochCurrent(request.sessionId, request.bootEpoch)) return false;
|
||||
return true;
|
||||
return sessions.some((session) => session.id === request.sessionId);
|
||||
}
|
||||
|
||||
export function removeKeyboardInteractiveRequest<T extends KeyboardInteractiveQueueItem>(
|
||||
@@ -419,9 +409,6 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
targetType: task.direction === "download" ? "local" : "sftp",
|
||||
sourceSftpId: task.direction === "download" ? sftpId : undefined,
|
||||
targetSftpId: task.direction === "upload" ? sftpId : undefined,
|
||||
// Keep host-scoped path gates across session reopen (Codex P1).
|
||||
sourceHostId: task.sourceHostId,
|
||||
targetHostId: task.targetHostId,
|
||||
totalBytes: task.totalBytes,
|
||||
resumable: task.resumable !== false,
|
||||
checkpointBytes,
|
||||
@@ -433,30 +420,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
});
|
||||
},
|
||||
);
|
||||
// Same-id retry stole ownership; wait for the live owner's terminal
|
||||
// status instead of treating this invoke as completed (Codex P2).
|
||||
if (result?.superseded === true) {
|
||||
// Wait for live owner terminal status only (no fixed deadline).
|
||||
for (;;) {
|
||||
const latest = sftpTransferCenterStore.getSnapshot().tasks.find((candidate) => candidate.id === task.id);
|
||||
const status = latest?.status;
|
||||
if (status === "completed" || status === "cancelled" || status === "failed") {
|
||||
if (status === "failed") {
|
||||
throw new Error(latest?.error || "Transfer failed");
|
||||
}
|
||||
if (status === "cancelled") {
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "cancelled",
|
||||
transferId: task.id,
|
||||
endedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
// completed: events already applied; cancelled handled above.
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
} else if (result?.cancelled || result?.error === "Transfer cancelled") {
|
||||
if (result?.cancelled || result?.error === "Transfer cancelled") {
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({ type: "cancelled", transferId: task.id, endedAt: Date.now() });
|
||||
} else if (result?.error) {
|
||||
throw new Error(result.error);
|
||||
@@ -529,12 +493,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
if (!bridge?.onKeyboardInteractive) return;
|
||||
|
||||
const unsubscribe = bridge.onKeyboardInteractive((request) => {
|
||||
if (!shouldQueueKeyboardInteractiveRequest(request, sessionsRef.current)) {
|
||||
if (request.scope === "terminal" && request.requestId) {
|
||||
void bridge.respondKeyboardInteractive?.(request.requestId, [], true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!shouldQueueKeyboardInteractiveRequest(request, sessionsRef.current)) return;
|
||||
console.log('[App] Keyboard-interactive request received:', request);
|
||||
// Add to queue instead of replacing - supports multiple concurrent sessions
|
||||
setKeyboardInteractiveQueue(prev => [...prev, {
|
||||
@@ -552,23 +511,10 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
const unsubscribeCancelled = bridge.onKeyboardInteractiveCancelled?.((event) => {
|
||||
setKeyboardInteractiveQueue(prev => removeKeyboardInteractiveRequest(prev, event.requestId));
|
||||
});
|
||||
const onTerminalDisconnected = (event: Event) => {
|
||||
const sessionId = (event as CustomEvent<{ sessionId?: string }>).detail?.sessionId;
|
||||
if (!sessionId) return;
|
||||
setKeyboardInteractiveQueue((prev) => {
|
||||
const doomed = prev.filter((request) => request.sessionId === sessionId);
|
||||
for (const request of doomed) {
|
||||
void bridge.respondKeyboardInteractive?.(request.requestId, [], true);
|
||||
}
|
||||
return prev.filter((request) => request.sessionId !== sessionId);
|
||||
});
|
||||
};
|
||||
window.addEventListener("netcatty:terminal-session-disconnected", onTerminalDisconnected);
|
||||
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
unsubscribeCancelled?.();
|
||||
window.removeEventListener("netcatty:terminal-session-disconnected", onTerminalDisconnected);
|
||||
};
|
||||
}, [enabled, setKeyboardInteractiveQueue]);
|
||||
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -404,14 +404,6 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.reject': 'Reject',
|
||||
'ai.chat.toolLabel': 'Tool',
|
||||
'ai.chat.targetLabel': 'Target',
|
||||
'ai.chat.rawCommand': 'Command',
|
||||
'ai.chat.copyCommand': 'Copy',
|
||||
'ai.chat.commandCopied': 'Copied',
|
||||
'ai.chat.approvalSession': 'Session',
|
||||
'ai.chat.approvalShell': 'Shell',
|
||||
'ai.chat.approvalCwd': 'Cwd',
|
||||
'ai.chat.approvalReason': 'Reason',
|
||||
'ai.chat.approvalInvocation': 'Invocation',
|
||||
'ai.chat.permissionRequired': 'Permission Required',
|
||||
'ai.chat.permissionDescription': 'The AI agent wants to execute a tool call that requires your approval.',
|
||||
'ai.chat.commandBlocked': 'This command is blocked by your security policy and cannot be executed.',
|
||||
@@ -451,8 +443,6 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.newChat': 'New Chat',
|
||||
'ai.chat.allSessions': 'All Sessions',
|
||||
'ai.chat.loadEarlierMessages': 'Load earlier messages ({n} more)',
|
||||
'ai.chat.jumpNav': 'Jump to message',
|
||||
'ai.chat.jumpUntitled': '(empty message)',
|
||||
'ai.chat.usedTools': 'Tools used: {n}',
|
||||
'ai.chat.loadMoreSessions': 'Load more sessions ({n} more)',
|
||||
'ai.chat.noSessions': 'No previous sessions',
|
||||
|
||||
@@ -197,9 +197,6 @@ export const enCoreMessages: Messages = {
|
||||
'settings.sessionLogs.timestamps': 'Add timestamps',
|
||||
'settings.sessionLogs.timestampsDesc': 'Prefix each line in plain text and HTML logs with the local time.',
|
||||
'settings.sessionLogs.hint': 'Session logs capture all terminal output for troubleshooting and auditing purposes.',
|
||||
'settings.sessionLogs.clearAll': 'Clear all logs',
|
||||
'settings.sessionLogs.clearAllDesc': 'Delete all saved session log files and host subdirectories from the save directory.',
|
||||
'settings.sessionLogs.clearConfirm': 'This will permanently delete all session log files in the save directory. This cannot be undone. Continue?',
|
||||
|
||||
// Settings > SSH Debug Logs
|
||||
'settings.sshDebugLogs.title': 'SSH Debug Logs',
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -268,8 +268,6 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.transfers.calculatingTotal': 'Calculating total size...',
|
||||
'sftp.transfers.filesCount': '{count} files',
|
||||
'sftp.transfers.filesProgress': '{current}/{total} files',
|
||||
// Progressive folder walk: total is "found so far", not a fixed grand total.
|
||||
'sftp.transfers.filesDiscoveredProgress': '{completed} done · {discovered} found',
|
||||
'sftp.transfers.expandChildren': 'Show files',
|
||||
'sftp.transfers.collapseChildren': 'Hide files',
|
||||
'sftp.transfers.expandChildList': 'Show detail',
|
||||
@@ -562,9 +560,6 @@ export const enVaultMessages: Messages = {
|
||||
'settings.sftp.compressedUpload.enable': 'Enable folder compression',
|
||||
'settings.sftp.compressedUpload.enableDesc': 'Automatically compress folders using tar before transfer. Requires tar support on the server. Falls back to regular transfer if not available.',
|
||||
|
||||
'settings.sftp.skipUnchanged.enable': 'Skip unchanged files',
|
||||
'settings.sftp.skipUnchanged.enableDesc': 'Skip files whose size and modification time already match the destination (rsync-style). Disabled when replacing a folder into a staging path.',
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': 'Search hosts or tabs',
|
||||
'qs.jumpTo': 'Jump To',
|
||||
|
||||
@@ -356,14 +356,6 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.reject': 'Отклонить',
|
||||
'ai.chat.toolLabel': 'Инструмент',
|
||||
'ai.chat.targetLabel': 'Цель',
|
||||
'ai.chat.rawCommand': 'Команда',
|
||||
'ai.chat.copyCommand': 'Копировать',
|
||||
'ai.chat.commandCopied': 'Скопировано',
|
||||
'ai.chat.approvalSession': 'Сессия',
|
||||
'ai.chat.approvalShell': 'Shell',
|
||||
'ai.chat.approvalCwd': 'Каталог',
|
||||
'ai.chat.approvalReason': 'Причина',
|
||||
'ai.chat.approvalInvocation': 'Вызов',
|
||||
'ai.chat.permissionRequired': 'Требуется разрешение',
|
||||
'ai.chat.permissionDescription': 'AI-агент хочет выполнить вызов инструмента, для которого требуется ваше одобрение.',
|
||||
'ai.chat.commandBlocked': 'Эта команда заблокирована вашей политикой безопасности и не может быть выполнена.',
|
||||
@@ -403,8 +395,6 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.newChat': 'Новый чат',
|
||||
'ai.chat.allSessions': 'Все сессии',
|
||||
'ai.chat.loadEarlierMessages': 'Загрузить более ранние сообщения (ещё {n})',
|
||||
'ai.chat.jumpNav': 'Перейти к сообщению',
|
||||
'ai.chat.jumpUntitled': '(пустое сообщение)',
|
||||
'ai.chat.usedTools': 'Использовано инструментов: {n}',
|
||||
'ai.chat.loadMoreSessions': 'Загрузить больше сессий (ещё {n})',
|
||||
'ai.chat.noSessions': 'Предыдущих сессий нет',
|
||||
|
||||
@@ -185,9 +185,6 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.sessionLogs.timestamps': 'Добавлять метки времени',
|
||||
'settings.sessionLogs.timestampsDesc': 'Добавлять локальное время в начало каждой строки в текстовых и HTML-журналах.',
|
||||
'settings.sessionLogs.hint': 'Журналы сессий сохраняют весь вывод терминала для диагностики и аудита.',
|
||||
'settings.sessionLogs.clearAll': 'Очистить все журналы',
|
||||
'settings.sessionLogs.clearAllDesc': 'Удалить все сохранённые файлы журналов сессий и вложенные папки хостов из папки сохранения.',
|
||||
'settings.sessionLogs.clearConfirm': 'Это приведёт к безвозвратному удалению всех файлов журналов сессий в папке сохранения. Это действие нельзя отменить. Продолжить?',
|
||||
|
||||
// Settings > SSH Debug Logs
|
||||
'settings.sshDebugLogs.title': 'Отладочные журналы SSH',
|
||||
@@ -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': 'Создан',
|
||||
|
||||
@@ -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} мин назад',
|
||||
|
||||
@@ -305,7 +305,6 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.transfers.calculatingTotal': 'Вычисление общего размера...',
|
||||
'sftp.transfers.filesCount': '{count} файл(ов)',
|
||||
'sftp.transfers.filesProgress': '{current}/{total} файл(ов)',
|
||||
'sftp.transfers.filesDiscoveredProgress': '{completed} готово · {discovered} найдено',
|
||||
'sftp.transfers.expandChildren': 'Показать файлы',
|
||||
'sftp.transfers.collapseChildren': 'Скрыть файлы',
|
||||
'sftp.transfers.expandChildList': 'Показать детали',
|
||||
@@ -598,9 +597,6 @@ export const ruVaultMessages: Messages = {
|
||||
'settings.sftp.compressedUpload.enable': 'Включить сжатие папок',
|
||||
'settings.sftp.compressedUpload.enableDesc': 'Автоматически сжимать папки с помощью tar перед передачей. Требует поддержки tar на сервере. Если она недоступна, будет использована обычная передача.',
|
||||
|
||||
'settings.sftp.skipUnchanged.enable': 'Пропускать неизменённые файлы',
|
||||
'settings.sftp.skipUnchanged.enableDesc': 'Пропускать файлы, у которых размер и время изменения уже совпадают с назначением (в стиле rsync). Отключается при замене папки во временный путь.',
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': 'Поиск хостов или вкладок',
|
||||
'qs.jumpTo': 'Перейти к',
|
||||
|
||||
@@ -404,14 +404,6 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.reject': '拒绝',
|
||||
'ai.chat.toolLabel': '工具',
|
||||
'ai.chat.targetLabel': '目标',
|
||||
'ai.chat.rawCommand': '命令',
|
||||
'ai.chat.copyCommand': '复制',
|
||||
'ai.chat.commandCopied': '已复制',
|
||||
'ai.chat.approvalSession': '会话',
|
||||
'ai.chat.approvalShell': 'Shell',
|
||||
'ai.chat.approvalCwd': '目录',
|
||||
'ai.chat.approvalReason': '原因',
|
||||
'ai.chat.approvalInvocation': '调用详情',
|
||||
'ai.chat.permissionRequired': '需要权限',
|
||||
'ai.chat.permissionDescription': 'AI Agent 希望执行一个需要你批准的工具调用。',
|
||||
'ai.chat.commandBlocked': '此命令已被安全策略拦截,无法执行。',
|
||||
@@ -451,8 +443,6 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.newChat': '新对话',
|
||||
'ai.chat.allSessions': '所有会话',
|
||||
'ai.chat.loadEarlierMessages': '加载更早的消息(还有 {n} 条)',
|
||||
'ai.chat.jumpNav': '跳转到消息',
|
||||
'ai.chat.jumpUntitled': '(空消息)',
|
||||
'ai.chat.usedTools': '已使用 {n} 个工具',
|
||||
'ai.chat.loadMoreSessions': '加载更多会话(还有 {n} 条)',
|
||||
'ai.chat.noSessions': '没有历史会话',
|
||||
|
||||
@@ -194,9 +194,6 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.sessionLogs.timestamps': '添加时间戳',
|
||||
'settings.sessionLogs.timestampsDesc': '为纯文本和 HTML 日志的每一行添加本地时间。',
|
||||
'settings.sessionLogs.hint': '会话日志用于记录终端输出,便于故障排查和审计。',
|
||||
'settings.sessionLogs.clearAll': '清空所有日志',
|
||||
'settings.sessionLogs.clearAllDesc': '删除保存目录中所有会话日志文件及主机子目录。',
|
||||
'settings.sessionLogs.clearConfirm': '此操作将永久删除保存目录中的所有会话日志文件,且无法撤销,是否继续?',
|
||||
|
||||
// Settings > SSH Debug Logs
|
||||
'settings.sshDebugLogs.title': 'SSH 调试日志',
|
||||
@@ -863,7 +860,6 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.transfers.calculatingTotal': '正在统计总大小...',
|
||||
'sftp.transfers.filesCount': '{count} 个文件',
|
||||
'sftp.transfers.filesProgress': '{current}/{total} 个文件',
|
||||
'sftp.transfers.filesDiscoveredProgress': '已完成 {completed} · 已发现 {discovered}',
|
||||
'sftp.transfers.expandChildren': '展开文件',
|
||||
'sftp.transfers.collapseChildren': '收起文件',
|
||||
'sftp.transfers.expandChildList': '展开详情',
|
||||
|
||||
@@ -10,7 +10,6 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.tabs.processes': '进程',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.tabs.gpu': 'GPU',
|
||||
'systemManager.popup.loading': '正在打开终端…',
|
||||
'systemManager.popup.startupFailed': '启动命令未成功。请确认目标仍然可用后重试。',
|
||||
|
||||
@@ -24,7 +23,6 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDockerStats': '加载容器性能数据失败',
|
||||
'systemManager.errors.loadDockerImages': '加载镜像列表失败',
|
||||
'systemManager.errors.loadOverview': '加载系统概览失败',
|
||||
'systemManager.errors.loadGpu': '加载 GPU / NPU 状态失败',
|
||||
'systemManager.errors.sshChannelUnavailable': '服务器拒绝打开新的执行通道。请稍后重试,或重新连接当前主机。',
|
||||
|
||||
'systemManager.overview.empty': '暂无系统概览数据。',
|
||||
@@ -189,23 +187,6 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.start': '启动',
|
||||
'systemManager.docker.stop': '停止',
|
||||
|
||||
'systemManager.gpu.unavailable': '此主机未检测到 NVIDIA GPU 或昇腾 NPU 工具。',
|
||||
'systemManager.gpu.loading': '正在加载加速卡状态…',
|
||||
'systemManager.gpu.empty': '已检测到加速卡工具,但未返回设备信息。',
|
||||
'systemManager.gpu.meta': '{{devices}} 张卡 · {{processes}} 个进程',
|
||||
'systemManager.gpu.devices': '设备',
|
||||
'systemManager.gpu.processes': '计算进程',
|
||||
'systemManager.gpu.noProcesses': '当前没有计算进程。',
|
||||
'systemManager.gpu.vendor.nvidia': 'NVIDIA',
|
||||
'systemManager.gpu.vendor.ascend': '昇腾',
|
||||
'systemManager.gpu.util': '利用率',
|
||||
'systemManager.gpu.memory': '显存',
|
||||
'systemManager.gpu.hbm': 'HBM',
|
||||
'systemManager.gpu.temperature': '温度',
|
||||
'systemManager.gpu.power': '功耗',
|
||||
'systemManager.gpu.fan': '风扇 {{value}}%',
|
||||
'systemManager.gpu.driver': '驱动 {{version}}',
|
||||
|
||||
'systemManager.inspect.status': '状态',
|
||||
'systemManager.inspect.image': '镜像',
|
||||
'systemManager.inspect.created': '创建时间',
|
||||
|
||||
@@ -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': '打开方式...',
|
||||
@@ -206,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': '选择主题',
|
||||
@@ -314,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 时发送配置的文本,而不是普通回车。',
|
||||
@@ -417,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': '刷新间隔',
|
||||
|
||||
@@ -427,10 +427,6 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.statusbar.copyHostname.tooltip': '复制主机地址({hostname})',
|
||||
'terminal.statusbar.copyHostname.toast': '已复制主机地址:{hostname}',
|
||||
'terminal.statusbar.copyHostname.error': '复制主机地址失败',
|
||||
'terminal.statusbar.disconnect.label': '断开连接',
|
||||
'terminal.statusbar.disconnect.tooltip': '断开当前会话,但保留标签页',
|
||||
'terminal.statusbar.reconnect.label': '重新连接',
|
||||
'terminal.statusbar.reconnect.tooltip': '重新连接当前会话',
|
||||
'terminal.serverStats.cpu': 'CPU 使用率',
|
||||
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
|
||||
'terminal.serverStats.memory': '内存使用',
|
||||
@@ -663,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} 分钟前',
|
||||
|
||||
@@ -403,14 +403,6 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.reject': '拒絕',
|
||||
'ai.chat.toolLabel': '工具',
|
||||
'ai.chat.targetLabel': '目標',
|
||||
'ai.chat.rawCommand': '指令',
|
||||
'ai.chat.copyCommand': '複製',
|
||||
'ai.chat.commandCopied': '已複製',
|
||||
'ai.chat.approvalSession': '工作階段',
|
||||
'ai.chat.approvalShell': 'Shell',
|
||||
'ai.chat.approvalCwd': '目錄',
|
||||
'ai.chat.approvalReason': '原因',
|
||||
'ai.chat.approvalInvocation': '呼叫詳情',
|
||||
'ai.chat.permissionRequired': '需要權限',
|
||||
'ai.chat.permissionDescription': 'AI Agent 希望執行一個需要你批准的工具呼叫。',
|
||||
'ai.chat.commandBlocked': '此指令已被安全策略攔截,無法執行。',
|
||||
@@ -450,8 +442,6 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.newChat': '新對話',
|
||||
'ai.chat.allSessions': '所有工作階段',
|
||||
'ai.chat.loadEarlierMessages': '載入更早的訊息(還有 {n} 條)',
|
||||
'ai.chat.jumpNav': '跳轉到訊息',
|
||||
'ai.chat.jumpUntitled': '(空訊息)',
|
||||
'ai.chat.usedTools': '已使用 {n} 個工具',
|
||||
'ai.chat.loadMoreSessions': '載入更多工作階段(還有 {n} 條)',
|
||||
'ai.chat.noSessions': '沒有歷史工作階段',
|
||||
|
||||
@@ -194,9 +194,6 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.sessionLogs.timestamps': '新增時間戳',
|
||||
'settings.sessionLogs.timestampsDesc': '為純文字和 HTML 日誌的每一行新增本機時間。',
|
||||
'settings.sessionLogs.hint': '工作階段日誌用於記錄終端輸出,便於故障排查和審計。',
|
||||
'settings.sessionLogs.clearAll': '清空所有日誌',
|
||||
'settings.sessionLogs.clearAllDesc': '刪除儲存目錄中所有工作階段日誌檔案及主機子目錄。',
|
||||
'settings.sessionLogs.clearConfirm': '此操作將永久刪除儲存目錄中的所有工作階段日誌檔案,且無法復原,是否繼續?',
|
||||
|
||||
// Settings > SSH Debug Logs
|
||||
'settings.sshDebugLogs.title': 'SSH 除錯日誌',
|
||||
@@ -863,7 +860,6 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sftp.transfers.calculatingTotal': '正在統計總大小...',
|
||||
'sftp.transfers.filesCount': '{count} 個檔案',
|
||||
'sftp.transfers.filesProgress': '{current}/{total} 個檔案',
|
||||
'sftp.transfers.filesDiscoveredProgress': '已完成 {completed} · 已發現 {discovered}',
|
||||
'sftp.transfers.expandChildren': '展開檔案',
|
||||
'sftp.transfers.collapseChildren': '收起檔案',
|
||||
'sftp.transfers.expandChildList': '展開詳情',
|
||||
|
||||
@@ -10,7 +10,6 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.tabs.processes': '程序',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.tabs.gpu': 'GPU',
|
||||
'systemManager.popup.loading': '正在開啟終端…',
|
||||
'systemManager.popup.startupFailed': '啟動指令未順利完成。請確認目標仍可使用後再試一次。',
|
||||
|
||||
@@ -24,7 +23,6 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDockerStats': '載入容器效能資料失敗',
|
||||
'systemManager.errors.loadDockerImages': '載入映像列表失敗',
|
||||
'systemManager.errors.loadOverview': '載入系統概覽失敗',
|
||||
'systemManager.errors.loadGpu': '載入 GPU / NPU 狀態失敗',
|
||||
'systemManager.errors.sshChannelUnavailable': '伺服器拒絕開啟新的執行通道。請稍後重試,或重新連線目前主機。',
|
||||
|
||||
'systemManager.overview.empty': '暫無系統概覽資料。',
|
||||
@@ -189,23 +187,6 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.start': '啟動',
|
||||
'systemManager.docker.stop': '停止',
|
||||
|
||||
'systemManager.gpu.unavailable': '此主機未偵測到 NVIDIA GPU 或昇騰 NPU 工具。',
|
||||
'systemManager.gpu.loading': '正在載入加速卡狀態…',
|
||||
'systemManager.gpu.empty': '已偵測到加速卡工具,但未回傳裝置資訊。',
|
||||
'systemManager.gpu.meta': '{{devices}} 張卡 · {{processes}} 個程序',
|
||||
'systemManager.gpu.devices': '裝置',
|
||||
'systemManager.gpu.processes': '計算程序',
|
||||
'systemManager.gpu.noProcesses': '目前沒有計算程序。',
|
||||
'systemManager.gpu.vendor.nvidia': 'NVIDIA',
|
||||
'systemManager.gpu.vendor.ascend': '昇騰',
|
||||
'systemManager.gpu.util': '使用率',
|
||||
'systemManager.gpu.memory': '顯示記憶體',
|
||||
'systemManager.gpu.hbm': 'HBM',
|
||||
'systemManager.gpu.temperature': '溫度',
|
||||
'systemManager.gpu.power': '功耗',
|
||||
'systemManager.gpu.fan': '風扇 {{value}}%',
|
||||
'systemManager.gpu.driver': '驅動 {{version}}',
|
||||
|
||||
'systemManager.inspect.status': '狀態',
|
||||
'systemManager.inspect.image': '映像',
|
||||
'systemManager.inspect.created': '建立時間',
|
||||
|
||||
@@ -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': '開啟方式...',
|
||||
@@ -206,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': '選擇主題',
|
||||
@@ -314,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。',
|
||||
@@ -417,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': '重新整理間隔',
|
||||
|
||||
@@ -427,10 +427,6 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.statusbar.copyHostname.tooltip': '複製主機地址({hostname})',
|
||||
'terminal.statusbar.copyHostname.toast': '已複製主機地址:{hostname}',
|
||||
'terminal.statusbar.copyHostname.error': '複製主機地址失敗',
|
||||
'terminal.statusbar.disconnect.label': '中斷連線',
|
||||
'terminal.statusbar.disconnect.tooltip': '中斷目前工作階段,但保留標籤頁',
|
||||
'terminal.statusbar.reconnect.label': '重新連線',
|
||||
'terminal.statusbar.reconnect.tooltip': '重新連線目前工作階段',
|
||||
'terminal.serverStats.cpu': 'CPU 使用率',
|
||||
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
|
||||
'terminal.serverStats.memory': '記憶體使用',
|
||||
@@ -663,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} 分鐘前',
|
||||
|
||||
@@ -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']]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
EMPTY_NOTES_SNAPSHOT,
|
||||
getEmptyNotesSnapshot,
|
||||
getNotesActions,
|
||||
getNotesSnapshot,
|
||||
publishNotesSnapshot,
|
||||
registerNotesActions,
|
||||
subscribeNotes,
|
||||
subscribeNotesNoop,
|
||||
} from './notesStore.ts';
|
||||
|
||||
test('notesStore notifies subscribers only when snapshot identity changes', () => {
|
||||
const events: number[] = [];
|
||||
const unsubscribe = subscribeNotes(() => {
|
||||
events.push(getNotesSnapshot().notes.length);
|
||||
});
|
||||
|
||||
const firstNotes = [{
|
||||
id: 'n1',
|
||||
title: 'One',
|
||||
content: 'body',
|
||||
tags: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
order: 1000,
|
||||
}];
|
||||
const firstGroups = ['Ops'];
|
||||
publishNotesSnapshot({ notes: firstNotes, noteGroups: firstGroups });
|
||||
assert.equal(events.at(-1), 1);
|
||||
assert.equal(getNotesSnapshot().notes, firstNotes);
|
||||
assert.equal(getNotesSnapshot().noteGroups, firstGroups);
|
||||
|
||||
publishNotesSnapshot({ notes: firstNotes, noteGroups: firstGroups });
|
||||
assert.equal(events.length, 1);
|
||||
|
||||
const secondNotes = [
|
||||
...firstNotes,
|
||||
{
|
||||
id: 'n2',
|
||||
title: 'Two',
|
||||
content: 'more',
|
||||
tags: [],
|
||||
createdAt: 2,
|
||||
updatedAt: 2,
|
||||
order: 2000,
|
||||
},
|
||||
];
|
||||
publishNotesSnapshot({ notes: secondNotes, noteGroups: firstGroups });
|
||||
assert.equal(events.at(-1), 2);
|
||||
|
||||
publishNotesSnapshot({ notes: secondNotes, noteGroups: ['Ops', 'DB'] });
|
||||
assert.equal(events.length, 3);
|
||||
assert.deepEqual(getNotesSnapshot().noteGroups, ['Ops', 'DB']);
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test('notesStore gated helpers stay empty and never notify', () => {
|
||||
let called = 0;
|
||||
const unsub = subscribeNotesNoop(() => {
|
||||
called += 1;
|
||||
});
|
||||
publishNotesSnapshot({
|
||||
notes: [{
|
||||
id: 'n1',
|
||||
title: 'One',
|
||||
content: 'body',
|
||||
tags: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
order: 1000,
|
||||
}],
|
||||
noteGroups: ['Ops'],
|
||||
});
|
||||
assert.equal(called, 0);
|
||||
assert.equal(getEmptyNotesSnapshot(), EMPTY_NOTES_SNAPSHOT);
|
||||
assert.equal(getEmptyNotesSnapshot().notes.length, 0);
|
||||
unsub();
|
||||
});
|
||||
|
||||
test('registerNotesActions exposes update handlers', () => {
|
||||
const calls: string[] = [];
|
||||
registerNotesActions({
|
||||
updateNotes: () => {
|
||||
calls.push('notes');
|
||||
},
|
||||
updateNoteGroups: () => {
|
||||
calls.push('groups');
|
||||
},
|
||||
});
|
||||
const actions = getNotesActions();
|
||||
assert.ok(actions);
|
||||
actions!.updateNotes([]);
|
||||
actions!.updateNoteGroups([]);
|
||||
assert.deepEqual(calls, ['notes', 'groups']);
|
||||
registerNotesActions(null);
|
||||
assert.equal(getNotesActions(), null);
|
||||
});
|
||||
@@ -1,138 +0,0 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
import type { VaultNote } from '../../domain/models';
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
export type NotesSnapshot = {
|
||||
notes: readonly VaultNote[];
|
||||
noteGroups: readonly string[];
|
||||
};
|
||||
|
||||
export type NotesActions = {
|
||||
updateNotes: (notes: VaultNote[]) => void;
|
||||
updateNoteGroups: (groups: string[]) => void;
|
||||
};
|
||||
|
||||
const EMPTY_NOTES: readonly VaultNote[] = Object.freeze([]);
|
||||
const EMPTY_NOTE_GROUPS: readonly string[] = Object.freeze([]);
|
||||
|
||||
export const EMPTY_NOTES_SNAPSHOT: NotesSnapshot = Object.freeze({
|
||||
notes: EMPTY_NOTES,
|
||||
noteGroups: EMPTY_NOTE_GROUPS,
|
||||
});
|
||||
|
||||
/**
|
||||
* External store for vault notes so Notes / AI side-panel consumers can
|
||||
* subscribe without forcing TerminalLayer re-renders on every note edit.
|
||||
*/
|
||||
class NotesStore {
|
||||
private snapshot: NotesSnapshot = EMPTY_NOTES_SNAPSHOT;
|
||||
private actions: NotesActions | null = null;
|
||||
private listeners = new Set<Listener>();
|
||||
private actionListeners = new Set<Listener>();
|
||||
|
||||
getSnapshot = (): NotesSnapshot => this.snapshot;
|
||||
|
||||
subscribe = (listener: Listener): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
setSnapshot(next: NotesSnapshot): void {
|
||||
if (
|
||||
this.snapshot.notes === next.notes
|
||||
&& this.snapshot.noteGroups === next.noteGroups
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.snapshot = next;
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
getActions = (): NotesActions | null => this.actions;
|
||||
|
||||
subscribeActions = (listener: Listener): (() => void) => {
|
||||
this.actionListeners.add(listener);
|
||||
return () => {
|
||||
this.actionListeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
setActions(next: NotesActions | null): void {
|
||||
if (this.actions === next) return;
|
||||
this.actions = next;
|
||||
for (const listener of this.actionListeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const notesStore = new NotesStore();
|
||||
|
||||
export function publishNotesSnapshot(snapshot: NotesSnapshot): void {
|
||||
notesStore.setSnapshot(snapshot);
|
||||
}
|
||||
|
||||
export function getNotesSnapshot(): NotesSnapshot {
|
||||
return notesStore.getSnapshot();
|
||||
}
|
||||
|
||||
export function subscribeNotes(listener: Listener): () => void {
|
||||
return notesStore.subscribe(listener);
|
||||
}
|
||||
|
||||
/** No-op subscribe for gated (hidden) panel mounts. */
|
||||
export function subscribeNotesNoop(_listener: Listener): () => void {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
export function getEmptyNotesSnapshot(): NotesSnapshot {
|
||||
return EMPTY_NOTES_SNAPSHOT;
|
||||
}
|
||||
|
||||
export function registerNotesActions(actions: NotesActions | null): void {
|
||||
notesStore.setActions(actions);
|
||||
}
|
||||
|
||||
export function getNotesActions(): NotesActions | null {
|
||||
return notesStore.getActions();
|
||||
}
|
||||
|
||||
export function subscribeNotesActions(listener: Listener): () => void {
|
||||
return notesStore.subscribeActions(listener);
|
||||
}
|
||||
|
||||
const noopUpdateNotes: NotesActions['updateNotes'] = () => {};
|
||||
const noopUpdateNoteGroups: NotesActions['updateNoteGroups'] = () => {};
|
||||
|
||||
/**
|
||||
* Subscribe to notes catalog + vault mutation actions for Notes / AI panels.
|
||||
*/
|
||||
export function useNotesStore(): {
|
||||
notes: VaultNote[];
|
||||
noteGroups: string[];
|
||||
updateNotes: NotesActions['updateNotes'];
|
||||
updateNoteGroups: NotesActions['updateNoteGroups'];
|
||||
} {
|
||||
const snapshot = useSyncExternalStore(
|
||||
subscribeNotes,
|
||||
getNotesSnapshot,
|
||||
getNotesSnapshot,
|
||||
);
|
||||
const actions = useSyncExternalStore(
|
||||
subscribeNotesActions,
|
||||
getNotesActions,
|
||||
getNotesActions,
|
||||
);
|
||||
return {
|
||||
notes: snapshot.notes as VaultNote[],
|
||||
noteGroups: snapshot.noteGroups as string[],
|
||||
updateNotes: actions?.updateNotes ?? noopUpdateNotes,
|
||||
updateNoteGroups: actions?.updateNoteGroups ?? noopUpdateNoteGroups,
|
||||
};
|
||||
}
|
||||
@@ -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 }>) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -2,13 +2,8 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY,
|
||||
DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY,
|
||||
DEFAULT_SFTP_SKIP_UNCHANGED,
|
||||
resolveSftpDirectoryListingConcurrency,
|
||||
resolveSftpSkipUnchangedEnabled,
|
||||
resolveSftpTransferConcurrency,
|
||||
runBoundedConcurrency,
|
||||
runSftpTransferWorkers,
|
||||
} from "./transferConcurrency";
|
||||
|
||||
@@ -17,20 +12,6 @@ test("defaults folder file transfers to two concurrent files", () => {
|
||||
assert.equal(DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY, 2);
|
||||
});
|
||||
|
||||
test("defaults directory listing fanout to four concurrent readdirs", () => {
|
||||
assert.equal(
|
||||
resolveSftpDirectoryListingConcurrency(() => null),
|
||||
DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY,
|
||||
);
|
||||
assert.equal(DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY, 4);
|
||||
});
|
||||
|
||||
test("defaults skip-unchanged to enabled", () => {
|
||||
assert.equal(resolveSftpSkipUnchangedEnabled(() => null), DEFAULT_SFTP_SKIP_UNCHANGED);
|
||||
assert.equal(DEFAULT_SFTP_SKIP_UNCHANGED, true);
|
||||
assert.equal(resolveSftpSkipUnchangedEnabled(() => false), false);
|
||||
});
|
||||
|
||||
test("keeps explicit folder transfer concurrency within the supported range", () => {
|
||||
assert.equal(resolveSftpTransferConcurrency(() => 1), 1);
|
||||
assert.equal(resolveSftpTransferConcurrency(() => 16), 16);
|
||||
@@ -38,19 +19,6 @@ test("keeps explicit folder transfer concurrency within the supported range", ()
|
||||
assert.equal(resolveSftpTransferConcurrency(() => 17), DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY);
|
||||
});
|
||||
|
||||
test("keeps directory listing concurrency within the supported range", () => {
|
||||
assert.equal(resolveSftpDirectoryListingConcurrency(() => 1), 1);
|
||||
assert.equal(resolveSftpDirectoryListingConcurrency(() => 8), 8);
|
||||
assert.equal(
|
||||
resolveSftpDirectoryListingConcurrency(() => 0),
|
||||
DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY,
|
||||
);
|
||||
assert.equal(
|
||||
resolveSftpDirectoryListingConcurrency(() => 9),
|
||||
DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY,
|
||||
);
|
||||
});
|
||||
|
||||
test("limits default multi-file transfer scheduling to two concurrent workers", async () => {
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
@@ -65,106 +33,6 @@ test("limits default multi-file transfer scheduling to two concurrent workers",
|
||||
assert.equal(maxActive, 2);
|
||||
});
|
||||
|
||||
test("runBoundedConcurrency respects an explicit limit", async () => {
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
await runBoundedConcurrency([1, 2, 3, 4, 5, 6], 3, async () => {
|
||||
active += 1;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
active -= 1;
|
||||
});
|
||||
assert.equal(maxActive, 3);
|
||||
});
|
||||
|
||||
test("runBoundedConcurrency drains siblings and stops new claims after an error", async () => {
|
||||
const started: number[] = [];
|
||||
const finished: number[] = [];
|
||||
let releaseSlow!: () => void;
|
||||
const slowGate = new Promise<void>((resolve) => {
|
||||
releaseSlow = resolve;
|
||||
});
|
||||
|
||||
const run = runBoundedConcurrency([0, 1, 2, 3, 4], 2, async (item) => {
|
||||
started.push(item);
|
||||
if (item === 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
throw new Error("boom");
|
||||
}
|
||||
if (item === 1) {
|
||||
await slowGate;
|
||||
}
|
||||
finished.push(item);
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
|
||||
let settledWhileSiblingRunning = false;
|
||||
await Promise.race([
|
||||
run.then(
|
||||
() => {
|
||||
settledWhileSiblingRunning = true;
|
||||
},
|
||||
() => {
|
||||
settledWhileSiblingRunning = true;
|
||||
},
|
||||
),
|
||||
new Promise((resolve) => setTimeout(resolve, 5)),
|
||||
]);
|
||||
assert.equal(
|
||||
settledWhileSiblingRunning,
|
||||
false,
|
||||
"must wait for in-flight siblings before propagating the error",
|
||||
);
|
||||
assert.ok(started.includes(1));
|
||||
assert.equal(finished.includes(1), false);
|
||||
|
||||
releaseSlow();
|
||||
await assert.rejects(run, /boom/);
|
||||
assert.ok(finished.includes(1), "in-flight sibling must finish before reject");
|
||||
assert.deepEqual(
|
||||
[...started].sort((a, b) => a - b),
|
||||
[0, 1],
|
||||
"must not claim additional queue items after the first error",
|
||||
);
|
||||
});
|
||||
|
||||
test("runSftpTransferWorkers drains siblings and stops new claims after an error", async () => {
|
||||
const started: number[] = [];
|
||||
const finished: number[] = [];
|
||||
let releaseSlow!: () => void;
|
||||
const slowGate = new Promise<void>((resolve) => {
|
||||
releaseSlow = resolve;
|
||||
});
|
||||
|
||||
const run = runSftpTransferWorkers([0, 1, 2, 3, 4], () => 2, async (item) => {
|
||||
started.push(item);
|
||||
if (item === 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
throw new Error("session lost");
|
||||
}
|
||||
if (item === 1) {
|
||||
await slowGate;
|
||||
}
|
||||
finished.push(item);
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
let settledEarly = false;
|
||||
await Promise.race([
|
||||
run.then(
|
||||
() => { settledEarly = true; },
|
||||
() => { settledEarly = true; },
|
||||
),
|
||||
new Promise((resolve) => setTimeout(resolve, 5)),
|
||||
]);
|
||||
assert.equal(settledEarly, false);
|
||||
releaseSlow();
|
||||
await assert.rejects(run, /session lost/);
|
||||
assert.ok(finished.includes(1));
|
||||
assert.deepEqual([...started].sort((a, b) => a - b), [0, 1]);
|
||||
});
|
||||
|
||||
test("beforeClaim runs before claiming the next queue index", async () => {
|
||||
const events: string[] = [];
|
||||
let paused = true;
|
||||
|
||||
@@ -2,20 +2,6 @@ export const DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY = 2;
|
||||
export const MIN_SFTP_FILE_TRANSFER_CONCURRENCY = 1;
|
||||
export const MAX_SFTP_FILE_TRANSFER_CONCURRENCY = 16;
|
||||
|
||||
/**
|
||||
* Bounded parallel directory listings while walking a folder tree.
|
||||
* SFTP has no recursive LIST; FileZilla/WinSCP still walk one dir at a time on
|
||||
* a single control channel. We pipeline several OPENDIR/READDIR requests so
|
||||
* wide trees discover total file counts much faster without a second full scan.
|
||||
* Keep this modest - listing shares the transfer SFTP session with file I/O.
|
||||
*/
|
||||
export const DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY = 4;
|
||||
export const MIN_SFTP_DIRECTORY_LISTING_CONCURRENCY = 1;
|
||||
export const MAX_SFTP_DIRECTORY_LISTING_CONCURRENCY = 8;
|
||||
|
||||
/** Default on: skip size+mtime matches like rsync's generator. */
|
||||
export const DEFAULT_SFTP_SKIP_UNCHANGED = true;
|
||||
|
||||
export function resolveSftpTransferConcurrency(readStoredValue: () => number | null | undefined): number {
|
||||
const stored = readStoredValue();
|
||||
return stored != null &&
|
||||
@@ -25,24 +11,6 @@ export function resolveSftpTransferConcurrency(readStoredValue: () => number | n
|
||||
: DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY;
|
||||
}
|
||||
|
||||
export function resolveSftpDirectoryListingConcurrency(
|
||||
readStoredValue?: () => number | null | undefined,
|
||||
): number {
|
||||
const stored = readStoredValue?.();
|
||||
return stored != null &&
|
||||
stored >= MIN_SFTP_DIRECTORY_LISTING_CONCURRENCY &&
|
||||
stored <= MAX_SFTP_DIRECTORY_LISTING_CONCURRENCY
|
||||
? stored
|
||||
: DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY;
|
||||
}
|
||||
|
||||
export function resolveSftpSkipUnchangedEnabled(
|
||||
readStoredValue: () => boolean | null | undefined,
|
||||
): boolean {
|
||||
const stored = readStoredValue();
|
||||
return stored == null ? DEFAULT_SFTP_SKIP_UNCHANGED : stored;
|
||||
}
|
||||
|
||||
export async function runSftpTransferWorkers<T>(
|
||||
items: T[],
|
||||
readStoredConcurrency: () => number | null | undefined,
|
||||
@@ -58,27 +26,17 @@ export async function runSftpTransferWorkers<T>(
|
||||
): Promise<void> {
|
||||
const concurrency = resolveSftpTransferConcurrency(readStoredConcurrency);
|
||||
let nextIndex = 0;
|
||||
let failed = false;
|
||||
let firstError: unknown;
|
||||
|
||||
const runNext = async () => {
|
||||
while (!failed && nextIndex < items.length) {
|
||||
try {
|
||||
// Wait BEFORE claiming so pause does not leave a claimed-but-not-started
|
||||
// index that arms as soon as soft-drain finishes the previous file.
|
||||
if (options?.beforeClaim) {
|
||||
await options.beforeClaim();
|
||||
}
|
||||
if (failed || nextIndex >= items.length) return;
|
||||
const index = nextIndex++;
|
||||
await worker(items[index], index);
|
||||
} catch (err) {
|
||||
if (!failed) {
|
||||
failed = true;
|
||||
firstError = err;
|
||||
}
|
||||
return;
|
||||
while (nextIndex < items.length) {
|
||||
// Wait BEFORE claiming so pause does not leave a claimed-but-not-started
|
||||
// index that arms as soon as soft-drain finishes the previous file.
|
||||
if (options?.beforeClaim) {
|
||||
await options.beforeClaim();
|
||||
}
|
||||
if (nextIndex >= items.length) return;
|
||||
const index = nextIndex++;
|
||||
await worker(items[index], index);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -86,47 +44,5 @@ export async function runSftpTransferWorkers<T>(
|
||||
{ length: Math.min(concurrency, items.length) },
|
||||
() => runNext(),
|
||||
);
|
||||
// Settle every started worker before propagating - Promise.all would reject
|
||||
// while siblings keep claiming files after the caller releases leases.
|
||||
await Promise.all(workers);
|
||||
if (failed) throw firstError;
|
||||
}
|
||||
|
||||
/** Run workers over a queue with an explicit concurrency (not settings-backed). */
|
||||
export async function runBoundedConcurrency<T>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
worker: (item: T, index: number) => Promise<void>,
|
||||
options?: {
|
||||
beforeClaim?: () => Promise<void>;
|
||||
},
|
||||
): Promise<void> {
|
||||
const limit = Math.max(1, Math.min(Math.floor(concurrency) || 1, items.length || 1));
|
||||
if (items.length === 0) return;
|
||||
let nextIndex = 0;
|
||||
let failed = false;
|
||||
let firstError: unknown;
|
||||
const runNext = async () => {
|
||||
while (!failed && nextIndex < items.length) {
|
||||
try {
|
||||
if (options?.beforeClaim) {
|
||||
await options.beforeClaim();
|
||||
}
|
||||
// Re-check after beforeClaim: a sibling may have failed while we waited.
|
||||
if (failed || nextIndex >= items.length) return;
|
||||
const index = nextIndex++;
|
||||
await worker(items[index], index);
|
||||
} catch (err) {
|
||||
if (!failed) {
|
||||
failed = true;
|
||||
firstError = err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
// Settle every started worker before propagating - Promise.all would reject
|
||||
// while siblings keep claiming directories / transferring after the caller cleans up.
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => runNext()));
|
||||
if (failed) throw firstError;
|
||||
}
|
||||
@@ -146,9 +146,7 @@ test("directory transfer discovers each directory once with bounded listing conc
|
||||
assert.equal(listCalls.size, directoryCount + 1);
|
||||
assert.ok(Array.from(listCalls.values()).every((count) => count === 1));
|
||||
assert.equal(listCalls.has("/source/loop"), false, "canonical symlink cycles must not be listed");
|
||||
// Interleaved walk processes sibling subdirectories sequentially so resume
|
||||
// manifests stay deterministic (no full-tree pre-scan fan-out).
|
||||
assert.equal(maxActiveListings, 1, `expected sequential listings, got ${maxActiveListings}`);
|
||||
assert.equal(maxActiveListings, 1);
|
||||
assert.equal(tasks.find((task) => task.id === root.id)?.totalBytes, directoryCount);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
|
||||
@@ -9,20 +9,11 @@ import {
|
||||
type SftpDirectoryTraversalBudget,
|
||||
shouldFollowSftpSymlinkDirectory,
|
||||
} from "../../../domain/sftpDirectoryCheckpoint";
|
||||
import { isUnchangedTransferCandidate } from "../../../domain/sftpTransferSkip";
|
||||
import {
|
||||
STORAGE_KEY_SFTP_SKIP_UNCHANGED,
|
||||
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
|
||||
} from "../../../infrastructure/config/storageKeys";
|
||||
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from "../../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import {
|
||||
DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY,
|
||||
resolveSftpDirectoryListingConcurrency,
|
||||
resolveSftpSkipUnchangedEnabled,
|
||||
runSftpTransferWorkers,
|
||||
} from "./transferConcurrency";
|
||||
import { runSftpTransferWorkers } from "./transferConcurrency";
|
||||
import { getSftpTransferResourceKeys, globalSftpTransferScheduler } from "./globalTransferScheduler";
|
||||
import { resolveDedicatedStreamEndpointIds } from "../../../domain/sftpDedicatedStreamPolicy";
|
||||
import { isSessionError } from "./errors";
|
||||
@@ -40,49 +31,6 @@ import {
|
||||
import { isTransferOrRootCancelled } from "./transferCancelLatch";
|
||||
import { joinPath, joinTransferTargetPath } from "./utils";
|
||||
|
||||
function createDirectoryListingGate(concurrency = DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY) {
|
||||
const limit = Math.max(1, Math.floor(concurrency) || 1);
|
||||
let active = 0;
|
||||
const waiters: Array<() => void> = [];
|
||||
const acquire = async () => {
|
||||
if (active < limit) {
|
||||
active += 1;
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
waiters.push(() => {
|
||||
active += 1;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
};
|
||||
const release = () => {
|
||||
active = Math.max(0, active - 1);
|
||||
const next = waiters.shift();
|
||||
if (next) next();
|
||||
};
|
||||
return {
|
||||
get active() {
|
||||
return active;
|
||||
},
|
||||
async run<T>(fn: () => Promise<T>): Promise<T> {
|
||||
await acquire();
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type DirectoryDiscoveryProgress = {
|
||||
discoveredFiles: number;
|
||||
nextEntryIndex: number;
|
||||
/** Tree-wide semaphore so nested parallel walks cannot multiply listings. */
|
||||
listingGate?: ReturnType<typeof createDirectoryListingGate>;
|
||||
};
|
||||
|
||||
function isCancelledLocalOrGlobal(
|
||||
cancelledTasksRef: { current: Set<string> },
|
||||
rootTaskId: string,
|
||||
@@ -97,40 +45,6 @@ function isCancelledLocalOrGlobal(
|
||||
return false;
|
||||
}
|
||||
|
||||
function readSkipUnchangedEnabled(): boolean {
|
||||
return resolveSftpSkipUnchangedEnabled(() => localStorageAdapter.readBoolean(STORAGE_KEY_SFTP_SKIP_UNCHANGED));
|
||||
}
|
||||
|
||||
async function tryStatTransferPath(
|
||||
filePath: string,
|
||||
isLocal: boolean,
|
||||
sftpId: string | null,
|
||||
encoding: SftpFilenameEncoding,
|
||||
): Promise<{ size: number; lastModified: number; type: string } | null> {
|
||||
try {
|
||||
if (isLocal) {
|
||||
const stat = await netcattyBridge.get()?.statLocal?.(filePath);
|
||||
if (!stat || stat.type === "directory") return null;
|
||||
return { size: Number(stat.size) || 0, lastModified: Number(stat.lastModified) || 0, type: stat.type };
|
||||
}
|
||||
if (!sftpId) return null;
|
||||
const stat = await netcattyBridge.get()?.statSftp?.(sftpId, filePath, encoding);
|
||||
if (!stat || stat.type === "directory") return null;
|
||||
return { size: Number(stat.size) || 0, lastModified: Number(stat.lastModified) || 0, type: stat.type };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function tryStatTransferTarget(
|
||||
targetPath: string,
|
||||
targetIsLocal: boolean,
|
||||
targetSftpId: string | null,
|
||||
targetEncoding: SftpFilenameEncoding,
|
||||
): Promise<{ size: number; lastModified: number; type: string } | null> {
|
||||
return tryStatTransferPath(targetPath, targetIsLocal, targetSftpId, targetEncoding);
|
||||
}
|
||||
|
||||
export type AcquireTransferSessionFn = (
|
||||
hostId: string,
|
||||
transferId: string,
|
||||
@@ -443,40 +357,13 @@ export function useSftpDirectoryTransferOps({
|
||||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||
}
|
||||
})();
|
||||
let result: { error?: string; cancelled?: boolean; superseded?: boolean } | undefined;
|
||||
let result: { error?: string; cancelled?: boolean } | undefined;
|
||||
try {
|
||||
result = await transferPromise;
|
||||
} finally {
|
||||
watchPaused = false;
|
||||
await pauseWatch.catch(() => {});
|
||||
}
|
||||
// Same-id retry stole ownership while OPEN was pending. The live
|
||||
// owner still drives progress/completion events; this invoke must
|
||||
// not mark the child completed or failed (Codex P2 on b17f64e9).
|
||||
if (result?.superseded === true) {
|
||||
// Wait for the live same-id owner only — no fixed deadline so
|
||||
// long transfers are not failed while still running (Codex P2).
|
||||
for (;;) {
|
||||
if (
|
||||
cancelledTasksRef.current.has(task.id)
|
||||
|| cancelledTasksRef.current.has(rootTaskId)
|
||||
) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
const latest = sftpTransferCenterStore.getTask(task.id)
|
||||
?? transfersRef.current.find((candidate) => candidate.id === task.id);
|
||||
const status = latest?.status;
|
||||
if (status === "completed") break;
|
||||
if (status === "failed") {
|
||||
throw new Error(latest?.error || "Transfer failed");
|
||||
}
|
||||
if (status === "cancelled") {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (result?.error || result?.cancelled) {
|
||||
throw new Error(result.error || "Transfer cancelled");
|
||||
}
|
||||
@@ -535,7 +422,7 @@ export function useSftpDirectoryTransferOps({
|
||||
sameHost?: boolean,
|
||||
symlinkDepth = 0,
|
||||
followSymlinks = false, // Only true for downloadToLocal — uploads/copies treat symlinks as files
|
||||
discoveryProgress?: DirectoryDiscoveryProgress,
|
||||
discoveryProgress?: { discoveredFiles: number; nextEntryIndex: number },
|
||||
traversalBudget?: SftpDirectoryTraversalBudget,
|
||||
) => {
|
||||
// Check if task or root task was cancelled before starting
|
||||
@@ -543,20 +430,8 @@ export function useSftpDirectoryTransferOps({
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
// Always interleave discovery with transfer (rsync --inc-recursive style).
|
||||
// Full-tree pre-scan was removed: non-compressed folder uploads start bytes
|
||||
// as soon as each directory is listed; compressed upload is a separate path.
|
||||
// UI should treat totalBytes as "discovered so far", not a fixed grand total.
|
||||
let totalErrors = 0;
|
||||
const progress = discoveryProgress ?? {
|
||||
discoveredFiles: 0,
|
||||
nextEntryIndex: 0,
|
||||
listingGate: createDirectoryListingGate(resolveSftpDirectoryListingConcurrency()),
|
||||
};
|
||||
if (!progress.listingGate) {
|
||||
progress.listingGate = createDirectoryListingGate(resolveSftpDirectoryListingConcurrency());
|
||||
}
|
||||
const listingGate = progress.listingGate;
|
||||
const progress = discoveryProgress ?? { discoveredFiles: 0, nextEntryIndex: 0 };
|
||||
const traversal = traversalBudget ?? createSftpDirectoryTraversalBudget();
|
||||
let claimedCanonicalPath: string | null = null;
|
||||
let regularFiles: SftpFileEntry[] = [];
|
||||
@@ -598,16 +473,13 @@ export function useSftpDirectoryTransferOps({
|
||||
}
|
||||
|
||||
let files: SftpFileEntry[];
|
||||
// Tree-wide listing gate: parallel siblings share one budget.
|
||||
files = await listingGate.run(async () => {
|
||||
if (sourceIsLocal) {
|
||||
return listLocalFiles(task.sourcePath);
|
||||
}
|
||||
if (sourceSftpId) {
|
||||
return listRemoteFiles(sourceSftpId, task.sourcePath, sourceEncoding);
|
||||
}
|
||||
if (sourceIsLocal) {
|
||||
files = await listLocalFiles(task.sourcePath);
|
||||
} else if (sourceSftpId) {
|
||||
files = await listRemoteFiles(sourceSftpId, task.sourcePath, sourceEncoding);
|
||||
} else {
|
||||
throw new Error("No source connection");
|
||||
});
|
||||
}
|
||||
|
||||
// Filter both "." and ".." — some SFTP servers include "." in readdir
|
||||
const filtered = files.filter((f) => f.name !== ".." && f.name !== ".");
|
||||
@@ -637,25 +509,26 @@ export function useSftpDirectoryTransferOps({
|
||||
regularFiles.sort((left, right) => left.name.localeCompare(right.name));
|
||||
|
||||
// Directory progress is discovered by the same traversal that performs
|
||||
// the transfer (single pass - no separate countDirectoryFiles walk).
|
||||
// Process subdirectories sequentially so directoryEntryIndex / manifest
|
||||
// order stay deterministic for resume, and sibling symlink aliases can
|
||||
// claim the same canonical path after the prior branch releases.
|
||||
// the transfer. This avoids a second full-tree list pass and lets the UI
|
||||
// grow the total incrementally without flooding the server at startup.
|
||||
progress.discoveredFiles += regularFiles.length;
|
||||
setTransfers((prev) => prev.map((candidate) => candidate.id === rootTaskId
|
||||
? {
|
||||
...candidate,
|
||||
totalBytes: Math.max(progress.discoveredFiles, candidate.transferredBytes),
|
||||
// Keep scanning only until the first file completes; later lists must
|
||||
// not flip an in-progress folder bar back to indeterminate thrash.
|
||||
...(candidate.transferredBytes <= 0 ? { phase: "scanning" as const } : null),
|
||||
}
|
||||
: candidate));
|
||||
|
||||
// Process subdirectories sequentially to avoid unbounded concurrent SFTP
|
||||
// requests from nested Promise.all + worker pools across the tree.
|
||||
// File-level concurrency within each directory is still governed by the
|
||||
// shared SFTP transfer worker scheduler below.
|
||||
for (const dir of dirs) {
|
||||
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
// Pause between subfolders — otherwise the walk enters the next tree while
|
||||
// the user thinks the whole folder transfer is paused.
|
||||
await waitWhileTransferPaused(rootTaskId);
|
||||
|
||||
const childTask: TransferTask = {
|
||||
@@ -671,7 +544,7 @@ export function useSftpDirectoryTransferOps({
|
||||
};
|
||||
|
||||
const isSymlink = dir.type === "symlink";
|
||||
totalErrors += await transferDirectory(
|
||||
const subdirErrors = await transferDirectory(
|
||||
childTask,
|
||||
sourceSftpId,
|
||||
targetSftpId,
|
||||
@@ -686,6 +559,7 @@ export function useSftpDirectoryTransferOps({
|
||||
progress,
|
||||
traversal,
|
||||
);
|
||||
totalErrors += subdirErrors;
|
||||
}
|
||||
} finally {
|
||||
// Release on success, cancellation, and traversal errors.
|
||||
@@ -696,12 +570,6 @@ export function useSftpDirectoryTransferOps({
|
||||
|
||||
// Transfer files in parallel with concurrency limit
|
||||
if (regularFiles.length > 0) {
|
||||
setTransfers((prev) => prev.map((candidate) => candidate.id === rootTaskId
|
||||
? {
|
||||
...candidate,
|
||||
phase: "transferring",
|
||||
}
|
||||
: candidate));
|
||||
const errors: Error[] = [];
|
||||
// If the SFTP session dies mid-directory, stop queueing more files
|
||||
// (remaining workers will still finish their current item).
|
||||
@@ -721,10 +589,8 @@ export function useSftpDirectoryTransferOps({
|
||||
const fileSize = getEntrySize(file);
|
||||
const sourcePath = joinPath(task.sourcePath, file.name);
|
||||
const targetPath = joinTransferTargetPath(task.targetPath, file.name);
|
||||
let transferSize = fileSize;
|
||||
let transferLastModified = file.lastModified;
|
||||
const directoryEntryIndex = directoryEntryBase + fileIndex;
|
||||
let directoryEntryIdentity = createDirectoryEntryIdentity({
|
||||
const directoryEntryIdentity = createDirectoryEntryIdentity({
|
||||
sourcePath,
|
||||
targetPath,
|
||||
size: fileSize,
|
||||
@@ -749,104 +615,6 @@ export function useSftpDirectoryTransferOps({
|
||||
if (isPauseLatched(rootTaskId)) {
|
||||
await waitWhileTransferPaused(rootTaskId);
|
||||
}
|
||||
|
||||
// Symlink listing attrs are the link node; transfer follows target bytes.
|
||||
// For regular files, re-stat so skip and transfer use current size/mtime
|
||||
// (Codex P1: listing attrs can go stale during a long interleaved walk).
|
||||
let freshSourceOk = false;
|
||||
if (file.type !== "symlink") {
|
||||
const freshSource = await tryStatTransferPath(
|
||||
sourcePath,
|
||||
sourceIsLocal,
|
||||
sourceSftpId,
|
||||
sourceEncoding,
|
||||
);
|
||||
if (freshSource && freshSource.type !== "directory") {
|
||||
transferSize = freshSource.size;
|
||||
transferLastModified = freshSource.lastModified;
|
||||
directoryEntryIdentity = createDirectoryEntryIdentity({
|
||||
sourcePath,
|
||||
targetPath,
|
||||
size: transferSize,
|
||||
lastModified: transferLastModified,
|
||||
});
|
||||
freshSourceOk = true;
|
||||
} else {
|
||||
// Do not pass stale listing size as an explicit snapshot: the
|
||||
// dedicated transfer session may still read a grown file (Codex P2).
|
||||
// Child totalBytes 0 is omitted at startStreamTransfer via
|
||||
// `|| undefined`, so the bridge re-stats instead of truncating.
|
||||
transferSize = 0;
|
||||
transferLastModified = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const skipUnchanged = readSkipUnchangedEnabled()
|
||||
&& !task.replaceExistingTarget
|
||||
&& !String(task.targetPath).includes(".netcatty-");
|
||||
// Missing fresh metadata must not fall back to listing attrs for skip.
|
||||
if (skipUnchanged && file.type !== "symlink" && freshSourceOk) {
|
||||
const existing = await tryStatTransferTarget(
|
||||
targetPath,
|
||||
targetIsLocal,
|
||||
targetSftpId,
|
||||
targetEncoding,
|
||||
);
|
||||
if (
|
||||
existing
|
||||
&& existing.type !== "directory"
|
||||
&& isUnchangedTransferCandidate(
|
||||
{ size: transferSize, lastModified: transferLastModified, mtimeUnit: "ms" },
|
||||
{ size: existing.size, lastModified: existing.lastModified, mtimeUnit: "ms" },
|
||||
)
|
||||
) {
|
||||
const skippedId = persistedChild?.id ?? crypto.randomUUID();
|
||||
setTransfers((prev) => {
|
||||
const hasChild = prev.some((row) => row.id === skippedId);
|
||||
const base = hasChild
|
||||
? prev.map((row) => row.id === skippedId
|
||||
? {
|
||||
...row,
|
||||
status: "completed" as TransferStatus,
|
||||
transferredBytes: transferSize,
|
||||
totalBytes: transferSize,
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
error: undefined,
|
||||
}
|
||||
: row)
|
||||
: [...prev, {
|
||||
...task,
|
||||
id: skippedId,
|
||||
fileName: file.name,
|
||||
originalFileName: file.name,
|
||||
sourcePath,
|
||||
targetPath,
|
||||
isDirectory: false,
|
||||
progressMode: "bytes" as const,
|
||||
parentTaskId: rootTaskId,
|
||||
totalBytes: transferSize,
|
||||
transferredBytes: transferSize,
|
||||
sourceLastModified: transferLastModified,
|
||||
directoryEntryIndex,
|
||||
directoryEntryIdentity,
|
||||
status: "completed" as TransferStatus,
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
endTime: Date.now(),
|
||||
}];
|
||||
return base.map((row) => {
|
||||
if (row.id !== rootTaskId) return row;
|
||||
if (row.status === "paused" || row.status === "pausing" || isPauseLatched(rootTaskId)) {
|
||||
return { ...row, speed: 0 };
|
||||
}
|
||||
return { ...row, transferredBytes: row.transferredBytes + 1 };
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const fileId = persistedChild?.id ?? crypto.randomUUID();
|
||||
|
||||
// Track child ID outside React state for immediate cancellation visibility
|
||||
@@ -866,11 +634,11 @@ export function useSftpDirectoryTransferOps({
|
||||
isDirectory: false,
|
||||
progressMode: "bytes",
|
||||
parentTaskId: rootTaskId,
|
||||
totalBytes: transferSize,
|
||||
sourceLastModified: transferLastModified || undefined,
|
||||
totalBytes: fileSize,
|
||||
sourceLastModified: file.lastModified,
|
||||
directoryEntryIndex,
|
||||
directoryEntryIdentity,
|
||||
// Inherit retryable from parent - downloadToLocal sets retryable: false
|
||||
// Inherit retryable from parent — downloadToLocal sets retryable: false
|
||||
// because "local" targetConnectionId can't be resolved by retryTransfer
|
||||
retryable: task.retryable,
|
||||
// New/restarted child streams arm at bridge lifecycleEpoch 0. Never
|
||||
@@ -978,7 +746,7 @@ export function useSftpDirectoryTransferOps({
|
||||
? {
|
||||
...t,
|
||||
status: "failed" as TransferStatus,
|
||||
error: "SFTP session lost - reconnect and resume remaining files",
|
||||
error: "SFTP session lost — reconnect and resume remaining files",
|
||||
speed: 0,
|
||||
endTime: Date.now(),
|
||||
}
|
||||
|
||||
@@ -59,96 +59,3 @@ test("upload task callbacks write through the transfer store without page callba
|
||||
assert.equal(patches[1].updates.transferredBytes, 100);
|
||||
assert.deepEqual(dismissed, []);
|
||||
});
|
||||
|
||||
test("progress promotes pending scanning folder rows into transferring", () => {
|
||||
const patches: Array<{ taskId: string; updates: Partial<TransferTask> }> = [];
|
||||
const liveTask: TransferTask = {
|
||||
id: "folder-1",
|
||||
fileName: "docs",
|
||||
sourcePath: "local",
|
||||
targetPath: "/remote/docs",
|
||||
sourceConnectionId: "external",
|
||||
targetConnectionId: "connection-1",
|
||||
direction: "upload",
|
||||
status: "pending",
|
||||
totalBytes: 0,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: 1,
|
||||
isDirectory: true,
|
||||
progressMode: "files",
|
||||
phase: "scanning",
|
||||
};
|
||||
const store = {
|
||||
upsertTasks: () => {},
|
||||
patchTask: (taskId: string, updates: Partial<TransferTask>) => patches.push({ taskId, updates }),
|
||||
dismiss: () => {},
|
||||
getTask: (taskId: string) => (taskId === "folder-1" ? liveTask : undefined),
|
||||
};
|
||||
const callbacks = createUploadTaskCallbacks({
|
||||
ownerId: "owner-1",
|
||||
connectionId: "connection-1",
|
||||
targetPath: "/remote",
|
||||
store,
|
||||
});
|
||||
|
||||
callbacks.onTaskProgress?.("folder-1", {
|
||||
transferred: 12,
|
||||
total: 400,
|
||||
speed: 0,
|
||||
percent: 3,
|
||||
phase: "transferring",
|
||||
});
|
||||
|
||||
assert.equal(patches[0].taskId, "folder-1");
|
||||
assert.equal(patches[0].updates.status, "transferring");
|
||||
assert.equal(patches[0].updates.transferredBytes, 12);
|
||||
assert.equal(patches[0].updates.totalBytes, 400);
|
||||
assert.equal(patches[0].updates.phase, "transferring");
|
||||
});
|
||||
|
||||
test("scanning callbacks expose live file counts in files progress mode", () => {
|
||||
const upserts: TransferTask[][] = [];
|
||||
const patches: Array<{ taskId: string; updates: Partial<TransferTask> }> = [];
|
||||
const store = {
|
||||
upsertTasks: (tasks: readonly TransferTask[]) => upserts.push([...tasks]),
|
||||
patchTask: (taskId: string, updates: Partial<TransferTask>) => patches.push({ taskId, updates }),
|
||||
dismiss: () => {},
|
||||
};
|
||||
const callbacks = createUploadTaskCallbacks({
|
||||
ownerId: "owner-1",
|
||||
connectionId: "connection-1",
|
||||
targetPath: "/remote",
|
||||
store,
|
||||
});
|
||||
|
||||
callbacks.onScanningStart?.("scan-1", { label: "docs" });
|
||||
callbacks.onScanningProgress?.("scan-1", {
|
||||
fileCount: 1284,
|
||||
directoryCount: 40,
|
||||
entryCount: 1324,
|
||||
label: "docs",
|
||||
});
|
||||
|
||||
assert.equal(upserts[0][0].fileName, "docs");
|
||||
assert.equal(upserts[0][0].phase, "scanning");
|
||||
assert.equal(upserts[0][0].progressMode, "files");
|
||||
assert.equal(upserts[0][0].status, "pending");
|
||||
assert.deepEqual(patches[0], {
|
||||
taskId: "scan-1",
|
||||
updates: {
|
||||
// Found files live in totalBytes; completed stays 0 during scan.
|
||||
transferredBytes: 0,
|
||||
totalBytes: 1284,
|
||||
progressMode: "files",
|
||||
phase: "scanning",
|
||||
fileName: "docs",
|
||||
},
|
||||
});
|
||||
|
||||
// Display contract: 0 done · N found (not N done · N found).
|
||||
const discovered = Math.max(patches[0].updates.totalBytes ?? 0, patches[0].updates.transferredBytes ?? 0);
|
||||
const completed = patches[0].updates.transferredBytes ?? 0;
|
||||
assert.equal(completed, 0);
|
||||
assert.equal(discovered, 1284);
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { joinPath } from "./utils";
|
||||
type UploadTransferStore = Pick<
|
||||
typeof sftpTransferCenterStore,
|
||||
"upsertTasks" | "patchTask" | "dismiss"
|
||||
> & Partial<Pick<typeof sftpTransferCenterStore, "getTask">>;
|
||||
>;
|
||||
|
||||
interface UploadTaskCallbacksParams {
|
||||
ownerId: string;
|
||||
@@ -27,11 +27,11 @@ export const createUploadTaskCallbacks = ({
|
||||
targetConnectionKey,
|
||||
store = sftpTransferCenterStore,
|
||||
}: UploadTaskCallbacksParams): UploadCallbacks => ({
|
||||
onScanningStart: (taskId: string, info?: { label?: string }) => {
|
||||
onScanningStart: (taskId: string) => {
|
||||
store.upsertTasks([{
|
||||
id: taskId,
|
||||
ownerId,
|
||||
fileName: info?.label?.trim() || "Scanning files...",
|
||||
fileName: "Scanning files...",
|
||||
sourcePath: "local",
|
||||
targetPath,
|
||||
sourceConnectionId: "external",
|
||||
@@ -42,30 +42,18 @@ export const createUploadTaskCallbacks = ({
|
||||
targetConnectionKey,
|
||||
direction: "upload",
|
||||
status: "pending" as TransferStatus,
|
||||
// Progressive discovery: totalBytes = found so far, completed stays 0
|
||||
// until real transfers start (UI: "0 done · N found").
|
||||
totalBytes: 0,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
isDirectory: true,
|
||||
progressMode: "files",
|
||||
progressMode: "bytes",
|
||||
origin: "drag-drop",
|
||||
background: false,
|
||||
resumable: true,
|
||||
phase: "scanning",
|
||||
}]);
|
||||
},
|
||||
onScanningProgress: (taskId: string, progress) => {
|
||||
store.patchTask(taskId, {
|
||||
// Found count is the growing total; nothing is completed during scan.
|
||||
totalBytes: Math.max(0, progress.fileCount),
|
||||
transferredBytes: 0,
|
||||
progressMode: "files",
|
||||
phase: "scanning",
|
||||
...(progress.label?.trim() ? { fileName: progress.label.trim() } : null),
|
||||
});
|
||||
},
|
||||
onScanningEnd: (taskId: string) => {
|
||||
store.dismiss(taskId);
|
||||
},
|
||||
@@ -102,36 +90,8 @@ export const createUploadTaskCallbacks = ({
|
||||
const durableCheckpoint = Number.isFinite(Number(progress.checkpointBytes))
|
||||
? Math.max(0, Math.trunc(Number(progress.checkpointBytes)))
|
||||
: progress.transferred;
|
||||
// Progressive folder walks keep the scanning row as pending until real
|
||||
// progress arrives. Promote pending/queued → transferring so the panel
|
||||
// leaves "Waiting..." and matches the transfer-center live state.
|
||||
const current = "getTask" in store && typeof store.getTask === "function"
|
||||
? store.getTask(taskId)
|
||||
: undefined;
|
||||
const shouldPromote =
|
||||
!!current
|
||||
&& (current.status === "pending" || current.status === "queued")
|
||||
&& current.reconnectRequired !== true
|
||||
&& (
|
||||
progress.phase === "scanning"
|
||||
|| progress.phase === "transferring"
|
||||
|| progress.transferred > 0
|
||||
|| progress.total > 0
|
||||
);
|
||||
// Only patch fingerprint/checkpoint while paused — do not keep animating
|
||||
// high-water transferred after the user hit Pause.
|
||||
const isPausedLike = current?.status === "paused" || current?.status === "pausing";
|
||||
if (isPausedLike) {
|
||||
store.patchTask(taskId, {
|
||||
checkpointBytes: durableCheckpoint,
|
||||
resumable: progress.resumable,
|
||||
pauseUnavailableReason: progress.pauseUnavailableReason,
|
||||
...("sourceFingerprint" in progress && progress.sourceFingerprint
|
||||
? { sourceFingerprint: progress.sourceFingerprint as string }
|
||||
: null),
|
||||
});
|
||||
return;
|
||||
}
|
||||
store.patchTask(taskId, {
|
||||
transferredBytes: progress.transferred,
|
||||
totalBytes: progress.total,
|
||||
@@ -141,7 +101,6 @@ export const createUploadTaskCallbacks = ({
|
||||
phase: progress.phase,
|
||||
resumable: progress.resumable,
|
||||
pauseUnavailableReason: progress.pauseUnavailableReason,
|
||||
...(shouldPromote ? { status: "transferring" as TransferStatus } : null),
|
||||
// Durable pause identity may arrive on a forced progress event while
|
||||
// status is already paused — keep it for restart/resume safety.
|
||||
...("sourceFingerprint" in progress && progress.sourceFingerprint
|
||||
@@ -162,7 +121,6 @@ export const createUploadTaskCallbacks = ({
|
||||
endTime: Date.now(),
|
||||
transferredBytes: totalBytes,
|
||||
speed: 0,
|
||||
phase: undefined,
|
||||
});
|
||||
},
|
||||
onTaskFailed: (taskId: string, error: string) => {
|
||||
@@ -171,7 +129,6 @@ export const createUploadTaskCallbacks = ({
|
||||
endTime: Date.now(),
|
||||
error,
|
||||
speed: 0,
|
||||
phase: undefined,
|
||||
});
|
||||
},
|
||||
onTaskCancelled: (taskId: string) => {
|
||||
@@ -179,7 +136,6 @@ export const createUploadTaskCallbacks = ({
|
||||
status: "cancelled" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
phase: undefined,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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" }]),
|
||||
|
||||
@@ -41,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 {
|
||||
@@ -84,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;
|
||||
@@ -93,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(
|
||||
@@ -244,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);
|
||||
@@ -460,7 +450,6 @@ export const useSftpConnections = ({
|
||||
connectionCacheKeys: connectionCacheKeyMapRef.current,
|
||||
clearCacheForConnection,
|
||||
closeSftp: async (sftpId) => netcattyBridge.get()?.closeSftp(sftpId),
|
||||
onRemoteSessionClosed: (sftpId) => notifyRemoteSessionClosed(sftpId),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -502,7 +491,6 @@ export const useSftpConnections = ({
|
||||
} catch {
|
||||
// Ignore errors when closing stale SFTP sessions
|
||||
}
|
||||
notifyRemoteSessionClosed(oldSftpId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -978,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,
|
||||
|
||||
@@ -60,10 +60,6 @@ import {
|
||||
} from "./transferConflictLifecycle";
|
||||
import { getParentPath, joinPath } from "./utils";
|
||||
import { promoteDirectoryReplaceStage as promoteDirectoryReplacePaths } from "./directoryReplacePromotion";
|
||||
import {
|
||||
isExternalDragDropFileUpload,
|
||||
retryExternalDragDropFileUpload,
|
||||
} from "./externalDragDropRetry";
|
||||
|
||||
/** Keep the MutableRefObject mirror in sync with the process-global latch set. */
|
||||
function syncPausedTasksRef(ref: { current: Set<string> }, taskId: string, latched: boolean) {
|
||||
@@ -1298,62 +1294,8 @@ export const useSftpTransfers = ({
|
||||
|
||||
const retryTransfer = useCallback(
|
||||
async (transferId: string) => {
|
||||
// Prefer the live owner list; fall back to the center store (drag-drop
|
||||
// children are upserted there and mirrored via subscribe).
|
||||
const task = transfersRef.current.find((t) => t.id === transferId)
|
||||
?? sftpTransferCenterStore.getTask(transferId);
|
||||
const task = transfersRef.current.find((t) => t.id === transferId);
|
||||
if (!task || task.retryable === false) return;
|
||||
|
||||
// Progressive / external drag-drop children never have dual-pane endpoints
|
||||
// (sourceConnectionId is "external"). Re-run startStreamTransfer in place.
|
||||
if (isExternalDragDropFileUpload(task)) {
|
||||
if (inFlightTransferIdsRef.current.has(transferId)) return;
|
||||
inFlightTransferIdsRef.current.add(transferId);
|
||||
clearTransferCancelled(transferId);
|
||||
cancelledTasksRef.current.delete(transferId);
|
||||
try {
|
||||
const result = await retryExternalDragDropFileUpload(task, {
|
||||
getBrowseSftpId: (connectionId) => sftpSessionsRef.current.get(connectionId),
|
||||
acquireTransferSession: acquireTransferSession
|
||||
? (hostId, id) => acquireTransferSession(hostId, id)
|
||||
: undefined,
|
||||
startStreamTransfer: async (options) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.startStreamTransfer) {
|
||||
return { error: "Stream transfer is unavailable" };
|
||||
}
|
||||
return bridge.startStreamTransfer(options);
|
||||
},
|
||||
clearPendingCancel: (id) => netcattyBridge.get()?.clearPendingTransferCancel?.(id),
|
||||
cleanupArtifacts: cleanupTaskArtifacts,
|
||||
getTask: (id) => sftpTransferCenterStore.getTask(id)
|
||||
?? transfersRef.current.find((row) => row.id === id),
|
||||
getChildTasks: (parentId) => {
|
||||
const fromStore = sftpTransferCenterStore.getOwnerTasks(ownerId)
|
||||
.filter((row) => row.parentTaskId === parentId);
|
||||
if (fromStore.length > 0) return fromStore;
|
||||
return transfersRef.current.filter((row) => row.parentTaskId === parentId);
|
||||
},
|
||||
onPatch: (taskId, updates) => {
|
||||
transferRuntime.patchTask(taskId, updates);
|
||||
setTransfers((prev) => {
|
||||
if (!prev.some((row) => row.id === taskId)) {
|
||||
// Store-only child: mirror the patch by re-pulling owner rows.
|
||||
return sftpTransferCenterStore.getOwnerTasks(ownerId);
|
||||
}
|
||||
return prev.map((row) => (row.id === taskId ? { ...row, ...updates } : row));
|
||||
});
|
||||
},
|
||||
});
|
||||
if (!result.success && result.error && !/cancelled/i.test(result.error)) {
|
||||
notify.warning(result.error, "SFTP");
|
||||
}
|
||||
} finally {
|
||||
inFlightTransferIdsRef.current.delete(transferId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await cleanupTaskArtifacts(task);
|
||||
|
||||
const retriedTask: TransferTask = {
|
||||
@@ -1374,13 +1316,7 @@ export const useSftpTransfers = ({
|
||||
};
|
||||
|
||||
const endpoints = resolveTaskEndpoints(task);
|
||||
if (!endpoints) {
|
||||
notify.warning(
|
||||
"Could not resolve transfer endpoints for retry. Reconnect the target and try again.",
|
||||
"SFTP",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!endpoints) return;
|
||||
const { targetSide, sourcePane, targetPane } = endpoints;
|
||||
|
||||
const completionHandler = completionHandlersRef.current.get(transferId);
|
||||
@@ -1399,7 +1335,7 @@ export const useSftpTransfers = ({
|
||||
await processTransfer(retriedTask, sourcePane, targetPane, targetSide);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- processTransfer is defined inline
|
||||
[acquireTransferSession, cleanupTaskArtifacts, ownerId, resolveTaskEndpoints, setTransfers, sftpSessionsRef],
|
||||
[cleanupTaskArtifacts, resolveTaskEndpoints, setTransfers],
|
||||
);
|
||||
|
||||
const clearCompletedTransfers = useCallback(() => {
|
||||
|
||||
@@ -191,48 +191,3 @@ test("auto-sync skips only the exact remote-applied data hash", () => {
|
||||
"remote-apply skip hash must survive temporary sync blockers and clear only after a successful sync",
|
||||
);
|
||||
});
|
||||
|
||||
test("startup local-wins and merge round-trips refuse device-bound credential placeholders", () => {
|
||||
const source = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
|
||||
const uploadLocalIndex = source.indexOf("if (conflictAction === 'upload-local')");
|
||||
const uploadGuardIndex = source.indexOf(
|
||||
"findSyncPayloadEncryptedCredentialPaths(localPayload)",
|
||||
uploadLocalIndex,
|
||||
);
|
||||
const uploadPushIndex = source.indexOf("manager.syncAllProviders(localPayload)", uploadGuardIndex);
|
||||
const mergeRoundTripBlockIndex = source.indexOf(
|
||||
"stripSyncPayloadEncryptedCredentials(mergeResult.payload)",
|
||||
);
|
||||
const mergeLocalHealIndex = source.indexOf(
|
||||
"healPoisonedSecretsForMerge(localPayload, remoteRaw, base)",
|
||||
);
|
||||
const mergeRemoteHealIndex = source.indexOf(
|
||||
"healPoisonedSecretsForMerge(remoteRaw, localPayload, base)",
|
||||
);
|
||||
const mergePushIndex = source.indexOf(
|
||||
"manager.syncAllProviders(portableMerge)",
|
||||
mergeRoundTripBlockIndex,
|
||||
);
|
||||
const stripRemoteIndex = source.indexOf(
|
||||
"stripSyncPayloadEncryptedCredentials(remoteRaw)",
|
||||
);
|
||||
|
||||
assert.notEqual(uploadLocalIndex, -1);
|
||||
assert.notEqual(uploadGuardIndex, -1);
|
||||
assert.notEqual(uploadPushIndex, -1);
|
||||
assert.notEqual(mergeLocalHealIndex, -1);
|
||||
assert.notEqual(mergeRemoteHealIndex, -1);
|
||||
assert.notEqual(mergeRoundTripBlockIndex, -1);
|
||||
assert.notEqual(mergePushIndex, -1);
|
||||
assert.notEqual(stripRemoteIndex, -1);
|
||||
assert.ok(
|
||||
uploadGuardIndex < uploadPushIndex,
|
||||
"startup local-wins must guard placeholders before syncAllProviders",
|
||||
);
|
||||
assert.ok(
|
||||
mergeLocalHealIndex < mergeRoundTripBlockIndex
|
||||
&& mergeRemoteHealIndex < mergeRoundTripBlockIndex
|
||||
&& mergeRoundTripBlockIndex < mergePushIndex,
|
||||
"startup merge must heal local+remote secrets, then strip, then upload",
|
||||
);
|
||||
});
|
||||
@@ -14,8 +14,6 @@ import { getCloudSyncManager } from '../../infrastructure/services/CloudSyncMana
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
healPoisonedSecretsForMerge,
|
||||
stripSyncPayloadEncryptedCredentials,
|
||||
} from '../../domain/credentials';
|
||||
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
|
||||
import { mergeSyncPayloads } from '../../domain/syncMerge';
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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,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,
|
||||
@@ -1848,7 +1744,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
uiFontFamilyId, uiLanguage, customCSS,
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
|
||||
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
]),
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -448,7 +444,6 @@ export const useSftpState = (
|
||||
clearCacheForConnection,
|
||||
createEmptyPane: createPane,
|
||||
autoConnectLocalOnMount: options?.autoConnectLocalOnMount,
|
||||
onRemoteSessionClosed: (sftpId) => forgetExternalEditTempsForSftpRef.current(sftpId),
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -572,8 +567,6 @@ export const useSftpState = (
|
||||
cancelExternalUpload,
|
||||
selectApplication,
|
||||
activeFileWatchCountRef,
|
||||
activeExternalEditCount,
|
||||
forgetExternalEditTempsForSftp,
|
||||
releaseExternalFileWatches,
|
||||
uploadConflicts,
|
||||
resolveUploadConflict,
|
||||
@@ -634,7 +627,6 @@ export const useSftpState = (
|
||||
useCompressedUpload: options?.useCompressedUpload,
|
||||
isTransferCancelled,
|
||||
});
|
||||
forgetExternalEditTempsForSftpRef.current = forgetExternalEditTempsForSftp;
|
||||
|
||||
const conflicts = useMemo(
|
||||
() => [...transferConflicts, ...uploadConflicts],
|
||||
@@ -674,7 +666,6 @@ export const useSftpState = (
|
||||
interactive,
|
||||
browseParked: browseParkedRef.current,
|
||||
activeTransfersCount: Math.max(activeTransfersCount, centerActive ? 1 : 0),
|
||||
activeExternalEditCount,
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
@@ -693,7 +684,6 @@ export const useSftpState = (
|
||||
} catch {
|
||||
// best-effort — session may already be gone
|
||||
}
|
||||
forgetExternalEditTempsForSftpRef.current(sftpId);
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -747,7 +737,6 @@ export const useSftpState = (
|
||||
}, [
|
||||
interactive,
|
||||
activeTransfersCount,
|
||||
activeExternalEditCount,
|
||||
clearCacheForConnection,
|
||||
connect,
|
||||
getActivePane,
|
||||
@@ -922,7 +911,6 @@ export const useSftpState = (
|
||||
rightTabs,
|
||||
transfers,
|
||||
activeTransfersCount,
|
||||
activeExternalEditCount,
|
||||
conflicts,
|
||||
hostKeyVerification,
|
||||
|
||||
@@ -944,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,
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
@@ -51,11 +51,7 @@ 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,
|
||||
mergeConnectionLogsFromStorage,
|
||||
@@ -65,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 {
|
||||
@@ -832,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) => {
|
||||
@@ -864,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">) => {
|
||||
@@ -1005,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);
|
||||
@@ -1540,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);
|
||||
@@ -1657,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)) {
|
||||
|
||||
+28
-380
@@ -43,17 +43,13 @@ const {
|
||||
applySyncPayload,
|
||||
buildLocalVaultPayload,
|
||||
buildCloudSyncPayload,
|
||||
withPluginSyncSidecars,
|
||||
buildSyncPayload,
|
||||
hasCloudSyncEntityData,
|
||||
hasMeaningfulCloudSyncData,
|
||||
hasMeaningfulSyncData,
|
||||
shouldPromptCloudVaultRecovery,
|
||||
SYNCABLE_SETTING_STORAGE_KEYS,
|
||||
} = await import("./syncPayload.ts");
|
||||
const storageKeys = await import("../infrastructure/config/storageKeys.ts");
|
||||
const { SYNC_STORAGE_KEYS } = await import("../domain/sync.ts");
|
||||
const { localStorageAdapter } = await import("../infrastructure/persistence/localStorageAdapter.ts");
|
||||
|
||||
const knownHost = (id = "kh-1"): KnownHost => ({
|
||||
id,
|
||||
@@ -139,56 +135,6 @@ test("sync payloads preserve opaque plugin hosts without requiring the plugin to
|
||||
assert.deepEqual(imported?.hosts, [pluginHost]);
|
||||
});
|
||||
|
||||
test("applySyncPayload preserves host startup command and appearance overrides (#2757)", async () => {
|
||||
const host = {
|
||||
id: "host-1",
|
||||
label: "Prod",
|
||||
hostname: "prod.example",
|
||||
username: "root",
|
||||
port: 22,
|
||||
protocol: "ssh" as const,
|
||||
tags: [],
|
||||
os: "linux" as const,
|
||||
startupCommand: "tmux attach || tmux",
|
||||
startupCommandRunMode: "lineDelay" as const,
|
||||
theme: "solarized-dark",
|
||||
themeOverride: true,
|
||||
};
|
||||
const groupConfigs = [
|
||||
{
|
||||
path: "prod",
|
||||
startupCommand: "cd /srv && exec bash -l",
|
||||
theme: "solarized-dark",
|
||||
themeOverride: true,
|
||||
},
|
||||
];
|
||||
const payload = buildSyncPayload({
|
||||
...vault(),
|
||||
hosts: [host],
|
||||
groupConfigs,
|
||||
});
|
||||
|
||||
assert.equal(payload.hosts[0]?.startupCommand, "tmux attach || tmux");
|
||||
assert.equal(payload.hosts[0]?.theme, "solarized-dark");
|
||||
assert.equal(payload.hosts[0]?.themeOverride, true);
|
||||
assert.equal(payload.groupConfigs?.[0]?.startupCommand, "cd /srv && exec bash -l");
|
||||
|
||||
let imported: Record<string, unknown> | null = null;
|
||||
await applySyncPayload(payload, {
|
||||
importVaultData: (json) => { imported = JSON.parse(json); },
|
||||
});
|
||||
|
||||
const importedHosts = imported?.hosts as typeof payload.hosts;
|
||||
assert.equal(importedHosts?.[0]?.startupCommand, "tmux attach || tmux");
|
||||
assert.equal(importedHosts?.[0]?.startupCommandRunMode, "lineDelay");
|
||||
assert.equal(importedHosts?.[0]?.theme, "solarized-dark");
|
||||
assert.equal(importedHosts?.[0]?.themeOverride, true);
|
||||
assert.equal(
|
||||
(imported?.groupConfigs as typeof groupConfigs)?.[0]?.startupCommand,
|
||||
"cd /srv && exec bash -l",
|
||||
);
|
||||
});
|
||||
|
||||
test("buildCloudSyncPayload includes notes and note groups", async () => {
|
||||
const payload = await buildCloudSyncPayload({
|
||||
...vault([]),
|
||||
@@ -212,13 +158,13 @@ test("buildSyncPayload includes AI configuration settings", () => {
|
||||
id: "openai-main",
|
||||
providerId: "openai",
|
||||
name: "OpenAI",
|
||||
apiKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
apiKey: "enc:v1:test",
|
||||
defaultModel: "gpt-test",
|
||||
enabled: true,
|
||||
}];
|
||||
const webSearch = {
|
||||
providerId: "tavily",
|
||||
apiKey: "enc:v1:djEwd2ViAAAAAAAAAAAAAAAAAA==",
|
||||
apiKey: "enc:v1:web",
|
||||
enabled: true,
|
||||
maxResults: 7,
|
||||
};
|
||||
@@ -239,11 +185,8 @@ test("buildSyncPayload includes AI configuration settings", () => {
|
||||
|
||||
const payload = buildSyncPayload(vault([]));
|
||||
|
||||
// Device-bound enc:v1 apiKeys are stripped from portable sync settings.
|
||||
const { apiKey: _providerKey, ...providerWithoutKey } = providers[0]!;
|
||||
const { apiKey: _webKey, ...webSearchWithoutKey } = webSearch;
|
||||
assert.deepEqual(payload.settings?.ai, {
|
||||
providers: [providerWithoutKey],
|
||||
providers,
|
||||
activeProviderId: "openai-main",
|
||||
activeModelId: "gpt-test",
|
||||
globalPermissionMode: "auto",
|
||||
@@ -254,7 +197,7 @@ test("buildSyncPayload includes AI configuration settings", () => {
|
||||
maxIterations: 10,
|
||||
agentModelMap: { codex: "gpt-test" },
|
||||
agentProviderMap: { catty: "openai-main" },
|
||||
webSearchConfig: webSearchWithoutKey,
|
||||
webSearchConfig: webSearch,
|
||||
showTerminalSelectionAction: false,
|
||||
});
|
||||
});
|
||||
@@ -343,12 +286,12 @@ test("buildSyncPayload omits device-bound encrypted AI API keys", () => {
|
||||
id: "openai-main",
|
||||
providerId: "openai",
|
||||
name: "OpenAI",
|
||||
apiKey: "enc:v1:djEwQUFBQQAAAAAAAAAAAAAAAA==",
|
||||
apiKey: "enc:v1:djEwAAAA",
|
||||
enabled: true,
|
||||
}]));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
|
||||
providerId: "tavily",
|
||||
apiKey: "enc:v1:djEwQUFBQQAAAAAAAAAAAAAAAA==",
|
||||
apiKey: "enc:v1:djEwAAAA",
|
||||
enabled: true,
|
||||
}));
|
||||
|
||||
@@ -363,8 +306,8 @@ test("buildCloudSyncPayload includes decrypted AI API keys for portable cloud sy
|
||||
value: {
|
||||
netcatty: {
|
||||
credentialsDecrypt: async (value: string) => {
|
||||
if (value === "enc:v1:djEwUFJPVklERVIAAAAAAAAAAA==") return "sk-provider";
|
||||
if (value === "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==") return "sk-web";
|
||||
if (value === "enc:v1:djEwPROVIDER") return "sk-provider";
|
||||
if (value === "enc:v1:djEwWEB") return "sk-web";
|
||||
return value;
|
||||
},
|
||||
},
|
||||
@@ -376,12 +319,12 @@ test("buildCloudSyncPayload includes decrypted AI API keys for portable cloud sy
|
||||
id: "openai-main",
|
||||
providerId: "openai",
|
||||
name: "OpenAI",
|
||||
apiKey: "enc:v1:djEwUFJPVklERVIAAAAAAAAAAA==",
|
||||
apiKey: "enc:v1:djEwPROVIDER",
|
||||
enabled: true,
|
||||
}]));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
|
||||
providerId: "tavily",
|
||||
apiKey: "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==",
|
||||
apiKey: "enc:v1:djEwWEB",
|
||||
enabled: true,
|
||||
}));
|
||||
|
||||
@@ -405,7 +348,7 @@ test("buildCloudSyncPayload fails instead of deleting API keys when decrypt fail
|
||||
id: "openai-main",
|
||||
providerId: "openai",
|
||||
name: "OpenAI",
|
||||
apiKey: "enc:v1:djEwUFJPVklERVIAAAAAAAAAAA==",
|
||||
apiKey: "enc:v1:djEwPROVIDER",
|
||||
enabled: true,
|
||||
}]));
|
||||
|
||||
@@ -420,12 +363,12 @@ test("applySyncPayload restores AI configuration settings", async () => {
|
||||
id: "anthropic-main",
|
||||
providerId: "anthropic",
|
||||
name: "Anthropic",
|
||||
apiKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
apiKey: "enc:v1:test",
|
||||
enabled: true,
|
||||
}];
|
||||
const webSearch = {
|
||||
providerId: "exa",
|
||||
apiKey: "enc:v1:djEwd2ViAAAAAAAAAAAAAAAAAA==",
|
||||
apiKey: "enc:v1:web",
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
@@ -476,12 +419,7 @@ test("applySyncPayload encrypts synced plaintext AI API keys before saving local
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: {
|
||||
netcatty: {
|
||||
credentialsEncrypt: async (value: string) => {
|
||||
const body = Buffer.alloc(19, 0);
|
||||
Buffer.from("v10", "utf8").copy(body, 0);
|
||||
Buffer.from(`LOCAL_${value}`.slice(0, 16), "utf8").copy(body, 3);
|
||||
return `enc:v1:${body.toString("base64")}`;
|
||||
},
|
||||
credentialsEncrypt: async (value: string) => `enc:v1:djEwLOCAL_${value}`,
|
||||
},
|
||||
dispatchEvent: () => true,
|
||||
},
|
||||
@@ -509,20 +447,8 @@ test("applySyncPayload encrypts synced plaintext AI API keys before saving local
|
||||
|
||||
const provider = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!)[0];
|
||||
const webSearch = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!);
|
||||
const expectedProviderKey = (() => {
|
||||
const body = Buffer.alloc(19, 0);
|
||||
Buffer.from("v10", "utf8").copy(body, 0);
|
||||
Buffer.from("LOCAL_sk-provider".slice(0, 16), "utf8").copy(body, 3);
|
||||
return `enc:v1:${body.toString("base64")}`;
|
||||
})();
|
||||
const expectedWebKey = (() => {
|
||||
const body = Buffer.alloc(19, 0);
|
||||
Buffer.from("v10", "utf8").copy(body, 0);
|
||||
Buffer.from("LOCAL_sk-web".slice(0, 16), "utf8").copy(body, 3);
|
||||
return `enc:v1:${body.toString("base64")}`;
|
||||
})();
|
||||
assert.equal(provider.apiKey, expectedProviderKey);
|
||||
assert.equal(webSearch.apiKey, expectedWebKey);
|
||||
assert.equal(provider.apiKey, "enc:v1:djEwLOCAL_sk-provider");
|
||||
assert.equal(webSearch.apiKey, "enc:v1:djEwLOCAL_sk-web");
|
||||
});
|
||||
|
||||
test("applySyncPayload restores host tree sidebar visibility setting", async () => {
|
||||
@@ -678,14 +604,14 @@ test("applySyncPayload preserves local AI provider apiKeys when synced payload o
|
||||
id: "openai-main",
|
||||
providerId: "openai",
|
||||
name: "OpenAI",
|
||||
apiKey: "enc:v1:djEwTE9DQUwAAAAAAAAAAAAAAA==",
|
||||
apiKey: "enc:v1:djEwLOCAL",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "anthropic-main",
|
||||
providerId: "anthropic",
|
||||
name: "Anthropic",
|
||||
apiKey: "enc:v1:djEwQU5USFJPUElDAAAAAAAAAA==",
|
||||
apiKey: "enc:v1:djEwANTHROPIC",
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
@@ -716,14 +642,14 @@ test("applySyncPayload preserves local AI provider apiKeys when synced payload o
|
||||
id: "openai-main",
|
||||
providerId: "openai",
|
||||
name: "OpenAI (renamed)",
|
||||
apiKey: "enc:v1:djEwTE9DQUwAAAAAAAAAAAAAAA==",
|
||||
apiKey: "enc:v1:djEwLOCAL",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "anthropic-main",
|
||||
providerId: "anthropic",
|
||||
name: "Anthropic",
|
||||
apiKey: "enc:v1:djEwQU5USFJPUElDAAAAAAAAAA==",
|
||||
apiKey: "enc:v1:djEwANTHROPIC",
|
||||
enabled: false,
|
||||
},
|
||||
]);
|
||||
@@ -731,7 +657,7 @@ test("applySyncPayload preserves local AI provider apiKeys when synced payload o
|
||||
|
||||
test("applySyncPayload prefers explicit synced apiKey over local apiKey", async () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([
|
||||
{ id: "openai-main", providerId: "openai", name: "OpenAI", apiKey: "enc:v1:djEwTE9DQUwAAAAAAAAAAAAAAA==", enabled: true },
|
||||
{ id: "openai-main", providerId: "openai", name: "OpenAI", apiKey: "enc:v1:djEwLOCAL", enabled: true },
|
||||
]));
|
||||
|
||||
const payload: SyncPayload = {
|
||||
@@ -759,7 +685,7 @@ test("applySyncPayload prefers explicit synced apiKey over local apiKey", async
|
||||
test("applySyncPayload preserves local web-search apiKey when synced config omits it", async () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
|
||||
providerId: "tavily",
|
||||
apiKey: "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==",
|
||||
apiKey: "enc:v1:djEwWEB",
|
||||
enabled: true,
|
||||
maxResults: 7,
|
||||
}));
|
||||
@@ -783,7 +709,7 @@ test("applySyncPayload preserves local web-search apiKey when synced config omit
|
||||
const stored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!);
|
||||
assert.deepEqual(stored, {
|
||||
providerId: "tavily",
|
||||
apiKey: "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==",
|
||||
apiKey: "enc:v1:djEwWEB",
|
||||
enabled: false,
|
||||
maxResults: 12,
|
||||
});
|
||||
@@ -792,7 +718,7 @@ test("applySyncPayload preserves local web-search apiKey when synced config omit
|
||||
test("applySyncPayload drops local web-search apiKey when synced config switches provider", async () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
|
||||
providerId: "tavily",
|
||||
apiKey: "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==",
|
||||
apiKey: "enc:v1:djEwWEB",
|
||||
enabled: true,
|
||||
}));
|
||||
|
||||
@@ -865,164 +791,6 @@ test("hasMeaningfulCloudSyncData ignores legacy cloud known hosts", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("buildLocalVaultPayload includes last-known plugin sidecars for protective backups", () => {
|
||||
localStorage.setItem(
|
||||
"netcatty_plugin_sidecars_last_known_v1",
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: "com.example.p",
|
||||
kind: "settings",
|
||||
key: "com.example.p.theme\0application\0application",
|
||||
value: "dark",
|
||||
updatedAt: 1,
|
||||
}],
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const payload = buildLocalVaultPayload(vault([]));
|
||||
assert.equal(payload.pluginSidecars?.entries?.length, 1);
|
||||
assert.equal(payload.pluginSidecars?.entries?.[0].value, "dark");
|
||||
assert.equal(hasMeaningfulSyncData(payload), true);
|
||||
} finally {
|
||||
localStorage.removeItem("netcatty_plugin_sidecars_last_known_v1");
|
||||
}
|
||||
});
|
||||
|
||||
test("buildLocalVaultPayloadAsync prefers live empty over last-known for backups", async () => {
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: {
|
||||
netcatty: {
|
||||
collectPluginSyncSidecars: async () => ({ version: 1, entries: [] }),
|
||||
pluginHostReady: () => true,
|
||||
},
|
||||
dispatchEvent: () => true,
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
localStorage.setItem(
|
||||
"netcatty_plugin_sidecars_last_known_v1",
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: "com.example.p",
|
||||
kind: "settings",
|
||||
key: "com.example.p.theme\0application\0application",
|
||||
value: "dark",
|
||||
updatedAt: 1,
|
||||
}],
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const { buildLocalVaultPayloadAsync } = await import("./syncPayload.ts");
|
||||
const payload = await buildLocalVaultPayloadAsync(vault([]));
|
||||
assert.equal(payload.pluginSidecars?.entries?.length, 0);
|
||||
} finally {
|
||||
localStorage.removeItem("netcatty_plugin_sidecars_last_known_v1");
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: previousWindow,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("hasMeaningfulCloudSyncData treats non-empty plugin sidecars as meaningful", () => {
|
||||
assert.equal(
|
||||
hasMeaningfulCloudSyncData({
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
pluginSidecars: {
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: "com.example.p",
|
||||
kind: "settings",
|
||||
key: "com.example.p.theme\0application\0application",
|
||||
value: "dark",
|
||||
updatedAt: 1,
|
||||
}],
|
||||
},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
// Empty bundle alone must not bypass the empty-vault upload guard.
|
||||
assert.equal(
|
||||
hasMeaningfulCloudSyncData({
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
pluginSidecars: { version: 1, entries: [] },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
hasMeaningfulCloudSyncData({
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("hasMeaningfulCloudSyncData does not treat last-known-only empty sidecars as meaningful", () => {
|
||||
const previous = localStorageAdapter.read(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN);
|
||||
try {
|
||||
localStorageAdapter.write(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN, {
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: "com.example.p",
|
||||
kind: "settings",
|
||||
key: "k",
|
||||
value: 1,
|
||||
updatedAt: 1,
|
||||
}],
|
||||
});
|
||||
// Empty vault + empty sidecar entries must stay blocked even when last-known
|
||||
// still remembers prior plugin settings (empty-vault upload guard).
|
||||
assert.equal(
|
||||
hasMeaningfulCloudSyncData({
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
pluginSidecars: { version: 1, entries: [] },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
if (previous == null) localStorageAdapter.remove(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN);
|
||||
else localStorageAdapter.write(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN, previous);
|
||||
}
|
||||
});
|
||||
|
||||
test("plugin sidecar storage keys stay aligned between sync domain and storageKeys registry", () => {
|
||||
assert.equal(
|
||||
storageKeys.STORAGE_KEY_PLUGIN_SIDECARS_LAST_KNOWN,
|
||||
SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN,
|
||||
);
|
||||
assert.equal(
|
||||
storageKeys.STORAGE_KEY_PLUGIN_SIDECARS_PENDING_REMOTE,
|
||||
SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_PENDING_REMOTE,
|
||||
);
|
||||
assert.equal(
|
||||
storageKeys.STORAGE_KEY_AVAILABLE_PLUGIN_SYNC_PROVIDERS,
|
||||
SYNC_STORAGE_KEYS.AVAILABLE_PLUGIN_SYNC_PROVIDERS,
|
||||
);
|
||||
});
|
||||
|
||||
test("hasCloudSyncEntityData ignores settings-only payloads for empty-vault recovery", () => {
|
||||
assert.equal(
|
||||
hasCloudSyncEntityData({
|
||||
@@ -1066,19 +834,19 @@ test("buildLocalVaultPayload preserves local AI API keys for protective backups"
|
||||
id: "openai-main",
|
||||
providerId: "openai",
|
||||
name: "OpenAI",
|
||||
apiKey: "enc:v1:djEwUFJPVklERVIAAAAAAAAAAA==",
|
||||
apiKey: "enc:v1:djEwPROVIDER",
|
||||
enabled: true,
|
||||
}]));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
|
||||
providerId: "tavily",
|
||||
apiKey: "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==",
|
||||
apiKey: "enc:v1:djEwWEB",
|
||||
enabled: true,
|
||||
}));
|
||||
|
||||
const payload = buildLocalVaultPayload(vault([]));
|
||||
|
||||
assert.equal(payload.settings?.ai?.providers?.[0]?.apiKey, "enc:v1:djEwUFJPVklERVIAAAAAAAAAAA==");
|
||||
assert.equal(payload.settings?.ai?.webSearchConfig?.apiKey, "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==");
|
||||
assert.equal(payload.settings?.ai?.providers?.[0]?.apiKey, "enc:v1:djEwPROVIDER");
|
||||
assert.equal(payload.settings?.ai?.webSearchConfig?.apiKey, "enc:v1:djEwWEB");
|
||||
});
|
||||
|
||||
test("applySyncPayload ignores legacy cloud known hosts", async () => {
|
||||
@@ -1517,51 +1285,6 @@ test("prepareLocalVaultPayloadApply does not import until the prepared callback
|
||||
assert.deepEqual(calls, ["prepare", "import", "commit"]);
|
||||
});
|
||||
|
||||
test("prepareLocalVaultPayloadApply sanitizes enc:v1 before convergent prepare and apply", async () => {
|
||||
const completeBlob = Buffer.alloc(31, 0);
|
||||
Buffer.from("v10", "utf8").copy(completeBlob, 0);
|
||||
const ENC = `enc:v1:${completeBlob.toString("base64")}`;
|
||||
const preparedPayloads: SyncPayload[] = [];
|
||||
const imported: Array<Record<string, unknown>> = [];
|
||||
|
||||
const applyPreparedPayload = await prepareLocalVaultPayloadApply({
|
||||
hosts: [{
|
||||
id: "h1",
|
||||
label: "prod",
|
||||
hostname: "prod.example",
|
||||
username: "root",
|
||||
password: ENC,
|
||||
port: 22,
|
||||
os: "linux",
|
||||
group: "",
|
||||
tags: [],
|
||||
protocol: "ssh",
|
||||
}],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
}, {
|
||||
importVaultData: (json) => {
|
||||
imported.push(JSON.parse(json) as Record<string, unknown>);
|
||||
},
|
||||
}, {
|
||||
prepareConvergentRestore: async (payload) => {
|
||||
preparedPayloads.push(payload);
|
||||
return async () => undefined;
|
||||
},
|
||||
});
|
||||
|
||||
await applyPreparedPayload();
|
||||
|
||||
assert.equal(preparedPayloads.length, 1);
|
||||
assert.equal(preparedPayloads[0]?.hosts[0]?.password, undefined);
|
||||
assert.equal(imported.length, 1);
|
||||
const hosts = imported[0]?.hosts as Array<{ password?: string }> | undefined;
|
||||
assert.equal(hosts?.[0]?.password, undefined);
|
||||
});
|
||||
|
||||
test("applyLocalVaultPayload leaves local data untouched when convergent preparation fails", async () => {
|
||||
let imported = false;
|
||||
const payload: SyncPayload = {
|
||||
@@ -1615,78 +1338,3 @@ test("applyLocalVaultPayload does not commit convergent writes when local import
|
||||
|
||||
assert.equal(committed, false);
|
||||
});
|
||||
|
||||
test("withPluginSyncSidecars attaches non-empty plugin sidecar bundles", () => {
|
||||
const base = buildSyncPayload({
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
});
|
||||
const withEmpty = withPluginSyncSidecars(base, { version: 1, entries: [] });
|
||||
assert.deepEqual(withEmpty.pluginSidecars, { version: 1, entries: [] });
|
||||
|
||||
const withData = withPluginSyncSidecars(base, {
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: "com.example.sync",
|
||||
kind: "settings",
|
||||
key: "com.example.sync.theme\0application\0application",
|
||||
value: "dark",
|
||||
updatedAt: 1,
|
||||
}],
|
||||
});
|
||||
assert.equal(withData.pluginSidecars?.entries.length, 1);
|
||||
assert.equal(withData.pluginSidecars?.entries[0].value, "dark");
|
||||
});
|
||||
|
||||
test("buildCloudSyncPayload includes plugin sidecars from the production collector hook", async () => {
|
||||
const payload = await buildCloudSyncPayload(vault([]), [], {
|
||||
collectPluginSidecars: async () => ({
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: "com.example.sync",
|
||||
kind: "account_baseline",
|
||||
key: "account",
|
||||
value: { id: "acct-1" },
|
||||
updatedAt: 9,
|
||||
}],
|
||||
}),
|
||||
});
|
||||
assert.equal(payload.pluginSidecars?.entries.length, 1);
|
||||
assert.equal(payload.pluginSidecars?.entries[0].kind, "account_baseline");
|
||||
assert.deepEqual(payload.pluginSidecars?.entries[0].value, { id: "acct-1" });
|
||||
});
|
||||
|
||||
test("applySyncPayload applies pluginSidecars through the production applier hook", async () => {
|
||||
let applied: unknown = null;
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
pluginSidecars: {
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: "com.missing.plugin",
|
||||
kind: "crdt_baseline",
|
||||
key: "replica",
|
||||
value: { clock: 3 },
|
||||
updatedAt: 2,
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
await applySyncPayload(payload, {
|
||||
importVaultData: () => {},
|
||||
}, {
|
||||
applyPluginSidecars: async (sidecars) => {
|
||||
applied = sidecars;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal((applied as SyncPayload["pluginSidecars"])?.entries[0].value.clock, 3);
|
||||
});
|
||||
+47
-233
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Sync Payload Builders - Single source of truth for constructing and applying
|
||||
* Sync Payload Builders — Single source of truth for constructing and applying
|
||||
* the encrypted cloud-sync payload.
|
||||
*
|
||||
* Both the main window (App.tsx) and the settings window (SettingsSyncTab.tsx)
|
||||
@@ -22,7 +22,6 @@ import type {
|
||||
import {
|
||||
CLOUD_SYNC_PAYLOAD_ENTITY_KEYS,
|
||||
SYNC_PAYLOAD_ENTITY_KEYS,
|
||||
SYNC_STORAGE_KEYS,
|
||||
hasSyncPayloadEntityData,
|
||||
type SyncPayload,
|
||||
} from '../domain/sync';
|
||||
@@ -32,10 +31,7 @@ import {
|
||||
parseCustomKeyBindingsStorageRecord,
|
||||
serializeCustomKeyBindingsStorageRecord,
|
||||
} from '../domain/customKeyBindings';
|
||||
import {
|
||||
isEncryptedCredentialPlaceholder,
|
||||
stripSyncPayloadEncryptedCredentials,
|
||||
} from '../domain/credentials';
|
||||
import { isEncryptedCredentialPlaceholder } from '../domain/credentials';
|
||||
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
|
||||
import { decryptField, encryptField } from '../infrastructure/persistence/secureFieldAdapter';
|
||||
import { sanitizeQuickMessages } from '../infrastructure/ai/quickMessages';
|
||||
@@ -46,10 +42,6 @@ import {
|
||||
parseTerminalFontSizeRecord,
|
||||
serializeTerminalFontSizeRecord,
|
||||
} from './state/terminalFontSizeSync';
|
||||
import {
|
||||
parseCustomAccentRecord,
|
||||
serializeCustomAccentRecord,
|
||||
} from './state/customAccentSync';
|
||||
import {
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
@@ -74,7 +66,6 @@ import {
|
||||
STORAGE_KEY_SFTP_AUTO_SYNC,
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_SFTP_SKIP_UNCHANGED,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
|
||||
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
|
||||
@@ -131,33 +122,15 @@ export interface SyncableVaultData {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the payload carries non-empty plugin sidecar entries.
|
||||
*
|
||||
* An explicit empty `{entries:[]}` bundle is intentionally NOT treated as
|
||||
* meaningful by itself — last-known cache alone must not authorize pushing a
|
||||
* wiped hosts/keys vault to cloud (empty-vault upload guard). Sidecar-only
|
||||
* wipes still sync when the vault also has entities/settings, or via Force Push.
|
||||
*/
|
||||
function hasMeaningfulPluginSidecars(payload: SyncPayload): boolean {
|
||||
return Array.isArray(payload.pluginSidecars?.entries)
|
||||
&& payload.pluginSidecars.entries.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when a payload contains entities, settings, or meaningful
|
||||
* plugin sidecars worth syncing / protecting with empty-vault guards.
|
||||
* Local-only trust records are intentionally ignored by the cloud variant.
|
||||
* Returns true when the payload contains any meaningful user data worth
|
||||
* protecting or syncing.
|
||||
*/
|
||||
export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
|
||||
if (hasSyncPayloadEntityData(payload, SYNC_PAYLOAD_ENTITY_KEYS)) return true;
|
||||
|
||||
if (
|
||||
payload.settings && Object.values(payload.settings).some((value) => value !== undefined)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return hasMeaningfulPluginSidecars(payload);
|
||||
return Boolean(
|
||||
payload.settings && Object.values(payload.settings).some((value) => value !== undefined),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,14 +140,9 @@ export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
|
||||
export function hasMeaningfulCloudSyncData(payload: SyncPayload): boolean {
|
||||
if (hasSyncPayloadEntityData(payload, CLOUD_SYNC_PAYLOAD_ENTITY_KEYS)) return true;
|
||||
|
||||
if (
|
||||
payload.settings && Object.values(payload.settings).some((value) => value !== undefined)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Plugin-only profiles (settings/baselines, no vault entities) must still sync.
|
||||
return hasMeaningfulPluginSidecars(payload);
|
||||
return Boolean(
|
||||
payload.settings && Object.values(payload.settings).some((value) => value !== undefined),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -246,10 +214,9 @@ const SYNCABLE_TERMINAL_KEYS = [
|
||||
'rightClickBehavior', 'showContextMenuOverFullscreenApps', 'middleClickBehavior', 'copyOnSelect', 'normalizeTextOnCopy', 'middleClickPaste', 'wordSeparators',
|
||||
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
||||
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
|
||||
'autoUploadClipboardImageOnPaste',
|
||||
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'dynamicTabTitleMode',
|
||||
'autoCloseOnExit',
|
||||
'showHostInfoBar', 'hostInfoBarTitleMode', 'showServerStats',
|
||||
'showHostInfoBar', 'showServerStats',
|
||||
'serverStatsRefreshInterval',
|
||||
'systemManagerProcessRefreshInterval', 'systemManagerTmuxRefreshInterval',
|
||||
'systemManagerDockerListRefreshInterval', 'systemManagerDockerStatsRefreshInterval',
|
||||
@@ -287,7 +254,6 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_SFTP_AUTO_SYNC,
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_SFTP_SKIP_UNCHANGED,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
|
||||
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
|
||||
@@ -389,7 +355,7 @@ const mergeAiProvidersPreservingLocalApiKeys = (
|
||||
|
||||
/**
|
||||
* Same rationale as `mergeAiProvidersPreservingLocalApiKeys`. Only restores the
|
||||
* local apiKey when the incoming config still points at the same providerId -
|
||||
* local apiKey when the incoming config still points at the same providerId —
|
||||
* switching providers must not silently leak a key meant for a different one.
|
||||
*/
|
||||
const mergeWebSearchConfigPreservingLocalApiKey = (
|
||||
@@ -417,8 +383,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (darkUi) settings.darkUiThemeId = darkUi;
|
||||
const accentMode = localStorageAdapter.readString(STORAGE_KEY_ACCENT_MODE);
|
||||
if (accentMode === 'theme' || accentMode === 'custom') settings.accentMode = accentMode;
|
||||
const accentRaw = localStorageAdapter.readString(STORAGE_KEY_COLOR);
|
||||
if (accentRaw) settings.customAccent = parseCustomAccentRecord(accentRaw).color;
|
||||
const accent = localStorageAdapter.readString(STORAGE_KEY_COLOR);
|
||||
if (accent) settings.customAccent = accent;
|
||||
const uiFont = localStorageAdapter.readString(STORAGE_KEY_UI_FONT_FAMILY);
|
||||
if (uiFont) settings.uiFontFamilyId = uiFont;
|
||||
const lang = localStorageAdapter.readString(STORAGE_KEY_UI_LANGUAGE);
|
||||
@@ -492,8 +458,6 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (hidden === 'true' || hidden === 'false') settings.sftpShowHiddenFiles = hidden === 'true';
|
||||
const compress = localStorageAdapter.readString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD);
|
||||
if (compress === 'true' || compress === 'false') settings.sftpUseCompressedUpload = compress === 'true';
|
||||
const skipUnchanged = localStorageAdapter.readBoolean(STORAGE_KEY_SFTP_SKIP_UNCHANGED);
|
||||
if (skipUnchanged != null) settings.sftpSkipUnchanged = skipUnchanged;
|
||||
const autoOpenSidebar = localStorageAdapter.readString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
|
||||
if (autoOpenSidebar === 'true' || autoOpenSidebar === 'false') settings.sftpAutoOpenSidebar = autoOpenSidebar === 'true';
|
||||
const followTerminalCwd = localStorageAdapter.readString(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD);
|
||||
@@ -501,7 +465,7 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
const defaultViewMode = localStorageAdapter.readString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
|
||||
if (defaultViewMode === 'list' || defaultViewMode === 'tree') settings.sftpDefaultViewMode = defaultViewMode;
|
||||
|
||||
// SFTP Bookmarks (global only - local bookmarks are device-specific)
|
||||
// SFTP Bookmarks (global only — local bookmarks are device-specific)
|
||||
const globalBookmarks = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS);
|
||||
if (globalBookmarks && Array.isArray(globalBookmarks)) settings.sftpGlobalBookmarks = globalBookmarks;
|
||||
|
||||
@@ -626,17 +590,7 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
|
||||
if (settings.lightUiThemeId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, settings.lightUiThemeId);
|
||||
if (settings.darkUiThemeId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, settings.darkUiThemeId);
|
||||
if (settings.accentMode != null) localStorageAdapter.writeString(STORAGE_KEY_ACCENT_MODE, settings.accentMode);
|
||||
if (settings.customAccent != null) {
|
||||
const existing = parseCustomAccentRecord(localStorageAdapter.readString(STORAGE_KEY_COLOR));
|
||||
localStorageAdapter.writeString(
|
||||
STORAGE_KEY_COLOR,
|
||||
serializeCustomAccentRecord({
|
||||
color: parseCustomAccentRecord(settings.customAccent).color,
|
||||
// Bump so peer windows' version gates accept the synced value.
|
||||
version: Math.max(existing.version, 0) + 1,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (settings.customAccent != null) localStorageAdapter.writeString(STORAGE_KEY_COLOR, settings.customAccent);
|
||||
if (settings.uiFontFamilyId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_FONT_FAMILY, settings.uiFontFamilyId);
|
||||
if (settings.uiLanguage != null) localStorageAdapter.writeString(STORAGE_KEY_UI_LANGUAGE, settings.uiLanguage);
|
||||
if (settings.customCSS != null) localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, settings.customCSS);
|
||||
@@ -670,7 +624,7 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB, settings.terminalSidePanelAutoOpenTab);
|
||||
}
|
||||
|
||||
// Terminal settings - merge with existing to preserve platform-specific keys
|
||||
// Terminal settings — merge with existing to preserve platform-specific keys
|
||||
if (settings.terminalSettings) {
|
||||
let existing: Record<string, unknown> = {};
|
||||
const raw = localStorageAdapter.readString(STORAGE_KEY_TERM_SETTINGS);
|
||||
@@ -730,7 +684,6 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
|
||||
if (settings.sftpAutoSync != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_SYNC, String(settings.sftpAutoSync));
|
||||
if (settings.sftpShowHiddenFiles != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, String(settings.sftpShowHiddenFiles));
|
||||
if (settings.sftpUseCompressedUpload != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, String(settings.sftpUseCompressedUpload));
|
||||
if (settings.sftpSkipUnchanged != null) localStorageAdapter.writeBoolean(STORAGE_KEY_SFTP_SKIP_UNCHANGED, settings.sftpSkipUnchanged);
|
||||
if (settings.sftpAutoOpenSidebar != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, String(settings.sftpAutoOpenSidebar));
|
||||
if (settings.sftpFollowTerminalCwd != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD, String(settings.sftpFollowTerminalCwd));
|
||||
if (settings.sftpDefaultViewMode != null) {
|
||||
@@ -811,7 +764,7 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
|
||||
// After all AI writes, reconcile per-agent bindings against the final
|
||||
// provider list. Sync payloads can land with a new `providers` set but
|
||||
// no `agentProviderMap`, or with a stale `agentProviderMap` that
|
||||
// points at ids the synced provider set doesn't include - either way
|
||||
// points at ids the synced provider set doesn't include — either way
|
||||
// we'd leak overrides bound to ghost providers. Mirrors the same
|
||||
// cleanup `removeProvider` does for explicit user deletes.
|
||||
pruneOrphanPerAgentBindings();
|
||||
@@ -876,7 +829,7 @@ function pruneOrphanPerAgentBindings(): void {
|
||||
nextProviderMap[agentId] = providerId;
|
||||
} else {
|
||||
providerChanged = true;
|
||||
// Drop the saved model too - that id belonged to the now-missing
|
||||
// Drop the saved model too — that id belonged to the now-missing
|
||||
// provider and isn't trustworthy against any other binding.
|
||||
if (agentId in nextModelMap) {
|
||||
delete nextModelMap[agentId];
|
||||
@@ -924,75 +877,11 @@ export function buildSyncPayload(
|
||||
};
|
||||
}
|
||||
|
||||
export type PluginSyncSidecarCollector = () =>
|
||||
| Promise<SyncPayload['pluginSidecars'] | null | undefined>
|
||||
| SyncPayload['pluginSidecars']
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
export type PluginSyncSidecarApplier = (
|
||||
sidecars: SyncPayload['pluginSidecars'] | null | undefined,
|
||||
) => Promise<void> | void;
|
||||
|
||||
/**
|
||||
* Attach a host-collected plugin sidecar bundle to a cloud payload.
|
||||
* Secrets must already be excluded by the collector; this only drops empty bundles.
|
||||
*/
|
||||
export function withPluginSyncSidecars(
|
||||
payload: SyncPayload,
|
||||
sidecars: SyncPayload['pluginSidecars'] | null | undefined,
|
||||
): SyncPayload {
|
||||
// null/undefined: collector unavailable — leave payload field unchanged
|
||||
// (callers should reattach last-known before upload).
|
||||
if (sidecars == null) {
|
||||
return payload;
|
||||
}
|
||||
// Explicit empty entries is a real reset and must remain on the wire so
|
||||
// apply paths can distinguish it from a legacy payload missing the field.
|
||||
return {
|
||||
...payload,
|
||||
pluginSidecars: {
|
||||
version: 1,
|
||||
entries: Array.isArray(sidecars.entries) ? sidecars.entries : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function defaultCollectPluginSyncSidecars(): Promise<SyncPayload['pluginSidecars'] | null | undefined> {
|
||||
const { collectPluginSyncSidecarsFromHost } = await import('./pluginSyncSidecarBridge');
|
||||
// Let operational failures (DB/runtime) propagate so cloud upload does not
|
||||
// silently strip previously synced sidecars from the remote snapshot.
|
||||
return collectPluginSyncSidecarsFromHost();
|
||||
}
|
||||
|
||||
async function defaultApplyPluginSyncSidecars(
|
||||
sidecars: SyncPayload['pluginSidecars'] | null | undefined,
|
||||
): Promise<void> {
|
||||
const {
|
||||
applyPluginSyncSidecarsFromHost,
|
||||
isPluginSidecarHostUnavailableError,
|
||||
} = await import('./pluginSyncSidecarBridge');
|
||||
try {
|
||||
await applyPluginSyncSidecarsFromHost(sidecars);
|
||||
} catch (error) {
|
||||
// Only tolerate the host-gated-off / manager-unavailable path so a later
|
||||
// plugin-enabled session can still apply from last-known cache. DB/runtime
|
||||
// failures must fail the surrounding sync so the same remote is retried.
|
||||
if (isPluginSidecarHostUnavailableError(error)) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildCloudSyncPayload(
|
||||
vault: SyncableVaultData,
|
||||
portForwardingRules?: PortForwardingRule[],
|
||||
options?: {
|
||||
collectPluginSidecars?: PluginSyncSidecarCollector;
|
||||
},
|
||||
): Promise<SyncPayload> {
|
||||
const base: SyncPayload = {
|
||||
return {
|
||||
hosts: vault.hosts,
|
||||
keys: vault.keys,
|
||||
identities: vault.identities,
|
||||
@@ -1007,9 +896,6 @@ export async function buildCloudSyncPayload(
|
||||
settings: await collectCloudSyncableSettings(),
|
||||
syncedAt: Date.now(),
|
||||
};
|
||||
const collect = options?.collectPluginSidecars ?? defaultCollectPluginSyncSidecars;
|
||||
const sidecars = await collect();
|
||||
return withPluginSyncSidecars(base, sidecars);
|
||||
}
|
||||
|
||||
/** Build a local backup/restore payload, including local-only trust records. */
|
||||
@@ -1017,60 +903,11 @@ export function buildLocalVaultPayload(
|
||||
vault: SyncableVaultData,
|
||||
portForwardingRules?: PortForwardingRule[],
|
||||
): SyncPayload {
|
||||
const base: SyncPayload = {
|
||||
return {
|
||||
...buildSyncPayload(vault, portForwardingRules),
|
||||
settings: collectLocalBackupSettings(),
|
||||
knownHosts: vault.knownHosts,
|
||||
};
|
||||
// Protective backups must capture plugin sidecars so restore can recover
|
||||
// plugin settings/baselines. Prefer the last successful host collect cache
|
||||
// (sync). Callers that can await should use buildLocalVaultPayloadAsync.
|
||||
try {
|
||||
const raw = localStorageAdapter.read<{ version?: number; entries?: unknown }>(
|
||||
SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN,
|
||||
);
|
||||
if (raw && Array.isArray(raw.entries)) {
|
||||
return withPluginSyncSidecars(base, {
|
||||
version: 1,
|
||||
entries: raw.entries as NonNullable<SyncPayload['pluginSidecars']>['entries'],
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore cache read failures; backup still carries vault entities
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Async local backup payload that prefers a live host collect when available,
|
||||
* then falls back to last-known cache. Use for protective/version-change backups.
|
||||
*/
|
||||
export async function buildLocalVaultPayloadAsync(
|
||||
vault: SyncableVaultData,
|
||||
portForwardingRules?: PortForwardingRule[],
|
||||
): Promise<SyncPayload> {
|
||||
const base = buildLocalVaultPayload(vault, portForwardingRules);
|
||||
const {
|
||||
collectPluginSyncSidecarsFromHost,
|
||||
isPluginSidecarHostUnavailableError,
|
||||
} = await import('./pluginSyncSidecarBridge');
|
||||
try {
|
||||
const live = await collectPluginSyncSidecarsFromHost();
|
||||
if (live) {
|
||||
// Uploads may still defer wiping last-known until a successful sync
|
||||
// commit, but protective/version-change backups must snapshot the live
|
||||
// authoritative-empty bundle so restore cannot resurrect cleared plugin data.
|
||||
return withPluginSyncSidecars(base, live);
|
||||
}
|
||||
} catch (error) {
|
||||
// Only fall back to last-known when the host is gated off. Operational
|
||||
// failures must abort protective backups so we never apply over data that
|
||||
// was not captured in the safety snapshot.
|
||||
if (!isPluginSidecarHostUnavailableError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1082,40 +919,33 @@ export async function buildLocalVaultPayloadAsync(
|
||||
function applyPayload(
|
||||
payload: SyncPayload,
|
||||
importers: SyncPayloadImporters,
|
||||
options: {
|
||||
includeLocalOnlyData: boolean;
|
||||
applyPluginSidecars?: PluginSyncSidecarApplier;
|
||||
},
|
||||
options: { includeLocalOnlyData: boolean },
|
||||
): Promise<void> {
|
||||
// Portable payloads must never keep device-bound enc:v1 blobs. Strip them
|
||||
// so a previously poisoned cloud/backup snapshot can still restore host
|
||||
// shells and let the user re-enter secrets (#2702).
|
||||
const sanitizedPayload = stripSyncPayloadEncryptedCredentials(payload);
|
||||
const legacyLineTimestampsEnabled = sanitizedPayload.settings?.terminalSettings?.showLineTimestamps === true;
|
||||
const legacyLineTimestampsEnabled = payload.settings?.terminalSettings?.showLineTimestamps === true;
|
||||
// Build the vault import object. Cloud sync intentionally ignores
|
||||
// local-only trust records even if legacy cloud snapshots still carry them.
|
||||
const vaultImport: Record<string, unknown> = {
|
||||
hosts: migrateHostsFromLegacyLineTimestamps(sanitizedPayload.hosts, legacyLineTimestampsEnabled),
|
||||
keys: sanitizedPayload.keys,
|
||||
identities: sanitizedPayload.identities,
|
||||
proxyProfiles: sanitizedPayload.proxyProfiles,
|
||||
snippets: sanitizedPayload.snippets,
|
||||
customGroups: sanitizedPayload.customGroups,
|
||||
hosts: migrateHostsFromLegacyLineTimestamps(payload.hosts, legacyLineTimestampsEnabled),
|
||||
keys: payload.keys,
|
||||
identities: payload.identities,
|
||||
proxyProfiles: payload.proxyProfiles,
|
||||
snippets: payload.snippets,
|
||||
customGroups: payload.customGroups,
|
||||
};
|
||||
if (sanitizedPayload.snippetPackages !== undefined) {
|
||||
vaultImport.snippetPackages = sanitizedPayload.snippetPackages;
|
||||
if (payload.snippetPackages !== undefined) {
|
||||
vaultImport.snippetPackages = payload.snippetPackages;
|
||||
}
|
||||
if (sanitizedPayload.notes !== undefined) {
|
||||
vaultImport.notes = sanitizedPayload.notes;
|
||||
if (payload.notes !== undefined) {
|
||||
vaultImport.notes = payload.notes;
|
||||
}
|
||||
if (sanitizedPayload.noteGroups !== undefined) {
|
||||
vaultImport.noteGroups = sanitizedPayload.noteGroups;
|
||||
if (payload.noteGroups !== undefined) {
|
||||
vaultImport.noteGroups = payload.noteGroups;
|
||||
}
|
||||
if (options.includeLocalOnlyData && sanitizedPayload.knownHosts !== undefined) {
|
||||
vaultImport.knownHosts = sanitizedPayload.knownHosts;
|
||||
if (options.includeLocalOnlyData && payload.knownHosts !== undefined) {
|
||||
vaultImport.knownHosts = payload.knownHosts;
|
||||
}
|
||||
if (Array.isArray(sanitizedPayload.groupConfigs)) {
|
||||
vaultImport.groupConfigs = sanitizedPayload.groupConfigs;
|
||||
if (Array.isArray(payload.groupConfigs)) {
|
||||
vaultImport.groupConfigs = payload.groupConfigs;
|
||||
}
|
||||
|
||||
return Promise.resolve(importers.importVaultData(JSON.stringify(vaultImport))).then(async () => {
|
||||
@@ -1123,38 +953,25 @@ function applyPayload(
|
||||
// them. Absent field = "payload was created before this feature existed",
|
||||
// so local rules are preserved. Explicitly present [] = "remote has no
|
||||
// rules, clear local state".
|
||||
if (sanitizedPayload.portForwardingRules !== undefined && importers.importPortForwardingRules) {
|
||||
importers.importPortForwardingRules(sanitizedPayload.portForwardingRules);
|
||||
if (payload.portForwardingRules !== undefined && importers.importPortForwardingRules) {
|
||||
importers.importPortForwardingRules(payload.portForwardingRules);
|
||||
}
|
||||
|
||||
// Apply synced settings
|
||||
if (sanitizedPayload.settings) {
|
||||
await applySyncableSettings(sanitizedPayload.settings);
|
||||
if (payload.settings) {
|
||||
await applySyncableSettings(payload.settings);
|
||||
// Rehydrate in-memory bookmark snapshot after localStorage was updated
|
||||
if (sanitizedPayload.settings.sftpGlobalBookmarks != null) rehydrateGlobalSftpBookmarks();
|
||||
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalSftpBookmarks();
|
||||
importers.onSettingsApplied?.();
|
||||
}
|
||||
|
||||
// Plugin encrypted sidecars: only apply when the field is present so
|
||||
// legacy payloads without pluginSidecars do not wipe installed settings.
|
||||
if (Object.prototype.hasOwnProperty.call(sanitizedPayload, 'pluginSidecars')) {
|
||||
const applySidecars = options.applyPluginSidecars ?? defaultApplyPluginSyncSidecars;
|
||||
await applySidecars(sanitizedPayload.pluginSidecars ?? { version: 1, entries: [] });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function applySyncPayload(
|
||||
payload: SyncPayload,
|
||||
importers: SyncPayloadImporters,
|
||||
options?: {
|
||||
applyPluginSidecars?: PluginSyncSidecarApplier;
|
||||
},
|
||||
): Promise<void> {
|
||||
return applyPayload(payload, importers, {
|
||||
includeLocalOnlyData: false,
|
||||
applyPluginSidecars: options?.applyPluginSidecars,
|
||||
});
|
||||
return applyPayload(payload, importers, { includeLocalOnlyData: false });
|
||||
}
|
||||
|
||||
export async function prepareLocalVaultPayloadApply(
|
||||
@@ -1166,14 +983,11 @@ export async function prepareLocalVaultPayloadApply(
|
||||
) => Promise<() => Promise<void>>;
|
||||
} = {},
|
||||
): Promise<() => Promise<void>> {
|
||||
// Sanitize once so vault import and convergent replica commit share the
|
||||
// same portable secrets view (no device-bound enc:v1 leftovers).
|
||||
const sanitizedPayload = stripSyncPayloadEncryptedCredentials(payload);
|
||||
const prepareConvergentRestore = dependencies.prepareConvergentRestore
|
||||
?? prepareRestoredPayloadConvergentWrites;
|
||||
const commitConvergentRestore = await prepareConvergentRestore(sanitizedPayload);
|
||||
const commitConvergentRestore = await prepareConvergentRestore(payload);
|
||||
return async () => {
|
||||
await applyPayload(sanitizedPayload, importers, { includeLocalOnlyData: true });
|
||||
await applyPayload(payload, importers, { includeLocalOnlyData: true });
|
||||
await commitConvergentRestore();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ test("CSV credentials exclude an explicitly unsaved Keychain passphrase for a di
|
||||
test("CSV credentials warn when a saved reference-key passphrase cannot be read", async () => {
|
||||
const result = await buildVaultCsvCredentialOptions(
|
||||
[host("key-1")],
|
||||
[referenceKey({ savePassphrase: true, passphrase: "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==" })],
|
||||
[referenceKey({ savePassphrase: true, passphrase: "enc:v1:djEwYWJj" })],
|
||||
async () => ({ values: [], unreadable: false, present: false }),
|
||||
);
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
stripSyncPayloadEncryptedCredentials,
|
||||
} from '../domain/credentials';
|
||||
import {
|
||||
isProviderReadyForSync,
|
||||
@@ -137,14 +136,10 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
}
|
||||
sync.cancelOAuthConnect();
|
||||
if (sync.convergentSyncConfig.initialized) return;
|
||||
const providers = new Set<CloudProvider>(['github', 'google', 'onedrive', 'webdav', 's3']);
|
||||
for (const id of Object.keys(sync.providers)) {
|
||||
providers.add(id as CloudProvider);
|
||||
}
|
||||
const providers: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
|
||||
for (const provider of providers) {
|
||||
if (provider === current) continue;
|
||||
const conn = sync.providers[provider];
|
||||
if (conn && isProviderReadyForSync(conn)) {
|
||||
if (isProviderReadyForSync(sync.providers[provider])) {
|
||||
await sync.disconnectProvider(provider);
|
||||
}
|
||||
}
|
||||
@@ -181,23 +176,18 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
);
|
||||
|
||||
const isConnectDisabled = (provider: CloudProvider): boolean => {
|
||||
const connection = sync.providers[provider];
|
||||
if (pendingConnectProvider && pendingConnectProvider !== provider) {
|
||||
return true;
|
||||
}
|
||||
if (pendingConnectProvider === provider) {
|
||||
return true;
|
||||
}
|
||||
if (hasConnectingProvider && connection?.status !== 'connecting') {
|
||||
if (hasConnectingProvider && sync.providers[provider].status !== 'connecting') {
|
||||
return true;
|
||||
}
|
||||
// Never-connected plugin providers still honor the single-provider gate.
|
||||
if (!connection) {
|
||||
return !sync.convergentSyncConfig.initialized && sync.hasAnyConnectedProvider;
|
||||
}
|
||||
return !sync.convergentSyncConfig.initialized
|
||||
&& sync.hasAnyConnectedProvider
|
||||
&& !isProviderReadyForSync(connection);
|
||||
&& !isProviderReadyForSync(sync.providers[provider]);
|
||||
};
|
||||
|
||||
const beginPendingConnect = (provider: CloudProvider): boolean => {
|
||||
@@ -676,10 +666,9 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
// requested provider fails, another provider can verify a newer
|
||||
// joined replica that must be applied before reporting the error.
|
||||
if (result.mergedPayload && !result.mergedPayloadApplied && onApplyPayload) {
|
||||
const portableMerged = stripSyncPayloadEncryptedCredentials(result.mergedPayload);
|
||||
await Promise.resolve(onApplyPayload(portableMerged));
|
||||
await Promise.resolve(onApplyPayload(result.mergedPayload));
|
||||
if (result.remoteFile) {
|
||||
await sync.commitRemoteInspection(result.provider, result.remoteFile, portableMerged, {
|
||||
await sync.commitRemoteInspection(result.provider, result.remoteFile, result.mergedPayload, {
|
||||
recordDownload: true,
|
||||
});
|
||||
}
|
||||
@@ -705,14 +694,13 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
// USE_REMOTE applies cloud data over local — same data-loss
|
||||
// shape as a local backup restore, so gate auto-sync in
|
||||
// every other window the same way.
|
||||
const portableRemote = stripSyncPayloadEncryptedCredentials(remoteResult.payload);
|
||||
await withRestoreBarrier(async () => {
|
||||
await Promise.resolve(onApplyPayload(portableRemote));
|
||||
await Promise.resolve(onApplyPayload(remoteResult.payload));
|
||||
});
|
||||
await sync.commitRemoteInspection(
|
||||
remoteResult.provider,
|
||||
remoteResult.remoteFile,
|
||||
portableRemote,
|
||||
remoteResult.payload,
|
||||
{ recordDownload: true },
|
||||
);
|
||||
toast.success(t('cloudSync.resolve.downloaded'));
|
||||
@@ -749,10 +737,9 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
// reflects what's now on cloud (in case remote changed during the merge).
|
||||
for (const result of (results as Map<CloudProvider, SyncResult>).values()) {
|
||||
if (result.mergedPayload && !result.mergedPayloadApplied) {
|
||||
const portableMerged = stripSyncPayloadEncryptedCredentials(result.mergedPayload);
|
||||
await Promise.resolve(onApplyPayload(portableMerged));
|
||||
await Promise.resolve(onApplyPayload(result.mergedPayload));
|
||||
if (result.remoteFile) {
|
||||
await sync.commitRemoteInspection(result.provider, result.remoteFile, portableMerged, {
|
||||
await sync.commitRemoteInspection(result.provider, result.remoteFile, result.mergedPayload, {
|
||||
recordDownload: true,
|
||||
});
|
||||
}
|
||||
@@ -923,7 +910,6 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
}}
|
||||
onResolveConvergentConflict={handleResolveConvergentConflict}
|
||||
onDowngradeConvergent={handleDowngradeConvergent}
|
||||
disconnectOtherProviders={disconnectOtherProviders}
|
||||
/>
|
||||
|
||||
<CloudSyncDialogs
|
||||
|
||||
@@ -129,11 +129,8 @@ test("directory parent detection matches side-queue file-count semantics", () =>
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("folder parent progress uses progressive discovery labels not byte formatting", () => {
|
||||
test("folder parent progress uses file counts not byte formatting", () => {
|
||||
const t = (key: string, params?: Record<string, string | number>) => {
|
||||
if (key === "sftp.transfers.filesDiscoveredProgress") {
|
||||
return `${params?.completed} done · ${params?.discovered} found`;
|
||||
}
|
||||
if (key === "sftp.transfers.filesProgress") {
|
||||
return `${params?.current}/${params?.total} files`;
|
||||
}
|
||||
@@ -149,35 +146,14 @@ test("folder parent progress uses progressive discovery labels not byte formatti
|
||||
totalBytes: 12,
|
||||
transferredBytes: 1,
|
||||
speed: 0,
|
||||
phase: "transferring",
|
||||
}, t);
|
||||
assert.equal(display.percent, (1 / 12) * 100);
|
||||
assert.equal(display.detail, "1 done · 12 found");
|
||||
assert.equal(display.detail, "1/12 files");
|
||||
assert.equal(display.indeterminate, false);
|
||||
// Must not look like "1 Bytes / 12 Bytes"
|
||||
assert.doesNotMatch(display.detail, /Bytes/i);
|
||||
});
|
||||
|
||||
test("folder parent scanning stays indeterminate while nothing has completed", () => {
|
||||
const t = (key: string, params?: Record<string, string | number>) => {
|
||||
if (key === "sftp.transfers.filesDiscoveredProgress") {
|
||||
return `${params?.completed} done · ${params?.discovered} found`;
|
||||
}
|
||||
return key;
|
||||
};
|
||||
const display = buildGlobalTransferProgressDisplay({
|
||||
status: "transferring",
|
||||
isDirectory: true,
|
||||
progressMode: "files",
|
||||
totalBytes: 40,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
phase: "scanning",
|
||||
}, t);
|
||||
assert.equal(display.detail, "0 done · 40 found");
|
||||
assert.equal(display.indeterminate, true);
|
||||
});
|
||||
|
||||
test("single-file progress still shows byte totals", () => {
|
||||
const t = (key: string) => key;
|
||||
const display = buildGlobalTransferProgressDisplay({
|
||||
|
||||
@@ -181,56 +181,28 @@ export type GlobalTransferProgressDisplay = {
|
||||
/**
|
||||
* Build progress labels for a top-level row. Directory parents use file counts
|
||||
* so we never show "1 Bytes / 12 Bytes" for n/m files.
|
||||
*
|
||||
* Folder walks interleave discovery with transfer: totalBytes is "found so far",
|
||||
* not a fixed grand total. Active rows use progressive copy so the UI does not
|
||||
* look like a lying overall percentage.
|
||||
*/
|
||||
export function buildGlobalTransferProgressDisplay(
|
||||
task: Pick<TransferTask, "status" | "isDirectory" | "parentTaskId" | "progressMode" | "totalBytes" | "transferredBytes" | "speed" | "phase">,
|
||||
task: Pick<TransferTask, "status" | "isDirectory" | "parentTaskId" | "progressMode" | "totalBytes" | "transferredBytes" | "speed">,
|
||||
t: (key: string, params?: Record<string, string | number>) => string,
|
||||
): GlobalTransferProgressDisplay {
|
||||
const isDirParent = isDirectoryParentTask(task);
|
||||
const percent = getGlobalTransferProgressPercent(task);
|
||||
const hasTotal = task.totalBytes > 0;
|
||||
const discovered = Math.max(task.totalBytes, task.transferredBytes);
|
||||
|
||||
if (isDirParent) {
|
||||
const isActive = task.status === "transferring"
|
||||
|| task.status === "pausing"
|
||||
|| task.status === "queued"
|
||||
|| task.status === "pending";
|
||||
// Still discovering, or no completed files yet — pulse rather than freeze a fake %.
|
||||
const indeterminate = isActive && (
|
||||
task.phase === "scanning"
|
||||
|| !hasTotal
|
||||
|| task.transferredBytes <= 0
|
||||
);
|
||||
const indeterminate = task.status === "transferring" && !hasTotal;
|
||||
let detail = "";
|
||||
if (isActive) {
|
||||
if (discovered > 0) {
|
||||
detail = t("sftp.transfers.filesDiscoveredProgress", {
|
||||
completed: task.transferredBytes,
|
||||
discovered,
|
||||
});
|
||||
}
|
||||
if (task.status === "transferring" || task.status === "pausing" || task.status === "queued" || task.status === "pending") {
|
||||
detail = hasTotal
|
||||
? t("sftp.transfers.filesProgress", { current: task.transferredBytes, total: task.totalBytes })
|
||||
: t("sftp.transfers.filesCount", { count: task.transferredBytes });
|
||||
} else if (task.status === "completed" && hasTotal) {
|
||||
detail = t("sftp.transfers.filesCount", { count: task.totalBytes });
|
||||
} else if (discovered > 0) {
|
||||
detail = t("sftp.transfers.filesDiscoveredProgress", {
|
||||
completed: task.transferredBytes,
|
||||
discovered,
|
||||
});
|
||||
} else if (hasTotal) {
|
||||
detail = t("sftp.transfers.filesProgress", { current: task.transferredBytes, total: task.totalBytes });
|
||||
}
|
||||
// Soft percent of files found so far (discovered may still grow).
|
||||
const softPercent = discovered > 0
|
||||
? Math.min(100, (task.transferredBytes / discovered) * 100)
|
||||
: 0;
|
||||
return {
|
||||
percent: task.status === "completed" ? 100 : softPercent,
|
||||
detail,
|
||||
indeterminate,
|
||||
};
|
||||
return { percent, detail, indeterminate };
|
||||
}
|
||||
|
||||
const detailParts: string[] = [];
|
||||
@@ -422,14 +394,6 @@ function TransferRow({
|
||||
if (task.status === "pausing" || task.status === "paused" || task.status === "interrupted") {
|
||||
return t(statusLabelKey(task.status));
|
||||
}
|
||||
// Scanning runs as pending/transferring; never override terminal statuses
|
||||
// (cancelled/failed/completed) that still carry a stale phase value.
|
||||
if (
|
||||
task.phase === "scanning"
|
||||
&& (task.status === "pending" || task.status === "queued" || task.status === "transferring")
|
||||
) {
|
||||
return t("sftp.transferCenter.phase.scanning");
|
||||
}
|
||||
if (task.phase && task.status === "transferring") {
|
||||
return t(`sftp.transferCenter.phase.${task.phase}`);
|
||||
}
|
||||
@@ -723,13 +687,6 @@ export function GlobalSftpTransferCenter() {
|
||||
// progress ticks used to re-render this TopTabs child on every byte.
|
||||
const badge = useSftpTransferCenterBadge();
|
||||
const [open, setOpen] = useState(false);
|
||||
// Folder drops fire this so the scanning row is visible immediately without
|
||||
// requiring the user to notice the badge first.
|
||||
useEffect(() => {
|
||||
const openCenter = () => setOpen(true);
|
||||
window.addEventListener("netcatty:open-sftp-transfer-center", openCenter);
|
||||
return () => window.removeEventListener("netcatty:open-sftp-transfer-center", openCenter);
|
||||
}, []);
|
||||
const snapshot = useSftpTransferCenterWhenOpen(open);
|
||||
const [bucket, setBucket] = useState<GlobalTransferBucket>("all");
|
||||
const [showBackground, setShowBackground] = useState(false);
|
||||
|
||||
@@ -18,16 +18,11 @@ import {
|
||||
RefreshCw,
|
||||
Search,
|
||||
Terminal as TerminalIcon,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import {
|
||||
shouldRemoveAutocompleteHistoryEntry,
|
||||
toGlobalHistoryDisplayEntries,
|
||||
} from '../domain/globalHistory';
|
||||
import { toGlobalHistoryDisplayEntries } from '../domain/globalHistory';
|
||||
import type { Host, RemoteHistoryEntry, ShellHistoryEntry } from '../domain/models';
|
||||
import { removeCommandHistoryEntry } from './terminal/autocomplete/commandHistoryStore';
|
||||
import { cn } from '../lib/utils';
|
||||
import type { RemoteHistoryHostState } from '../application/state/useRemoteHistoryState';
|
||||
import {
|
||||
@@ -45,7 +40,6 @@ export interface HistorySidePanelProps {
|
||||
state: RemoteHistoryHostState;
|
||||
globalEntries: ShellHistoryEntry[];
|
||||
onFetch: (sessionId: string, hostId: string) => void;
|
||||
onDeleteGlobalEntry?: (entryId: string) => void;
|
||||
/** Paste into the terminal without executing (no trailing Enter). */
|
||||
onPasteToTerminal: (command: string) => void;
|
||||
/** Write to the terminal and execute (append Enter). */
|
||||
@@ -66,7 +60,6 @@ const DETAIL_ACTIONS_HEIGHT = 24;
|
||||
interface HistoryPanelEntry {
|
||||
id: string;
|
||||
command: string;
|
||||
hostId?: string;
|
||||
timestamp?: number;
|
||||
hostLabel?: string;
|
||||
}
|
||||
@@ -114,7 +107,6 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
|
||||
state,
|
||||
globalEntries,
|
||||
onFetch,
|
||||
onDeleteGlobalEntry,
|
||||
onPasteToTerminal,
|
||||
onRunInTerminal,
|
||||
isVisible = true,
|
||||
@@ -193,21 +185,6 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleDeleteGlobalEntry = useCallback((entryId: string) => {
|
||||
if (scope !== 'global' || !onDeleteGlobalEntry) return;
|
||||
const entry = sourceEntries.find((candidate) => candidate.id === entryId);
|
||||
if (!entry) return;
|
||||
// Always remove the global row; only touch autocomplete when host is known.
|
||||
if (
|
||||
entry.hostId
|
||||
&& shouldRemoveAutocompleteHistoryEntry(globalEntries, entryId)
|
||||
) {
|
||||
removeCommandHistoryEntry(entry.command, entry.hostId);
|
||||
}
|
||||
onDeleteGlobalEntry(entryId);
|
||||
setSelectedEntryId(null);
|
||||
}, [globalEntries, onDeleteGlobalEntry, scope, sourceEntries]);
|
||||
|
||||
const handleRowClick = useCallback((entryId: string) => {
|
||||
setSelectedEntryId((current) => {
|
||||
const next = current === entryId ? null : entryId;
|
||||
@@ -239,7 +216,6 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
|
||||
paste: t('history.action.paste'),
|
||||
run: t('history.action.run'),
|
||||
save: t('history.action.saveAsSnippet'),
|
||||
delete: t('history.action.delete'),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
@@ -382,11 +358,6 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
|
||||
onRun={() => onRunInTerminal(row.entry.command)}
|
||||
onPaste={() => onPasteToTerminal(row.entry.command)}
|
||||
onSave={() => handleSaveAsSnippet(row.entry)}
|
||||
onDelete={
|
||||
scope === 'global' && onDeleteGlobalEntry
|
||||
? () => handleDeleteGlobalEntry(row.entry.id)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -400,11 +371,6 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
|
||||
onRun={() => onRunInTerminal(row.entry.command)}
|
||||
onPaste={() => onPasteToTerminal(row.entry.command)}
|
||||
onSave={() => handleSaveAsSnippet(row.entry)}
|
||||
onDelete={
|
||||
scope === 'global' && onDeleteGlobalEntry
|
||||
? () => handleDeleteGlobalEntry(row.entry.id)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
@@ -450,15 +416,14 @@ const EmptyState: React.FC<{ message: string }> = ({ message }) => (
|
||||
|
||||
interface HistoryDetailStripProps {
|
||||
entry: HistoryPanelEntry;
|
||||
labels: { paste: string; run: string; save: string; delete: string };
|
||||
labels: { paste: string; run: string; save: string };
|
||||
onRun: () => void;
|
||||
onPaste: () => void;
|
||||
onSave: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
const HistoryDetailStrip: React.FC<HistoryDetailStripProps> = memo(
|
||||
({ entry, labels, onRun, onPaste, onSave, onDelete }) => (
|
||||
({ entry, labels, onRun, onPaste, onSave }) => (
|
||||
<div
|
||||
className="border-b border-border/40 bg-muted/20 px-3 py-1.5"
|
||||
data-section="history-detail"
|
||||
@@ -491,11 +456,6 @@ const HistoryDetailStrip: React.FC<HistoryDetailStripProps> = memo(
|
||||
<IconButton title={labels.save} onClick={onSave}>
|
||||
<FileCode size={12} />
|
||||
</IconButton>
|
||||
{onDelete ? (
|
||||
<IconButton title={labels.delete} onClick={onDelete}>
|
||||
<Trash2 size={12} />
|
||||
</IconButton>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
@@ -506,16 +466,15 @@ interface HistoryRowProps {
|
||||
entry: HistoryPanelEntry;
|
||||
isSelected: boolean;
|
||||
showHostLabel: boolean;
|
||||
labels: { paste: string; run: string; save: string; delete: string };
|
||||
labels: { paste: string; run: string; save: string };
|
||||
onSelect: () => void;
|
||||
onRun: () => void;
|
||||
onPaste: () => void;
|
||||
onSave: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
const HistoryRow: React.FC<HistoryRowProps> = memo(
|
||||
({ entry, isSelected, showHostLabel, labels, onSelect, onRun, onPaste, onSave, onDelete }) => {
|
||||
({ entry, isSelected, showHostLabel, labels, onSelect, onRun, onPaste, onSave }) => {
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
@@ -572,11 +531,6 @@ const HistoryRow: React.FC<HistoryRowProps> = memo(
|
||||
<IconButton title={labels.save} onClick={onSave}>
|
||||
<FileCode size={12} />
|
||||
</IconButton>
|
||||
{onDelete ? (
|
||||
<IconButton title={labels.delete} onClick={onDelete}>
|
||||
<Trash2 size={12} />
|
||||
</IconButton>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -406,7 +406,7 @@ test("validateProxyConfigForSave rejects unreadable proxy identity passwords", (
|
||||
label: "Proxy login",
|
||||
username: "proxy-user",
|
||||
authMethod: "password",
|
||||
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
created: 1,
|
||||
}],
|
||||
}),
|
||||
@@ -459,7 +459,7 @@ test("prepareProxyConfigForSave returns identity errors before save handlers bui
|
||||
label: "Proxy login",
|
||||
username: "",
|
||||
authMethod: "password",
|
||||
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
created: 1,
|
||||
}],
|
||||
}),
|
||||
|
||||
+30
-106
@@ -9,7 +9,7 @@ import {
|
||||
Tag,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import React, { useEffect, useMemo, useState, useCallback, useRef } from "react";
|
||||
import React, { useEffect, useMemo, useState, useCallback } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useApplicationBackend, type SshAgentStatus } from "../application/state/useApplicationBackend";
|
||||
import { applyGroupDefaults, resolveGroupDefaults, resolveGroupTerminalThemeId } from "../domain/groupConfig";
|
||||
@@ -70,7 +70,9 @@ import {
|
||||
} from "./host-details";
|
||||
import { HostNotesEditor } from "./host/HostNotesEditor";
|
||||
import { HostDetailsScriptsSection } from "./host/HostDetailsScriptsSection";
|
||||
import { ensureHostConnectScriptIds, getEditableHostConnectScriptIds, syncSnippetsForHostConnectQueueSave } from "@/domain/hostConnectScripts.ts";
|
||||
import { ensureHostConnectScriptIds, getHostConnectScriptIds, prepareSnippetForHostConnectQueue } from "@/domain/hostConnectScripts.ts";
|
||||
import { isScriptSnippet } from "@/domain/snippetScript.ts";
|
||||
import { unlinkHostFromScripts } from "@/domain/snippetTargets.ts";
|
||||
import {
|
||||
isPluginHostProtocol,
|
||||
sanitizePluginConnection,
|
||||
@@ -110,7 +112,6 @@ interface HostDetailsPanelProps {
|
||||
onImportKey?: (draft: Partial<SSHKey>) => SSHKey;
|
||||
snippets?: Snippet[];
|
||||
onSnippetsChange?: (snippets: Snippet[]) => void;
|
||||
onHostsChange?: (hosts: Host[] | ((prev: Host[]) => Host[])) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -138,7 +139,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
onImportKey,
|
||||
snippets = [],
|
||||
onSnippetsChange,
|
||||
onHostsChange,
|
||||
className,
|
||||
resizable,
|
||||
persistWidthStorageKey,
|
||||
@@ -151,9 +151,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
resizeAriaLabel,
|
||||
};
|
||||
const { checkSshAgent } = useApplicationBackend();
|
||||
const baselineSnippetsRef = useRef(snippets);
|
||||
const baselineHydratedRef = useRef(snippets.length > 0);
|
||||
const openingQueueIdsRef = useRef<string[]>([]);
|
||||
const [form, setForm] = useState<Host>(
|
||||
() =>
|
||||
(initialData ? normalizePrimaryTelnetState(initialData) : null) ||
|
||||
@@ -241,36 +238,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
|
||||
const initialHostId = initialData?.id;
|
||||
|
||||
useEffect(() => {
|
||||
baselineSnippetsRef.current = snippets;
|
||||
baselineHydratedRef.current = snippets.length > 0;
|
||||
if (!initialData) {
|
||||
openingQueueIdsRef.current = [];
|
||||
} else {
|
||||
const normalized = normalizePrimaryTelnetState(initialData);
|
||||
const ensured = snippets.length > 0
|
||||
? ensureHostConnectScriptIds(normalized, snippets)
|
||||
: normalized;
|
||||
openingQueueIdsRef.current = getEditableHostConnectScriptIds(ensured, snippets);
|
||||
}
|
||||
// Snapshot only when opening/reopening a host editor, not on live snippet edits.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialHostId]);
|
||||
|
||||
useEffect(() => {
|
||||
// If the editor opened before snippets hydrated, capture the first non-empty
|
||||
// catalog as baseline so concurrent-edit guards still have a reference.
|
||||
if (baselineHydratedRef.current) return;
|
||||
if (snippets.length === 0) return;
|
||||
baselineSnippetsRef.current = snippets;
|
||||
baselineHydratedRef.current = true;
|
||||
if (initialData) {
|
||||
const normalized = normalizePrimaryTelnetState(initialData);
|
||||
const ensured = ensureHostConnectScriptIds(normalized, snippets);
|
||||
openingQueueIdsRef.current = getEditableHostConnectScriptIds(ensured, snippets);
|
||||
}
|
||||
}, [initialData, snippets]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialData) return;
|
||||
const normalized = normalizePrimaryTelnetState(initialData);
|
||||
@@ -626,80 +593,37 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
if ((cleaned.protocol && cleaned.protocol !== "ssh") || cleaned.moshEnabled || cleaned.etEnabled) {
|
||||
delete cleaned.x11Forwarding;
|
||||
}
|
||||
if (onSnippetsChange || onHostsChange) {
|
||||
if (onSnippetsChange && initialData) {
|
||||
const hostId = cleaned.id;
|
||||
if (snippets.length === 0 && !baselineHydratedRef.current) {
|
||||
// Vault may publish hosts before snippets hydrate. Do not wipe unresolved
|
||||
// connectScriptIds while the script catalog is still incomplete.
|
||||
} else {
|
||||
const savedQueueIds = openingQueueIdsRef.current;
|
||||
const finalQueueIds = getEditableHostConnectScriptIds(cleaned, snippets);
|
||||
const synced = syncSnippetsForHostConnectQueueSave(
|
||||
snippets,
|
||||
hostId,
|
||||
savedQueueIds,
|
||||
finalQueueIds,
|
||||
{
|
||||
baselineSnippets: baselineSnippetsRef.current,
|
||||
hosts: allHosts,
|
||||
},
|
||||
);
|
||||
cleaned = {
|
||||
...cleaned,
|
||||
connectScriptIds: synced.connectScriptIds.length > 0
|
||||
? synced.connectScriptIds
|
||||
: undefined,
|
||||
// sanitizeHost reconstitutes connectScriptIds from loginScriptId when
|
||||
// the explicit queue is absent; clear the legacy field when emptied.
|
||||
...(synced.connectScriptIds.length === 0
|
||||
? { loginScriptId: undefined }
|
||||
: {}),
|
||||
};
|
||||
if (synced.changed && onSnippetsChange) {
|
||||
onSnippetsChange(synced.snippets);
|
||||
const savedQueueIds = initialData.connectScriptIds ?? getHostConnectScriptIds(initialData, snippets);
|
||||
const finalQueueIds = cleaned.connectScriptIds ?? getHostConnectScriptIds(cleaned, snippets);
|
||||
const savedSet = new Set(savedQueueIds);
|
||||
const finalSet = new Set(finalQueueIds);
|
||||
let nextSnippets = snippets;
|
||||
let changed = false;
|
||||
|
||||
for (const scriptId of finalQueueIds) {
|
||||
if (!savedSet.has(scriptId)) {
|
||||
nextSnippets = nextSnippets.map((item) => (
|
||||
item.id === scriptId && isScriptSnippet(item)
|
||||
? prepareSnippetForHostConnectQueue(item, hostId)
|
||||
: item
|
||||
));
|
||||
changed = true;
|
||||
}
|
||||
if (onHostsChange && synced.hosts.length > 0) {
|
||||
const peerChanged = synced.hosts.some((host) => {
|
||||
if (host.id === cleaned.id) return false;
|
||||
const original = allHosts.find((entry) => entry.id === host.id);
|
||||
return original !== undefined && original !== host;
|
||||
});
|
||||
if (peerChanged) {
|
||||
// Append only the script IDs this save newly queued onto each peer.
|
||||
// Leave the edited host alone so onSave can three-way merge it.
|
||||
onHostsChange((prevHosts) => {
|
||||
const peerAdds = new Map<string, string[]>();
|
||||
for (const syncedHost of synced.hosts) {
|
||||
if (syncedHost.id === cleaned.id) continue;
|
||||
const snapshot = allHosts.find((entry) => entry.id === syncedHost.id);
|
||||
if (!snapshot || snapshot === syncedHost) continue;
|
||||
const snapshotIds = snapshot.connectScriptIds ?? [];
|
||||
const addedIds = (syncedHost.connectScriptIds ?? []).filter(
|
||||
(id) => !snapshotIds.includes(id),
|
||||
);
|
||||
if (addedIds.length > 0) peerAdds.set(syncedHost.id, addedIds);
|
||||
}
|
||||
return prevHosts.map((host) => {
|
||||
if (host.id === cleaned.id) return host;
|
||||
const addedIds = peerAdds.get(host.id);
|
||||
if (!addedIds || addedIds.length === 0) return host;
|
||||
const currentIds = host.connectScriptIds ?? [];
|
||||
const mergedIds = [...currentIds];
|
||||
for (const id of addedIds) {
|
||||
if (!mergedIds.includes(id)) mergedIds.push(id);
|
||||
}
|
||||
if (
|
||||
mergedIds.length === currentIds.length
|
||||
&& mergedIds.every((id, index) => id === currentIds[index])
|
||||
) {
|
||||
return host;
|
||||
}
|
||||
return { ...host, connectScriptIds: mergedIds };
|
||||
});
|
||||
});
|
||||
}
|
||||
for (const scriptId of savedQueueIds) {
|
||||
if (!finalSet.has(scriptId)) {
|
||||
const unlinked = unlinkHostFromScripts(nextSnippets, hostId, scriptId);
|
||||
if (unlinked !== nextSnippets) {
|
||||
nextSnippets = unlinked;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
onSnippetsChange(nextSnippets);
|
||||
}
|
||||
}
|
||||
onSave(stripBuiltInConnectionFieldsForPluginHost(cleaned));
|
||||
};
|
||||
|
||||
@@ -21,8 +21,6 @@ export interface PassphraseRequest {
|
||||
keyPath: string;
|
||||
keyName: string;
|
||||
hostname?: string;
|
||||
sessionId?: string;
|
||||
bootEpoch?: number;
|
||||
}
|
||||
|
||||
/** Opt-in only: do not default to remembering key passphrases (#2024). */
|
||||
|
||||
@@ -178,7 +178,7 @@ test("ProxyPanel warns when a saved proxy profile has an unreadable identity pas
|
||||
},
|
||||
}],
|
||||
selectedProxyProfileId: proxyProfile.id,
|
||||
identities: [{ ...proxyIdentity, password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==" }],
|
||||
identities: [{ ...proxyIdentity, password: "enc:v1:djEwAAAA" }],
|
||||
});
|
||||
|
||||
assert.match(markup, /Proxy identity password cannot be read/);
|
||||
|
||||
@@ -193,7 +193,7 @@ test("prepareProxyProfileForSave rejects unreadable proxy identity passwords", (
|
||||
identityId: proxyIdentity.id,
|
||||
},
|
||||
},
|
||||
[{ ...proxyIdentity, password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==" }],
|
||||
[{ ...proxyIdentity, password: "enc:v1:djEwAAAA" }],
|
||||
).error,
|
||||
"unreadableIdentity",
|
||||
);
|
||||
|
||||
@@ -19,14 +19,6 @@ test('keeps the new workspace action outside the scrollable results', () => {
|
||||
assert.match(source, /VariableSizeVirtualList/);
|
||||
});
|
||||
|
||||
test('host rows expose optional edit-host context menu', () => {
|
||||
const source = readFileSync(new URL('./QuickSwitcher.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /onEditHost\?: \(host: Host\) => void/);
|
||||
assert.match(source, /terminal\.layer\.hostTree\.editHost/);
|
||||
assert.match(source, /ContextMenuTrigger asChild/);
|
||||
assert.match(source, /#netcatty-context-menu-root/);
|
||||
});
|
||||
|
||||
test('pointer hover never rewrites the keyboard selection', () => {
|
||||
const quickSwitcherSource = readFileSync(new URL('./QuickSwitcher.tsx', import.meta.url), 'utf8');
|
||||
const workspacePickerSource = readFileSync(new URL('./workspace/AddToWorkspaceDialog.tsx', import.meta.url), 'utf8');
|
||||
|
||||
@@ -101,7 +101,6 @@ export function buildPluginPaletteItems(
|
||||
});
|
||||
}
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from "./ui/context-menu";
|
||||
import { Input } from "./ui/input";
|
||||
import {
|
||||
VariableSizeVirtualList,
|
||||
@@ -138,7 +137,6 @@ interface QuickSwitcherProps {
|
||||
workspaces: Workspace[];
|
||||
onQueryChange: (value: string) => void;
|
||||
onSelect: (host: Host) => void;
|
||||
onEditHost?: (host: Host) => void;
|
||||
onSelectTab: (tabId: string) => void;
|
||||
onClose: () => void;
|
||||
onCreateLocalTerminal?: (shell?: { command: string; args?: string[]; name?: string; icon?: string }) => void;
|
||||
@@ -156,7 +154,6 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
workspaces,
|
||||
onQueryChange,
|
||||
onSelect,
|
||||
onEditHost,
|
||||
onSelectTab,
|
||||
onClose,
|
||||
onCreateLocalTerminal,
|
||||
@@ -230,18 +227,12 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target;
|
||||
if (!(target instanceof Node)) return;
|
||||
if (containerRef.current?.contains(target)) return;
|
||||
// ContextMenuContent portals to #netcatty-context-menu-root; treat those
|
||||
// clicks as inside so Connect / Edit host can run before unmount.
|
||||
if (
|
||||
target instanceof Element &&
|
||||
target.closest("#netcatty-context-menu-root")
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
return;
|
||||
onClose();
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
@@ -536,7 +527,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
|
||||
if (item.type === "host") {
|
||||
const host = item.data as Host;
|
||||
const hostRow = (
|
||||
return (
|
||||
<div
|
||||
className={`flex h-full min-h-0 items-center justify-between overflow-hidden px-4 py-2.5 cursor-pointer transition-colors ${getQuickSwitcherRowStateClass(isSelected, isKeyboardNavigating)}`}
|
||||
onClick={() => onSelect(host)}
|
||||
@@ -554,20 +545,6 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
if (!onEditHost) return hostRow;
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{hostRow}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => onSelect(host)}>
|
||||
{t('vault.hosts.connect')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onEditHost(host)}>
|
||||
{t('terminal.layer.hostTree.editHost')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "tab") {
|
||||
|
||||
@@ -10,16 +10,7 @@
|
||||
* Used in TerminalLayer to provide SFTP alongside terminal sessions.
|
||||
*/
|
||||
|
||||
import React, {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type MutableRefObject,
|
||||
} from "react";
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
|
||||
import { SftpSidePanelDeferredMount } from "./SftpSidePanelDeferredMount";
|
||||
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from "./terminalLayer/terminalSidePanelChrome";
|
||||
import { formatHostPort } from "../domain/host";
|
||||
@@ -112,8 +103,6 @@ interface SftpSidePanelProps {
|
||||
onInitialLocationApplied?: (location: { hostId: string; path: string }) => void;
|
||||
onCurrentPathChange?: (location: { hostId: string; connectionKey: string; path: string }) => void;
|
||||
onActiveTransfersChange?: (count: number) => void;
|
||||
/** External-editor temps that must keep this owner mounted after panel close. */
|
||||
onActiveExternalEditsChange?: (count: number) => void;
|
||||
showWorkspaceHostHeader?: boolean;
|
||||
isVisible?: boolean;
|
||||
renderOverlays?: boolean;
|
||||
@@ -161,7 +150,6 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
onInitialLocationApplied,
|
||||
onCurrentPathChange,
|
||||
onActiveTransfersChange,
|
||||
onActiveExternalEditsChange,
|
||||
showWorkspaceHostHeader = false,
|
||||
isVisible = true,
|
||||
renderOverlays = true,
|
||||
@@ -227,7 +215,6 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
|
||||
const ownedEditorSessionIdsRef = useRef<ReadonlySet<string>>(new Set());
|
||||
const ownedEditorSftpTabIdsRef = useRef<ReadonlySet<string>>(new Set());
|
||||
const activeExternalEditCountRef = useRef(0);
|
||||
// Re-render on tab open/close/session remap only — not on every editor keystroke.
|
||||
useEditorTabPresenceRevision();
|
||||
const hasOwnedEditorTab = editorTabStore.hasOwnedEditorForSftpOwner({
|
||||
@@ -243,12 +230,9 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
surfaceVisible: isVisible,
|
||||
// A promoted editor still saves through this owner after the side panel
|
||||
// becomes hidden, so its browse channel must stay alive until the editor closes.
|
||||
// External editor temps (Notepad++ etc.) likewise need the session: parking
|
||||
// calls closeSftp which deletes those local files.
|
||||
interactive: isBrowseSessionInteractive({
|
||||
surfaceVisible: isVisible,
|
||||
hasOwnedEditorTab,
|
||||
hasActiveExternalEdit: activeExternalEditCountRef.current > 0,
|
||||
}),
|
||||
useCompressedUpload: sftpUseCompressedUpload,
|
||||
defaultShowHiddenFiles: sftpShowHiddenFiles,
|
||||
@@ -273,7 +257,6 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
]);
|
||||
|
||||
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
|
||||
activeExternalEditCountRef.current = sftp.activeExternalEditCount ?? 0;
|
||||
ownedEditorSessionIdsRef.current = new Set(
|
||||
listRemoteBrowseConnectionIds([
|
||||
...sftp.leftTabs.tabs,
|
||||
@@ -417,14 +400,6 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
onActiveTransfersChange,
|
||||
});
|
||||
|
||||
// Parent retain-on-close only sees transfers unless we also publish external
|
||||
// editor temp activity (closeSftp deletes those files on unmount).
|
||||
const onActiveExternalEditsChangeRef = useRef(onActiveExternalEditsChange);
|
||||
onActiveExternalEditsChangeRef.current = onActiveExternalEditsChange;
|
||||
useLayoutEffect(() => {
|
||||
onActiveExternalEditsChangeRef.current?.(sftp.activeExternalEditCount ?? 0);
|
||||
}, [sftp.activeExternalEditCount]);
|
||||
|
||||
// Register this instance's writeTextFileByConnection with the editor bridge
|
||||
// so editor tabs promoted from SFTP files opened in a terminal side panel
|
||||
// can still route saves through this useSftpState.
|
||||
@@ -499,8 +474,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
|
||||
const s = sftpRef.current;
|
||||
const hasActiveWork = interactiveWorkActive
|
||||
|| (s.activeFileWatchCountRef?.current ?? 0) > 0
|
||||
|| (s.activeExternalEditCount ?? 0) > 0;
|
||||
|| (s.activeFileWatchCountRef?.current ?? 0) > 0;
|
||||
|
||||
const proto = activeHost.protocol;
|
||||
if (proto === 'serial' || activeHost.id?.startsWith('serial-')) {
|
||||
@@ -1185,8 +1159,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
}, [followTerminalCwdHost, onGetTerminalCwd]);
|
||||
|
||||
const hasActiveWork = showTextEditor || !!permissionsState || showFileOpenerDialog
|
||||
|| (sftp.activeFileWatchCountRef?.current ?? 0) > 0
|
||||
|| (sftp.activeExternalEditCount ?? 0) > 0;
|
||||
|| (sftp.activeFileWatchCountRef?.current ?? 0) > 0;
|
||||
|
||||
const blockedFollowRef = useRef<SftpFollowTerminalCwdBlock | null>(null);
|
||||
const handledFollowRef = useRef<SftpFollowTerminalCwdBlock | null>(null);
|
||||
@@ -1750,7 +1723,6 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
|
||||
prev.onRequestTerminalFocus === next.onRequestTerminalFocus &&
|
||||
prev.onCurrentPathChange === next.onCurrentPathChange &&
|
||||
prev.onActiveTransfersChange === next.onActiveTransfersChange &&
|
||||
prev.onActiveExternalEditsChange === next.onActiveExternalEditsChange &&
|
||||
prev.initialLocation?.hostId === next.initialLocation?.hostId &&
|
||||
prev.initialLocation?.path === next.initialLocation?.path &&
|
||||
// Only the keepalive fields of terminalSettings affect SFTP connection
|
||||
|
||||
@@ -64,70 +64,6 @@ test("renders active transfer cancel action with an item-specific label", () =>
|
||||
assert.match(markup, /aria-label="Pause: archive\.tar\.gz"/);
|
||||
});
|
||||
|
||||
test("directory parents show transfer-center done/found progress instead of Waiting", () => {
|
||||
const markup = renderTransferItem({
|
||||
...baseTask,
|
||||
id: "folder-1",
|
||||
fileName: "big-tree",
|
||||
isDirectory: true,
|
||||
progressMode: "files",
|
||||
status: "pending",
|
||||
phase: "transferring",
|
||||
transferredBytes: 65,
|
||||
totalBytes: 108202,
|
||||
error: undefined,
|
||||
speed: 0,
|
||||
});
|
||||
|
||||
assert.match(markup, /65 done · 108202 found/);
|
||||
assert.doesNotMatch(markup, />Waiting\.\.\.</);
|
||||
});
|
||||
|
||||
test("scanning directory parents keep scanning label with live discovery counts", () => {
|
||||
const markup = renderTransferItem({
|
||||
...baseTask,
|
||||
id: "folder-scan",
|
||||
fileName: "docs",
|
||||
isDirectory: true,
|
||||
progressMode: "files",
|
||||
status: "pending",
|
||||
phase: "scanning",
|
||||
transferredBytes: 0,
|
||||
totalBytes: 1284,
|
||||
error: undefined,
|
||||
speed: 0,
|
||||
});
|
||||
|
||||
assert.match(markup, /Scanning/);
|
||||
assert.match(markup, /0 done · 1284 found/);
|
||||
});
|
||||
|
||||
test("child rows reserve an action column wide enough for pause and cancel", () => {
|
||||
const markup = renderTransferItem(
|
||||
{
|
||||
...baseTask,
|
||||
id: "child-transfer-actions",
|
||||
parentTaskId: "transfer-1",
|
||||
status: "transferring",
|
||||
error: undefined,
|
||||
transferredBytes: 256,
|
||||
totalBytes: 1024,
|
||||
speed: 128,
|
||||
resumable: true,
|
||||
},
|
||||
{
|
||||
isChild: true,
|
||||
childNameColumnWidth: 260,
|
||||
onResizeNameColumn: () => {},
|
||||
onSetNameColumnWidth: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
assert.match(markup, /grid-template-columns:24px 260px 10px minmax\(0, 1fr\) 56px/);
|
||||
assert.match(markup, /aria-label="Pause: archive\.tar\.gz"/);
|
||||
assert.match(markup, /aria-label="Cancel: archive\.tar\.gz"/);
|
||||
});
|
||||
|
||||
test("renders paused transfer resume action", () => {
|
||||
const markup = renderTransferItem({
|
||||
...baseTask,
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
CloudOff,
|
||||
Github,
|
||||
Loader2,
|
||||
Plug,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
X,
|
||||
@@ -27,7 +26,6 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { useCloudSync } from '../application/state/useCloudSync';
|
||||
import { isProviderReadyForSync, type CloudProvider, formatSyncDateTime } from '../domain/sync';
|
||||
import { isPluginCloudProviderId } from '../domain/cloudProviderIds';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Button } from './ui/button';
|
||||
@@ -55,7 +53,7 @@ const OneDriveIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const providerIcons: Partial<Record<CloudProvider, React.ReactNode>> = {
|
||||
const providerIcons: Record<CloudProvider, React.ReactNode> = {
|
||||
github: <Github size={16} />,
|
||||
google: <GoogleDriveIcon className="w-4 h-4" />,
|
||||
onedrive: <OneDriveIcon className="w-4 h-4" />,
|
||||
@@ -63,7 +61,7 @@ const providerIcons: Partial<Record<CloudProvider, React.ReactNode>> = {
|
||||
s3: <Database size={16} />,
|
||||
};
|
||||
|
||||
const providerNames: Partial<Record<CloudProvider, string>> = {
|
||||
const providerNames: Record<CloudProvider, string> = {
|
||||
github: 'GitHub Gist',
|
||||
google: 'Google Drive',
|
||||
onedrive: 'OneDrive',
|
||||
@@ -71,12 +69,6 @@ const providerNames: Partial<Record<CloudProvider, string>> = {
|
||||
s3: 'S3 Compatible',
|
||||
};
|
||||
|
||||
const resolveProviderIcon = (provider: CloudProvider): React.ReactNode =>
|
||||
providerIcons[provider] ?? <Plug size={16} />;
|
||||
|
||||
const resolveProviderName = (provider: CloudProvider): string =>
|
||||
providerNames[provider] ?? provider;
|
||||
|
||||
// ============================================================================
|
||||
// Status Dot Component
|
||||
// ============================================================================
|
||||
@@ -131,17 +123,13 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
|
||||
|
||||
// State is now automatically synced via useSyncExternalStore - no manual refresh needed
|
||||
|
||||
// Get connected provider (built-ins first, then any namespaced plugin provider).
|
||||
// Get connected provider (include syncing status as it's still connected)
|
||||
const getConnectedProvider = (): CloudProvider | null => {
|
||||
const preferred: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
|
||||
for (const id of preferred) {
|
||||
const conn = sync.providers[id];
|
||||
if (conn && isProviderReadyForSync(conn)) return id;
|
||||
}
|
||||
for (const [id, conn] of Object.entries(sync.providers)) {
|
||||
if (preferred.includes(id as CloudProvider)) continue;
|
||||
if (conn && isProviderReadyForSync(conn)) return id as CloudProvider;
|
||||
}
|
||||
if (isProviderReadyForSync(sync.providers.github)) return 'github';
|
||||
if (isProviderReadyForSync(sync.providers.google)) return 'google';
|
||||
if (isProviderReadyForSync(sync.providers.onedrive)) return 'onedrive';
|
||||
if (isProviderReadyForSync(sync.providers.webdav)) return 'webdav';
|
||||
if (isProviderReadyForSync(sync.providers.s3)) return 's3';
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -298,17 +286,15 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
|
||||
{connectedProvider && providerConnection && (
|
||||
<div className="flex items-center gap-3 p-2 rounded-lg bg-muted/30">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 text-primary flex items-center justify-center">
|
||||
{resolveProviderIcon(connectedProvider)}
|
||||
{providerIcons[connectedProvider]}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{resolveProviderName(connectedProvider)}</span>
|
||||
<span className="text-sm font-medium">{providerNames[connectedProvider]}</span>
|
||||
<StatusIndicator status={overallStatus} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{providerConnection.account?.avatarUrl
|
||||
&& connectedProvider
|
||||
&& !isPluginCloudProviderId(connectedProvider) && (
|
||||
{providerConnection.account?.avatarUrl && (
|
||||
<img
|
||||
src={providerConnection.account.avatarUrl}
|
||||
alt=""
|
||||
|
||||
+42
-299
@@ -3,7 +3,7 @@ import { FitAddon } from "@xterm/addon-fit";
|
||||
import { SerializeAddon } from "@xterm/addon-serialize";
|
||||
import { SearchAddon } from "@xterm/addon-search";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { Activity, Cpu, Clock3, Copy, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine, RefreshCcw, Sparkles, SquareArrowOutUpRight, Unplug } from "lucide-react";
|
||||
import { Activity, Cpu, Clock3, Copy, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine, Sparkles, SquareArrowOutUpRight } from "lucide-react";
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { detectLocalOs } from "../lib/localShell";
|
||||
@@ -48,7 +48,6 @@ import { supportsZmodemTerminalDragDrop } from "../lib/zmodemDragDrop";
|
||||
import { resolveHostAuth, resolveHostAutofillPassword } from "../domain/sshAuth";
|
||||
import { resolveEffectiveTerminalProtocol } from "../domain/terminalProtocol";
|
||||
import { isPluginHostProtocol } from "../domain/pluginConnection";
|
||||
import { clearTerminalBootEpoch, setTerminalBootEpoch } from "../domain/terminalBootEpoch";
|
||||
import {
|
||||
appendTerminalPromptSecurityTail,
|
||||
isConfirmedTerminalShellPrompt,
|
||||
@@ -66,7 +65,6 @@ import { useStoredBoolean } from "../application/state/useStoredBoolean";
|
||||
import { readOptionalStoredStringValue, useStoredString } from "../application/state/useStoredString";
|
||||
import { useSessionLogBackend } from "../application/state/useSessionLogBackend";
|
||||
import { useTerminalLayoutSuppressActive } from "../application/state/terminalLayoutSuppressStore";
|
||||
import { useAppearanceChromeStore } from "../application/state/appearanceChromeStore";
|
||||
import {
|
||||
shouldPublishPluginTerminalSessionMountLifecycle,
|
||||
usePluginTerminalSessionLifecycle,
|
||||
@@ -110,12 +108,7 @@ import {
|
||||
resumeScriptRun,
|
||||
stopScriptRun,
|
||||
} from "@/application/state/scriptAutomationCoordinator.ts";
|
||||
import {
|
||||
hasUnresolvedConnectScriptBindings,
|
||||
resolveConnectScriptsForHost,
|
||||
shouldMarkConnectAutomationConsumed,
|
||||
shouldUseFreshSshConnectionForAutomation,
|
||||
} from "@/domain/hostConnectScripts.ts";
|
||||
import { resolveConnectScriptsForHost, hasUnresolvedConnectScriptBindings } from "@/domain/hostConnectScripts.ts";
|
||||
import { isVaultInitialized } from "@/application/state/vaultInitStore.ts";
|
||||
import { netcattyBridge } from "@/infrastructure/services/netcattyBridge.ts";
|
||||
import { ScriptExecutionOverlay } from "./terminal/ScriptExecutionOverlay";
|
||||
@@ -261,6 +254,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
fontSize,
|
||||
terminalTheme,
|
||||
followAppTerminalTheme = false,
|
||||
accentMode = "theme",
|
||||
customAccent = "",
|
||||
terminalSettings,
|
||||
sessionId,
|
||||
workspaceId,
|
||||
@@ -357,16 +352,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const connectScriptsInFlightRef = useRef(false);
|
||||
const pendingScriptRunIdRef = useRef<string | null>(null);
|
||||
const pendingScriptHandledRef = useRef<Snippet | null>(null);
|
||||
const pendingScriptRef = useRef(pendingScript);
|
||||
const pendingScriptIdRef = useRef(pendingScriptId);
|
||||
pendingScriptRef.current = pendingScript;
|
||||
pendingScriptIdRef.current = pendingScriptId;
|
||||
const isPendingScriptAlreadyHandled = useCallback((snippet: Snippet) => {
|
||||
if (snippet.id) {
|
||||
return pendingScriptRunIdRef.current === snippet.id;
|
||||
}
|
||||
return pendingScriptHandledRef.current === snippet;
|
||||
}, []);
|
||||
// Mosh marks status=connected during the SSH handshake so interactive
|
||||
// prompts remain reachable. Connect/pending scripts must wait until
|
||||
// mosh-client is ready (#2199). closeSession clears preload ready
|
||||
@@ -448,7 +433,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const hibernateContextViewportSnapshotRef = useRef("");
|
||||
const hibernateContextScrollbackSnapshotRef = useRef("");
|
||||
const hibernatePendingBufferRef = useRef("");
|
||||
const hibernatePendingCapDisabledRef = useRef(false);
|
||||
const hibernateAlternateScreenRef = useRef(false);
|
||||
const hibernateRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const fullHibernateRuntimeRef = useRef<(() => Promise<boolean>) | null>(null);
|
||||
@@ -458,48 +442,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const wakePromiseRef = useRef<Promise<boolean> | null>(null);
|
||||
const sessionRef = useRef<string | null>(null);
|
||||
const sessionCleanupPromiseRef = useRef<Promise<void> | null>(null);
|
||||
/** Epoch owned by an in-flight disconnect/teardown close (before bump). */
|
||||
const pendingCloseBootEpochRef = useRef<number | undefined>(undefined);
|
||||
const trackSessionCleanup = (promise: Promise<unknown>) => {
|
||||
const settled = Promise.resolve(promise).then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
const previous = sessionCleanupPromiseRef.current;
|
||||
const next = previous
|
||||
? Promise.all([previous, settled]).then(() => undefined)
|
||||
: settled;
|
||||
sessionCleanupPromiseRef.current = next;
|
||||
void next.finally(() => {
|
||||
if (sessionCleanupPromiseRef.current === next) {
|
||||
sessionCleanupPromiseRef.current = null;
|
||||
}
|
||||
});
|
||||
};
|
||||
const isBootActiveRef = useRef(false);
|
||||
const bootEpochRef = useRef(0);
|
||||
const publishBootEpoch = () => {
|
||||
setTerminalBootEpoch(sessionId, bootEpochRef.current);
|
||||
};
|
||||
const bumpBootEpoch = () => {
|
||||
bootEpochRef.current += 1;
|
||||
publishBootEpoch();
|
||||
};
|
||||
/** Invalidate the current boot and remember its epoch for the matching closeSession. */
|
||||
const invalidateBootEpochForClose = () => {
|
||||
const closingEpoch = bootEpochRef.current;
|
||||
bumpBootEpoch();
|
||||
pendingCloseBootEpochRef.current = closingEpoch;
|
||||
return closingEpoch;
|
||||
};
|
||||
const resolveCloseBootEpoch = () => {
|
||||
if (pendingCloseBootEpochRef.current !== undefined) {
|
||||
const epoch = pendingCloseBootEpochRef.current;
|
||||
pendingCloseBootEpochRef.current = undefined;
|
||||
return epoch;
|
||||
}
|
||||
return bootEpochRef.current;
|
||||
};
|
||||
const hasConnectedRef = useRef(false);
|
||||
const hasRunStartupCommandRef = useRef(false);
|
||||
const restoreCwdIntentRef = useRef<{ cwd: string; command: string } | null>(null);
|
||||
@@ -518,10 +461,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
/** Connected wake for multi-tab snippet fan-out (reattaches session listeners). */
|
||||
const wakeHibernatedRuntimeForConnectedRef = useRef<(() => Promise<boolean>) | null>(null);
|
||||
const reconnectWakeInFlightRef = useRef(false);
|
||||
// When a hibernated reconnect wake is invalidated: 'dispose' for unmount/
|
||||
// session teardown (orphan runtime must go), 'keep' for Disconnect so the
|
||||
// pane retains an xterm instance and can reconnect without being remounted.
|
||||
const reconnectWakeInvalidateModeRef = useRef<"dispose" | "keep">("dispose");
|
||||
const reconnectWakeTokenRef = useRef<symbol | null>(null);
|
||||
const manualReconnectRequestRef = useRef<() => void>(() => {});
|
||||
const terminalDataCapturedRef = useRef(false);
|
||||
@@ -543,7 +482,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}, [onSessionExit]);
|
||||
|
||||
useEffect(() => () => {
|
||||
reconnectWakeInvalidateModeRef.current = "dispose";
|
||||
reconnectWakeTokenRef.current = null;
|
||||
}, [sessionId]);
|
||||
|
||||
@@ -786,9 +724,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const [progressValue, setProgressValue] = useState(15);
|
||||
const [isDisconnectedDialogDismissed, setIsDisconnectedDialogDismissed] = useState(false);
|
||||
const [connectionReuseFellBack, setConnectionReuseFellBack] = useState(false);
|
||||
const [connectionReuseAttemptSourceId, setConnectionReuseAttemptSourceId] = useState(
|
||||
reuseConnectionFromSessionId,
|
||||
);
|
||||
|
||||
const statusRef = useRef<TerminalSession["status"]>(status);
|
||||
statusRef.current = status;
|
||||
@@ -1107,14 +1042,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const passwordPickerEmptyText = t("terminal.passwordPicker.empty");
|
||||
const sudoHintText = t("terminal.sudoHint.pressEnter");
|
||||
const sessionStartersRef = useRef<ReturnType<typeof createTerminalSessionStarters> | null>(null);
|
||||
const reuseConnectionSourceRef = useRef(reuseConnectionFromSessionId);
|
||||
const reuseConnectionSourceAttemptedRef = useRef(false);
|
||||
const previousReuseConnectionSourcePropRef = useRef(reuseConnectionFromSessionId);
|
||||
if (previousReuseConnectionSourcePropRef.current !== reuseConnectionFromSessionId) {
|
||||
previousReuseConnectionSourcePropRef.current = reuseConnectionFromSessionId;
|
||||
reuseConnectionSourceRef.current = reuseConnectionFromSessionId;
|
||||
reuseConnectionSourceAttemptedRef.current = false;
|
||||
}
|
||||
const auth = useTerminalAuthState({
|
||||
host,
|
||||
pendingAuthRef,
|
||||
@@ -1263,8 +1190,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
});
|
||||
}, [availableFonts, fontFamilyId, hasFontFamilyOverride, host.fontFamily, terminalSettings?.fallbackFont]);
|
||||
|
||||
const { accentMode, customAccent } = useAppearanceChromeStore();
|
||||
|
||||
const baseEffectiveTheme = useMemo(() => {
|
||||
if (appearanceTheme) return appearanceTheme;
|
||||
if (followAppTerminalTheme) {
|
||||
@@ -1595,17 +1520,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
if (!closingSessionId) {
|
||||
disposeSessionListeners();
|
||||
// Still notify main so in-flight SSH passphrase prompts for this UI
|
||||
// sessionId are aborted even before a backend session was attached.
|
||||
try {
|
||||
await terminalBackend.closeSession(sessionId, { bootEpoch: resolveCloseBootEpoch() });
|
||||
} catch (err) {
|
||||
logger.warn("Failed to cancel pending session boot on disconnect", err);
|
||||
}
|
||||
const postDisposeCleanup = sessionCleanupPromiseRef.current;
|
||||
if (postDisposeCleanup) {
|
||||
await postDisposeCleanup;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1707,7 +1621,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
clearTerminalSessionFlowAck(closingSessionId);
|
||||
}
|
||||
try {
|
||||
await terminalBackend.closeSession(closingSessionId, { bootEpoch: resolveCloseBootEpoch() });
|
||||
await terminalBackend.closeSession(closingSessionId);
|
||||
} catch (err) {
|
||||
logger.warn("Failed to close SSH session", err);
|
||||
}
|
||||
@@ -1845,12 +1759,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
backendId,
|
||||
(chunk, meta) => {
|
||||
observeTerminalInputPrompt(chunk, meta);
|
||||
hibernatePendingBufferRef.current = hibernatePendingCapDisabledRef.current
|
||||
? hibernatePendingBufferRef.current + chunk
|
||||
: appendHibernatePendingBuffer(
|
||||
hibernatePendingBufferRef.current,
|
||||
chunk,
|
||||
);
|
||||
hibernatePendingBufferRef.current = appendHibernatePendingBuffer(
|
||||
hibernatePendingBufferRef.current,
|
||||
chunk,
|
||||
);
|
||||
const pluginPipelineIngressBytes = Number.isFinite(meta?.pluginPipelineIngressBytes)
|
||||
? Math.max(0, Number(meta.pluginPipelineIngressBytes))
|
||||
: chunk.length;
|
||||
@@ -1870,12 +1782,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
setError(evt.error);
|
||||
}
|
||||
const exitMessage = `\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`;
|
||||
hibernatePendingBufferRef.current = hibernatePendingCapDisabledRef.current
|
||||
? hibernatePendingBufferRef.current + exitMessage
|
||||
: appendHibernatePendingBuffer(
|
||||
hibernatePendingBufferRef.current,
|
||||
exitMessage,
|
||||
);
|
||||
hibernatePendingBufferRef.current = appendHibernatePendingBuffer(
|
||||
hibernatePendingBufferRef.current,
|
||||
exitMessage,
|
||||
);
|
||||
onSessionExitRef.current?.(sessionId, evt);
|
||||
scheduleAutoReconnect({ evt });
|
||||
});
|
||||
@@ -2131,14 +2041,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
useEffect(() => () => {
|
||||
clearKittyKeyboardBroadcastSession(sessionId);
|
||||
clearTerminalBootEpoch(sessionId);
|
||||
}, [sessionId]);
|
||||
|
||||
const teardown = () => {
|
||||
// Capture the live epoch before invalidating so closeSession still matches
|
||||
// the registered backend session / pending passphrase boot.
|
||||
invalidateBootEpochForClose();
|
||||
clearTerminalBootEpoch(sessionId);
|
||||
isBootActiveRef.current = false;
|
||||
retryTokenRef.current = null;
|
||||
reconnectPreparationTokenRef.current = null;
|
||||
@@ -2232,29 +2137,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
knownHosts,
|
||||
resolvedChainHosts,
|
||||
sessionId,
|
||||
reuseConnectionFromSessionIdRef: reuseConnectionSourceRef,
|
||||
reuseConnectionSourceAttemptedRef,
|
||||
setConnectionReuseAttemptSourceId,
|
||||
shouldUseFreshSshConnection: () => {
|
||||
const currentPendingScript = pendingScriptRef.current;
|
||||
const currentPendingScriptId = pendingScriptIdRef.current;
|
||||
const hasUnhandledPendingScript = currentPendingScript && isScriptSnippet(currentPendingScript)
|
||||
? !isPendingScriptAlreadyHandled(currentPendingScript)
|
||||
: Boolean(
|
||||
currentPendingScriptId
|
||||
&& pendingScriptRunIdRef.current !== currentPendingScriptId,
|
||||
);
|
||||
return shouldUseFreshSshConnectionForAutomation({
|
||||
host: hostRef.current,
|
||||
snippets: snippetsRef.current,
|
||||
vaultInitialized: isVaultInitialized(),
|
||||
hasPendingScript: hasUnhandledPendingScript,
|
||||
connectAutomationConsumed: connectScriptsConsumedRef.current,
|
||||
});
|
||||
},
|
||||
onConnectAutomationSnapshotCommitted: () => {
|
||||
connectScriptsConsumedRef.current = true;
|
||||
},
|
||||
reuseConnectionFromSessionId,
|
||||
isNetworkDevice,
|
||||
startupCommand,
|
||||
noAutoRun,
|
||||
@@ -2269,7 +2152,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
isPaneVisibleRef: isVisibleRef,
|
||||
isVisibleRef: isRendererActiveRef,
|
||||
isBootActiveRef,
|
||||
bootEpochRef,
|
||||
pendingOutputScrollRef,
|
||||
sessionRef,
|
||||
hasConnectedRef,
|
||||
@@ -2277,7 +2159,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
restoreCwdIntentRef,
|
||||
disposeDataRef,
|
||||
disposeExitRef,
|
||||
trackSessionCleanup,
|
||||
disposeTelnetEchoModeRef,
|
||||
fitAddonRef,
|
||||
serializeAddonRef,
|
||||
@@ -2400,15 +2281,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return;
|
||||
}
|
||||
setMoshShellReady(false);
|
||||
disposeMoshReadyRef.current = terminalBackend.onMoshSessionReady(sessionId, (evt) => {
|
||||
const currentEpoch = bootEpochRef.current;
|
||||
if (
|
||||
Number.isFinite(currentEpoch)
|
||||
&& Number.isFinite(evt?.bootEpoch)
|
||||
&& evt.bootEpoch !== currentEpoch
|
||||
) {
|
||||
return;
|
||||
}
|
||||
disposeMoshReadyRef.current = terminalBackend.onMoshSessionReady(sessionId, () => {
|
||||
setMoshShellReady(true);
|
||||
}) ?? null;
|
||||
}, [effectiveTerminalProtocol, sessionId, terminalBackend]);
|
||||
@@ -2441,6 +2314,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
pendingScriptHandledRef.current = null;
|
||||
}, [pendingScript?.id, pendingScriptId]);
|
||||
|
||||
const isPendingScriptAlreadyHandled = useCallback((snippet: Snippet) => {
|
||||
if (snippet.id) {
|
||||
return pendingScriptRunIdRef.current === snippet.id;
|
||||
}
|
||||
return pendingScriptHandledRef.current === snippet;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== 'connected') return;
|
||||
if (effectiveTerminalProtocol === 'mosh' && !moshShellReady) return;
|
||||
@@ -2457,22 +2337,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedConnectScripts = resolveConnectScriptsForHost(host, snippets);
|
||||
// An empty hydrated vault is a final decision for this connection. Lock it
|
||||
// before the output-settling delay so a sync arriving during that window
|
||||
// cannot inject a new connect script into an already-reused SSH session.
|
||||
if (
|
||||
!connectScriptsConsumedRef.current
|
||||
&& resolvedConnectScripts.length === 0
|
||||
&& shouldMarkConnectAutomationConsumed({
|
||||
allConnectScriptsDone: true,
|
||||
vaultInitialized: isVaultInitialized(),
|
||||
hasUnresolvedBindings: hasUnresolvedConnectScriptBindings(host, snippets),
|
||||
})
|
||||
) {
|
||||
connectScriptsConsumedRef.current = true;
|
||||
}
|
||||
|
||||
const shouldEvaluateConnect = !connectScriptsConsumedRef.current;
|
||||
const hasPendingWork = Boolean(pendingOne);
|
||||
if (!shouldEvaluateConnect && !hasPendingWork) return;
|
||||
@@ -2484,7 +2348,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const runPending = Boolean(pendingOne);
|
||||
const connectQueueNow = connectScriptsConsumedRef.current
|
||||
? []
|
||||
: resolvedConnectScripts.filter(
|
||||
: resolveConnectScriptsForHost(host, snippets).filter(
|
||||
(item) => item.id && !connectScriptsCompletedIdsRef.current.has(item.id),
|
||||
);
|
||||
|
||||
@@ -2496,17 +2360,20 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
scriptsToRun.push(pendingOne);
|
||||
}
|
||||
|
||||
const resolvedConnectScripts = resolveConnectScriptsForHost(host, snippets);
|
||||
const allConnectScriptsDone = resolvedConnectScripts.length === 0
|
||||
|| resolvedConnectScripts.every(
|
||||
(item) => item.id && connectScriptsCompletedIdsRef.current.has(item.id),
|
||||
);
|
||||
|
||||
if (scriptsToRun.length === 0) {
|
||||
if (!connectScriptsConsumedRef.current && shouldMarkConnectAutomationConsumed({
|
||||
allConnectScriptsDone,
|
||||
vaultInitialized: isVaultInitialized(),
|
||||
hasUnresolvedBindings: hasUnresolvedConnectScriptBindings(host, snippets),
|
||||
})) {
|
||||
if (
|
||||
!connectScriptsConsumedRef.current
|
||||
&& allConnectScriptsDone
|
||||
&& isVaultInitialized()
|
||||
&& snippets.length > 0
|
||||
&& !hasUnresolvedConnectScriptBindings(host, snippets)
|
||||
) {
|
||||
connectScriptsConsumedRef.current = true;
|
||||
}
|
||||
return;
|
||||
@@ -2857,8 +2724,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
clearWipesScrollbackRef.current = terminalSettings?.clearWipesScrollback ?? true;
|
||||
const normalizeTextOnCopyRef = useRef(terminalSettings?.normalizeTextOnCopy ?? true);
|
||||
normalizeTextOnCopyRef.current = terminalSettings?.normalizeTextOnCopy ?? true;
|
||||
const autoUploadClipboardImageOnPasteRef = useRef(terminalSettings?.autoUploadClipboardImageOnPaste ?? false);
|
||||
autoUploadClipboardImageOnPasteRef.current = terminalSettings?.autoUploadClipboardImageOnPaste ?? false;
|
||||
|
||||
const scrollToBottomAfterProgrammaticInput = useCallback((data: string) => {
|
||||
if (!termRef.current) return;
|
||||
@@ -3024,7 +2889,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
passwordPromptActiveRef,
|
||||
isLocalConnection,
|
||||
supportsRemoteImagePaste,
|
||||
autoUploadClipboardImageOnPasteRef,
|
||||
terminalBackend,
|
||||
getRemoteCwd: () => resolveSftpInitialPath({ preferFreshBackend: true }),
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
@@ -3175,50 +3039,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onCloseSession?.(sessionId);
|
||||
};
|
||||
|
||||
/** Tear down the live transport but keep the tab/pane for a later reconnect. */
|
||||
const handleDisconnect = () => {
|
||||
if (attachExistingSession) return;
|
||||
if (statusRef.current === "disconnected") return;
|
||||
if (pendingHostKeyRequestId) {
|
||||
void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, false);
|
||||
}
|
||||
clearAutoReconnect({ stopLoop: true });
|
||||
retryTokenRef.current = null;
|
||||
reconnectPreparationTokenRef.current = null;
|
||||
restoreCwdIntentRef.current = null;
|
||||
// A hibernated reconnect may still be waking the runtime; invalidate that
|
||||
// continuation so it cannot re-arm boot after this disconnect. Keep any
|
||||
// runtime already created — wakeHibernatedRuntime clears hibernatedRef
|
||||
// before returning, and disposing here would leave no term and no
|
||||
// hibernation marker, permanently blocking later Reconnect.
|
||||
reconnectWakeInvalidateModeRef.current = "keep";
|
||||
reconnectWakeTokenRef.current = null;
|
||||
reconnectWakeInFlightRef.current = false;
|
||||
// Cancel closes the tab (effect cleanup flips boot-active). Disconnect keeps
|
||||
// the pane mounted, so mark boot inactive here or a late startSSH/startMosh
|
||||
// attach can still bring the session back after the user asked to stop.
|
||||
// Bump the epoch so a later reconnect cannot revive this aborted attempt,
|
||||
// but closeSession must still target the pre-bump epoch.
|
||||
invalidateBootEpochForClose();
|
||||
isBootActiveRef.current = false;
|
||||
setIsCancelling(true);
|
||||
auth.setNeedsAuth(false);
|
||||
auth.setAuthRetryMessage(null);
|
||||
setNeedsHostKeyVerification(false);
|
||||
setPendingHostKeyInfo(null);
|
||||
setPendingHostKeyRequestId(null);
|
||||
setError(null);
|
||||
setProgressLogs((prev) => [...prev, "Disconnected by user."]);
|
||||
void cleanupSession();
|
||||
updateStatus("disconnected");
|
||||
setChainProgress(null);
|
||||
setIsDisconnectedDialogDismissed(false);
|
||||
window.dispatchEvent(new CustomEvent("netcatty:terminal-session-disconnected", {
|
||||
detail: { sessionId },
|
||||
}));
|
||||
setTimeout(() => setIsCancelling(false), 600);
|
||||
};
|
||||
|
||||
const handleDismissDisconnectedDialog = () => {
|
||||
setIsDisconnectedDialogDismissed(true);
|
||||
queueMicrotask(() => termRef.current?.focus());
|
||||
@@ -3279,15 +3099,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
reconnectWakeInFlightRef.current = true;
|
||||
const wakeToken = Symbol();
|
||||
reconnectWakeInvalidateModeRef.current = "dispose";
|
||||
reconnectWakeTokenRef.current = wakeToken;
|
||||
updateStatus("connecting");
|
||||
void wakeForReconnect().then((woke) => {
|
||||
if (reconnectWakeTokenRef.current !== wakeToken) {
|
||||
reconnectWakeInFlightRef.current = false;
|
||||
if (reconnectWakeInvalidateModeRef.current === "dispose") {
|
||||
disposeRuntimeOnly();
|
||||
}
|
||||
disposeRuntimeOnly();
|
||||
return;
|
||||
}
|
||||
reconnectWakeTokenRef.current = null;
|
||||
@@ -3299,10 +3115,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
updateStatus("disconnected");
|
||||
}).catch(() => {
|
||||
if (reconnectWakeTokenRef.current !== wakeToken) {
|
||||
reconnectWakeInFlightRef.current = false;
|
||||
if (reconnectWakeInvalidateModeRef.current === "dispose") {
|
||||
disposeRuntimeOnly();
|
||||
}
|
||||
disposeRuntimeOnly();
|
||||
return;
|
||||
}
|
||||
reconnectWakeTokenRef.current = null;
|
||||
@@ -3360,8 +3173,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// ghost backend session with no owning UI.
|
||||
const retryStillActive = () => retryTokenStillCurrent() && termRef.current === term;
|
||||
|
||||
bootEpochRef.current += 1;
|
||||
publishBootEpoch();
|
||||
isBootActiveRef.current = true;
|
||||
auth.resetForRetry();
|
||||
terminalDataCapturedRef.current = false;
|
||||
@@ -3370,7 +3181,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
setIsDisconnectedDialogDismissed(false);
|
||||
setConnectionReuseFellBack(false);
|
||||
setConnectionReuseAttemptSourceId(undefined);
|
||||
updateStatus("connecting");
|
||||
setError(null);
|
||||
setProgressLogs((prev) => (
|
||||
@@ -3460,7 +3270,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
isSerialConnection,
|
||||
isDisconnectedDialogDismissed,
|
||||
hideConnectingDialogForConnectionReuse: shouldHideConnectingDialogForConnectionReuse({
|
||||
reuseConnectionFromSessionId: connectionReuseAttemptSourceId,
|
||||
reuseConnectionFromSessionId,
|
||||
host,
|
||||
connectionReuseFellBack,
|
||||
}),
|
||||
@@ -3499,10 +3309,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onPasteData: broadcastUserPasteData,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
containerRef,
|
||||
autoUploadClipboardImage:
|
||||
supportsRemoteImagePaste && terminalSettings?.autoUploadClipboardImageOnPaste === true,
|
||||
getRemoteCwd: () => resolveSftpInitialPath({ preferFreshBackend: true }),
|
||||
onClipboardImageUploadResult: handleClipboardImageUploadResult,
|
||||
});
|
||||
|
||||
const handleToggleSessionLog = useCallback(async () => {
|
||||
@@ -3818,22 +3624,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
wakeInProgressRef.current = true;
|
||||
setPasswordPickerState(null);
|
||||
|
||||
const stopHibernateDataListener = () => {
|
||||
const stopHibernateListeners = () => {
|
||||
const backendId = sessionRef.current;
|
||||
disposeDataRef.current?.();
|
||||
disposeDataRef.current = null;
|
||||
};
|
||||
|
||||
const stopHibernateListeners = (opts?: { keepPaused?: boolean }) => {
|
||||
const backendId = sessionRef.current;
|
||||
stopHibernateDataListener();
|
||||
disposeExitRef.current?.();
|
||||
disposeExitRef.current = null;
|
||||
if (backendId) {
|
||||
flushTerminalSessionFlowAck(backendId);
|
||||
clearTerminalSessionFlowAck(backendId);
|
||||
if (!opts?.keepPaused) {
|
||||
terminalBackend.setSessionFlowPaused?.(backendId, false);
|
||||
}
|
||||
terminalBackend.setSessionFlowPaused?.(backendId, false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3842,64 +3642,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
runtimeContext,
|
||||
container,
|
||||
getPayload,
|
||||
prepareWakeFlow: async () => {
|
||||
const backendId = sessionRef.current;
|
||||
if (!backendId) return true;
|
||||
// Always pause when a backend exists, including reconnect wakes
|
||||
// (sessionConnected=false) that will not reattach. Stopping the
|
||||
// hibernate listener without a pause drops live output into the
|
||||
// preload backlog without flow ACKs until cleanupSession.
|
||||
if (terminalBackend.setSessionFlowPausedAndWait) {
|
||||
const result = await terminalBackend.setSessionFlowPausedAndWait(backendId, true);
|
||||
return result?.success === true;
|
||||
}
|
||||
terminalBackend.setSessionFlowPaused?.(backendId, true);
|
||||
return false;
|
||||
},
|
||||
takePendingBuffer: () => {
|
||||
const pending = hibernatePendingBufferRef.current;
|
||||
hibernatePendingBufferRef.current = "";
|
||||
return pending;
|
||||
},
|
||||
stopHibernateDataListener,
|
||||
setHibernatePendingCapDisabled: (disabled) => {
|
||||
hibernatePendingCapDisabledRef.current = disabled;
|
||||
},
|
||||
stopHibernateListeners: () => stopHibernateListeners({ keepPaused: true }),
|
||||
restoreAfterFailedWake: (takenPending) => {
|
||||
hibernatePendingCapDisabledRef.current = false;
|
||||
const pendingStillInRef = hibernatePendingBufferRef.current;
|
||||
disposeRuntimeOnly();
|
||||
const backendId = sessionRef.current;
|
||||
if (!backendId) {
|
||||
// No backend to reattach listeners to; still keep taken bytes for a
|
||||
// later wake of a disconnected hibernated tab.
|
||||
hibernatePendingBufferRef.current = appendHibernatePendingBuffer(
|
||||
takenPending || "",
|
||||
pendingStillInRef,
|
||||
);
|
||||
return;
|
||||
}
|
||||
beginHibernatedSessionListeners(backendId);
|
||||
// beginHibernatedSessionListeners clears pending; restore take-and-cleared
|
||||
// bytes from this wake plus anything that arrived after the last take.
|
||||
const restored = appendHibernatePendingBuffer(
|
||||
takenPending || "",
|
||||
pendingStillInRef,
|
||||
);
|
||||
if (restored) {
|
||||
hibernatePendingBufferRef.current = appendHibernatePendingBuffer(
|
||||
restored,
|
||||
hibernatePendingBufferRef.current,
|
||||
);
|
||||
}
|
||||
},
|
||||
resumeAfterReattach: () => {
|
||||
hibernatePendingCapDisabledRef.current = false;
|
||||
const backendId = sessionRef.current;
|
||||
if (!backendId) return;
|
||||
terminalBackend.setSessionFlowPaused?.(backendId, false);
|
||||
},
|
||||
stopHibernateListeners,
|
||||
sessionConnected: options.sessionConnected,
|
||||
getSessionConnected: () => getSessionConnectedRef.current(),
|
||||
reattachSession: (term) => {
|
||||
@@ -3925,7 +3668,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
});
|
||||
wakePromiseRef.current = wakePromise;
|
||||
return wakePromise;
|
||||
}, [sessionId, terminalBackend, terminalRuntimeRefs, resizeSession, terminalSettings, beginHibernatedSessionListeners]);
|
||||
}, [sessionId, terminalBackend, terminalRuntimeRefs, resizeSession, terminalSettings]);
|
||||
|
||||
const wakeHibernatedRuntime = useCallback(async (sessionConnected: boolean): Promise<boolean> => {
|
||||
if (!hibernatedRef.current) {
|
||||
@@ -4000,11 +3743,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onWake: wakeFromHibernateRuntime,
|
||||
});
|
||||
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
|
||||
|
||||
return (
|
||||
<>
|
||||
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, RefreshCcw, Sparkles, SquareArrowOutUpRight, Unplug, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost: !!onUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI: onAddSelectionToAI ? handleAddSelectionToAI : undefined, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect: (attachExistingSession || compactToolbar) ? undefined : handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
|
||||
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost: !!onUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI: onAddSelectionToAI ? handleAddSelectionToAI : undefined, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
|
||||
<ScriptExecutionOverlay
|
||||
run={activeScriptRun}
|
||||
onPause={() => { void pauseScriptRun(activeScriptRun.runId); }}
|
||||
@@ -4013,7 +3756,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onDismiss={dismissScriptOverlay}
|
||||
compactTopChrome={terminalSettings?.showHostInfoBar === false}
|
||||
/>
|
||||
) : null, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showConnectionControls: !attachExistingSession && !compactToolbar, showLogs, showSelectionAIAction: Boolean(showSelectionAIAction && onAddSelectionToAI), isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect: handlePasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />
|
||||
) : null, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction: Boolean(showSelectionAIAction && onAddSelectionToAI), isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect: handlePasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />
|
||||
<ScriptSaveRecordingDialog
|
||||
open={saveRecordingOpen}
|
||||
code={recordedCode}
|
||||
|
||||
@@ -217,32 +217,6 @@ test("TerminalLayer ignores shellHistory prop churn (reads shellHistoryStore ins
|
||||
);
|
||||
});
|
||||
|
||||
test("TerminalLayer ignores notes prop churn (reads notesStore instead)", () => {
|
||||
assert.equal(
|
||||
terminalLayerAreEqual(
|
||||
baseProps as never,
|
||||
{
|
||||
...baseProps,
|
||||
notes: [{ id: "n1", title: "One", content: "body", tags: [], createdAt: 1, updatedAt: 1, order: 1000 }],
|
||||
noteGroups: ["Ops"],
|
||||
updateNotes: () => {},
|
||||
updateNoteGroups: () => {},
|
||||
} as never,
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("TerminalLayer ignores customAccent prop churn (reads appearanceChromeStore instead)", () => {
|
||||
assert.equal(
|
||||
terminalLayerAreEqual(
|
||||
baseProps as never,
|
||||
{ ...baseProps, accentMode: "custom", customAccent: "#ff00aa" } as never,
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("TerminalLayer ignores sessions dynamicTitle-only updates", () => {
|
||||
const sessionsA = [{
|
||||
id: "s1",
|
||||
|
||||
@@ -173,6 +173,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
identities,
|
||||
snippets,
|
||||
snippetPackages,
|
||||
notes,
|
||||
noteGroups,
|
||||
openNoteRequest,
|
||||
onOpenVaultNoteFromChat,
|
||||
onOpenVaultHostFromChat,
|
||||
@@ -214,7 +216,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onUpdateHost,
|
||||
onAddKnownHost,
|
||||
onCommandExecuted,
|
||||
onDeleteShellHistoryEntry,
|
||||
onTerminalDataCapture,
|
||||
onCreateWorkspaceFromSessions,
|
||||
onAddSessionToWorkspace,
|
||||
@@ -235,6 +236,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
updateHosts,
|
||||
updateSnippets,
|
||||
updateSnippetPackages,
|
||||
updateNotes,
|
||||
updateNoteGroups,
|
||||
sftpDefaultViewMode,
|
||||
sftpDoubleClickBehavior,
|
||||
sftpAutoSync,
|
||||
@@ -575,7 +578,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const sftpHostForTabRef = useRef(sftpHostForTab);
|
||||
sftpHostForTabRef.current = sftpHostForTab;
|
||||
const sftpActiveTransfersByTabRef = useRef<Map<string, number>>(new Map());
|
||||
const sftpActiveExternalEditsByTabRef = useRef<Map<string, number>>(new Map());
|
||||
const sftpRetainedAfterCloseTabIdsRef = useRef<Set<string>>(new Set());
|
||||
const sftpOpeningTabIdsRef = useRef<Set<string>>(new Set());
|
||||
const sftpRetainedCleanupTimersRef = useRef<Map<string, number>>(new Map());
|
||||
@@ -610,7 +612,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sftpRetainedCleanupTimersRef.current.delete(tabId);
|
||||
}
|
||||
sftpActiveTransfersByTabRef.current.delete(tabId);
|
||||
sftpActiveExternalEditsByTabRef.current.delete(tabId);
|
||||
sftpRetainedAfterCloseTabIdsRef.current.delete(tabId);
|
||||
sftpOpeningTabIdsRef.current.delete(tabId);
|
||||
setSftpHostForTab(prev => {
|
||||
@@ -643,26 +644,20 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const resolveTabActiveExternalEditCount = useCallback((tabId: string, reportedCount?: number) => {
|
||||
return Math.max(0, reportedCount ?? sftpActiveExternalEditsByTabRef.current.get(tabId) ?? 0);
|
||||
}, []);
|
||||
|
||||
const evaluateSftpPanelRetentionAfterActivity = useCallback((tabId: string, params: {
|
||||
activeTransfersCount: number;
|
||||
activeExternalEditCount: number;
|
||||
}) => {
|
||||
const { activeTransfersCount, activeExternalEditCount } = params;
|
||||
if (activeTransfersCount > 0 || activeExternalEditCount > 0) {
|
||||
const handleSftpActiveTransfersChange = useCallback((tabId: string, count: number) => {
|
||||
const activeTransfersCount = resolveTabActiveTransfersCount(tabId, Math.max(0, count));
|
||||
if (activeTransfersCount > 0) {
|
||||
const cleanupTimer = sftpRetainedCleanupTimersRef.current.get(tabId);
|
||||
if (cleanupTimer !== undefined) {
|
||||
window.clearTimeout(cleanupTimer);
|
||||
sftpRetainedCleanupTimersRef.current.delete(tabId);
|
||||
}
|
||||
sftpActiveTransfersByTabRef.current.set(tabId, activeTransfersCount);
|
||||
return;
|
||||
}
|
||||
sftpActiveTransfersByTabRef.current.delete(tabId);
|
||||
const lifecycle = {
|
||||
activeTransfersCount,
|
||||
activeExternalEditCount,
|
||||
panelOpen: sidePanelOpenTabsRef.current.has(tabId) || sftpOpeningTabIdsRef.current.has(tabId),
|
||||
retainedAfterClose: sftpRetainedAfterCloseTabIdsRef.current.has(tabId),
|
||||
};
|
||||
@@ -673,18 +668,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
if (
|
||||
shouldScheduleSftpRetainedPanelCleanup({
|
||||
activeTransfersCount: lifecycle.activeTransfersCount,
|
||||
activeExternalEditCount: lifecycle.activeExternalEditCount,
|
||||
retainedAfterClose: lifecycle.retainedAfterClose,
|
||||
})
|
||||
&& !sftpRetainedCleanupTimersRef.current.has(tabId)
|
||||
) {
|
||||
const cleanupTimer = window.setTimeout(() => {
|
||||
sftpRetainedCleanupTimersRef.current.delete(tabId);
|
||||
const currentTransfers = resolveTabActiveTransfersCount(tabId);
|
||||
const currentExternalEdits = resolveTabActiveExternalEditCount(tabId);
|
||||
const currentCount = resolveTabActiveTransfersCount(tabId);
|
||||
if (
|
||||
currentTransfers <= 0
|
||||
&& currentExternalEdits <= 0
|
||||
currentCount <= 0
|
||||
&& !sidePanelOpenTabsRef.current.has(tabId)
|
||||
&& !sftpOpeningTabIdsRef.current.has(tabId)
|
||||
&& sftpRetainedAfterCloseTabIdsRef.current.has(tabId)
|
||||
@@ -694,46 +686,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}, SFTP_TRANSFER_HISTORY_RETENTION_MS);
|
||||
sftpRetainedCleanupTimersRef.current.set(tabId, cleanupTimer);
|
||||
}
|
||||
}, [
|
||||
clearSftpPanelState,
|
||||
resolveTabActiveExternalEditCount,
|
||||
resolveTabActiveTransfersCount,
|
||||
sidePanelOpenTabsRef,
|
||||
]);
|
||||
|
||||
const handleSftpActiveTransfersChange = useCallback((tabId: string, count: number) => {
|
||||
const activeTransfersCount = resolveTabActiveTransfersCount(tabId, Math.max(0, count));
|
||||
if (activeTransfersCount > 0) {
|
||||
sftpActiveTransfersByTabRef.current.set(tabId, activeTransfersCount);
|
||||
} else {
|
||||
sftpActiveTransfersByTabRef.current.delete(tabId);
|
||||
}
|
||||
evaluateSftpPanelRetentionAfterActivity(tabId, {
|
||||
activeTransfersCount,
|
||||
activeExternalEditCount: resolveTabActiveExternalEditCount(tabId),
|
||||
});
|
||||
}, [
|
||||
evaluateSftpPanelRetentionAfterActivity,
|
||||
resolveTabActiveExternalEditCount,
|
||||
resolveTabActiveTransfersCount,
|
||||
]);
|
||||
|
||||
const handleSftpActiveExternalEditsChange = useCallback((tabId: string, count: number) => {
|
||||
const activeExternalEditCount = resolveTabActiveExternalEditCount(tabId, Math.max(0, count));
|
||||
if (activeExternalEditCount > 0) {
|
||||
sftpActiveExternalEditsByTabRef.current.set(tabId, activeExternalEditCount);
|
||||
} else {
|
||||
sftpActiveExternalEditsByTabRef.current.delete(tabId);
|
||||
}
|
||||
evaluateSftpPanelRetentionAfterActivity(tabId, {
|
||||
activeTransfersCount: resolveTabActiveTransfersCount(tabId),
|
||||
activeExternalEditCount,
|
||||
});
|
||||
}, [
|
||||
evaluateSftpPanelRetentionAfterActivity,
|
||||
resolveTabActiveExternalEditCount,
|
||||
resolveTabActiveTransfersCount,
|
||||
]);
|
||||
}, [clearSftpPanelState, resolveTabActiveTransfersCount, sidePanelOpenTabsRef]);
|
||||
|
||||
const closeTerminalSidePanelTab = useCallback((tabId: string) => {
|
||||
setSidePanelOpenTabs((previous) => {
|
||||
@@ -743,15 +696,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
});
|
||||
sftpOpeningTabIdsRef.current.delete(tabId);
|
||||
const activeTransfersCount = resolveTabActiveTransfersCount(tabId);
|
||||
const activeExternalEditCount = resolveTabActiveExternalEditCount(tabId);
|
||||
if (shouldKeepSftpMountedAfterClose({ activeTransfersCount, activeExternalEditCount })) {
|
||||
if (shouldKeepSftpMountedAfterClose(activeTransfersCount)) {
|
||||
sftpRetainedAfterCloseTabIdsRef.current.add(tabId);
|
||||
if (activeTransfersCount > 0) {
|
||||
sftpActiveTransfersByTabRef.current.set(tabId, activeTransfersCount);
|
||||
}
|
||||
if (activeExternalEditCount > 0) {
|
||||
sftpActiveExternalEditsByTabRef.current.set(tabId, activeExternalEditCount);
|
||||
}
|
||||
sftpActiveTransfersByTabRef.current.set(tabId, activeTransfersCount);
|
||||
} else {
|
||||
clearSftpPanelState(tabId);
|
||||
}
|
||||
@@ -766,12 +713,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
return next;
|
||||
});
|
||||
notesReturnTabRef.current.delete(tabId);
|
||||
}, [
|
||||
clearSftpPanelState,
|
||||
resolveTabActiveExternalEditCount,
|
||||
resolveTabActiveTransfersCount,
|
||||
setSidePanelOpenTabs,
|
||||
]);
|
||||
}, [clearSftpPanelState, resolveTabActiveTransfersCount, setSidePanelOpenTabs]);
|
||||
|
||||
const handleToggleWorkspaceComposeBar = useCallback(() => {
|
||||
setIsComposeBarOpen(prev => !prev);
|
||||
@@ -1157,11 +1099,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const storeActiveTransferTabIds = listTerminalTabIdsWithRetainingTransfers(
|
||||
sftpTransferCenterStore.getSnapshot().tasks,
|
||||
);
|
||||
// Keep owners with unfinished transfers or external-editor temps mounted
|
||||
// even after the terminal tab id drops out of the valid set.
|
||||
const activeTransferTabIds = new Set([
|
||||
...sftpActiveTransfersByTabRef.current.keys(),
|
||||
...sftpActiveExternalEditsByTabRef.current.keys(),
|
||||
...storeActiveTransferTabIds,
|
||||
]);
|
||||
const invalidTabIds = listInvalidSftpPanelTabIds({
|
||||
@@ -2004,7 +1943,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
handleCloseSession,
|
||||
handleCloseSidePanel,
|
||||
handleCommandExecuted,
|
||||
handleHistoryDelete: onDeleteShellHistoryEntry,
|
||||
handleCommandSubmitted,
|
||||
handleComposeSend,
|
||||
handleHistoryPaste,
|
||||
@@ -2028,7 +1966,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
handleSessionExit,
|
||||
handleSftpCurrentPathChange,
|
||||
handleSftpActiveTransfersChange,
|
||||
handleSftpActiveExternalEditsChange,
|
||||
handleSftpInitialLocationApplied,
|
||||
persistSidePanelWidth,
|
||||
handleSnippetClickForFocusedSession,
|
||||
@@ -2161,6 +2098,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
programmaticCommandLogRewriteHandlersRef,
|
||||
snippetPackages,
|
||||
snippets,
|
||||
noteGroups,
|
||||
notes,
|
||||
onOpenVaultHostFromChat,
|
||||
onOpenVaultNoteFromChat: handleOpenVaultNoteFromAiPanel,
|
||||
onOpenVaultSectionFromChat,
|
||||
@@ -2183,6 +2122,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
updateHosts,
|
||||
updateNoteGroups,
|
||||
updateNotes,
|
||||
updateSnippetPackages,
|
||||
updateSnippets,
|
||||
X,
|
||||
|
||||
@@ -131,7 +131,6 @@ interface TopTabsProps {
|
||||
onRenameSession: (sessionId: string) => void;
|
||||
onCopySession: (sessionId: string) => void;
|
||||
onCopySessionToNewWindow: (sessionId: string) => void;
|
||||
onEditHost?: (host: Host) => void;
|
||||
onRenameWorkspace: (workspaceId: string) => void;
|
||||
onCopyWorkspace: (workspaceId: string) => void;
|
||||
onCloseWorkspace: (workspaceId: string) => void;
|
||||
@@ -178,7 +177,6 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onRenameSession,
|
||||
onCopySession,
|
||||
onCopySessionToNewWindow,
|
||||
onEditHost,
|
||||
onRenameWorkspace,
|
||||
onCopyWorkspace,
|
||||
onCloseWorkspace,
|
||||
@@ -806,7 +804,6 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onRenameSession={onRenameSession}
|
||||
onCopySession={onCopySession}
|
||||
onCopySessionToNewWindow={onCopySessionToNewWindow}
|
||||
onEditHost={onEditHost}
|
||||
renderBulkCloseItems={renderBulkCloseItems}
|
||||
dynamicTabTitleMode={dynamicTabTitleMode}
|
||||
t={t}
|
||||
@@ -1173,7 +1170,6 @@ export const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean
|
||||
prev.isMacClient === next.isMacClient &&
|
||||
prev.onCopySession === next.onCopySession &&
|
||||
prev.onCopySessionToNewWindow === next.onCopySessionToNewWindow &&
|
||||
prev.onEditHost === next.onEditHost &&
|
||||
prev.onCopyWorkspace === next.onCopyWorkspace &&
|
||||
prev.onOpenSettings === next.onOpenSettings &&
|
||||
prev.externalMcpEnabled === next.externalMcpEnabled &&
|
||||
|
||||
@@ -25,17 +25,12 @@ export const ConversationContent = ({ className, ...props }: ConversationContent
|
||||
/>
|
||||
);
|
||||
|
||||
export const ConversationScrollButton = ({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
|
||||
export const ConversationScrollButton = ({ className, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
|
||||
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
||||
|
||||
const handleClick = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onClick?.(event);
|
||||
const handleClick = useCallback(() => {
|
||||
scrollToBottom();
|
||||
}, [onClick, scrollToBottom]);
|
||||
}, [scrollToBottom]);
|
||||
|
||||
if (isAtBottom) return null;
|
||||
|
||||
|
||||
@@ -1,24 +1,12 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
approvalArgsHaveExtraContext,
|
||||
approvalCommandWasUnwrapped,
|
||||
extractApprovalExecutionContext,
|
||||
extractDisplayCommand,
|
||||
isNestedInteractiveApprovalTarget,
|
||||
MAX_TOOL_COMMAND_TOOLTIP_CHARS,
|
||||
truncateToolCommandTooltip,
|
||||
} from './tool-call';
|
||||
|
||||
const toolCallSource = readFileSync(
|
||||
join(dirname(fileURLToPath(import.meta.url)), 'tool-call.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// Codex (SDK) emits command_execution.command as a STRING that wraps the real
|
||||
// command in `<shell> -lc '<full>'`. Under Skills + CLI the real command is a
|
||||
// netcatty-tool-cli call. The title must unwrap the shell layer first, else the
|
||||
@@ -56,30 +44,6 @@ test('raw (unwrapped) netcatty-tool-cli exec still works', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('netcatty-tool-cli.cjs wrapper still unwraps to remote command', () => {
|
||||
assert.equal(
|
||||
extractDisplayCommand({
|
||||
command: `/bin/zsh -lc '"/abs/netcatty-tool-cli.cjs" exec --session X -- "uptime"'`,
|
||||
}),
|
||||
'uptime',
|
||||
);
|
||||
assert.equal(
|
||||
extractDisplayCommand({
|
||||
command: `"/Resources/netcatty-tool-cli.cjs" exec --session X -- "df -h"`,
|
||||
}),
|
||||
'df -h',
|
||||
);
|
||||
});
|
||||
|
||||
test('netcatty-tool-cli.cmd wrapper still unwraps to remote command', () => {
|
||||
assert.equal(
|
||||
extractDisplayCommand({
|
||||
command: `"C:\\\\App\\\\netcatty-tool-cli.cmd" exec --session X -- "whoami"`,
|
||||
}),
|
||||
'whoami',
|
||||
);
|
||||
});
|
||||
|
||||
test('netcatty-tool-cli env -> list sessions', () => {
|
||||
assert.equal(extractDisplayCommand({ command: 'netcatty-tool-cli env' }), 'netcatty: list sessions');
|
||||
});
|
||||
@@ -106,116 +70,3 @@ test('empty / missing args -> null', () => {
|
||||
assert.equal(extractDisplayCommand(undefined), null);
|
||||
assert.equal(extractDisplayCommand({ command: '' }), null);
|
||||
});
|
||||
|
||||
test('extractApprovalExecutionContext surfaces session/cwd/shell without rewriting command', () => {
|
||||
assert.deepEqual(
|
||||
extractApprovalExecutionContext({
|
||||
sessionId: 'term-1',
|
||||
cwd: '/var/log',
|
||||
command: ['zsh', '-lc', 'df -h | sort'],
|
||||
}),
|
||||
{ sessionId: 'term-1', cwd: '/var/log', shell: 'zsh', reason: undefined },
|
||||
);
|
||||
assert.deepEqual(
|
||||
extractApprovalExecutionContext({
|
||||
command: `/bin/bash -lc 'uptime'`,
|
||||
}),
|
||||
{ sessionId: undefined, cwd: undefined, shell: 'bash', reason: undefined },
|
||||
);
|
||||
assert.equal(extractApprovalExecutionContext({ path: '/tmp' }), null);
|
||||
});
|
||||
|
||||
test('extractApprovalExecutionContext reads --session from netcatty-tool-cli wrappers', () => {
|
||||
assert.deepEqual(
|
||||
extractApprovalExecutionContext({
|
||||
command: `/bin/zsh -lc '"/abs/netcatty-tool-cli" exec --session term-9 --chat-session chat-1 -- "uptime"'`,
|
||||
}),
|
||||
{ sessionId: 'term-9', cwd: undefined, shell: 'zsh', reason: undefined },
|
||||
);
|
||||
});
|
||||
|
||||
test('extractApprovalExecutionContext surfaces Codex reason for approval review', () => {
|
||||
assert.deepEqual(
|
||||
extractApprovalExecutionContext({
|
||||
command: 'rm -rf /tmp/x',
|
||||
cwd: '/tmp',
|
||||
reason: 'Clean stale build artifacts',
|
||||
commandActions: [{ type: 'delete' }],
|
||||
}),
|
||||
{
|
||||
sessionId: undefined,
|
||||
cwd: '/tmp',
|
||||
shell: undefined,
|
||||
reason: 'Clean stale build artifacts',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('approvalCommandWasUnwrapped detects Skills+CLI display unwrap', () => {
|
||||
const args = {
|
||||
command: `/bin/zsh -lc '"/abs/netcatty-tool-cli" exec --session X -- "uptime"'`,
|
||||
};
|
||||
const display = extractDisplayCommand(args);
|
||||
assert.equal(display, 'uptime');
|
||||
assert.equal(approvalCommandWasUnwrapped(args, display), true);
|
||||
assert.equal(approvalCommandWasUnwrapped({ command: 'uptime' }, 'uptime'), false);
|
||||
});
|
||||
|
||||
test('approvalArgsHaveExtraContext keeps commandActions visible beside the command block', () => {
|
||||
assert.equal(
|
||||
approvalArgsHaveExtraContext({
|
||||
command: 'echo hi',
|
||||
reason: 'demo',
|
||||
commandActions: [{ type: 'read' }],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
approvalArgsHaveExtraContext({ command: 'echo hi', cwd: '/tmp', reason: 'demo' }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('isNestedInteractiveApprovalTarget ignores Enter on Copy/Expand review controls', () => {
|
||||
const card = { id: 'card' };
|
||||
const copyBtn = {
|
||||
closest: (selector: string) => (selector.includes('button') ? copyBtn : null),
|
||||
};
|
||||
const plainSpan = {
|
||||
closest: () => null,
|
||||
};
|
||||
|
||||
assert.equal(isNestedInteractiveApprovalTarget(copyBtn, card), true);
|
||||
assert.equal(isNestedInteractiveApprovalTarget(plainSpan, card), false);
|
||||
assert.equal(isNestedInteractiveApprovalTarget(card, card), false);
|
||||
assert.equal(isNestedInteractiveApprovalTarget(null, card), false);
|
||||
});
|
||||
|
||||
// Pending approvals with no display command (Codex file-change/permissions,
|
||||
// write tools with JSON-only args) only render the overflow args <pre>. Wheel /
|
||||
// trackpad scroll must re-arm idle the same way the command block does.
|
||||
test('args overflow block re-arms review timers on scroll/wheel while pending', () => {
|
||||
assert.match(
|
||||
toolCallSource,
|
||||
/JSON\.stringify\(args,\s*null,\s*2\)[\s\S]{0,200}?<\/pre>/,
|
||||
);
|
||||
// Both review surfaces (command + args) wire markReviewing for continuous scroll.
|
||||
const markReviewingScrollBindings = toolCallSource.match(
|
||||
/onScroll=\{(?:isPendingApproval \? markReviewing : undefined|markReviewing)\}/g,
|
||||
);
|
||||
const markReviewingWheelBindings = toolCallSource.match(
|
||||
/onWheel=\{(?:isPendingApproval \? markReviewing : undefined|markReviewing)\}/g,
|
||||
);
|
||||
assert.ok(
|
||||
markReviewingScrollBindings && markReviewingScrollBindings.length >= 2,
|
||||
'command and args overflow blocks must both call markReviewing onScroll',
|
||||
);
|
||||
assert.ok(
|
||||
markReviewingWheelBindings && markReviewingWheelBindings.length >= 2,
|
||||
'command and args overflow blocks must both call markReviewing onWheel',
|
||||
);
|
||||
assert.match(
|
||||
toolCallSource,
|
||||
/onScroll=\{isPendingApproval \? markReviewing : undefined\}[\s\S]{0,80}onWheel=\{isPendingApproval \? markReviewing : undefined\}[\s\S]{0,80}JSON\.stringify\(args/,
|
||||
);
|
||||
});
|
||||
@@ -1,36 +1,13 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Check, ChevronDown, ChevronRight, CheckCircle2, Copy, Loader2, ShieldAlert, X, XCircle, Slash } from 'lucide-react';
|
||||
import { Check, ChevronDown, ChevronRight, CheckCircle2, Loader2, ShieldAlert, X, XCircle, Slash } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cancelApprovalTimeout } from '../../infrastructure/ai/shared/approvalGate';
|
||||
|
||||
export const MAX_TOOL_COMMAND_TOOLTIP_CHARS = 240;
|
||||
/** Collapsed approval command block max height (px). Full text remains scrollable. */
|
||||
export const APPROVAL_COMMAND_COLLAPSED_MAX_HEIGHT_PX = 144;
|
||||
/** Expanded approval command block max height (px). */
|
||||
export const APPROVAL_COMMAND_EXPANDED_MAX_HEIGHT_PX = 384;
|
||||
/** Prefer expand control when the raw command exceeds this many characters. */
|
||||
export const APPROVAL_COMMAND_EXPAND_CHAR_THRESHOLD = 180;
|
||||
|
||||
const NESTED_INTERACTIVE_SELECTOR = 'button, a, input, textarea, select, [role="button"]';
|
||||
|
||||
/**
|
||||
* Enter on the pending-card root means Approve Once. Enter on nested review
|
||||
* controls (Copy / Expand / action buttons) must not approve — those controls
|
||||
* also stopPropagation on Enter so the card handler is a second line of defense.
|
||||
*/
|
||||
export function isNestedInteractiveApprovalTarget(
|
||||
target: { closest?: (selector: string) => unknown } | null,
|
||||
currentTarget: unknown,
|
||||
): boolean {
|
||||
if (!target || target === currentTarget) return false;
|
||||
if (typeof target.closest !== 'function') return false;
|
||||
return Boolean(target.closest(NESTED_INTERACTIVE_SELECTOR));
|
||||
}
|
||||
|
||||
export function truncateToolCommandTooltip(
|
||||
command: string,
|
||||
@@ -95,14 +72,10 @@ export function extractDisplayCommand(args: Record<string, unknown> | undefined)
|
||||
if (strWrap) cmdString = strWrap[2];
|
||||
|
||||
// Netcatty CLI wrapper extraction.
|
||||
// Packaged / Windows paths may be `netcatty-tool-cli.cjs` or `.cmd`; strip the
|
||||
// optional extension so the subcommand after the binary is still found.
|
||||
const cliIdx = cmdString.search(/netcatty-tool-cli(?:\.(?:cjs|cmd|exe|js))?/i);
|
||||
const cliIdx = cmdString.indexOf('netcatty-tool-cli');
|
||||
if (cliIdx >= 0) {
|
||||
const cliMatch = cmdString.slice(cliIdx).match(/^netcatty-tool-cli(?:\.(?:cjs|cmd|exe|js))?/i);
|
||||
const cliTokenLen = cliMatch?.[0]?.length ?? 'netcatty-tool-cli'.length;
|
||||
const afterCli = cmdString
|
||||
.slice(cliIdx + cliTokenLen)
|
||||
.slice(cliIdx + 'netcatty-tool-cli'.length)
|
||||
.replace(/^["']?\s*/, '');
|
||||
const subMatch = afterCli.match(/^(\S+)/);
|
||||
const sub = subMatch ? subMatch[1] : '';
|
||||
@@ -133,110 +106,6 @@ export function extractDisplayCommand(args: Record<string, unknown> | undefined)
|
||||
return cmdString;
|
||||
}
|
||||
|
||||
export interface ApprovalExecutionContext {
|
||||
sessionId?: string;
|
||||
cwd?: string;
|
||||
shell?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
function rawCommandString(args: Record<string, unknown> | undefined): string | null {
|
||||
if (!args) return null;
|
||||
const raw = (args as { command?: unknown }).command;
|
||||
if (typeof raw === 'string') return raw || null;
|
||||
if (Array.isArray(raw) && raw.length > 0) return raw.map((p) => String(p)).join(' ');
|
||||
return null;
|
||||
}
|
||||
|
||||
const APPROVAL_CONTEXT_ARG_KEYS = new Set([
|
||||
'command',
|
||||
'cwd',
|
||||
'working_directory',
|
||||
'workdir',
|
||||
'workingDirectory',
|
||||
'sessionId',
|
||||
'shell',
|
||||
'reason',
|
||||
]);
|
||||
|
||||
/**
|
||||
* True when pending args still carry review-relevant fields beyond the
|
||||
* command block / execution-context strip (e.g. commandActions).
|
||||
*/
|
||||
export function approvalArgsHaveExtraContext(
|
||||
args: Record<string, unknown> | undefined,
|
||||
): boolean {
|
||||
if (!args) return false;
|
||||
return Object.keys(args).some((key) => !APPROVAL_CONTEXT_ARG_KEYS.has(key));
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the reviewable display command was unwrapped from a Skills+CLI /
|
||||
* shell wrapper — the pending card should still surface target flags.
|
||||
*/
|
||||
export function approvalCommandWasUnwrapped(
|
||||
args: Record<string, unknown> | undefined,
|
||||
displayCommand: string | null,
|
||||
): boolean {
|
||||
if (!displayCommand) return false;
|
||||
const raw = rawCommandString(args);
|
||||
if (!raw || raw === displayCommand) return false;
|
||||
return raw.includes('netcatty-tool-cli') || /(?:^|\/)(sh|bash|zsh|fish|ash|dash)\s+-l?c\s+/.test(raw)
|
||||
|| (Array.isArray(args?.command) && args.command.length >= 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort execution context for approval review (session / cwd / shell).
|
||||
* Never invents host names; only surfaces fields already present on tool args
|
||||
* or explicit netcatty-tool-cli flags in the command string.
|
||||
*/
|
||||
export function extractApprovalExecutionContext(
|
||||
args: Record<string, unknown> | undefined,
|
||||
): ApprovalExecutionContext | null {
|
||||
if (!args) return null;
|
||||
|
||||
let sessionId = typeof args.sessionId === 'string' && args.sessionId.trim()
|
||||
? args.sessionId.trim()
|
||||
: undefined;
|
||||
|
||||
const cwdCandidate = [args.cwd, args.working_directory, args.workdir, args.workingDirectory]
|
||||
.find((value) => typeof value === 'string' && value.trim());
|
||||
const cwd = typeof cwdCandidate === 'string' ? cwdCandidate.trim() : undefined;
|
||||
|
||||
let shell = typeof args.shell === 'string' && args.shell.trim()
|
||||
? args.shell.trim()
|
||||
: undefined;
|
||||
|
||||
const reason = typeof args.reason === 'string' && args.reason.trim()
|
||||
? args.reason.trim()
|
||||
: undefined;
|
||||
|
||||
const raw = (args as { command?: unknown }).command;
|
||||
if (!shell) {
|
||||
if (Array.isArray(raw) && raw.length >= 2) {
|
||||
const first = String(raw[0] ?? '');
|
||||
const shellMatch = first.match(/(?:^|\/)(sh|bash|zsh|fish|ash|dash)$/);
|
||||
if (shellMatch) shell = shellMatch[1];
|
||||
} else if (typeof raw === 'string') {
|
||||
const shellMatch = raw.match(/^(?:\S*\/)?(sh|bash|zsh|fish|ash|dash)\s+-l?c\s+/);
|
||||
if (shellMatch) shell = shellMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Skills+CLI wrappers keep the Netcatty target only on CLI flags after unwrap.
|
||||
if (!sessionId) {
|
||||
const cmd = rawCommandString(args);
|
||||
if (cmd && cmd.includes('netcatty-tool-cli')) {
|
||||
const sessionMatch = cmd.match(/--session(?:\s+|=)(?:"([^"]+)"|'([^']+)'|(\S+))/);
|
||||
const fromFlag = sessionMatch?.[1] ?? sessionMatch?.[2] ?? sessionMatch?.[3];
|
||||
if (fromFlag) sessionId = fromFlag;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionId && !cwd && !shell && !reason) return null;
|
||||
return { sessionId, cwd, shell, reason };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format tool result for display. Extracts stdout/stderr from structured
|
||||
* command results for terminal-like output.
|
||||
@@ -280,8 +149,6 @@ export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
|
||||
isInterrupted?: boolean;
|
||||
/** Approval state for this tool call (from the approval gate). */
|
||||
approvalStatus?: 'pending' | 'approved' | 'denied';
|
||||
/** Pending approval id used to cancel the auto-deny timer on review. */
|
||||
approvalId?: string;
|
||||
/** Called when user approves this tool call. */
|
||||
onApprove?: () => void;
|
||||
/** Called when user rejects this tool call. */
|
||||
@@ -294,72 +161,18 @@ export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
|
||||
alwaysAllowLabel?: string;
|
||||
}
|
||||
|
||||
async function copyTextToClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
try {
|
||||
if (typeof document === 'undefined') return false;
|
||||
const el = document.createElement('textarea');
|
||||
el.value = text;
|
||||
el.setAttribute('readonly', '');
|
||||
el.style.position = 'fixed';
|
||||
el.style.left = '-9999px';
|
||||
document.body.appendChild(el);
|
||||
el.select();
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(el);
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const ToolCall = ({
|
||||
name, args, result, isError, isLoading, isInterrupted,
|
||||
approvalStatus, approvalId, onApprove, onReject, onApproveOnce, onAlwaysAllow, alwaysAllowLabel,
|
||||
approvalStatus, onApprove, onReject, onApproveOnce, onAlwaysAllow, alwaysAllowLabel,
|
||||
className, ...props
|
||||
}: ToolCallProps) => {
|
||||
const { t } = useI18n();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [commandExpanded, setCommandExpanded] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [frozenCommand, setFrozenCommand] = useState<string | null>(null);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const approveBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const [responded, setResponded] = useState(false);
|
||||
|
||||
const isPendingApproval = approvalStatus === 'pending' && !responded;
|
||||
const liveDisplayCommand = extractDisplayCommand(args);
|
||||
const reviewCommand = isPendingApproval
|
||||
? (frozenCommand ?? liveDisplayCommand)
|
||||
: liveDisplayCommand;
|
||||
const executionContext = extractApprovalExecutionContext(args);
|
||||
const showApprovalCommand = Boolean(isPendingApproval && reviewCommand);
|
||||
const showArgsAlongsideCommand = Boolean(
|
||||
showApprovalCommand
|
||||
&& args
|
||||
&& Object.keys(args).length > 0
|
||||
&& (approvalCommandWasUnwrapped(args, reviewCommand) || approvalArgsHaveExtraContext(args)),
|
||||
);
|
||||
const commandNeedsExpand = Boolean(
|
||||
reviewCommand
|
||||
&& (reviewCommand.length > APPROVAL_COMMAND_EXPAND_CHAR_THRESHOLD
|
||||
|| reviewCommand.includes('\n')),
|
||||
);
|
||||
|
||||
// Each review interaction re-arms the Catty idle window (capped by hard
|
||||
// deadline). Do not one-shot cancel — subsequent focus/scroll/key events
|
||||
// must keep extending idle while the user is still deciding.
|
||||
const markReviewing = useCallback(() => {
|
||||
if (!isPendingApproval || !approvalId) return;
|
||||
cancelApprovalTimeout(approvalId);
|
||||
}, [approvalId, isPendingApproval]);
|
||||
|
||||
const handleApproveOnce = useCallback(() => {
|
||||
if (!isPendingApproval) return;
|
||||
@@ -379,68 +192,28 @@ export const ToolCall = ({
|
||||
onReject?.();
|
||||
}, [isPendingApproval, onReject]);
|
||||
|
||||
const handleCopyCommand = useCallback(async () => {
|
||||
if (!reviewCommand) return;
|
||||
markReviewing();
|
||||
const ok = await copyTextToClipboard(reviewCommand);
|
||||
if (!ok) return;
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
}, [markReviewing, reviewCommand]);
|
||||
|
||||
// Keyboard: Enter = approve, Escape = reject (when pending).
|
||||
// Ignore Enter from nested controls (Copy / Expand / action buttons) so it
|
||||
// activates that control instead of approving the pending command.
|
||||
// Keyboard: Enter = approve, Escape = reject (when pending)
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (!isPendingApproval) return;
|
||||
if (e.key === 'Enter') {
|
||||
if (isNestedInteractiveApprovalTarget(e.target as HTMLElement | null, e.currentTarget)) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
handleApproveOnce();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleReject();
|
||||
} else {
|
||||
// Typing / navigation while reviewing cancels the idle auto-deny timer.
|
||||
markReviewing();
|
||||
}
|
||||
}, [isPendingApproval, handleApproveOnce, handleReject, markReviewing]);
|
||||
if (e.key === 'Enter') { e.preventDefault(); handleApproveOnce(); }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); handleReject(); }
|
||||
}, [isPendingApproval, handleApproveOnce, handleReject]);
|
||||
|
||||
// Auto-focus and auto-scroll when approval is pending.
|
||||
// Do not treat this programmatic expand/focus as user review (timeout stays armed).
|
||||
// Auto-focus and auto-scroll when approval is pending
|
||||
useEffect(() => {
|
||||
if (!isPendingApproval || !cardRef.current) return;
|
||||
cardRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
// Small delay to let the UI render, then expand and focus
|
||||
setExpanded(true);
|
||||
const focusTimer = setTimeout(() => approveBtnRef.current?.focus(), 100);
|
||||
return () => clearTimeout(focusTimer);
|
||||
}, [isPendingApproval]);
|
||||
|
||||
// Freeze the reviewable command for the life of this pending approval.
|
||||
// Do not reset review/timeout state when args identity churns while still pending.
|
||||
// Reset responded state when approvalStatus changes (e.g. new approval)
|
||||
useEffect(() => {
|
||||
if (approvalStatus === 'pending') {
|
||||
setResponded(false);
|
||||
setCommandExpanded(false);
|
||||
setFrozenCommand(extractDisplayCommand(args));
|
||||
return;
|
||||
}
|
||||
setFrozenCommand(null);
|
||||
setCommandExpanded(false);
|
||||
// Intentionally depend only on approvalStatus so late arg patches cannot
|
||||
// replace the command the user is already reviewing.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- freeze on pending enter
|
||||
if (approvalStatus === 'pending') setResponded(false);
|
||||
}, [approvalStatus]);
|
||||
|
||||
// If the first pending paint had no command yet, accept the first non-empty one.
|
||||
useEffect(() => {
|
||||
if (!isPendingApproval || frozenCommand) return;
|
||||
const next = extractDisplayCommand(args);
|
||||
if (next) setFrozenCommand(next);
|
||||
}, [args, frozenCommand, isPendingApproval]);
|
||||
|
||||
// Border/bg color based on approval status
|
||||
const borderClass = approvalStatus === 'pending'
|
||||
? 'border-yellow-500/30 bg-yellow-500/[0.04]'
|
||||
@@ -463,48 +236,46 @@ export const ToolCall = ({
|
||||
<CheckCircle2 size={12} className={cn('text-green-400/70', statusIconClass)} />
|
||||
) : null;
|
||||
|
||||
const headerCommand = reviewCommand ?? liveDisplayCommand;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
tabIndex={isPendingApproval ? 0 : undefined}
|
||||
onKeyDown={isPendingApproval ? handleKeyDown : undefined}
|
||||
onPointerDownCapture={isPendingApproval ? markReviewing : undefined}
|
||||
className={cn('min-w-0 rounded-md border overflow-hidden text-[12px] outline-none', borderClass, className)}
|
||||
{...props}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isPendingApproval) markReviewing();
|
||||
setExpanded((e) => !e);
|
||||
}}
|
||||
onClick={() => setExpanded(e => !e)}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-muted/20 transition-colors cursor-pointer"
|
||||
>
|
||||
{expanded
|
||||
? <ChevronDown size={12} className="text-muted-foreground/40 shrink-0" />
|
||||
: <ChevronRight size={12} className="text-muted-foreground/40 shrink-0" />
|
||||
}
|
||||
{headerCommand ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="font-mono text-muted-foreground/70 truncate cursor-default">
|
||||
<span className="text-muted-foreground/40">$ </span>{headerCommand}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="start"
|
||||
collisionPadding={12}
|
||||
className="w-[calc(100vw-24px)] max-w-[420px] whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed [overflow-wrap:anywhere]"
|
||||
>
|
||||
{truncateToolCommandTooltip(headerCommand)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="font-mono text-muted-foreground/70 truncate">{name}</span>
|
||||
)}
|
||||
{(() => {
|
||||
const displayCmd = extractDisplayCommand(args);
|
||||
if (displayCmd) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="font-mono text-muted-foreground/70 truncate cursor-default">
|
||||
<span className="text-muted-foreground/40">$ </span>{displayCmd}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="start"
|
||||
collisionPadding={12}
|
||||
className="w-[calc(100vw-24px)] max-w-[420px] whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed [overflow-wrap:anywhere]"
|
||||
>
|
||||
{truncateToolCommandTooltip(displayCmd)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return <span className="font-mono text-muted-foreground/70 truncate">{name}</span>;
|
||||
})()}
|
||||
<span className="flex-1" />
|
||||
{/* Approval badge for resolved approvals */}
|
||||
{approvalStatus === 'approved' && (
|
||||
@@ -522,103 +293,10 @@ export const ToolCall = ({
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t border-border/20">
|
||||
{showApprovalCommand && reviewCommand && (
|
||||
<div className="px-3 py-2 space-y-1.5">
|
||||
{executionContext && (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-[10px] text-muted-foreground/45">
|
||||
<span className="font-medium uppercase tracking-wider text-muted-foreground/30">
|
||||
{t('ai.chat.targetLabel')}
|
||||
</span>
|
||||
{executionContext.sessionId && (
|
||||
<span className="font-mono truncate" title={executionContext.sessionId}>
|
||||
{t('ai.chat.approvalSession')}: {executionContext.sessionId}
|
||||
</span>
|
||||
)}
|
||||
{executionContext.shell && (
|
||||
<span className="font-mono">
|
||||
{t('ai.chat.approvalShell')}: {executionContext.shell}
|
||||
</span>
|
||||
)}
|
||||
{executionContext.cwd && (
|
||||
<span className="font-mono truncate" title={executionContext.cwd}>
|
||||
{t('ai.chat.approvalCwd')}: {executionContext.cwd}
|
||||
</span>
|
||||
)}
|
||||
{executionContext.reason && (
|
||||
<span className="truncate" title={executionContext.reason}>
|
||||
{t('ai.chat.approvalReason')}: {executionContext.reason}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30">
|
||||
{t('ai.chat.rawCommand')}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{commandNeedsExpand && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] text-muted-foreground/50 hover:text-muted-foreground px-1.5 py-0.5 rounded hover:bg-muted/30"
|
||||
onClick={() => {
|
||||
markReviewing();
|
||||
setCommandExpanded((v) => !v);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// Keep Enter on this control; let Escape bubble to card reject.
|
||||
if (e.key === 'Enter') e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{commandExpanded ? t('ai.chat.collapse') : t('ai.chat.expand')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 text-[10px] text-muted-foreground/50 hover:text-muted-foreground px-1.5 py-0.5 rounded hover:bg-muted/30"
|
||||
onClick={() => { void handleCopyCommand(); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Copy size={10} className="shrink-0" />
|
||||
{copied ? t('ai.chat.commandCopied') : t('ai.chat.copyCommand')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre
|
||||
className={cn(
|
||||
'overflow-auto rounded-md border border-border/25 bg-muted/20 px-2.5 py-2',
|
||||
'text-[11px] font-mono leading-relaxed text-foreground/80',
|
||||
'whitespace-pre-wrap break-words [overflow-wrap:anywhere]',
|
||||
)}
|
||||
style={{
|
||||
maxHeight: commandExpanded
|
||||
? APPROVAL_COMMAND_EXPANDED_MAX_HEIGHT_PX
|
||||
: APPROVAL_COMMAND_COLLAPSED_MAX_HEIGHT_PX,
|
||||
}}
|
||||
onScroll={markReviewing}
|
||||
onWheel={markReviewing}
|
||||
>
|
||||
{reviewCommand}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{args && Object.keys(args).length > 0 && (!showApprovalCommand || showArgsAlongsideCommand) && (
|
||||
{args && Object.keys(args).length > 0 && (
|
||||
<div className="px-3 py-2">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">
|
||||
{showArgsAlongsideCommand ? t('ai.chat.approvalInvocation') : 'Arguments'}
|
||||
</div>
|
||||
{/*
|
||||
Args-only approvals (Codex file-change/permissions, write tools with
|
||||
JSON args) have no command pre — wheel/trackpad scroll must re-arm
|
||||
idle the same way the command overflow block does.
|
||||
*/}
|
||||
<pre
|
||||
className="max-h-64 overflow-auto text-[11px] font-mono text-muted-foreground/50 whitespace-pre [overflow-wrap:normal]"
|
||||
onScroll={isPendingApproval ? markReviewing : undefined}
|
||||
onWheel={isPendingApproval ? markReviewing : undefined}
|
||||
>
|
||||
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">Arguments</div>
|
||||
<pre className="max-h-64 overflow-auto text-[11px] font-mono text-muted-foreground/50 whitespace-pre [overflow-wrap:normal]">
|
||||
{JSON.stringify(args, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
Loaded 100 of 362 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user