mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-04 13:02:33 +00:00
Compare commits
119
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d6ef5f701 | ||
|
|
6e858a7e4e | ||
|
|
1acd80338e | ||
|
|
a10c043705 | ||
|
|
abe0846a65 | ||
|
|
4cedaf482d | ||
|
|
fd545e6577 | ||
|
|
12202a1757 | ||
|
|
4cdcf5dc4c | ||
|
|
780f967177 | ||
|
|
69766dcda1 | ||
|
|
f98ce74a54 | ||
|
|
d9553d8e71 | ||
|
|
9b234f38b3 | ||
|
|
711683a90b | ||
|
|
f7222761a2 | ||
|
|
2e2988cacd | ||
|
|
2b9d000a89 | ||
|
|
5187e7ea66 | ||
|
|
778010a271 | ||
|
|
09d2a18ed3 | ||
|
|
904c59c395 | ||
|
|
d5f4b54ae7 | ||
|
|
25ca616622 | ||
|
|
396fb97191 | ||
|
|
fba5059083 | ||
|
|
8e7791a0fc | ||
|
|
dee922385a | ||
|
|
0c8ac89172 | ||
|
|
7763b1ed20 | ||
|
|
c03e46fced | ||
|
|
4e5f2825c6 | ||
|
|
abf32a23cc | ||
|
|
4a85ae32ff | ||
|
|
177c574ffc | ||
|
|
66ad27b4bf | ||
|
|
2afd36ab33 | ||
|
|
4717a6ec8e | ||
|
|
b941556b1d | ||
|
|
8c7bc0c706 | ||
|
|
7a91b31290 | ||
|
|
885b0c0177 | ||
|
|
a39af9d7b0 | ||
|
|
a9252d5902 | ||
|
|
e44afc3916 | ||
|
|
2b1bfadb45 | ||
|
|
5329a47d23 | ||
|
|
162b5a599f | ||
|
|
c5308a573d | ||
|
|
f8c220dbd1 | ||
|
|
d43885bdbc | ||
|
|
c98e688ad6 | ||
|
|
5c2d60f694 | ||
|
|
443202ba4e | ||
|
|
6c246bb8f4 | ||
|
|
90df595e43 | ||
|
|
ab3580982d | ||
|
|
bd81b1fbcf | ||
|
|
205d1591e8 | ||
|
|
30004e7716 | ||
|
|
75452055ae | ||
|
|
08d1bb5f81 | ||
|
|
6bc65a43de | ||
|
|
904af93a1d | ||
|
|
a8bd5e0f16 | ||
|
|
61839acaca | ||
|
|
d7b749d23f | ||
|
|
a1b338414f | ||
|
|
c4015b9b68 | ||
|
|
8bbead1261 | ||
|
|
c3a7a4758b | ||
|
|
9a3d96be76 | ||
|
|
69df81613b | ||
|
|
583c75b107 | ||
|
|
ae74f25588 | ||
|
|
07c914a73d | ||
|
|
a97002c0a7 | ||
|
|
2ea5efacba | ||
|
|
9c5da74486 | ||
|
|
7bdbbe2e16 | ||
|
|
4747c45a99 | ||
|
|
e40aace189 | ||
|
|
ea6fe18539 | ||
|
|
13fa68a720 | ||
|
|
ea3642cb56 | ||
|
|
857429f6dc | ||
|
|
9ac4c46df9 | ||
|
|
4adbfb8817 | ||
|
|
bd12cac427 | ||
|
|
68ca65dce8 | ||
|
|
f395b7c9e9 | ||
|
|
32f0e5abeb | ||
|
|
83e0e98795 | ||
|
|
f5edd32366 | ||
|
|
735c047a09 | ||
|
|
cd8371e3fc | ||
|
|
eee9be01f1 | ||
|
|
f59518a2ff | ||
|
|
6acc2883fd | ||
|
|
b7e8f9a04a | ||
|
|
93abe8446a | ||
|
|
4ed86c4ddd | ||
|
|
f2b8a72324 | ||
|
|
85d9710a8b | ||
|
|
887d228a1b | ||
|
|
9af475c16a | ||
|
|
68269ce2bd | ||
|
|
0d6df02bf6 | ||
|
|
6b1c5632e3 | ||
|
|
a603a0e1f0 | ||
|
|
e35e2b23ea | ||
|
|
005739230c | ||
|
|
a4ef55f174 | ||
|
|
2f8f7daa56 | ||
|
|
ad8b5aa2a7 | ||
|
|
3e54c585af | ||
|
|
55b2dacbcc | ||
|
|
f200aafc8f | ||
|
|
9d876cf6d5 |
No files matched your search
@@ -176,9 +176,10 @@ jobs:
|
||||
pack_script: pack:mac
|
||||
- name: windows
|
||||
os: windows-latest
|
||||
# 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.
|
||||
# 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.
|
||||
pack_script: pack:win-x64
|
||||
env:
|
||||
MOSH_BIN_RELEASE: ${{ needs.resolve-mosh.outputs.mosh_bin_release }}
|
||||
|
||||
@@ -196,9 +196,16 @@ jobs:
|
||||
}
|
||||
|
||||
if (event === 'issues') {
|
||||
return set('issue_classify', {
|
||||
issue_number: String(context.payload.issue.number),
|
||||
reason: `issues:${context.payload.action}`,
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -254,6 +261,25 @@ 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) : '',
|
||||
@@ -274,9 +300,8 @@ jobs:
|
||||
repository: `${context.repo.owner}/${context.repo.repo}`,
|
||||
});
|
||||
if (
|
||||
sameRepo &&
|
||||
context.payload.action === 'closed' &&
|
||||
auto.shouldGatePullOnSourceIssueFollowups(pr, {
|
||||
auto.shouldCleanupSourceIssueAfterPull(pr, {
|
||||
ownActors,
|
||||
repository: `${context.repo.owner}/${context.repo.repo}`,
|
||||
})
|
||||
@@ -284,7 +309,7 @@ jobs:
|
||||
return set('source_issue_cleanup', {
|
||||
issue_number: String(auto.extractSourceIssueNumber(pr)),
|
||||
pull_number: String(pr.number),
|
||||
reason: pr.merged ? 'automation PR merged' : 'automation PR closed',
|
||||
reason: pr.merged ? 'merged PR closed source issue' : 'automation PR closed',
|
||||
});
|
||||
}
|
||||
if (sameRepo) {
|
||||
@@ -332,6 +357,41 @@ 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
|
||||
@@ -370,16 +430,16 @@ jobs:
|
||||
core.warning(`PR #${pullNumber} is no longer closed; skipping source cleanup.`);
|
||||
return;
|
||||
}
|
||||
if (!auto.shouldGatePullOnSourceIssueFollowups(pull, {
|
||||
if (!auto.shouldCleanupSourceIssueAfterPull(pull, {
|
||||
ownActors: process.env.OWN_ACTORS,
|
||||
repository: `${context.repo.owner}/${context.repo.repo}`,
|
||||
})) {
|
||||
core.warning(`PR #${pullNumber} is not a trusted automation pull request; skipping source cleanup.`);
|
||||
core.warning(`PR #${pullNumber} is not eligible for source cleanup; skipping.`);
|
||||
return;
|
||||
}
|
||||
const issueNumber = auto.extractSourceIssueNumber(pull);
|
||||
const issueNumbers = auto.extractSourceIssueNumbers(pull);
|
||||
const expectedIssueNumber = Number(process.env.SOURCE_ISSUE_NUMBER);
|
||||
if (!issueNumber || issueNumber !== expectedIssueNumber) {
|
||||
if (!issueNumbers.includes(expectedIssueNumber)) {
|
||||
core.warning(`PR #${pullNumber} source issue changed while queued; skipping cleanup.`);
|
||||
return;
|
||||
}
|
||||
@@ -394,20 +454,161 @@ jobs:
|
||||
if (error.status !== 404) throw error;
|
||||
}
|
||||
};
|
||||
for (const name of ['ready-for-agent', 'needs-info', 'ready-for-human']) {
|
||||
await removeLabel(issueNumber, name);
|
||||
}
|
||||
if (!pull.merged) {
|
||||
await github.rest.issues.addLabels({
|
||||
for (const issueNumber of issueNumbers) {
|
||||
const { data: sourceIssue } = await github.rest.issues.get({
|
||||
...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
|
||||
@@ -693,6 +894,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Research external context for classification
|
||||
id: research
|
||||
if: steps.prepare.outputs.should_run == 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ''
|
||||
@@ -802,6 +1004,7 @@ jobs:
|
||||
run: *stage_cursor_api_key
|
||||
|
||||
- name: Classify with Cursor CLI
|
||||
id: classify_agent
|
||||
if: steps.prepare.outputs.should_run == 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ''
|
||||
@@ -857,6 +1060,15 @@ 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'
|
||||
@@ -890,6 +1102,20 @@ 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({
|
||||
@@ -933,22 +1159,42 @@ 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,
|
||||
'',
|
||||
isChinese
|
||||
? '收到这条补充了,但自动复核没有安全完成,已经转给维护者继续处理。'
|
||||
: 'We received the additional information, but the automatic review could not finish safely. A maintainer has been notified.',
|
||||
failureMessage,
|
||||
].filter(Boolean).join('\n'),
|
||||
});
|
||||
}
|
||||
@@ -1760,6 +2006,7 @@ 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);
|
||||
@@ -1855,6 +2102,7 @@ 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);
|
||||
@@ -1888,6 +2136,7 @@ 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",
|
||||
@@ -1909,6 +2158,7 @@ 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
|
||||
@@ -2084,10 +2334,19 @@ jobs:
|
||||
...context.repo,
|
||||
issue_number: issueNumber,
|
||||
});
|
||||
const fallback = auto.buildIssueFollowupFallbackReply(
|
||||
issue,
|
||||
'processing_failed',
|
||||
const protectedPaths = auto.readProtectedPathReport(
|
||||
process.env.RUNNER_TEMP + '/protected-paths.json',
|
||||
);
|
||||
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 },
|
||||
@@ -2813,29 +3072,32 @@ 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 bot PR already open
|
||||
- name: Skip if trusted related PR already open
|
||||
id: existing
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const auto = require(`${process.env.RUNNER_TEMP}/cursor-automation.cjs`);
|
||||
const existing = await auto.findOpenBotPrForIssue({
|
||||
const botPull = 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 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'),
|
||||
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} -->`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2992,6 +3254,7 @@ 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);
|
||||
@@ -3114,6 +3377,7 @@ 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);
|
||||
@@ -3160,6 +3424,7 @@ 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",
|
||||
@@ -3170,6 +3435,7 @@ jobs:
|
||||
'
|
||||
|
||||
- name: Upload implement patch
|
||||
id: upload_patch
|
||||
if: steps.patch.outputs.artifact_ready == 'true'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -3180,6 +3446,7 @@ 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
|
||||
@@ -3189,6 +3456,14 @@ 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
|
||||
@@ -3203,14 +3478,17 @@ 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 = protectedFailure
|
||||
const kind = protectedPaths.length
|
||||
? 'protected_path'
|
||||
: artifactReady && verifyFailed
|
||||
? 'verification_failed'
|
||||
@@ -3222,7 +3500,10 @@ jobs:
|
||||
message: auto.buildImplementationFailureMessage(issue, {
|
||||
kind,
|
||||
workflowUrl: `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
|
||||
artifactName: artifactReady ? `implement-patch-${context.runId}` : '',
|
||||
artifactName: artifactReady && artifactUploaded
|
||||
? `implement-patch-${context.runId}`
|
||||
: '',
|
||||
protectedPaths,
|
||||
}),
|
||||
dedupeMarker: `<!-- cursor-implement-failure:base=${{ steps.branch.outputs.base_sha }};kind=${kind} -->`,
|
||||
});
|
||||
@@ -3258,7 +3539,60 @@ 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
|
||||
@@ -3301,7 +3635,7 @@ jobs:
|
||||
exit 1
|
||||
|
||||
- name: Mark needs human on publish failure
|
||||
if: failure() && steps.publish.outputs.handoff == 'true'
|
||||
if: failure() && steps.existing.outputs.exists != 'true' && steps.publish.outputs.handoff == 'true'
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
ISSUE_NUMBER: ${{ needs.implement.outputs.issue_number }}
|
||||
@@ -3329,32 +3663,6 @@ 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
|
||||
@@ -3373,6 +3681,29 @@ 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 {
|
||||
@@ -4400,6 +4731,7 @@ 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);
|
||||
@@ -4498,6 +4830,7 @@ 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);
|
||||
@@ -4534,6 +4867,7 @@ 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",
|
||||
@@ -4544,6 +4878,7 @@ 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:
|
||||
@@ -4552,6 +4887,7 @@ 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
|
||||
@@ -4561,6 +4897,14 @@ 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
|
||||
@@ -4578,9 +4922,12 @@ jobs:
|
||||
issue_number: pull_number,
|
||||
body: [
|
||||
auto.TRIAGE_MARKER,
|
||||
auto.DISCLAIMER,
|
||||
`<!-- cursor-codex-fix-failure:kind=no_changes;run=${context.runId} -->`,
|
||||
'',
|
||||
'Automatic fix did not produce additional code changes for the latest Codex findings. Marking for human review.',
|
||||
auto.buildCodexFixFailureMessage({
|
||||
kind: 'no_changes',
|
||||
workflowUrl: `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
|
||||
}),
|
||||
].join('\n'),
|
||||
});
|
||||
await auto.applyCodexTerminalLabels({
|
||||
@@ -4604,14 +4951,32 @@ 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,
|
||||
auto.DISCLAIMER,
|
||||
`<!-- cursor-codex-fix-failure:kind=${kind};run=${context.runId} -->`,
|
||||
'',
|
||||
'Automatic Codex fix did not finish cleanly (agent, protected paths, or verification failed). Marking for human review.',
|
||||
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}`,
|
||||
}),
|
||||
].join('\n'),
|
||||
});
|
||||
await auto.applyCodexTerminalLabels({
|
||||
@@ -4996,8 +5361,7 @@ 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,6 +22,7 @@ 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';
|
||||
@@ -34,7 +35,7 @@ import { buildTelnetDeepLinkConnectionHost, buildTelnetDeepLinkEphemeralHostFrom
|
||||
import { buildJmsDeepLinkEphemeralHost, isSupportedJmsProtocol, parseJmsDeepLink } from './domain/jmsDeepLink';
|
||||
import { applyEphemeralHostsUpdate, splitHostsUpdateByEphemeral } from './domain/ephemeralHosts';
|
||||
import { resolveHostAuth } from './domain/sshAuth';
|
||||
import { isEncryptedCredentialPlaceholder } from './domain/credentials';
|
||||
import { isEncryptedCredentialPlaceholder, stripSyncPayloadEncryptedCredentials } from './domain/credentials';
|
||||
import {
|
||||
mergeTerminalHostUpdate,
|
||||
TERMINAL_THEME_AUTO,
|
||||
@@ -50,7 +51,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, buildLocalVaultPayload, hasMeaningfulSyncData } from './application/syncPayload';
|
||||
import { applySyncPayload, buildLocalVaultPayloadAsync, hasMeaningfulSyncData } from './application/syncPayload';
|
||||
import {
|
||||
applyProtectedSyncPayload,
|
||||
ensureVersionChangeBackup,
|
||||
@@ -248,6 +249,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
updateKnownHosts,
|
||||
updateManagedSources,
|
||||
addShellHistoryEntry,
|
||||
removeShellHistoryEntry,
|
||||
addConnectionLog,
|
||||
updateConnectionLog,
|
||||
toggleConnectionLogSaved,
|
||||
@@ -289,15 +291,11 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
draggingSessionId,
|
||||
setDraggingSessionId,
|
||||
sessionRenameTarget,
|
||||
sessionRenameValue,
|
||||
setSessionRenameValue,
|
||||
startSessionRename,
|
||||
renameSessionInline,
|
||||
submitSessionRename,
|
||||
resetSessionRename,
|
||||
workspaceRenameTarget,
|
||||
workspaceRenameValue,
|
||||
setWorkspaceRenameValue,
|
||||
startWorkspaceRename,
|
||||
submitWorkspaceRename,
|
||||
resetWorkspaceRename,
|
||||
@@ -489,7 +487,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
[portForwardingRules],
|
||||
);
|
||||
|
||||
const buildCurrentSyncPayload = useCallback(() => {
|
||||
const buildCurrentSyncPayload = useCallback(async () => {
|
||||
let effectivePortForwardingRules = portForwardingRulesForSync;
|
||||
if (effectivePortForwardingRules.length === 0) {
|
||||
const stored = localStorageAdapter.read<typeof portForwardingRulesForSync>(
|
||||
@@ -505,7 +503,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}
|
||||
}
|
||||
|
||||
return buildLocalVaultPayload(
|
||||
return buildLocalVaultPayloadAsync(
|
||||
{
|
||||
hosts,
|
||||
keys,
|
||||
@@ -548,6 +546,10 @@ 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-
|
||||
@@ -559,6 +561,25 @@ 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
|
||||
@@ -567,30 +588,47 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// in which case the effect continues to no-op).
|
||||
useEffect(() => {
|
||||
if (isPeerSessionWindow || !isVaultInitialized || versionBackupAttemptedRef.current) return;
|
||||
const payload = buildCurrentSyncPayloadRef.current();
|
||||
if (!hasMeaningfulSyncData(payload)) return;
|
||||
versionBackupAttemptedRef.current = true;
|
||||
// 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;
|
||||
|
||||
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) {
|
||||
// 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);
|
||||
}
|
||||
} 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);
|
||||
});
|
||||
}
|
||||
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]);
|
||||
}, [isPeerSessionWindow, isVaultInitialized, hosts, keys, identities, proxyProfiles, snippets, customGroups, snippetPackages, notes, noteGroups, knownHosts, pluginSidecarReadyTick]);
|
||||
|
||||
// Memoized "apply a remote payload safely" callback. Stable identity
|
||||
// across renders so useAutoSync's `syncNow` useCallback doesn't rebuild
|
||||
@@ -623,7 +661,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
applyProtectedSyncPayload({
|
||||
buildPreApplyPayload: () => buildCurrentSyncPayload(),
|
||||
applyPayload: async () => {
|
||||
await applySyncPayload(payload, {
|
||||
const portable = stripSyncPayloadEncryptedCredentials(payload);
|
||||
await applySyncPayload(portable, {
|
||||
importVaultData: importDataFromString,
|
||||
importPortForwardingRules,
|
||||
onSettingsApplied: settings.rehydrateAllFromStorage,
|
||||
@@ -785,12 +824,28 @@ 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;
|
||||
@@ -798,6 +853,10 @@ 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
|
||||
@@ -815,18 +874,29 @@ 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,
|
||||
}]);
|
||||
});
|
||||
|
||||
@@ -835,6 +905,31 @@ 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]);
|
||||
|
||||
@@ -1697,6 +1792,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
const appVaultDomain = useMemo(() => ({
|
||||
addShellHistoryEntry,
|
||||
removeShellHistoryEntry,
|
||||
commitPluginImporterData,
|
||||
commitVaultImportTransaction,
|
||||
connectionLogs,
|
||||
@@ -1742,7 +1838,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, 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, removeShellHistoryEntry, setDeepLinkHostDraft, setNavigateToSection, setVaultFocusRequest, snippetPackages, snippets, unmanageSource, updateCustomGroups, updateGroupConfigs, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, vaultFocusRequest]);
|
||||
|
||||
const appTerminalDomain = useMemo(() => ({
|
||||
addSessionToWorkspace,
|
||||
@@ -1845,10 +1941,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
resetWorkspaceRename,
|
||||
resolvedTheme,
|
||||
sessionRenameTarget,
|
||||
sessionRenameValue,
|
||||
setActiveTabId,
|
||||
setSessionRenameValue,
|
||||
setWorkspaceRenameValue,
|
||||
settings,
|
||||
startSessionRename,
|
||||
renameSessionInline,
|
||||
@@ -1860,8 +1953,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
toggleConnectionLogSaved,
|
||||
updateConnectionLog,
|
||||
workspaceRenameTarget,
|
||||
workspaceRenameValue,
|
||||
}), [accentMode, clearUnsavedConnectionLogs, closeLogView, customAccent, deleteConnectionLog, handleEndSessionDrag, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handleSyncNowManual, isMacClient, logViews, openLogView, orderedTabsWithEditors, orphanSessionsForShell, reorderWorkTabs, resetSessionRename, resetWorkspaceRename, resolvedTheme, sessionRenameTarget, sessionRenameValue, setActiveTabId, setSessionRenameValue, setWorkspaceRenameValue, settings, startSessionRename, renameSessionInline, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, themeById, toggleConnectionLogSaved, updateConnectionLog, workspaceRenameTarget, workspaceRenameValue]);
|
||||
}), [accentMode, clearUnsavedConnectionLogs, closeLogView, customAccent, deleteConnectionLog, handleEndSessionDrag, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handleSyncNowManual, isMacClient, logViews, openLogView, orderedTabsWithEditors, orphanSessionsForShell, reorderWorkTabs, resetSessionRename, resetWorkspaceRename, resolvedTheme, sessionRenameTarget, setActiveTabId, settings, startSessionRename, renameSessionInline, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, themeById, toggleConnectionLogSaved, updateConnectionLog, workspaceRenameTarget]);
|
||||
|
||||
const appDialogsDomain = useMemo(() => ({
|
||||
addToWorkspaceDialog,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
## [Unreleased] - 2026-03-11
|
||||
|
||||
### 功能
|
||||
- 终端新增"粘贴时自动上传剪贴板图片"选项:剪贴板含图片时,在远程会话中粘贴(快捷键/右键/中键)自动通过 SFTP 上传图片到远端 `.netcatty-paste-images/` 目录并输入远端路径,否则保持原有文本粘贴行为(设置 > 终端 > 行为,默认关闭)
|
||||
- 修复自动更新 IPC 事件仅发送到单个窗口的问题,改为广播所有窗口(主窗口 + 设置窗口均可收到)
|
||||
- 统一手动检查更新与自动更新的状态机,消除三套并行状态
|
||||
- 手动"检查更新"通过 GitHub API 检测版本,发现更新后异步触发 electron-updater 下载
|
||||
|
||||
@@ -92,6 +92,7 @@ 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> = ({
|
||||
@@ -117,6 +118,7 @@ export const AppHostEditorLayer: React.FC<AppHostEditorLayerProps> = ({
|
||||
onCreateGroup,
|
||||
onImportOrReuseKey,
|
||||
onUpdateSnippets,
|
||||
onUpdateHosts,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const derivedSurfaceVisible = useWorkSurfaceVisible({
|
||||
@@ -189,6 +191,7 @@ export const AppHostEditorLayer: React.FC<AppHostEditorLayerProps> = ({
|
||||
groupConfigs={groupConfigs}
|
||||
snippets={snippets}
|
||||
onSnippetsChange={onUpdateSnippets}
|
||||
onHostsChange={onUpdateHosts}
|
||||
onImportKey={onImportOrReuseKey}
|
||||
onSave={onSave}
|
||||
onCancel={onCancel}
|
||||
|
||||
@@ -53,3 +53,8 @@ test('shared host tree forwards work-surface host management callbacks', () => {
|
||||
assert.match(hostTreeLayerSource, /onNewHost=\{onNewHost\}/);
|
||||
assert.match(hostTreeLayerSource, /onEditHost=\{onEditHost\}/);
|
||||
});
|
||||
|
||||
test('shared host tree layer is memoized with a custom areEqual', () => {
|
||||
assert.match(hostTreeLayerSource, /memo\(AppHostTreeLayerInner,\s*appHostTreeLayerAreEqual\)/);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
|
||||
import { useActiveTabId } from '../state/activeTabStore';
|
||||
import type { EditorTabChrome } from '../state/editorTabStore';
|
||||
@@ -47,7 +47,33 @@ export function getAppHostTreeLayerStyle(surfaceVisible: boolean): React.CSSProp
|
||||
};
|
||||
}
|
||||
|
||||
export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
|
||||
function appHostTreeLayerAreEqual(
|
||||
prev: AppHostTreeLayerProps,
|
||||
next: AppHostTreeLayerProps,
|
||||
): boolean {
|
||||
return prev.enabled === next.enabled
|
||||
&& prev.hosts === next.hosts
|
||||
&& prev.customGroups === next.customGroups
|
||||
&& prev.groupConfigs === next.groupConfigs
|
||||
&& prev.sessions === next.sessions
|
||||
&& prev.workspaces === next.workspaces
|
||||
&& prev.editorTabs === next.editorTabs
|
||||
&& prev.logViews === next.logViews
|
||||
&& prev.orderedTabs === next.orderedTabs
|
||||
&& prev.accentMode === next.accentMode
|
||||
&& prev.currentTerminalTheme === next.currentTerminalTheme
|
||||
&& prev.customAccent === next.customAccent
|
||||
&& prev.followAppTerminalTheme === next.followAppTerminalTheme
|
||||
&& prev.hostById === next.hostById
|
||||
&& prev.themeById === next.themeById
|
||||
&& prev.resolveSessionAppearance === next.resolveSessionAppearance
|
||||
&& prev.onConnect === next.onConnect
|
||||
&& prev.onNewHost === next.onNewHost
|
||||
&& prev.onEditHost === next.onEditHost
|
||||
&& prev.onCreateLocalTerminal === next.onCreateLocalTerminal;
|
||||
}
|
||||
|
||||
const AppHostTreeLayerInner: React.FC<AppHostTreeLayerProps> = ({
|
||||
enabled,
|
||||
hosts,
|
||||
customGroups,
|
||||
@@ -154,3 +180,6 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AppHostTreeLayer = memo(AppHostTreeLayerInner, appHostTreeLayerAreEqual);
|
||||
AppHostTreeLayer.displayName = 'AppHostTreeLayer';
|
||||
+184
-113
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo } from 'react';
|
||||
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AlertTriangle, Download, Trash2 } from 'lucide-react';
|
||||
import { activeTabStore, toEditorTabId, useIsEditorTabActive } from '../state/activeTabStore';
|
||||
import { editorTabStore } from '../state/editorTabStore';
|
||||
@@ -12,7 +12,7 @@ import { QuickScriptEditorDialog } from '../../components/scripts/QuickScriptEdi
|
||||
import { AddToWorkspaceDialog } from '../../components/workspace/AddToWorkspaceDialog';
|
||||
import { KeyboardInteractiveModal } from '../../components/KeyboardInteractiveModal';
|
||||
import { PassphraseModal } from '../../components/PassphraseModal';
|
||||
import { UnsavedChangesProvider } from '../../components/editor/UnsavedChangesDialog';
|
||||
import { UnsavedChangesProvider, promptUnsavedChanges } from '../../components/editor/UnsavedChangesDialog';
|
||||
import { SnippetExecutionProvider } from '../../components/SnippetExecutionProvider';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../../components/ui/dialog';
|
||||
@@ -23,6 +23,7 @@ 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';
|
||||
@@ -65,6 +66,68 @@ const TextEditorTabFallback = ({ tabId }: { tabId: string }) => {
|
||||
);
|
||||
};
|
||||
|
||||
/** Local draft so keystrokes do not rebuild App chrome domain every character. */
|
||||
function RenameDraftDialog({
|
||||
open,
|
||||
inputId,
|
||||
initialName,
|
||||
title,
|
||||
nameLabel,
|
||||
placeholder,
|
||||
cancelLabel,
|
||||
saveLabel,
|
||||
onCancel,
|
||||
onSave,
|
||||
}: {
|
||||
open: boolean;
|
||||
inputId: string;
|
||||
initialName: string;
|
||||
title: string;
|
||||
nameLabel: string;
|
||||
placeholder: string;
|
||||
cancelLabel: string;
|
||||
saveLabel: string;
|
||||
onCancel: () => void;
|
||||
onSave: (name: string) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(initialName);
|
||||
useEffect(() => {
|
||||
if (open) setDraft(initialName);
|
||||
}, [open, initialName]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) onCancel();
|
||||
}}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 py-2">
|
||||
<Label htmlFor={inputId}>{nameLabel}</Label>
|
||||
<Input
|
||||
id={inputId}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
if (!draft.trim()) return;
|
||||
onSave(draft);
|
||||
}}
|
||||
autoFocus
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onCancel}>{cancelLabel}</Button>
|
||||
<Button onClick={() => onSave(draft)} disabled={!draft.trim()}>{saveLabel}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export type AppViewProps = {
|
||||
domains: AppViewDomains;
|
||||
};
|
||||
@@ -113,7 +176,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
useMainWindowInputFocusRecovery({ onPageHidden: dismissTransientOverlays });
|
||||
|
||||
const {
|
||||
accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
|
||||
accentMode, addShellHistoryEntry, removeShellHistoryEntry, 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,
|
||||
@@ -126,14 +189,14 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
|
||||
passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
|
||||
resolveEmptyVaultConflict, resolvedTheme, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
||||
sessionRenameValue, sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
|
||||
setNavigateToSection, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
|
||||
setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
|
||||
sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
|
||||
setNavigateToSection, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
|
||||
setWorkspaceFocusedSession, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
|
||||
sftpShowHiddenFiles, sftpUseCompressedUpload, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
|
||||
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById,
|
||||
toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog,
|
||||
readPersistedHosts, readPersistedManagedSources, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources,
|
||||
updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaceRenameValue, workspaces,
|
||||
updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaces,
|
||||
VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper,
|
||||
} = ctx;
|
||||
|
||||
@@ -182,6 +245,21 @@ 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]);
|
||||
@@ -195,6 +273,70 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
pluginViewTabStore.close(tabId);
|
||||
}, [orderedTabsWithEditors]);
|
||||
|
||||
const orderedTabsWithEditorsRef = useRef(orderedTabsWithEditors);
|
||||
orderedTabsWithEditorsRef.current = orderedTabsWithEditors;
|
||||
|
||||
// Stable for TopTabs memo: read ordered tabs via ref; prompt via module singleton.
|
||||
const handleRequestCloseEditorTab = useCallback(async (id: string): Promise<boolean> => {
|
||||
const tab = editorTabStore.getTab(id);
|
||||
if (!tab) return false;
|
||||
|
||||
const closeEditorAndActivateNeighbor = () => {
|
||||
const closingTabId = toEditorTabId(id);
|
||||
const list = orderedTabsWithEditorsRef.current;
|
||||
const idx = list.indexOf(closingTabId);
|
||||
releaseEditorTabSaveCoordinator(id);
|
||||
editorTabStore.close(id);
|
||||
if (activeTabStore.getActiveTabId() !== closingTabId) return;
|
||||
const next = list[idx - 1] ?? list[idx + 1] ?? 'vault';
|
||||
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
|
||||
};
|
||||
|
||||
const dirty = tab.content !== tab.baselineContent;
|
||||
if (!dirty) {
|
||||
closeEditorAndActivateNeighbor();
|
||||
return true;
|
||||
}
|
||||
const choice = await promptUnsavedChanges(tab.fileName);
|
||||
if (choice === 'cancel') return false;
|
||||
if (choice === 'discard') {
|
||||
closeEditorAndActivateNeighbor();
|
||||
return true;
|
||||
}
|
||||
if (choice === 'save') {
|
||||
const ok = await saveEditorTab(id);
|
||||
if (!ok) {
|
||||
const msg = editorTabStore.getTab(id)?.saveError ?? 'Save failed';
|
||||
toast.error(msg, 'SFTP');
|
||||
return false;
|
||||
}
|
||||
const latest = editorTabStore.getTab(id);
|
||||
if (!latest || latest.content !== latest.baselineContent) return false;
|
||||
closeEditorAndActivateNeighbor();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
// Keep the hotkey ref current during render so Cmd/Ctrl+W never sees the
|
||||
// App.tsx stub `() => false` between commit and useEffect.
|
||||
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
|
||||
|
||||
const handleSaveSessionRename = useCallback((name: string) => {
|
||||
if (!sessionRenameTarget) return;
|
||||
if (!name.trim()) return;
|
||||
submitSessionRename(sessionRenameTarget.id, name);
|
||||
resetSessionRename();
|
||||
}, [resetSessionRename, sessionRenameTarget, submitSessionRename]);
|
||||
|
||||
const handleSaveWorkspaceRename = useCallback((name: string) => {
|
||||
if (!workspaceRenameTarget) return;
|
||||
if (!name.trim()) return;
|
||||
submitWorkspaceRename(workspaceRenameTarget.id, name);
|
||||
resetWorkspaceRename();
|
||||
}, [resetWorkspaceRename, submitWorkspaceRename, workspaceRenameTarget]);
|
||||
|
||||
useEffect(() => {
|
||||
const catalog = buildPluginSettingScopeCatalog({
|
||||
hosts,
|
||||
@@ -208,54 +350,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
return (
|
||||
<SnippetExecutionProvider>
|
||||
<UnsavedChangesProvider>
|
||||
{({ prompt }) => {
|
||||
// Helper: close an editor tab and activate the neighbor (left-preference), or vault.
|
||||
const closeEditorAndActivateNeighbor = (id: string) => {
|
||||
const closingTabId = toEditorTabId(id);
|
||||
const list = orderedTabsWithEditors;
|
||||
const idx = list.indexOf(closingTabId);
|
||||
releaseEditorTabSaveCoordinator(id);
|
||||
editorTabStore.close(id);
|
||||
if (activeTabStore.getActiveTabId() !== closingTabId) return;
|
||||
const next = list[idx - 1] ?? list[idx + 1] ?? 'vault';
|
||||
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
|
||||
};
|
||||
|
||||
// Real dirty-confirm close handler.
|
||||
const handleRequestCloseEditorTab = async (id: string): Promise<boolean> => {
|
||||
const tab = editorTabStore.getTab(id);
|
||||
if (!tab) return false;
|
||||
const dirty = tab.content !== tab.baselineContent;
|
||||
if (!dirty) {
|
||||
closeEditorAndActivateNeighbor(id);
|
||||
return true;
|
||||
}
|
||||
const choice = await prompt(tab.fileName);
|
||||
if (choice === 'cancel') return false;
|
||||
if (choice === 'discard') {
|
||||
closeEditorAndActivateNeighbor(id);
|
||||
return true;
|
||||
}
|
||||
if (choice === 'save') {
|
||||
const ok = await saveEditorTab(id);
|
||||
if (!ok) {
|
||||
const msg = editorTabStore.getTab(id)?.saveError ?? 'Save failed';
|
||||
toast.error(msg, 'SFTP');
|
||||
return false;
|
||||
}
|
||||
const latest = editorTabStore.getTab(id);
|
||||
if (!latest || latest.content !== latest.baselineContent) return false;
|
||||
closeEditorAndActivateNeighbor(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Expose to the hotkey dispatcher (Cmd/Ctrl+W).
|
||||
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
|
||||
|
||||
return (
|
||||
{() => (
|
||||
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" data-terminal-appearance-root onContextMenu={handleRootContextMenu}>
|
||||
<TopTabs
|
||||
theme={resolvedTheme}
|
||||
@@ -272,6 +367,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onRenameSession={startSessionRename}
|
||||
onCopySession={copySessionWithCurrentShell}
|
||||
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
|
||||
onEditHost={handleEditHostFromOverlay}
|
||||
onRenameWorkspace={startWorkspaceRename}
|
||||
onCopyWorkspace={copyWorkspaceWithCurrentShell}
|
||||
onCloseWorkspace={closeWorkspace}
|
||||
@@ -345,6 +441,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onCreateGroup={handleCreateWorkSurfaceHostGroup}
|
||||
onImportOrReuseKey={importOrReuseKey}
|
||||
onUpdateSnippets={updateSnippets}
|
||||
onUpdateHosts={updateHosts}
|
||||
/>
|
||||
|
||||
<VaultViewContainer appThemeStyle={appThemeStyle}>
|
||||
@@ -446,8 +543,6 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
identities={identities}
|
||||
snippets={snippets}
|
||||
snippetPackages={snippetPackages}
|
||||
notes={notes}
|
||||
noteGroups={noteGroups}
|
||||
sessions={sessions}
|
||||
workspaces={workspaces}
|
||||
knownHosts={effectiveKnownHosts}
|
||||
@@ -485,6 +580,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onUpdateHost={handleUpdateHostFromTerminal}
|
||||
onAddKnownHost={handleAddKnownHost}
|
||||
onCommandExecuted={handleTerminalCommandExecuted}
|
||||
onDeleteShellHistoryEntry={removeShellHistoryEntry}
|
||||
onTerminalDataCapture={handleTerminalDataCapture}
|
||||
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
|
||||
onAddSessionToWorkspace={addSessionToWorkspace}
|
||||
@@ -510,8 +606,6 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
updateHosts={updateTerminalHosts}
|
||||
updateSnippets={updateSnippets}
|
||||
updateSnippetPackages={updateSnippetPackages}
|
||||
updateNotes={updateNotes}
|
||||
updateNoteGroups={updateNoteGroups}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
||||
sftpAutoSync={sftpAutoSync}
|
||||
@@ -665,6 +759,11 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
showSftpTab={settings.showSftpTab}
|
||||
onQueryChange={setQuickSearch}
|
||||
onSelect={handleHostConnectWithProtocolCheck}
|
||||
onEditHost={(host) => {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
handleEditHostFromOverlay(host);
|
||||
}}
|
||||
onSelectTab={(tabId) => {
|
||||
setActiveTabId(tabId);
|
||||
setIsQuickSwitcherOpen(false);
|
||||
@@ -691,59 +790,32 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
</LazyLoadBoundary>
|
||||
)}
|
||||
|
||||
<Dialog open={!!sessionRenameTarget} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
resetSessionRename();
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('dialog.renameSession.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 py-2">
|
||||
<Label htmlFor="session-name">{t('field.name')}</Label>
|
||||
<Input
|
||||
id="session-name"
|
||||
value={sessionRenameValue}
|
||||
onChange={(e) => setSessionRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') submitSessionRename(); }}
|
||||
autoFocus
|
||||
placeholder={t('placeholder.sessionName')}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={resetSessionRename}>{t('common.cancel')}</Button>
|
||||
<Button onClick={submitSessionRename} disabled={!sessionRenameValue.trim()}>{t('common.save')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<RenameDraftDialog
|
||||
open={!!sessionRenameTarget}
|
||||
inputId="session-name"
|
||||
initialName={sessionRenameTarget ? (sessionRenameTarget.customName || sessionRenameTarget.hostLabel) : ''}
|
||||
title={t('dialog.renameSession.title')}
|
||||
nameLabel={t('field.name')}
|
||||
placeholder={t('placeholder.sessionName')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
saveLabel={t('common.save')}
|
||||
onCancel={resetSessionRename}
|
||||
onSave={handleSaveSessionRename}
|
||||
/>
|
||||
|
||||
<RenameDraftDialog
|
||||
open={!!workspaceRenameTarget}
|
||||
inputId="workspace-name"
|
||||
initialName={workspaceRenameTarget ? workspaceRenameTarget.title : ''}
|
||||
title={t('dialog.renameWorkspace.title')}
|
||||
nameLabel={t('field.name')}
|
||||
placeholder={t('placeholder.workspaceName')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
saveLabel={t('common.save')}
|
||||
onCancel={resetWorkspaceRename}
|
||||
onSave={handleSaveWorkspaceRename}
|
||||
/>
|
||||
|
||||
<Dialog open={!!workspaceRenameTarget} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
resetWorkspaceRename();
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('dialog.renameWorkspace.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 py-2">
|
||||
<Label htmlFor="workspace-name">{t('field.name')}</Label>
|
||||
<Input
|
||||
id="workspace-name"
|
||||
value={workspaceRenameValue}
|
||||
onChange={(e) => setWorkspaceRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') submitWorkspaceRename(); }}
|
||||
autoFocus
|
||||
placeholder={t('placeholder.workspaceName')}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={resetWorkspaceRename}>{t('common.cancel')}</Button>
|
||||
<Button onClick={submitWorkspaceRename} disabled={!workspaceRenameValue.trim()}>{t('common.save')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{isCreateWorkspaceOpen && (
|
||||
<LazyLoadBoundary name="Create workspace" resetKey="create-workspace">
|
||||
@@ -847,8 +919,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
)}
|
||||
</UnsavedChangesProvider>
|
||||
</SnippetExecutionProvider>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,10 @@ import {
|
||||
removeKeyboardInteractiveRequest,
|
||||
shouldQueueKeyboardInteractiveRequest,
|
||||
} from "./useAppStartupEffects.ts";
|
||||
import {
|
||||
clearTerminalBootEpoch,
|
||||
setTerminalBootEpoch,
|
||||
} from "../../domain/terminalBootEpoch.ts";
|
||||
|
||||
const sessions = [{ id: "terminal-1" }, { id: "terminal-2" }];
|
||||
|
||||
@@ -19,6 +23,42 @@ test("terminal-scoped keyboard-interactive requests are limited to owned session
|
||||
);
|
||||
});
|
||||
|
||||
test("disconnected terminal sessions do not queue keyboard-interactive prompts", () => {
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest(
|
||||
{ scope: "terminal", sessionId: "terminal-1" },
|
||||
[{ id: "terminal-1", status: "disconnected" }],
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest(
|
||||
{ scope: "terminal", sessionId: "terminal-1" },
|
||||
[{ id: "terminal-1", status: "connecting" }],
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("superseded terminal boot epochs do not queue keyboard-interactive prompts", () => {
|
||||
setTerminalBootEpoch("terminal-1", 3);
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest(
|
||||
{ scope: "terminal", sessionId: "terminal-1", bootEpoch: 1 },
|
||||
[{ id: "terminal-1", status: "connecting" }],
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest(
|
||||
{ scope: "terminal", sessionId: "terminal-1", bootEpoch: 3 },
|
||||
[{ id: "terminal-1", status: "connecting" }],
|
||||
),
|
||||
true,
|
||||
);
|
||||
clearTerminalBootEpoch("terminal-1");
|
||||
});
|
||||
|
||||
test("external keyboard-interactive requests are not filtered by terminal session ids", () => {
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest({ scope: "external", sessionId: "sftp-conn-1" }, sessions),
|
||||
|
||||
@@ -9,8 +9,12 @@ import { sftpTransferCenterStore } from '../state/sftpTransferCenterStore';
|
||||
import { resumeTransferWithDedicatedSession } from '../state/sftp/dedicatedTransferResume';
|
||||
import { getSftpTransferResourceKeys, globalSftpTransferScheduler } from '../state/sftp/globalTransferScheduler';
|
||||
import { hasNewSourceFingerprint } from '../state/sftp/transferProgressMetadata';
|
||||
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from '../../infrastructure/config/storageKeys';
|
||||
import {
|
||||
STORAGE_KEY_AUTO_UPDATE_ENABLED,
|
||||
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type { TransferTask } from '../../domain/models';
|
||||
import { isTerminalBootEpochCurrent } from '../../domain/terminalBootEpoch';
|
||||
import {
|
||||
canApplyDedicatedResumeProgress,
|
||||
createDedicatedResumeChildUpdateBatcher,
|
||||
@@ -24,8 +28,10 @@ type KeyboardInteractiveRequestLike = {
|
||||
scope?: KeyboardInteractiveScope;
|
||||
sessionId?: string;
|
||||
hostId?: string;
|
||||
requestId?: string;
|
||||
bootEpoch?: number;
|
||||
};
|
||||
type SessionIdLike = { id: string; hostId?: string; hostname?: string };
|
||||
type SessionIdLike = { id: string; hostId?: string; hostname?: string; status?: string };
|
||||
type KeyboardInteractiveQueueItem = { requestId: string };
|
||||
|
||||
export function shouldQueueKeyboardInteractiveRequest(
|
||||
@@ -34,7 +40,14 @@ export function shouldQueueKeyboardInteractiveRequest(
|
||||
): boolean {
|
||||
if (request.scope !== "terminal") return true;
|
||||
if (!request.sessionId) return false;
|
||||
return sessions.some((session) => session.id === request.sessionId);
|
||||
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;
|
||||
}
|
||||
|
||||
export function removeKeyboardInteractiveRequest<T extends KeyboardInteractiveQueueItem>(
|
||||
@@ -208,7 +221,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
// Skip "update available" toast if auto-download has already started or completed
|
||||
if (updateState.autoDownloadStatus !== 'idle') return;
|
||||
// Don't show automatic notification when auto-update is disabled
|
||||
if (localStorageAdapter.readString('netcatty_auto_update_enabled_v1') === 'false') return;
|
||||
if (localStorageAdapter.readString(STORAGE_KEY_AUTO_UPDATE_ENABLED) === 'false') return;
|
||||
if (updateState.hasUpdate && updateState.latestRelease) {
|
||||
const version = updateState.latestRelease.version;
|
||||
toast.info(
|
||||
@@ -409,6 +422,9 @@ 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,
|
||||
@@ -420,7 +436,30 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
});
|
||||
},
|
||||
);
|
||||
if (result?.cancelled || result?.error === "Transfer cancelled") {
|
||||
// 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") {
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({ type: "cancelled", transferId: task.id, endedAt: Date.now() });
|
||||
} else if (result?.error) {
|
||||
throw new Error(result.error);
|
||||
@@ -493,7 +532,12 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
if (!bridge?.onKeyboardInteractive) return;
|
||||
|
||||
const unsubscribe = bridge.onKeyboardInteractive((request) => {
|
||||
if (!shouldQueueKeyboardInteractiveRequest(request, sessionsRef.current)) return;
|
||||
if (!shouldQueueKeyboardInteractiveRequest(request, sessionsRef.current)) {
|
||||
if (request.scope === "terminal" && request.requestId) {
|
||||
void bridge.respondKeyboardInteractive?.(request.requestId, [], true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.log('[App] Keyboard-interactive request received:', request);
|
||||
// Add to queue instead of replacing - supports multiple concurrent sessions
|
||||
setKeyboardInteractiveQueue(prev => [...prev, {
|
||||
@@ -511,10 +555,23 @@ 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,6 +9,7 @@ import {
|
||||
reorderWorkTabIds,
|
||||
resolveWorkTabActiveHostId,
|
||||
resolveWorkTabHostTreeTheme,
|
||||
shouldOpenHostEditOnWorkSurface,
|
||||
} from './workTabSurface';
|
||||
import type { EditorTab } from '../state/editorTabStore';
|
||||
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
|
||||
@@ -77,6 +78,15 @@ 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,6 +23,14 @@ 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,6 +404,14 @@ 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.',
|
||||
@@ -443,6 +451,8 @@ 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,6 +197,9 @@ 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',
|
||||
@@ -574,6 +577,9 @@ 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.',
|
||||
@@ -680,6 +686,10 @@ 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,6 +10,7 @@ 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.',
|
||||
|
||||
@@ -23,6 +24,7 @@ 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.',
|
||||
@@ -187,6 +189,23 @@ 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,6 +34,7 @@ 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',
|
||||
@@ -81,6 +82,10 @@ 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',
|
||||
@@ -332,6 +337,13 @@ 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',
|
||||
|
||||
@@ -113,8 +113,6 @@ export const enVaultMessages: Messages = {
|
||||
'knownHosts.empty.title': 'No Known Hosts',
|
||||
'knownHosts.empty.desc':
|
||||
"Known hosts are SSH servers you've connected to before. Import from your system's known_hosts file to get started.",
|
||||
'knownHosts.results.showingLimited':
|
||||
'Showing {shown} of {total} hosts. Use search to find specific hosts.',
|
||||
'knownHosts.toast.scanUnavailable': 'System scan is unavailable on this platform.',
|
||||
'knownHosts.toast.scanNoFile': 'No system known_hosts file found.',
|
||||
'knownHosts.toast.scanNoEntries': 'No usable entries found in known_hosts.',
|
||||
@@ -268,6 +266,8 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.transfers.calculatingTotal': 'Calculating total size...',
|
||||
'sftp.transfers.filesCount': '{count} files',
|
||||
'sftp.transfers.filesProgress': '{current}/{total} files',
|
||||
// Progressive folder walk: total is "found so far", not a fixed grand total.
|
||||
'sftp.transfers.filesDiscoveredProgress': '{completed} done · {discovered} found',
|
||||
'sftp.transfers.expandChildren': 'Show files',
|
||||
'sftp.transfers.collapseChildren': 'Hide files',
|
||||
'sftp.transfers.expandChildList': 'Show detail',
|
||||
@@ -448,13 +448,6 @@ export const enVaultMessages: Messages = {
|
||||
'settings.tab.sftpFileAssociations': 'SFTP',
|
||||
'settings.sftp.transferConcurrency': 'Transfer Concurrency',
|
||||
'settings.sftp.transferConcurrency.desc': 'Maximum number of files transferred in parallel for the same endpoint, including folder contents and separately selected files. Higher values may improve speed but can overwhelm some servers.',
|
||||
'settings.sftp.transferPoolIdleTtl': 'Transfer connection keep-alive',
|
||||
'settings.sftp.transferPoolIdleTtl.desc': 'How long dedicated transfer SFTP connections stay warm after idle. Longer values reduce cold-start delay before the next upload or download. Busy transfers are never closed.',
|
||||
'settings.sftp.transferPoolIdleTtl.1m': '1 minute',
|
||||
'settings.sftp.transferPoolIdleTtl.5m': '5 minutes',
|
||||
'settings.sftp.transferPoolIdleTtl.15m': '15 minutes',
|
||||
'settings.sftp.transferPoolIdleTtl.30m': '30 minutes',
|
||||
'settings.sftp.transferPoolIdleTtl.never': 'Until app quit',
|
||||
'settings.ssh.transportIdleTtl': 'SSH connection keep-alive',
|
||||
'settings.ssh.transportIdleTtl.desc': 'How long a shared SSH connection stays warm after the last terminal, SFTP browse, bulk transfer, or port-forward lease is released. While warm, reconnecting to the same host can skip re-authentication (including MFA). Active sessions and transfers are never closed.',
|
||||
'settings.ssh.transportIdleTtl.1m': '1 minute',
|
||||
@@ -560,6 +553,9 @@ 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',
|
||||
@@ -908,30 +904,8 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.plugin.unavailable': 'The configured provider is unavailable. Configuration is preserved until the plugin is restored.',
|
||||
'hostDetails.plugin.useSsh': 'Use SSH instead',
|
||||
|
||||
// Host form (legacy modal)
|
||||
'hostForm.title.edit': 'Edit Host',
|
||||
'hostForm.title.new': 'New Host',
|
||||
'hostForm.desc.edit': 'Update connection details for this host',
|
||||
'hostForm.desc.new': 'Create a new SSH host entry',
|
||||
'hostForm.field.label': 'Label',
|
||||
'hostForm.placeholder.label': 'My Production Server',
|
||||
'hostForm.field.hostname': 'Hostname / IP',
|
||||
'hostForm.placeholder.hostname': '192.168.1.1',
|
||||
'hostForm.field.port': 'Port',
|
||||
'hostForm.field.username': 'Username',
|
||||
'hostForm.field.osType': 'OS Type',
|
||||
'hostForm.placeholder.selectOs': 'Select OS',
|
||||
'hostForm.field.group': 'Group',
|
||||
'hostForm.placeholder.group': 'e.g. AWS, DigitalOcean',
|
||||
'hostForm.field.tags': 'Tags',
|
||||
'hostForm.placeholder.addTag': 'Add a tag...',
|
||||
'hostForm.auth.method': 'Authentication Method',
|
||||
'hostForm.auth.password': 'Password',
|
||||
'hostForm.auth.sshKey': 'SSH Key',
|
||||
// Host form (legacy keys still referenced by TerminalAuthDialog)
|
||||
'hostForm.auth.selectKey': 'Select an SSH Key',
|
||||
'hostForm.auth.noKeys': 'No keys available',
|
||||
'hostForm.auth.noKeysHint': 'No SSH keys found in Keychain. Please create one first.',
|
||||
'hostForm.saveHost': 'Save Host',
|
||||
|
||||
// Connection logs
|
||||
'logs.table.date': 'Date',
|
||||
|
||||
@@ -356,6 +356,14 @@ 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': 'Эта команда заблокирована вашей политикой безопасности и не может быть выполнена.',
|
||||
@@ -395,6 +403,8 @@ 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,6 +185,9 @@ 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',
|
||||
@@ -535,6 +538,9 @@ 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 в терминале.',
|
||||
@@ -637,6 +643,10 @@ 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,6 +10,7 @@ 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': 'Команда запуска не была выполнена успешно. Проверьте, что цель доступна, и повторите попытку.',
|
||||
|
||||
@@ -23,6 +24,7 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDockerStats': 'Не удалось загрузить статистику контейнеров',
|
||||
'systemManager.errors.loadDockerImages': 'Не удалось загрузить образы',
|
||||
'systemManager.errors.loadOverview': 'Не удалось загрузить обзор системы',
|
||||
'systemManager.errors.loadGpu': 'Не удалось загрузить статистику GPU / NPU',
|
||||
'systemManager.errors.sshChannelUnavailable': 'Сервер отказался открыть новый канал выполнения. Повторите попытку позже или переподключите этот хост.',
|
||||
|
||||
'systemManager.overview.empty': 'Данных обзора пока нет.',
|
||||
@@ -187,6 +189,23 @@ 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,6 +55,7 @@ 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': 'Настройки терминала',
|
||||
@@ -102,6 +103,10 @@ 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': 'Использование памяти',
|
||||
@@ -348,6 +353,13 @@ 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} мин назад',
|
||||
|
||||
@@ -150,8 +150,6 @@ export const ruVaultMessages: Messages = {
|
||||
'knownHosts.empty.title': 'Нет известных хостов',
|
||||
'knownHosts.empty.desc':
|
||||
'Известные хосты — это SSH-серверы, к которым вы подключались раньше. Импортируйте системный файл known_hosts, чтобы начать.',
|
||||
'knownHosts.results.showingLimited':
|
||||
'Показано {shown} из {total} хостов. Используйте поиск, чтобы найти нужные хосты.',
|
||||
'knownHosts.toast.scanUnavailable': 'Сканирование системы недоступно на этой платформе.',
|
||||
'knownHosts.toast.scanNoFile': 'Системный файл known_hosts не найден.',
|
||||
'knownHosts.toast.scanNoEntries': 'В known_hosts не найдено пригодных записей.',
|
||||
@@ -305,6 +303,7 @@ 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': 'Показать детали',
|
||||
@@ -485,13 +484,6 @@ export const ruVaultMessages: Messages = {
|
||||
'settings.tab.sftpFileAssociations': 'SFTP',
|
||||
'settings.sftp.transferConcurrency': 'Параллелизм передачи',
|
||||
'settings.sftp.transferConcurrency.desc': 'Максимальное число файлов, одновременно передаваемых для одной цели, включая содержимое папок и отдельно выбранные файлы. Большие значения могут ускорить передачу, но перегрузить некоторые серверы.',
|
||||
'settings.sftp.transferPoolIdleTtl': 'Удержание transfer-соединений',
|
||||
'settings.sftp.transferPoolIdleTtl.desc': 'Как долго простаивающие SFTP-соединения пула передачи остаются открытыми. Большее время снижает задержку перед следующей передачей. Активные передачи не закрываются.',
|
||||
'settings.sftp.transferPoolIdleTtl.1m': '1 минута',
|
||||
'settings.sftp.transferPoolIdleTtl.5m': '5 минут',
|
||||
'settings.sftp.transferPoolIdleTtl.15m': '15 минут',
|
||||
'settings.sftp.transferPoolIdleTtl.30m': '30 минут',
|
||||
'settings.sftp.transferPoolIdleTtl.never': 'До выхода из приложения',
|
||||
'settings.ssh.transportIdleTtl': 'Удержание SSH-соединений',
|
||||
'settings.ssh.transportIdleTtl.desc': 'Как долго общее SSH-соединение остаётся открытым после освобождения последнего lease терминала, SFTP-браузера, пакетной передачи или проброса портов. Пока оно тёплое, повторное подключение к тому же хосту может обойти повторную аутентификацию (включая MFA). Активные сессии и передачи не закрываются.',
|
||||
'settings.ssh.transportIdleTtl.1m': '1 минута',
|
||||
@@ -597,6 +589,9 @@ 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': 'Перейти к',
|
||||
@@ -941,30 +936,8 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.plugin.unavailable': 'Настроенный провайдер недоступен. Конфигурация сохранится до восстановления плагина.',
|
||||
'hostDetails.plugin.useSsh': 'Использовать SSH',
|
||||
|
||||
// Host form (legacy modal)
|
||||
'hostForm.title.edit': 'Редактировать хост',
|
||||
'hostForm.title.new': 'Новый хост',
|
||||
'hostForm.desc.edit': 'Обновите параметры подключения для этого хоста',
|
||||
'hostForm.desc.new': 'Создайте новую запись SSH-хоста',
|
||||
'hostForm.field.label': 'Метка',
|
||||
'hostForm.placeholder.label': 'Мой production-сервер',
|
||||
'hostForm.field.hostname': 'Имя хоста / IP',
|
||||
'hostForm.placeholder.hostname': '192.168.1.1',
|
||||
'hostForm.field.port': 'Порт',
|
||||
'hostForm.field.username': 'Имя пользователя',
|
||||
'hostForm.field.osType': 'Тип ОС',
|
||||
'hostForm.placeholder.selectOs': 'Выберите ОС',
|
||||
'hostForm.field.group': 'Группа',
|
||||
'hostForm.placeholder.group': 'например, AWS, DigitalOcean',
|
||||
'hostForm.field.tags': 'Теги',
|
||||
'hostForm.placeholder.addTag': 'Добавить тег...',
|
||||
'hostForm.auth.method': 'Метод аутентификации',
|
||||
'hostForm.auth.password': 'Пароль',
|
||||
'hostForm.auth.sshKey': 'SSH-ключ',
|
||||
// Host form (legacy keys still referenced by TerminalAuthDialog)
|
||||
'hostForm.auth.selectKey': 'Выберите SSH-ключ',
|
||||
'hostForm.auth.noKeys': 'Нет доступных ключей',
|
||||
'hostForm.auth.noKeysHint': 'В связке ключей не найдено SSH-ключей. Сначала создайте один.',
|
||||
'hostForm.saveHost': 'Сохранить хост',
|
||||
'vault.import.plugins.title': 'Импортёры плагинов',
|
||||
'vault.import.plugins.loading': 'Создание безопасного предварительного просмотра…',
|
||||
'vault.import.plugins.progress': 'Обработано {completed} из {total} {message}',
|
||||
|
||||
@@ -404,6 +404,14 @@ 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': '此命令已被安全策略拦截,无法执行。',
|
||||
@@ -443,6 +451,8 @@ 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,6 +194,9 @@ 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 调试日志',
|
||||
@@ -729,7 +732,6 @@ export const zhCNCoreMessages: Messages = {
|
||||
'knownHosts.empty.title': '暂无已知主机',
|
||||
'knownHosts.empty.desc':
|
||||
'Known Hosts 是你之前连接过的 SSH server。导入系统的 known_hosts 文件以开始。',
|
||||
'knownHosts.results.showingLimited': '显示 {shown}/{total} 个主机。使用搜索查找特定主机。',
|
||||
'knownHosts.toast.scanUnavailable': '当前平台无法扫描系统 known_hosts。',
|
||||
'knownHosts.toast.scanNoFile': '未找到系统 known_hosts 文件。',
|
||||
'knownHosts.toast.scanNoEntries': 'known_hosts 中没有可用条目。',
|
||||
@@ -860,6 +862,7 @@ 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,6 +10,7 @@ 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': '启动命令未成功。请确认目标仍然可用后重试。',
|
||||
|
||||
@@ -23,6 +24,7 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDockerStats': '加载容器性能数据失败',
|
||||
'systemManager.errors.loadDockerImages': '加载镜像列表失败',
|
||||
'systemManager.errors.loadOverview': '加载系统概览失败',
|
||||
'systemManager.errors.loadGpu': '加载 GPU / NPU 状态失败',
|
||||
'systemManager.errors.sshChannelUnavailable': '服务器拒绝打开新的执行通道。请稍后重试,或重新连接当前主机。',
|
||||
|
||||
'systemManager.overview.empty': '暂无系统概览数据。',
|
||||
@@ -187,6 +189,23 @@ 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,6 +52,7 @@ 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': '打开方式...',
|
||||
@@ -92,13 +93,6 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.tab.sftpFileAssociations': 'SFTP',
|
||||
'settings.sftp.transferConcurrency': '传输并发数',
|
||||
'settings.sftp.transferConcurrency.desc': '同一目标同时传输的文件数量上限,包括文件夹内文件和单独多选的文件。较高的值可能提高速度,但可能导致某些服务器过载。',
|
||||
'settings.sftp.transferPoolIdleTtl': '传输连接保活时间',
|
||||
'settings.sftp.transferPoolIdleTtl.desc': '专用传输 SFTP 连接空闲多久后关闭。时间更长可减少下次上传/下载的冷启动等待;进行中的传输不会被关闭。',
|
||||
'settings.sftp.transferPoolIdleTtl.1m': '1 分钟',
|
||||
'settings.sftp.transferPoolIdleTtl.5m': '5 分钟',
|
||||
'settings.sftp.transferPoolIdleTtl.15m': '15 分钟',
|
||||
'settings.sftp.transferPoolIdleTtl.30m': '30 分钟',
|
||||
'settings.sftp.transferPoolIdleTtl.never': '直到退出应用',
|
||||
'settings.ssh.transportIdleTtl': 'SSH 连接保活时间',
|
||||
'settings.ssh.transportIdleTtl.desc': '最后一个终端、SFTP 浏览、批量传输或端口转发释放后,共享 SSH 连接再保留多久。保留期间再次打开同主机可跳过重新认证(含 MFA);进行中的会话与传输不会被关闭。',
|
||||
'settings.ssh.transportIdleTtl.1m': '1 分钟',
|
||||
@@ -205,6 +199,9 @@ 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': '选择主题',
|
||||
@@ -310,6 +307,9 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.behavior.bracketedPaste': '括号粘贴模式',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'粘贴文本时使用转义序列包裹,以便终端区分粘贴和键入。如果出现 ^[[200~ 字样请关闭此选项。',
|
||||
'settings.terminal.behavior.autoUploadClipboardImage': '粘贴时自动上传剪贴板图片',
|
||||
'settings.terminal.behavior.autoUploadClipboardImage.desc':
|
||||
'当剪贴板包含图片时,在远程会话中粘贴会通过 SFTP 将图片上传到远端当前目录的 .netcatty-paste-images/ 文件夹,并在终端输入远端路径,而不是粘贴文本。',
|
||||
'settings.terminal.behavior.shiftEnterNewline': 'Shift+Enter 发送文本',
|
||||
'settings.terminal.behavior.shiftEnterNewline.desc':
|
||||
'在终端中按 Shift+Enter 时发送配置的文本,而不是普通回车。',
|
||||
@@ -410,6 +410,10 @@ 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': '刷新间隔',
|
||||
|
||||
@@ -331,29 +331,8 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.plugin.useSsh': '改用 SSH',
|
||||
|
||||
// Host form (legacy modal)
|
||||
'hostForm.title.edit': '编辑主机',
|
||||
'hostForm.title.new': '新建主机',
|
||||
'hostForm.desc.edit': '更新该主机的连接信息',
|
||||
'hostForm.desc.new': '创建一个新的 SSH 主机条目',
|
||||
'hostForm.field.label': '名称',
|
||||
'hostForm.placeholder.label': 'My Production Server',
|
||||
'hostForm.field.hostname': 'Hostname / IP',
|
||||
'hostForm.placeholder.hostname': '192.168.1.1',
|
||||
'hostForm.field.port': '端口',
|
||||
'hostForm.field.username': '用户名',
|
||||
'hostForm.field.osType': '操作系统类型',
|
||||
'hostForm.placeholder.selectOs': '选择操作系统',
|
||||
'hostForm.field.group': '分组',
|
||||
'hostForm.placeholder.group': '例如:AWS、DigitalOcean',
|
||||
'hostForm.field.tags': '标签',
|
||||
'hostForm.placeholder.addTag': '添加标签…',
|
||||
'hostForm.auth.method': '认证方式',
|
||||
'hostForm.auth.password': '密码',
|
||||
'hostForm.auth.sshKey': 'SSH密钥',
|
||||
// Host form (legacy keys still referenced by TerminalAuthDialog)
|
||||
'hostForm.auth.selectKey': '选择 SSH密钥',
|
||||
'hostForm.auth.noKeys': '暂无密钥',
|
||||
'hostForm.auth.noKeysHint': '钥匙串中未找到 SSH密钥,请先创建一个。',
|
||||
'hostForm.saveHost': '保存主机',
|
||||
|
||||
// Connection logs
|
||||
'logs.table.date': '日期',
|
||||
@@ -427,6 +406,10 @@ 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': '内存使用',
|
||||
@@ -659,6 +642,13 @@ 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,6 +403,14 @@ 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': '此指令已被安全策略攔截,無法執行。',
|
||||
@@ -442,6 +450,8 @@ 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,6 +194,9 @@ 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 除錯日誌',
|
||||
@@ -729,7 +732,6 @@ export const zhTWCoreMessages: Messages = {
|
||||
'knownHosts.empty.title': '暫無已知主機',
|
||||
'knownHosts.empty.desc':
|
||||
'Known Hosts 是你之前連線過的 SSH server。匯入系統的 known_hosts 檔案以開始。',
|
||||
'knownHosts.results.showingLimited': '顯示 {shown}/{total} 個主機。使用搜索查詢特定主機。',
|
||||
'knownHosts.toast.scanUnavailable': '目前平台無法掃描系統 known_hosts。',
|
||||
'knownHosts.toast.scanNoFile': '未找到系統 known_hosts 檔案。',
|
||||
'knownHosts.toast.scanNoEntries': 'known_hosts 中沒有可用條目。',
|
||||
@@ -860,6 +862,7 @@ 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,6 +10,7 @@ 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': '啟動指令未順利完成。請確認目標仍可使用後再試一次。',
|
||||
|
||||
@@ -23,6 +24,7 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDockerStats': '載入容器效能資料失敗',
|
||||
'systemManager.errors.loadDockerImages': '載入映像列表失敗',
|
||||
'systemManager.errors.loadOverview': '載入系統概覽失敗',
|
||||
'systemManager.errors.loadGpu': '載入 GPU / NPU 狀態失敗',
|
||||
'systemManager.errors.sshChannelUnavailable': '伺服器拒絕開啟新的執行通道。請稍後重試,或重新連線目前主機。',
|
||||
|
||||
'systemManager.overview.empty': '暫無系統概覽資料。',
|
||||
@@ -187,6 +189,23 @@ 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,6 +52,7 @@ 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': '開啟方式...',
|
||||
@@ -92,13 +93,6 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.tab.sftpFileAssociations': 'SFTP',
|
||||
'settings.sftp.transferConcurrency': '傳輸併發數',
|
||||
'settings.sftp.transferConcurrency.desc': '同一目標同時傳輸的檔案數量上限,包括資料夾內檔案和單獨多選的檔案。較高的值可能提高速度,但可能導致某些伺服器過載。',
|
||||
'settings.sftp.transferPoolIdleTtl': '傳輸連線保活時間',
|
||||
'settings.sftp.transferPoolIdleTtl.desc': '專用傳輸 SFTP 連線閒置多久後關閉。時間更長可減少下次上傳/下載的冷啟動等待;進行中的傳輸不會被關閉。',
|
||||
'settings.sftp.transferPoolIdleTtl.1m': '1 分鐘',
|
||||
'settings.sftp.transferPoolIdleTtl.5m': '5 分鐘',
|
||||
'settings.sftp.transferPoolIdleTtl.15m': '15 分鐘',
|
||||
'settings.sftp.transferPoolIdleTtl.30m': '30 分鐘',
|
||||
'settings.sftp.transferPoolIdleTtl.never': '直到結束應用',
|
||||
'settings.ssh.transportIdleTtl': 'SSH 連線保活時間',
|
||||
'settings.ssh.transportIdleTtl.desc': '最後一個終端、SFTP 瀏覽、批次傳輸或連接埠轉發釋放後,共享 SSH 連線再保留多久。保留期間再次開啟同主機可略過重新驗證(含 MFA);進行中的工作階段與傳輸不會被關閉。',
|
||||
'settings.ssh.transportIdleTtl.1m': '1 分鐘',
|
||||
@@ -205,6 +199,9 @@ 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': '選擇主題',
|
||||
@@ -310,6 +307,9 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.behavior.bracketedPaste': '括號貼上模式',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'貼上文字時使用轉義序列包裹,以便終端區分貼上和鍵入。如果出現 ^[[200~ 字樣請關閉此選項。',
|
||||
'settings.terminal.behavior.autoUploadClipboardImage': '貼上時自動上傳剪貼簿圖片',
|
||||
'settings.terminal.behavior.autoUploadClipboardImage.desc':
|
||||
'當剪貼簿包含圖片時,在遠端工作階段中貼上會透過 SFTP 將圖片上傳到遠端目前目錄的 .netcatty-paste-images/ 資料夾,並在終端輸入遠端路徑,而不是貼上文字。',
|
||||
'settings.terminal.behavior.shiftEnterNewline': 'Shift+Enter 傳送文字',
|
||||
'settings.terminal.behavior.shiftEnterNewline.desc':
|
||||
'在終端中按 Shift+Enter 時傳送設定的文字,而不是普通 Enter。',
|
||||
@@ -410,6 +410,10 @@ 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': '重新整理間隔',
|
||||
|
||||
@@ -331,29 +331,8 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.plugin.useSsh': '改用 SSH',
|
||||
|
||||
// Host form (legacy modal)
|
||||
'hostForm.title.edit': '編輯主機',
|
||||
'hostForm.title.new': '新建主機',
|
||||
'hostForm.desc.edit': '更新該主機的連線資訊',
|
||||
'hostForm.desc.new': '建立一個新的 SSH 主機條目',
|
||||
'hostForm.field.label': '名稱',
|
||||
'hostForm.placeholder.label': 'My Production Server',
|
||||
'hostForm.field.hostname': 'Hostname / IP',
|
||||
'hostForm.placeholder.hostname': '192.168.1.1',
|
||||
'hostForm.field.port': '埠',
|
||||
'hostForm.field.username': '使用者名稱',
|
||||
'hostForm.field.osType': '作業系統型別',
|
||||
'hostForm.placeholder.selectOs': '選擇作業系統',
|
||||
'hostForm.field.group': '分組',
|
||||
'hostForm.placeholder.group': '例如:AWS、DigitalOcean',
|
||||
'hostForm.field.tags': '標籤',
|
||||
'hostForm.placeholder.addTag': '新增標籤…',
|
||||
'hostForm.auth.method': '認證方式',
|
||||
'hostForm.auth.password': '密碼',
|
||||
'hostForm.auth.sshKey': 'SSH金鑰',
|
||||
// Host form (legacy keys still referenced by TerminalAuthDialog)
|
||||
'hostForm.auth.selectKey': '選擇 SSH金鑰',
|
||||
'hostForm.auth.noKeys': '暫無金鑰',
|
||||
'hostForm.auth.noKeysHint': '鑰匙串中未找到 SSH金鑰,請先建立一個。',
|
||||
'hostForm.saveHost': '儲存主機',
|
||||
|
||||
// Connection logs
|
||||
'logs.table.date': '日期',
|
||||
@@ -427,6 +406,10 @@ 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': '記憶體使用',
|
||||
@@ -659,6 +642,13 @@ 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;
|
||||
buildPreApplyPayload: () => SyncPayload | Promise<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 = buildPreApplyPayload();
|
||||
const pre = await buildPreApplyPayload();
|
||||
let protectiveBackupId: string | null = null;
|
||||
try {
|
||||
const backup = await createRequiredProtectiveLocalVaultBackup(pre);
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
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']]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
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');
|
||||
});
|
||||
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type React from 'react';
|
||||
import {
|
||||
STORAGE_KEY_AI_PANEL_DIAGNOSTIC_HIDE,
|
||||
STORAGE_KEY_AI_PANEL_DIAGNOSTIC_PROFILE,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
|
||||
export const AI_PANEL_DIAGNOSTIC_HIDE_KEY = STORAGE_KEY_AI_PANEL_DIAGNOSTIC_HIDE;
|
||||
export const AI_PANEL_DIAGNOSTIC_PROFILE_KEY = STORAGE_KEY_AI_PANEL_DIAGNOSTIC_PROFILE;
|
||||
export const AI_PANEL_FORCE_HIDE_ALL_CONTENT = false;
|
||||
export const AI_PANEL_FORCE_HIDE_SHELL = false;
|
||||
|
||||
export type AIPanelDiagnosticPart =
|
||||
| 'all'
|
||||
| 'attachments'
|
||||
| 'header'
|
||||
| 'history'
|
||||
| 'input'
|
||||
| 'markdown'
|
||||
| 'messages'
|
||||
| 'recent'
|
||||
| 'toolcalls';
|
||||
|
||||
function readLocalStorageValue(key: string): string {
|
||||
try {
|
||||
return localStorageAdapter.readString(key) ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function getAIPanelDiagnosticHiddenParts(): ReadonlySet<string> {
|
||||
if (AI_PANEL_FORCE_HIDE_ALL_CONTENT) {
|
||||
return new Set(['all']);
|
||||
}
|
||||
const raw = readLocalStorageValue(AI_PANEL_DIAGNOSTIC_HIDE_KEY);
|
||||
return new Set(
|
||||
raw
|
||||
.split(',')
|
||||
.map((part) => part.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
export function isAIPanelDiagnosticPartHidden(
|
||||
part: AIPanelDiagnosticPart,
|
||||
hiddenParts = getAIPanelDiagnosticHiddenParts(),
|
||||
): boolean {
|
||||
return hiddenParts.has('all') || hiddenParts.has(part);
|
||||
}
|
||||
|
||||
export function isAIPanelDiagnosticsProfilingEnabled(): boolean {
|
||||
const raw = readLocalStorageValue(AI_PANEL_DIAGNOSTIC_PROFILE_KEY).trim().toLowerCase();
|
||||
return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
|
||||
}
|
||||
|
||||
export function logAIPanelProfiler(
|
||||
id: string,
|
||||
phase: 'mount' | 'update' | 'nested-update',
|
||||
actualDuration: number,
|
||||
baseDuration: number,
|
||||
): void {
|
||||
if (!isAIPanelDiagnosticsProfilingEnabled()) return;
|
||||
console.info(
|
||||
`[AI panel profile] ${id} ${phase}: actual=${actualDuration.toFixed(1)}ms base=${baseDuration.toFixed(1)}ms`,
|
||||
);
|
||||
}
|
||||
|
||||
export function profileAIPanelCalculation<T>(label: string, calculate: () => T): T {
|
||||
if (!isAIPanelDiagnosticsProfilingEnabled()) return calculate();
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
return calculate();
|
||||
} finally {
|
||||
const elapsed = performance.now() - startedAt;
|
||||
console.info(`[AI panel profile] ${label}: ${elapsed.toFixed(1)}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
export function getAIPanelProfilerProps(id: string): Pick<React.ProfilerProps, 'id' | 'onRender'> {
|
||||
return {
|
||||
id,
|
||||
onRender: logAIPanelProfiler,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
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,6 +27,7 @@ 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", () => {
|
||||
@@ -97,6 +98,7 @@ 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 = {
|
||||
@@ -151,6 +153,22 @@ 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,
|
||||
@@ -168,6 +186,7 @@ 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 };
|
||||
|
||||
@@ -196,6 +215,7 @@ 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({
|
||||
@@ -292,7 +312,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, customAccent)", writeIndex);
|
||||
const colorNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_COLOR, customAccentRecord)", 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");
|
||||
@@ -301,6 +321,8 @@ 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,
|
||||
@@ -321,3 +343,26 @@ 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,10 +6,14 @@ 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";
|
||||
@@ -17,6 +21,8 @@ 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 = {
|
||||
@@ -50,7 +56,8 @@ export function hasPersistedAppearanceChanged(
|
||||
|| previous.lightUiThemeId !== current.lightUiThemeId
|
||||
|| previous.darkUiThemeId !== current.darkUiThemeId
|
||||
|| previous.accentMode !== current.accentMode
|
||||
|| previous.customAccent !== current.customAccent;
|
||||
|| previous.customAccent !== current.customAccent
|
||||
|| previous.customAccentVersion !== current.customAccentVersion;
|
||||
}
|
||||
|
||||
export function resolveIncomingAppearanceValue<T>(
|
||||
@@ -106,13 +113,20 @@ export function resolveAppearanceSyncState(
|
||||
current.accentMode,
|
||||
(value): value is AppearanceState['accentMode'] => value === 'theme' || value === 'custom',
|
||||
);
|
||||
const customAccent = resolveIncomingAppearanceValue(
|
||||
incoming,
|
||||
STORAGE_KEY_COLOR,
|
||||
stored.customAccent,
|
||||
current.customAccent,
|
||||
(value): value is string => typeof value === 'string' && isValidHslToken(value),
|
||||
);
|
||||
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;
|
||||
|
||||
return {
|
||||
theme: isValidTheme(theme) ? theme : current.theme,
|
||||
@@ -123,9 +137,8 @@ export function resolveAppearanceSyncState(
|
||||
? darkUiThemeId
|
||||
: current.darkUiThemeId,
|
||||
accentMode: accentMode === 'theme' || accentMode === 'custom' ? accentMode : current.accentMode,
|
||||
customAccent: typeof customAccent === 'string' && isValidHslToken(customAccent)
|
||||
? customAccent.trim()
|
||||
: current.customAccent,
|
||||
customAccent: nextAccentRecord.color,
|
||||
customAccentVersion: nextAccentRecord.version,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -165,11 +178,24 @@ 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: newValue && isValidHslToken(newValue)
|
||||
? { ...current, customAccent: newValue.trim() }
|
||||
: current,
|
||||
next: {
|
||||
...current,
|
||||
customAccent: incoming.color,
|
||||
customAccentVersion: incoming.version,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { handled: false, next: current };
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
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:djEwYWJj",
|
||||
[keyPath]: "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==",
|
||||
"/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:djEwYWJj";
|
||||
const encrypted = "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==";
|
||||
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:djEwYWJj";
|
||||
const encrypted = "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==";
|
||||
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:djEwYWJj",
|
||||
passphrase: "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==",
|
||||
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:djEwYWJj",
|
||||
"/Users/alice/.ssh/id_ed25519": "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==",
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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:djEwYWJj" }),
|
||||
JSON.stringify({ "/Users/alice/.ssh/id_old": "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==" }),
|
||||
);
|
||||
|
||||
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:djEwYWJj",
|
||||
"~/.ssh/id_ed25519": "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==",
|
||||
"/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:djEwYWJj",
|
||||
"/Users/alice/.ssh/id_ed25519": "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==",
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -913,7 +913,7 @@ test("shouldUpdateReferenceKeyPassphrase replaces missing or undecryptable passp
|
||||
assert.equal(
|
||||
shouldUpdateReferenceKeyPassphrase({
|
||||
...referenceKey(),
|
||||
passphrase: "enc:v1:djEwAAAA",
|
||||
passphrase: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
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") {
|
||||
async listProviders(kind: "connection" | "authentication" | "importer" | "sync") {
|
||||
return requireBridge().listPluginExtensionProviders?.({ kind }) ?? [];
|
||||
},
|
||||
async updateCredentialCatalog(entries: ReadonlyArray<{ id: string; ciphertext: string }>) {
|
||||
|
||||
@@ -28,6 +28,7 @@ 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,
|
||||
@@ -66,6 +67,7 @@ interface UseSettingsStorageSyncParams {
|
||||
darkUiThemeId: string;
|
||||
accentMode: 'theme' | 'custom';
|
||||
customAccent: string;
|
||||
customAccentVersion: number;
|
||||
customCSS: string;
|
||||
uiFontFamilyId: string;
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
@@ -78,6 +80,7 @@ interface UseSettingsStorageSyncParams {
|
||||
sftpAutoSync: boolean;
|
||||
sftpShowHiddenFiles: boolean;
|
||||
sftpUseCompressedUpload: boolean;
|
||||
sftpSkipUnchanged: boolean;
|
||||
sftpAutoOpenSidebar: boolean;
|
||||
sftpFollowTerminalCwd: boolean;
|
||||
sftpDefaultViewMode: 'list' | 'tree';
|
||||
@@ -109,7 +112,7 @@ interface UseSettingsStorageSyncParams {
|
||||
setLightUiThemeId: Dispatch<SetStateAction<string>>;
|
||||
setDarkUiThemeId: Dispatch<SetStateAction<string>>;
|
||||
setAccentMode: Dispatch<SetStateAction<'theme' | 'custom'>>;
|
||||
setCustomAccent: Dispatch<SetStateAction<string>>;
|
||||
applyIncomingCustomAccent: (raw: unknown) => void;
|
||||
setCustomCSS: Dispatch<SetStateAction<string>>;
|
||||
setUiFontFamilyId: Dispatch<SetStateAction<string>>;
|
||||
setHotkeyScheme: Dispatch<SetStateAction<HotkeyScheme>>;
|
||||
@@ -124,6 +127,7 @@ 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'>>;
|
||||
@@ -160,20 +164,20 @@ interface UseSettingsStorageSyncParams {
|
||||
|
||||
export function useSettingsStorageSync({
|
||||
enabled = true,
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentVersion,
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, 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, setCustomAccent,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent,
|
||||
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
|
||||
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setSftpUseCompressedUpload, setSftpSkipUnchanged, 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,
|
||||
@@ -184,21 +188,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,
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentVersion,
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, 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,
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentVersion,
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, 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,
|
||||
@@ -215,7 +219,15 @@ 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) setCustomAccent(appearance.next.customAccent);
|
||||
if (
|
||||
appearance.next.customAccent !== s.customAccent
|
||||
|| appearance.next.customAccentVersion !== s.customAccentVersion
|
||||
) {
|
||||
applyIncomingCustomAccent({
|
||||
color: appearance.next.customAccent,
|
||||
version: appearance.next.customAccentVersion,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === STORAGE_KEY_CUSTOM_CSS && e.newValue !== null) {
|
||||
@@ -370,6 +382,12 @@ 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';
|
||||
@@ -505,7 +523,7 @@ export function useSettingsStorageSync({
|
||||
mergeIncomingTerminalSettings,
|
||||
setAccentMode,
|
||||
setAutoUpdateEnabled,
|
||||
setCustomAccent,
|
||||
applyIncomingCustomAccent,
|
||||
setCustomCSS,
|
||||
setDarkUiThemeId,
|
||||
setEditorWordWrapState,
|
||||
@@ -533,6 +551,7 @@ export function useSettingsStorageSync({
|
||||
setSftpTransferConcurrencyState,
|
||||
setSshTransportIdleTtlMsState,
|
||||
setSftpUseCompressedUpload,
|
||||
setSftpSkipUnchanged,
|
||||
setShowOnlyUngroupedHostsInRootState,
|
||||
setShowHostTreeSidebarState,
|
||||
setTerminalSidePanelAutoOpenState,
|
||||
|
||||
@@ -50,6 +50,26 @@ 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,8 +9,12 @@
|
||||
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;
|
||||
return params.surfaceVisible
|
||||
|| params.hasOwnedEditorTab
|
||||
|| !!params.hasActiveExternalEdit;
|
||||
}
|
||||
|
||||
export function listRemoteBrowseConnectionIds(
|
||||
@@ -49,8 +53,14 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
export type SortField = "name" | "size" | "modified" | "type";
|
||||
export type SortOrder = "asc" | "desc";
|
||||
|
||||
export interface ColumnWidths {
|
||||
name: number;
|
||||
modified: number;
|
||||
size: number;
|
||||
type: number;
|
||||
}
|
||||
|
||||
export type SftpColumnVisibility = Record<keyof ColumnWidths, boolean>;
|
||||
|
||||
export const DEFAULT_SFTP_COLUMN_VISIBILITY: SftpColumnVisibility = {
|
||||
name: true,
|
||||
modified: true,
|
||||
size: true,
|
||||
type: true,
|
||||
};
|
||||
|
||||
export const normalizeSftpColumnVisibility = (value: unknown): SftpColumnVisibility => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return DEFAULT_SFTP_COLUMN_VISIBILITY;
|
||||
}
|
||||
|
||||
const stored = value as Partial<Record<keyof ColumnWidths, unknown>>;
|
||||
return {
|
||||
name: true,
|
||||
modified: stored.modified !== false,
|
||||
size: stored.size !== false,
|
||||
type: stored.type !== false,
|
||||
};
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { resumeCompressedUploadSafely } from "./compressedUploadControl.ts";
|
||||
|
||||
test("live compressed resume failure stays paused instead of starting a second transfer", async () => {
|
||||
const outcome = await resumeCompressedUploadSafely({
|
||||
transferId: "compressed-live",
|
||||
reconnectRequired: false,
|
||||
resume: async () => ({ success: false, reason: "Upload resume is unavailable" }),
|
||||
});
|
||||
assert.deepEqual(outcome, { kind: "failed", reason: "Upload resume is unavailable" });
|
||||
});
|
||||
|
||||
test("restored compressed task may restart when its old worker no longer exists", async () => {
|
||||
const outcome = await resumeCompressedUploadSafely({
|
||||
transferId: "compressed-restored",
|
||||
reconnectRequired: true,
|
||||
resume: async () => ({ success: false, reason: "Compression is not active" }),
|
||||
});
|
||||
assert.deepEqual(outcome, { kind: "restart", reason: "Compression is not active" });
|
||||
});
|
||||
|
||||
test("successful compressed resume rejoins the existing job", async () => {
|
||||
const outcome = await resumeCompressedUploadSafely({
|
||||
transferId: "compressed-live",
|
||||
reconnectRequired: false,
|
||||
resume: async () => ({ success: true }),
|
||||
});
|
||||
assert.deepEqual(outcome, { kind: "resumed" });
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
export type CompressedUploadResumeOutcome =
|
||||
| { kind: "resumed" }
|
||||
| { kind: "restart"; reason?: string }
|
||||
| { kind: "failed"; reason: string };
|
||||
|
||||
export async function resumeCompressedUploadSafely(params: {
|
||||
transferId: string;
|
||||
reconnectRequired: boolean;
|
||||
resume?: (transferId: string) => Promise<{ success: boolean; reason?: string }>;
|
||||
}): Promise<CompressedUploadResumeOutcome> {
|
||||
const result = await (params.resume?.(params.transferId)
|
||||
?? { success: false, reason: "Resume unavailable" });
|
||||
if (result.success) return { kind: "resumed" };
|
||||
if (params.reconnectRequired) return { kind: "restart", reason: result.reason };
|
||||
return { kind: "failed", reason: result.reason ?? "Could not resume the compressed upload." };
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
isMissingDirectoryReplacePathError,
|
||||
promoteDirectoryReplaceStage as promoteDirectoryReplacePaths,
|
||||
} from "./directoryReplacePromotion";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
|
||||
export interface DedicatedResumeDeps {
|
||||
hosts: readonly Host[];
|
||||
@@ -708,8 +709,19 @@ async function resumeSingleFileWithDedicatedSession(
|
||||
skipAdmission: true,
|
||||
});
|
||||
|
||||
if (streamResult?.error) {
|
||||
throw new Error(streamResult.error);
|
||||
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");
|
||||
}
|
||||
}, { retries: 1, delayMs: 600 });
|
||||
return { transferId: task.id };
|
||||
@@ -1258,7 +1270,17 @@ async function resumeDirectoryWithDedicatedSession(
|
||||
skipAdmission: true,
|
||||
});
|
||||
|
||||
if (streamResult?.error || streamResult?.cancelled) {
|
||||
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) {
|
||||
throw new Error(streamResult.error || "Transfer cancelled");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
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");
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 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,9 +138,13 @@ 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 }) => void) | null = null;
|
||||
let onBackendStopped: ((payload: {
|
||||
watchId: string;
|
||||
localPath?: string;
|
||||
}) => void) | null = null;
|
||||
let lifecycle: ExternalFileWatchLifecycle | null = null;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const forgottenPaths: string[] = [];
|
||||
|
||||
function Probe() {
|
||||
lifecycle = useExternalFileWatchLifecycle(
|
||||
@@ -151,6 +155,7 @@ test("backend session cleanup removes a stopped watch from renderer ownership",
|
||||
onBackendStopped = callback;
|
||||
return () => { onBackendStopped = null; };
|
||||
},
|
||||
(localPath) => { forgottenPaths.push(localPath); },
|
||||
);
|
||||
return null;
|
||||
}
|
||||
@@ -162,9 +167,13 @@ 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" });
|
||||
onBackendStopped?.({
|
||||
watchId: "watch-closed-with-session",
|
||||
localPath: "/tmp/externally-deleted.txt",
|
||||
});
|
||||
});
|
||||
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,7 +19,12 @@ export type StopExternalFileWatch = (
|
||||
) => void | Promise<unknown>;
|
||||
|
||||
export type SubscribeExternalFileWatchStopped = (
|
||||
callback: (payload: { watchId: string }) => void,
|
||||
callback: (payload: {
|
||||
watchId: string;
|
||||
localPath?: string;
|
||||
remotePath?: string;
|
||||
sftpId?: string;
|
||||
}) => void,
|
||||
) => (() => void) | void;
|
||||
|
||||
export interface ExternalFileWatchLifecycle {
|
||||
@@ -32,16 +37,19 @@ 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, []);
|
||||
|
||||
@@ -86,9 +94,10 @@ export function useExternalFileWatchLifecycle(
|
||||
};
|
||||
}, [releaseAll]);
|
||||
|
||||
useEffect(() => subscribeStoppedRef.current?.(({ watchId }) => {
|
||||
useEffect(() => subscribeStoppedRef.current?.(({ watchId, localPath }) => {
|
||||
if (!watchId || !watchIdsRef.current.delete(watchId)) return;
|
||||
activeCountRef.current = watchIdsRef.current.size;
|
||||
if (localPath) onStoppedLocalPathRef.current?.(localPath);
|
||||
}), []);
|
||||
|
||||
return { activeCountRef, captureGeneration, remember, releaseAll };
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||
import type { SftpBookmark } from "../../../domain/models";
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { STORAGE_KEY_SFTP_LOCAL_BOOKMARKS } from "../../../infrastructure/config/storageKeys";
|
||||
import { createSftpBookmark } from "./bookmarkHelpers";
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
let snapshot: SftpBookmark[] =
|
||||
localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS) ?? [];
|
||||
|
||||
export function subscribeLocalSftpBookmarks(listener: Listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function getLocalSftpBookmarksSnapshot() {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function rehydrateLocalSftpBookmarks() {
|
||||
snapshot = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS) ?? [];
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
export function setLocalSftpBookmarks(
|
||||
next: SftpBookmark[] | ((prev: SftpBookmark[]) => SftpBookmark[]),
|
||||
) {
|
||||
snapshot = typeof next === "function" ? next(snapshot) : next;
|
||||
localStorageAdapter.write(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS, snapshot);
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("storage", (event) => {
|
||||
if (event.key === STORAGE_KEY_SFTP_LOCAL_BOOKMARKS) {
|
||||
rehydrateLocalSftpBookmarks();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
interface UseLocalSftpBookmarksParams {
|
||||
currentPath: string | undefined;
|
||||
}
|
||||
|
||||
export const useLocalSftpBookmarks = ({
|
||||
currentPath,
|
||||
}: UseLocalSftpBookmarksParams) => {
|
||||
const bookmarks = useSyncExternalStore(
|
||||
subscribeLocalSftpBookmarks,
|
||||
getLocalSftpBookmarksSnapshot,
|
||||
getLocalSftpBookmarksSnapshot,
|
||||
);
|
||||
|
||||
const isCurrentPathBookmarked = useMemo(
|
||||
() => !!currentPath && bookmarks.some((b) => b.path === currentPath),
|
||||
[currentPath, bookmarks],
|
||||
);
|
||||
|
||||
const toggleBookmark = useCallback(() => {
|
||||
if (!currentPath) return;
|
||||
if (isCurrentPathBookmarked) {
|
||||
setLocalSftpBookmarks((prev) => prev.filter((b) => b.path !== currentPath));
|
||||
} else {
|
||||
setLocalSftpBookmarks((prev) => [...prev, createSftpBookmark(currentPath)]);
|
||||
}
|
||||
}, [currentPath, isCurrentPathBookmarked]);
|
||||
|
||||
const deleteBookmark = useCallback((id: string) => {
|
||||
setLocalSftpBookmarks((prev) => prev.filter((b) => b.id !== id));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
bookmarks,
|
||||
isCurrentPathBookmarked,
|
||||
toggleBookmark,
|
||||
deleteBookmark,
|
||||
};
|
||||
};
|
||||
File renamed without changes.
+1
-1
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore, useEffect } from "react";
|
||||
import { sftpFocusStore, SftpFocusedSide } from "./useSftpFocusedPane";
|
||||
import { sftpFocusStore, SftpFocusedSide } from "./sftpFocusStore";
|
||||
|
||||
type SftpDialogActionType = "rename" | "delete" | "newFolder" | "newFile" | null;
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -2,8 +2,13 @@ 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";
|
||||
|
||||
@@ -12,6 +17,20 @@ 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);
|
||||
@@ -19,6 +38,19 @@ 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;
|
||||
@@ -33,6 +65,106 @@ 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,6 +2,20 @@ 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 &&
|
||||
@@ -11,6 +25,24 @@ 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,
|
||||
@@ -26,17 +58,27 @@ export async function runSftpTransferWorkers<T>(
|
||||
): Promise<void> {
|
||||
const concurrency = resolveSftpTransferConcurrency(readStoredConcurrency);
|
||||
let nextIndex = 0;
|
||||
let failed = false;
|
||||
let firstError: unknown;
|
||||
|
||||
const runNext = async () => {
|
||||
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();
|
||||
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;
|
||||
}
|
||||
if (nextIndex >= items.length) return;
|
||||
const index = nextIndex++;
|
||||
await worker(items[index], index);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -44,5 +86,47 @@ 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,7 +146,9 @@ 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");
|
||||
assert.equal(maxActiveListings, 1);
|
||||
// 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(tasks.find((task) => task.id === root.id)?.totalBytes, directoryCount);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
|
||||
@@ -9,11 +9,20 @@ import {
|
||||
type SftpDirectoryTraversalBudget,
|
||||
shouldFollowSftpSymlinkDirectory,
|
||||
} from "../../../domain/sftpDirectoryCheckpoint";
|
||||
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from "../../../infrastructure/config/storageKeys";
|
||||
import { isUnchangedTransferCandidate } from "../../../domain/sftpTransferSkip";
|
||||
import {
|
||||
STORAGE_KEY_SFTP_SKIP_UNCHANGED,
|
||||
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 { runSftpTransferWorkers } from "./transferConcurrency";
|
||||
import {
|
||||
DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY,
|
||||
resolveSftpDirectoryListingConcurrency,
|
||||
resolveSftpSkipUnchangedEnabled,
|
||||
runSftpTransferWorkers,
|
||||
} from "./transferConcurrency";
|
||||
import { getSftpTransferResourceKeys, globalSftpTransferScheduler } from "./globalTransferScheduler";
|
||||
import { resolveDedicatedStreamEndpointIds } from "../../../domain/sftpDedicatedStreamPolicy";
|
||||
import { isSessionError } from "./errors";
|
||||
@@ -31,6 +40,49 @@ 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,
|
||||
@@ -45,6 +97,40 @@ 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,
|
||||
@@ -357,13 +443,40 @@ export function useSftpDirectoryTransferOps({
|
||||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||
}
|
||||
})();
|
||||
let result: { error?: string; cancelled?: boolean } | undefined;
|
||||
let result: { error?: string; cancelled?: boolean; superseded?: 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");
|
||||
}
|
||||
@@ -422,7 +535,7 @@ export function useSftpDirectoryTransferOps({
|
||||
sameHost?: boolean,
|
||||
symlinkDepth = 0,
|
||||
followSymlinks = false, // Only true for downloadToLocal — uploads/copies treat symlinks as files
|
||||
discoveryProgress?: { discoveredFiles: number; nextEntryIndex: number },
|
||||
discoveryProgress?: DirectoryDiscoveryProgress,
|
||||
traversalBudget?: SftpDirectoryTraversalBudget,
|
||||
) => {
|
||||
// Check if task or root task was cancelled before starting
|
||||
@@ -430,8 +543,20 @@ 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 };
|
||||
const progress = discoveryProgress ?? {
|
||||
discoveredFiles: 0,
|
||||
nextEntryIndex: 0,
|
||||
listingGate: createDirectoryListingGate(resolveSftpDirectoryListingConcurrency()),
|
||||
};
|
||||
if (!progress.listingGate) {
|
||||
progress.listingGate = createDirectoryListingGate(resolveSftpDirectoryListingConcurrency());
|
||||
}
|
||||
const listingGate = progress.listingGate;
|
||||
const traversal = traversalBudget ?? createSftpDirectoryTraversalBudget();
|
||||
let claimedCanonicalPath: string | null = null;
|
||||
let regularFiles: SftpFileEntry[] = [];
|
||||
@@ -473,13 +598,16 @@ export function useSftpDirectoryTransferOps({
|
||||
}
|
||||
|
||||
let files: SftpFileEntry[];
|
||||
if (sourceIsLocal) {
|
||||
files = await listLocalFiles(task.sourcePath);
|
||||
} else if (sourceSftpId) {
|
||||
files = await listRemoteFiles(sourceSftpId, task.sourcePath, sourceEncoding);
|
||||
} else {
|
||||
// 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);
|
||||
}
|
||||
throw new Error("No source connection");
|
||||
}
|
||||
});
|
||||
|
||||
// Filter both "." and ".." — some SFTP servers include "." in readdir
|
||||
const filtered = files.filter((f) => f.name !== ".." && f.name !== ".");
|
||||
@@ -509,26 +637,25 @@ export function useSftpDirectoryTransferOps({
|
||||
regularFiles.sort((left, right) => left.name.localeCompare(right.name));
|
||||
|
||||
// Directory progress is discovered by the same traversal that performs
|
||||
// the transfer. This avoids a second full-tree list pass and lets the UI
|
||||
// grow the total incrementally without flooding the server at startup.
|
||||
// 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.
|
||||
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 = {
|
||||
@@ -544,7 +671,7 @@ export function useSftpDirectoryTransferOps({
|
||||
};
|
||||
|
||||
const isSymlink = dir.type === "symlink";
|
||||
const subdirErrors = await transferDirectory(
|
||||
totalErrors += await transferDirectory(
|
||||
childTask,
|
||||
sourceSftpId,
|
||||
targetSftpId,
|
||||
@@ -559,7 +686,6 @@ export function useSftpDirectoryTransferOps({
|
||||
progress,
|
||||
traversal,
|
||||
);
|
||||
totalErrors += subdirErrors;
|
||||
}
|
||||
} finally {
|
||||
// Release on success, cancellation, and traversal errors.
|
||||
@@ -570,6 +696,12 @@ 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).
|
||||
@@ -589,8 +721,10 @@ 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;
|
||||
const directoryEntryIdentity = createDirectoryEntryIdentity({
|
||||
let directoryEntryIdentity = createDirectoryEntryIdentity({
|
||||
sourcePath,
|
||||
targetPath,
|
||||
size: fileSize,
|
||||
@@ -615,6 +749,104 @@ 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
|
||||
@@ -634,11 +866,11 @@ export function useSftpDirectoryTransferOps({
|
||||
isDirectory: false,
|
||||
progressMode: "bytes",
|
||||
parentTaskId: rootTaskId,
|
||||
totalBytes: fileSize,
|
||||
sourceLastModified: file.lastModified,
|
||||
totalBytes: transferSize,
|
||||
sourceLastModified: transferLastModified || undefined,
|
||||
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
|
||||
@@ -746,7 +978,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(),
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
DEFAULT_SFTP_TRANSFER_POOL_IDLE_TTL_MS,
|
||||
isTransferPoolIdleReclaimDisabled,
|
||||
resolveSftpTransferPoolIdleTtlMs,
|
||||
} from "../../../infrastructure/config/sftpTransferPool.ts";
|
||||
|
||||
test("default pool idle TTL is five minutes", () => {
|
||||
assert.equal(DEFAULT_SFTP_TRANSFER_POOL_IDLE_TTL_MS, 5 * 60_000);
|
||||
assert.equal(resolveSftpTransferPoolIdleTtlMs(() => null), DEFAULT_SFTP_TRANSFER_POOL_IDLE_TTL_MS);
|
||||
});
|
||||
|
||||
test("accepts preset values including never-reclaim zero", () => {
|
||||
assert.equal(resolveSftpTransferPoolIdleTtlMs(() => 60_000), 60_000);
|
||||
assert.equal(resolveSftpTransferPoolIdleTtlMs(() => 0), 0);
|
||||
assert.equal(resolveSftpTransferPoolIdleTtlMs(() => 123), DEFAULT_SFTP_TRANSFER_POOL_IDLE_TTL_MS);
|
||||
});
|
||||
|
||||
test("transfer pool idle reclaim helper is deprecated and always reclaim-enabled", () => {
|
||||
// Channels close when idle; SSH park is separate. The helper always returns false.
|
||||
assert.equal(isTransferPoolIdleReclaimDisabled(0), false);
|
||||
assert.equal(isTransferPoolIdleReclaimDisabled(300_000), false);
|
||||
});
|
||||
@@ -111,9 +111,4 @@ export interface SftpStateOptions {
|
||||
* This keeps side-panel tab switches on the already-authenticated SSH transport.
|
||||
*/
|
||||
resolveBrowseSourceSessionId?: (hostId: string, host?: Host) => string | undefined;
|
||||
/**
|
||||
* @deprecated Transfer channels no longer park independently. SSH keep-alive
|
||||
* is controlled by sshTransportIdleTtlMs in settings.
|
||||
*/
|
||||
transferPoolIdleTtlMs?: number;
|
||||
}
|
||||
@@ -59,3 +59,96 @@ 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) => {
|
||||
onScanningStart: (taskId: string, info?: { label?: string }) => {
|
||||
store.upsertTasks([{
|
||||
id: taskId,
|
||||
ownerId,
|
||||
fileName: "Scanning files...",
|
||||
fileName: info?.label?.trim() || "Scanning files...",
|
||||
sourcePath: "local",
|
||||
targetPath,
|
||||
sourceConnectionId: "external",
|
||||
@@ -42,18 +42,30 @@ 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: "bytes",
|
||||
progressMode: "files",
|
||||
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);
|
||||
},
|
||||
@@ -90,8 +102,36 @@ 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,
|
||||
@@ -101,6 +141,7 @@ 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
|
||||
@@ -121,6 +162,7 @@ export const createUploadTaskCallbacks = ({
|
||||
endTime: Date.now(),
|
||||
transferredBytes: totalBytes,
|
||||
speed: 0,
|
||||
phase: undefined,
|
||||
});
|
||||
},
|
||||
onTaskFailed: (taskId: string, error: string) => {
|
||||
@@ -129,6 +171,7 @@ export const createUploadTaskCallbacks = ({
|
||||
endTime: Date.now(),
|
||||
error,
|
||||
speed: 0,
|
||||
phase: undefined,
|
||||
});
|
||||
},
|
||||
onTaskCancelled: (taskId: string) => {
|
||||
@@ -136,6 +179,7 @@ export const createUploadTaskCallbacks = ({
|
||||
status: "cancelled" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
phase: undefined,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -157,6 +157,23 @@ 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" }]),
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import type { Host, Identity, KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
|
||||
import { createKnownHostFromHostKeyInfo } from "../../../domain/knownHosts";
|
||||
import type { SftpHostKeyInfo, SftpHostKeyVerificationState, SftpPane } from "./types";
|
||||
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
|
||||
import { useSftpHostCredentials } from "./useSftpHostCredentials";
|
||||
@@ -41,6 +42,8 @@ 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 {
|
||||
@@ -82,6 +85,7 @@ 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;
|
||||
@@ -90,6 +94,7 @@ export async function releaseSftpConnectionMetadata(params: {
|
||||
} catch {
|
||||
// Best-effort: backend owner cleanup remains the final safety net.
|
||||
}
|
||||
params.onRemoteSessionClosed?.(sftpId);
|
||||
}
|
||||
|
||||
export function createSftpConnectionId(
|
||||
@@ -202,15 +207,7 @@ const createKnownHostFromSftpHostKeyInfo = (
|
||||
hostKeyInfo: SftpHostKeyInfo,
|
||||
now = Date.now(),
|
||||
idSuffix = Math.random().toString(36).slice(2, 11),
|
||||
): KnownHost => ({
|
||||
id: hostKeyInfo.knownHostId || `kh-${now}-${idSuffix}`,
|
||||
hostname: hostKeyInfo.hostname,
|
||||
port: hostKeyInfo.port || 22,
|
||||
keyType: hostKeyInfo.keyType,
|
||||
publicKey: hostKeyInfo.publicKey || `SHA256:${hostKeyInfo.fingerprint}`,
|
||||
fingerprint: hostKeyInfo.fingerprint,
|
||||
discoveredAt: now,
|
||||
});
|
||||
): KnownHost => createKnownHostFromHostKeyInfo(hostKeyInfo, { now, idSuffix });
|
||||
|
||||
export const useSftpConnections = ({
|
||||
hosts,
|
||||
@@ -240,7 +237,13 @@ 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);
|
||||
@@ -450,6 +453,7 @@ export const useSftpConnections = ({
|
||||
connectionCacheKeys: connectionCacheKeyMapRef.current,
|
||||
clearCacheForConnection,
|
||||
closeSftp: async (sftpId) => netcattyBridge.get()?.closeSftp(sftpId),
|
||||
onRemoteSessionClosed: (sftpId) => notifyRemoteSessionClosed(sftpId),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -491,6 +495,7 @@ export const useSftpConnections = ({
|
||||
} catch {
|
||||
// Ignore errors when closing stale SFTP sessions
|
||||
}
|
||||
notifyRemoteSessionClosed(oldSftpId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -966,6 +971,7 @@ export const useSftpConnections = ({
|
||||
} catch {
|
||||
// Ignore errors when closing SFTP session during disconnect
|
||||
}
|
||||
notifyRemoteSessionClosed(sftpId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,11 +15,63 @@ import {
|
||||
UploadResult,
|
||||
startUploadScanningTask,
|
||||
} from "../../../lib/uploadService";
|
||||
import { extractDropEntries, type DropEntry } from "../../../lib/sftpFileUtils";
|
||||
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);
|
||||
}
|
||||
|
||||
// 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";
|
||||
@@ -41,12 +93,17 @@ 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;
|
||||
@@ -150,9 +207,12 @@ export const useSftpExternalOperations = (
|
||||
callbacks: UploadCallbacks,
|
||||
): UploadCallbacks => ({
|
||||
...callbacks,
|
||||
onScanningStart: (taskId) => {
|
||||
onScanningStart: (taskId, info) => {
|
||||
registerUploadController(taskId, controller);
|
||||
callbacks.onScanningStart?.(taskId);
|
||||
callbacks.onScanningStart?.(taskId, info);
|
||||
},
|
||||
onScanningProgress: (taskId, progress) => {
|
||||
callbacks.onScanningProgress?.(taskId, progress);
|
||||
},
|
||||
onTaskCreated: (task) => {
|
||||
registerUploadController(task.id, controller);
|
||||
@@ -163,23 +223,49 @@ 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 }) => void,
|
||||
callback: (payload: { watchId: string; localPath?: string }) => void,
|
||||
) => netcattyBridge.get()?.onFileWatchStopped?.(callback), []);
|
||||
const {
|
||||
activeCountRef: activeFileWatchCountRef,
|
||||
captureGeneration: captureExternalFileWatchGeneration,
|
||||
remember: rememberExternalFileWatch,
|
||||
releaseAll: releaseExternalFileWatches,
|
||||
releaseAll: releaseExternalFileWatchesBase,
|
||||
} = 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. */
|
||||
@@ -483,6 +569,7 @@ 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);
|
||||
}
|
||||
@@ -490,7 +577,7 @@ export const useSftpExternalOperations = (
|
||||
|
||||
return { localTempPath, sftpId, externalTransferId };
|
||||
},
|
||||
[getActivePane, isTransferCancelled, ownerId, sftpSessionsRef],
|
||||
[getActivePane, isTransferCancelled, ownerId, rememberExternalEditTemp, sftpSessionsRef],
|
||||
);
|
||||
|
||||
const downloadToTempAndOpen = useCallback(
|
||||
@@ -525,6 +612,7 @@ 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,
|
||||
@@ -555,7 +643,13 @@ export const useSftpExternalOperations = (
|
||||
|
||||
return { localTempPath, watchId };
|
||||
},
|
||||
[captureExternalFileWatchGeneration, downloadToTemp, getActivePane, rememberExternalFileWatch],
|
||||
[
|
||||
captureExternalFileWatchGeneration,
|
||||
downloadToTemp,
|
||||
forgetExternalEditTemp,
|
||||
getActivePane,
|
||||
rememberExternalFileWatch,
|
||||
],
|
||||
);
|
||||
|
||||
const openWithSystemDefault = useCallback(
|
||||
@@ -590,12 +684,14 @@ 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, {
|
||||
@@ -627,7 +723,13 @@ export const useSftpExternalOperations = (
|
||||
notify.error(err instanceof Error ? err.message : String(err), "SFTP");
|
||||
}
|
||||
},
|
||||
[captureExternalFileWatchGeneration, downloadToTemp, getActivePane, rememberExternalFileWatch],
|
||||
[
|
||||
captureExternalFileWatchGeneration,
|
||||
downloadToTemp,
|
||||
forgetExternalEditTemp,
|
||||
getActivePane,
|
||||
rememberExternalFileWatch,
|
||||
],
|
||||
);
|
||||
|
||||
// Create upload callbacks that translate to TransferTask updates
|
||||
@@ -721,9 +823,12 @@ 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
|
||||
@@ -845,52 +950,271 @@ 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 entries BEFORE
|
||||
// DataTransfer is only valid during the drop event. Capture roots BEFORE
|
||||
// any await (session reconnect can take seconds while a transfer is busy).
|
||||
// Otherwise extractDropEntries returns [] and the UI toasts "Uploaded files: 0".
|
||||
let capturedEntries: DropEntry[];
|
||||
try {
|
||||
capturedEntries = await extractDropEntries(dataTransfer);
|
||||
} catch (error) {
|
||||
logger.error("[SFTP] Failed to read dropped files:", error);
|
||||
throw error;
|
||||
}
|
||||
if (capturedEntries.length === 0) {
|
||||
// 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[] = [];
|
||||
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`,
|
||||
);
|
||||
} 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 pane = getActivePane(side);
|
||||
if (!pane?.connection) {
|
||||
const { sftpId, release } = await resolveRemoteSftpId(side, { forceReconnect });
|
||||
const livePane = getActivePane(side) ?? pane;
|
||||
if (!livePane.connection) {
|
||||
release();
|
||||
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 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 liveTargetPath = targetPath || livePane.connection.currentPath;
|
||||
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("/")
|
||||
));
|
||||
@@ -908,13 +1232,13 @@ export const useSftpExternalOperations = (
|
||||
run: async (uploadSftpId) => uploadEntriesDirect(
|
||||
capturedEntries,
|
||||
{
|
||||
targetPath: uploadTargetPath,
|
||||
targetPath: liveTargetPath,
|
||||
sftpId: uploadSftpId,
|
||||
targetHostId: livePane.connection!.isLocal ? undefined : livePane.connection!.hostId,
|
||||
isLocal: livePane.connection!.isLocal,
|
||||
bridge: uploadBridge,
|
||||
joinPath,
|
||||
callbacks,
|
||||
callbacks: transferCallbacks,
|
||||
useCompressedUpload,
|
||||
resolveConflict: createUploadConflictResolver(controller),
|
||||
},
|
||||
@@ -923,15 +1247,19 @@ export const useSftpExternalOperations = (
|
||||
});
|
||||
|
||||
if (clearDirCacheEntry && targetPath) {
|
||||
clearDirCacheEntry(livePane.connection.id, uploadTargetPath);
|
||||
clearDirCacheEntry(livePane.connection.id, liveTargetPath);
|
||||
}
|
||||
if (uploadTargetPath === livePane.connection.currentPath) {
|
||||
if (liveTargetPath === 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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -940,10 +1268,26 @@ export const useSftpExternalOperations = (
|
||||
} catch (error) {
|
||||
if (isSessionError(error) && ensureRemoteSftpId) {
|
||||
logger.warn("[SFTP] Upload session lost; reconnecting and retrying once", error);
|
||||
return await run(true);
|
||||
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);
|
||||
}
|
||||
logger.error("[SFTP] Upload failed:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
detachScanCancel();
|
||||
unregisterUploadController(controller);
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -1103,105 +1447,125 @@ 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[]> => {
|
||||
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);
|
||||
|
||||
if (controller.isCancelled()) {
|
||||
scanningTask.cancel();
|
||||
return [{ fileName: "", success: false, cancelled: true }];
|
||||
}
|
||||
let release = () => {};
|
||||
try {
|
||||
const localEntries = await bridge.listLocalTree(folderPath);
|
||||
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);
|
||||
}
|
||||
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,
|
||||
@@ -1215,7 +1579,7 @@ export const useSftpExternalOperations = (
|
||||
: undefined,
|
||||
shouldDiscard: isSessionError,
|
||||
run: async (uploadSftpId) => uploadEntriesDirect(
|
||||
entries,
|
||||
capturedEntries,
|
||||
{
|
||||
targetPath: uploadTargetPath,
|
||||
sftpId: uploadSftpId,
|
||||
@@ -1223,7 +1587,7 @@ export const useSftpExternalOperations = (
|
||||
isLocal: livePane.connection!.isLocal,
|
||||
bridge: uploadBridge,
|
||||
joinPath,
|
||||
callbacks,
|
||||
callbacks: transferCallbacks,
|
||||
useCompressedUpload,
|
||||
resolveConflict: createUploadConflictResolver(controller),
|
||||
},
|
||||
@@ -1239,18 +1603,8 @@ 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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1259,11 +1613,33 @@ export const useSftpExternalOperations = (
|
||||
} catch (error) {
|
||||
if (isSessionError(error) && ensureRemoteSftpId) {
|
||||
logger.warn("[SFTP] Folder upload session lost; reconnecting and retrying once", error);
|
||||
return await run(true);
|
||||
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;
|
||||
}
|
||||
}
|
||||
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,
|
||||
@@ -1491,6 +1867,8 @@ export const useSftpExternalOperations = (
|
||||
cancelExternalUpload,
|
||||
selectApplication,
|
||||
activeFileWatchCountRef,
|
||||
activeExternalEditCount,
|
||||
forgetExternalEditTempsForSftp,
|
||||
releaseExternalFileWatches,
|
||||
uploadConflicts,
|
||||
resolveUploadConflict,
|
||||
|
||||
@@ -68,6 +68,10 @@ 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:djEwAAAA",
|
||||
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
created: 1,
|
||||
}],
|
||||
}),
|
||||
@@ -285,7 +285,7 @@ test("buildSftpHostCredentials rejects incomplete proxy identities on jump hosts
|
||||
label: "Proxy login",
|
||||
username: "",
|
||||
authMethod: "password",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
created: 1,
|
||||
}],
|
||||
}),
|
||||
@@ -368,7 +368,7 @@ test("buildSftpHostCredentials rejects undecryptable proxy identity passwords",
|
||||
label: "Proxy login",
|
||||
username: "proxy-user",
|
||||
authMethod: "password",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
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:djEwAAAA",
|
||||
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
created: 1,
|
||||
}],
|
||||
}),
|
||||
@@ -539,7 +539,7 @@ test("buildSftpHostCredentials rejects undecryptable saved password credentials"
|
||||
() => buildSftpHostCredentials({
|
||||
host: host({
|
||||
authMethod: "password",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
}),
|
||||
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:djEwAAAA",
|
||||
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
}),
|
||||
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:djEwAAAA",
|
||||
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
});
|
||||
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:djEwAAAA",
|
||||
privateKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
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:djEwAAAA",
|
||||
privateKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
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:djEwAAAA",
|
||||
privateKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
source: "imported",
|
||||
category: "key",
|
||||
created: 1,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("./useSftpPaneSorting.ts", import.meta.url), "utf8");
|
||||
|
||||
test("useSftpPaneSorting imports column layout from application state", () => {
|
||||
assert.doesNotMatch(source, /from ["'].*components\//);
|
||||
assert.match(source, /from ["']\.\/columnLayout["']/);
|
||||
});
|
||||
+2
-2
@@ -6,13 +6,13 @@ import {
|
||||
type SftpColumnVisibility,
|
||||
type SortField,
|
||||
type SortOrder,
|
||||
} from "../utils";
|
||||
} from "./columnLayout";
|
||||
import { STORAGE_KEY_SFTP_VISIBLE_COLUMNS } from "../../../infrastructure/config/storageKeys";
|
||||
import {
|
||||
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
|
||||
localStorageAdapter,
|
||||
} from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { useSftpDirectoriesFirst } from "../../../application/state/sftp/useSftpDirectoriesFirst";
|
||||
import { useSftpDirectoriesFirst } from "./useSftpDirectoriesFirst";
|
||||
|
||||
export interface UseSftpPaneSortingResult {
|
||||
sortField: SortField;
|
||||
@@ -2,25 +2,6 @@ import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
|
||||
export function useWarmSftpTransferPool(params: {
|
||||
hostIds: readonly string[];
|
||||
activeHostId?: string;
|
||||
warmTransferPoolForHost?: (hostId: string) => void | Promise<void>;
|
||||
}) {
|
||||
const warmRef = useRef(params.warmTransferPoolForHost);
|
||||
warmRef.current = params.warmTransferPoolForHost;
|
||||
const hostIdsKey = [...new Set([
|
||||
...params.hostIds,
|
||||
...(params.activeHostId ? [params.activeHostId] : []),
|
||||
])].sort().join("\u0000");
|
||||
|
||||
useEffect(() => {
|
||||
for (const hostId of hostIdsKey.split("\u0000").filter(Boolean)) {
|
||||
void warmRef.current?.(hostId);
|
||||
}
|
||||
}, [hostIdsKey]);
|
||||
}
|
||||
|
||||
export function useReportSftpTransferOwnerActivity(params: {
|
||||
ownerId: string;
|
||||
activeTransfersCount: number;
|
||||
|
||||
@@ -60,6 +60,10 @@ 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) {
|
||||
@@ -1294,8 +1298,62 @@ export const useSftpTransfers = ({
|
||||
|
||||
const retryTransfer = useCallback(
|
||||
async (transferId: string) => {
|
||||
const task = transfersRef.current.find((t) => t.id === transferId);
|
||||
// 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);
|
||||
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 = {
|
||||
@@ -1316,7 +1374,13 @@ export const useSftpTransfers = ({
|
||||
};
|
||||
|
||||
const endpoints = resolveTaskEndpoints(task);
|
||||
if (!endpoints) return;
|
||||
if (!endpoints) {
|
||||
notify.warning(
|
||||
"Could not resolve transfer endpoints for retry. Reconnect the target and try again.",
|
||||
"SFTP",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const { targetSide, sourcePane, targetPane } = endpoints;
|
||||
|
||||
const completionHandler = completionHandlersRef.current.get(transferId);
|
||||
@@ -1335,7 +1399,7 @@ export const useSftpTransfers = ({
|
||||
await processTransfer(retriedTask, sourcePane, targetPane, targetSide);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- processTransfer is defined inline
|
||||
[cleanupTaskArtifacts, resolveTaskEndpoints, setTransfers],
|
||||
[acquireTransferSession, cleanupTaskArtifacts, ownerId, resolveTaskEndpoints, setTransfers, sftpSessionsRef],
|
||||
);
|
||||
|
||||
const clearCompletedTransfers = useCallback(() => {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { SftpFileEntry, TransferTask } from "../../../domain/models";
|
||||
import { formatBytes } from "../../../domain/formatBytes";
|
||||
|
||||
export const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return "--";
|
||||
const units = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
const size = bytes / Math.pow(1024, i);
|
||||
return `${size.toFixed(i === 0 ? 0 : 2)} ${units[i]}`;
|
||||
return formatBytes(bytes, {
|
||||
zeroDisplay: "--",
|
||||
includeTB: true,
|
||||
fractionDigits: 2,
|
||||
byteUnit: "Bytes",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -2318,7 +2318,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
const browserPersistence: StorePersistence | undefined = typeof globalThis.localStorage === "undefined"
|
||||
? undefined
|
||||
: {
|
||||
read: () => globalThis.localStorage.getItem(STORAGE_KEY_SFTP_TRANSFER_CENTER),
|
||||
read: () => localStorageAdapter.readString(STORAGE_KEY_SFTP_TRANSFER_CENTER),
|
||||
write: (value) => { localStorageAdapter.writeString(STORAGE_KEY_SFTP_TRANSFER_CENTER, value); },
|
||||
};
|
||||
|
||||
|
||||
File renamed without changes.
@@ -172,15 +172,6 @@ export const useUIFontsLoaded = (): boolean => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a font ID is valid
|
||||
*/
|
||||
export const isValidUiFontId = (fontId: string): boolean => {
|
||||
// Local fonts are always considered valid (they start with 'local-')
|
||||
if (fontId.startsWith('local-')) return true;
|
||||
return uiFontStore.getAvailableFonts().some(f => f.id === fontId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize UI fonts eagerly
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("./useAIChatStreaming.ts", import.meta.url), "utf8");
|
||||
|
||||
test("useAIChatStreaming imports streaming support from infrastructure", () => {
|
||||
assert.match(source, /from ['"]\.\.\/\.\.\/infrastructure\/ai\/aiChatStreamingSupport['"]/);
|
||||
assert.doesNotMatch(source, /from ['"].*components\//);
|
||||
});
|
||||
+10
-10
@@ -15,26 +15,26 @@ import type {
|
||||
ExternalAgentConfig,
|
||||
ProviderConfig,
|
||||
WebSearchConfig,
|
||||
} from '../../../infrastructure/ai/types';
|
||||
import type { ExecutorContext } from '../../../infrastructure/ai/cattyAgent/executor';
|
||||
import { getAgentRuntime } from '../../../infrastructure/ai/harness/globalAgentRuntime';
|
||||
} from '../../infrastructure/ai/types';
|
||||
import type { ExecutorContext } from '../../infrastructure/ai/cattyAgent/executor';
|
||||
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime';
|
||||
import type {
|
||||
TurnSteerInput,
|
||||
TurnSteerResult,
|
||||
} from '../../../infrastructure/ai/harness/turnDrivers/types';
|
||||
import { classifyError } from '../../../infrastructure/ai/errorClassifier';
|
||||
import { latestAISessionsSnapshot } from '../../../application/state/aiStateSnapshots';
|
||||
} from '../../infrastructure/ai/harness/turnDrivers/types';
|
||||
import { classifyError } from '../../infrastructure/ai/errorClassifier';
|
||||
import { latestAISessionsSnapshot } from './aiStateSnapshots';
|
||||
import {
|
||||
generateId,
|
||||
getNetcattyBridge,
|
||||
type DefaultTargetSessionHint,
|
||||
type TerminalSessionInfo,
|
||||
} from './aiChatStreamingSupport';
|
||||
} from '../../infrastructure/ai/aiChatStreamingSupport';
|
||||
import { useAgentCompactionUi } from './useAgentCompactionUi';
|
||||
|
||||
export { getNetcattyBridge } from './aiChatStreamingSupport';
|
||||
export { getNetcattyBridge } from '../../infrastructure/ai/aiChatStreamingSupport';
|
||||
export type { ActiveCompactionUi } from './useAgentCompactionUi';
|
||||
export type { DefaultTargetSessionHint } from './aiChatStreamingSupport';
|
||||
export type { DefaultTargetSessionHint } from '../../infrastructure/ai/aiChatStreamingSupport';
|
||||
|
||||
const sharedStreamingSessionIds = new Set<string>();
|
||||
const sharedAbortControllers = new Map<string, AbortController>();
|
||||
@@ -62,7 +62,7 @@ export interface UseAIChatStreamingParams {
|
||||
updateMessageById: (sessionId: string, messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
|
||||
persistContextCompaction?: (
|
||||
sessionId: string,
|
||||
compaction: import('../../../infrastructure/ai/types').AISessionContextCompaction,
|
||||
compaction: import('../../infrastructure/ai/types').AISessionContextCompaction,
|
||||
) => void;
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ContextPrepareTrigger } from '../../../infrastructure/ai/harness/types';
|
||||
import { getAgentRuntime } from '../../../infrastructure/ai/harness/globalAgentRuntime';
|
||||
import { CATTY_COMPACTION_STATUS_KEYS } from '../../../infrastructure/ai/harness/compactionStatusKeys';
|
||||
import type { ContextPrepareTrigger } from '../../infrastructure/ai/harness/types';
|
||||
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime';
|
||||
import { CATTY_COMPACTION_STATUS_KEYS } from '../../infrastructure/ai/harness/compactionStatusKeys';
|
||||
|
||||
export interface ActiveCompactionUi {
|
||||
sessionId: string;
|
||||
@@ -191,3 +191,48 @@ 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,6 +14,8 @@ 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';
|
||||
@@ -31,6 +33,7 @@ import {
|
||||
sanitizePortForwardingRulesForSync,
|
||||
shouldPromptCloudVaultRecovery,
|
||||
} from '../syncPayload';
|
||||
import { commitPluginSidecarsAfterSuccessfulSync } from '../pluginSyncSidecarBridge';
|
||||
import { readInterruptedVaultApply } from '../localVaultBackups';
|
||||
import {
|
||||
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
|
||||
@@ -76,6 +79,21 @@ 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
|
||||
@@ -125,6 +143,7 @@ const getSyncPayloadDataHash = (payload: SyncPayload): string => {
|
||||
portForwardingRules: sanitizePortForwardingRulesForSync(payload.portForwardingRules),
|
||||
groupConfigs: payload.groupConfigs,
|
||||
settings: payload.settings,
|
||||
pluginSidecars: payload.pluginSidecars,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -188,6 +207,21 @@ 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) => {
|
||||
@@ -239,19 +273,24 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
config.groupConfigs,
|
||||
]);
|
||||
|
||||
// Build sync payload
|
||||
// Build sync payload (includes plugin sidecars when host is available)
|
||||
const buildPayload = useCallback(async (): Promise<SyncPayload> => {
|
||||
return {
|
||||
const { withPluginSyncSidecars } = await import('../syncPayload');
|
||||
const { collectPluginSyncSidecarsFromHost } = await import('../pluginSyncSidecarBridge');
|
||||
const base: SyncPayload = {
|
||||
...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)
|
||||
// Create a hash of current data for comparison (includes settings + sidecars)
|
||||
const getDataHash = useCallback(async () => {
|
||||
return JSON.stringify({ ...getSyncSnapshot(), settings: await collectCloudSyncableSettings() });
|
||||
}, [getSyncSnapshot]);
|
||||
const payload = await buildPayload();
|
||||
return getSyncPayloadDataHash(payload);
|
||||
}, [buildPayload]);
|
||||
|
||||
// Sync now handler - get fresh state directly from manager
|
||||
const syncNow = useCallback(async (options?: SyncNowOptions): Promise<boolean> => {
|
||||
@@ -346,8 +385,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
|
||||
@@ -398,13 +437,14 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
|
||||
for (const result of resultList) {
|
||||
if (result.mergedPayload && !result.mergedPayloadApplied) {
|
||||
await Promise.resolve(onApplyPayload(result.mergedPayload));
|
||||
const portableMerged = stripSyncPayloadEncryptedCredentials(result.mergedPayload);
|
||||
await Promise.resolve(onApplyPayload(portableMerged));
|
||||
if (result.remoteFile) {
|
||||
await sync.commitRemoteInspection(result.provider, result.remoteFile, result.mergedPayload, {
|
||||
await sync.commitRemoteInspection(result.provider, result.remoteFile, portableMerged, {
|
||||
recordDownload: true,
|
||||
});
|
||||
}
|
||||
skipNextSyncHashRef.current = allProvidersSynced ? getSyncPayloadDataHash(result.mergedPayload) : null;
|
||||
skipNextSyncHashRef.current = allProvidersSynced ? getSyncPayloadDataHash(portableMerged) : null;
|
||||
if (!allProvidersSynced) {
|
||||
console.warn('[AutoSync] Remote payload applied locally, but not every provider synced; leaving next auto-sync enabled for retry.');
|
||||
}
|
||||
@@ -421,6 +461,10 @@ 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);
|
||||
|
||||
@@ -451,7 +495,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
} finally {
|
||||
isSyncRunningRef.current = false;
|
||||
}
|
||||
}, [enabled, sync, buildPayload, getDataHash, onApplyConvergentPayload, onApplyPayload, t]);
|
||||
}, [enabled, sync, buildPayload, onApplyConvergentPayload, onApplyPayload, t]);
|
||||
|
||||
const syncNowRef = useRef(syncNow);
|
||||
useEffect(() => {
|
||||
@@ -517,13 +561,19 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
if (!remoteCheckDoneRef.current) {
|
||||
return;
|
||||
}
|
||||
const currentHash = await getDataHashRef.current();
|
||||
const hashDecision = resolveAutoSyncHashDecision({
|
||||
currentHash,
|
||||
lastSyncedHash: lastSyncedDataRef.current,
|
||||
appliedSkipHash: skipNextSyncHashRef.current,
|
||||
});
|
||||
manager.setPendingLocalSync(hashDecision === 'sync');
|
||||
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);
|
||||
}
|
||||
}, [convergentSyncPaused, enabled, sync.hasAnyConnectedProvider, sync.isUnlocked]);
|
||||
|
||||
const refreshPendingLocalSyncRef = useRef(refreshPendingLocalSync);
|
||||
@@ -567,9 +617,7 @@ 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 = AUTO_SYNC_PROVIDER_ORDER.find((provider) =>
|
||||
isProviderReadyForSync(state.providers[provider]),
|
||||
) ?? null;
|
||||
const connectedProvider = pickConnectedAutoSyncProvider(state.providers) ?? null;
|
||||
|
||||
if (!connectedProvider) {
|
||||
// Nothing to check — mark as done so the auto-sync gate opens.
|
||||
@@ -662,7 +710,11 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
inspectedRemoteChange = true;
|
||||
|
||||
const remoteFile = inspection.remoteFile;
|
||||
const remotePayload = inspection.payload;
|
||||
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 localPayload = await buildPayloadRef.current();
|
||||
|
||||
// If local vault is empty but cloud has data, this almost certainly
|
||||
@@ -715,6 +767,7 @@ 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);
|
||||
@@ -732,8 +785,17 @@ 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);
|
||||
@@ -750,15 +812,20 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
throw new Error('Startup local-wins sync failed for one or more providers');
|
||||
}
|
||||
|
||||
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
|
||||
// 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);
|
||||
|
||||
// Apply merged payload to local state BEFORE committing. If the apply
|
||||
// throws, the next startup will re-run the merge with fresh data.
|
||||
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.
|
||||
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 manager.commitRemoteInspection(connectedProvider, remoteFile, remotePayload);
|
||||
startupConsistent = true;
|
||||
markCurrentDataSynced = false;
|
||||
@@ -779,8 +846,9 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// that only approximated the correct ordering.
|
||||
if (mergeResult.payload) {
|
||||
try {
|
||||
const roundTripResults = await manager.syncAllProviders(mergeResult.payload);
|
||||
const roundTripResults = await manager.syncAllProviders(portableMerge);
|
||||
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);
|
||||
@@ -828,7 +896,11 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
isInitializedRef.current = true;
|
||||
}
|
||||
if (markCurrentDataSynced && (!hadInitialBaseline || inspectedRemoteChange)) {
|
||||
lastSyncedDataRef.current = await getDataHashRef.current();
|
||||
try {
|
||||
lastSyncedDataRef.current = await getDataHashRef.current();
|
||||
} catch (error) {
|
||||
console.warn('[AutoSync] Failed to capture post-inspect baseline hash:', error);
|
||||
}
|
||||
} else if (!markCurrentDataSynced) {
|
||||
lastSyncedDataRef.current = '';
|
||||
}
|
||||
@@ -873,9 +945,13 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const establishInitialBaseline = () => {
|
||||
isInitializedRef.current = true;
|
||||
void (async () => {
|
||||
const currentHash = await getDataHash();
|
||||
if (cancelled) return;
|
||||
lastSyncedDataRef.current = currentHash;
|
||||
try {
|
||||
const currentHash = await getDataHash();
|
||||
if (cancelled) return;
|
||||
lastSyncedDataRef.current = currentHash;
|
||||
} catch (error) {
|
||||
console.warn('[AutoSync] Failed to establish sync baseline hash:', error);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
@@ -916,7 +992,13 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
syncTimeoutRef.current = setTimeout(() => {
|
||||
syncTimeoutRef.current = null;
|
||||
void (async () => {
|
||||
const currentHash = await getDataHash();
|
||||
let currentHash: string;
|
||||
try {
|
||||
currentHash = await getDataHash();
|
||||
} catch (error) {
|
||||
console.warn('[AutoSync] Failed to hash local vault before auto-sync:', error);
|
||||
return;
|
||||
}
|
||||
if (cancelled) return;
|
||||
|
||||
// After a remote apply or merge, skip only that exact applied data.
|
||||
@@ -986,6 +1068,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
config.settingsVersion,
|
||||
enabled,
|
||||
bookmarksVersion,
|
||||
pluginSidecarsVersion,
|
||||
convergentSyncPaused,
|
||||
syncableSettingsStorageVersion,
|
||||
]);
|
||||
@@ -1002,6 +1085,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
config.settingsVersion,
|
||||
enabled,
|
||||
bookmarksVersion,
|
||||
pluginSidecarsVersion,
|
||||
syncableSettingsStorageVersion,
|
||||
]);
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ interface CloudSyncRunOptions {
|
||||
overrideShrink?: boolean;
|
||||
conflictActionOverride?: CloudSyncConflictAction;
|
||||
applyConvergentPayload?: ConvergentPayloadApplier;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface CloudSyncHook {
|
||||
@@ -107,6 +108,12 @@ 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>;
|
||||
@@ -124,7 +131,7 @@ export interface CloudSyncHook {
|
||||
syncToProvider: (
|
||||
provider: CloudProvider,
|
||||
payload: SyncPayload,
|
||||
opts?: Pick<CloudSyncRunOptions, 'overrideShrink' | 'applyConvergentPayload'>,
|
||||
opts?: Pick<CloudSyncRunOptions, 'overrideShrink' | 'applyConvergentPayload' | 'signal'>,
|
||||
) => Promise<SyncResult>;
|
||||
downloadFromProvider: (provider: CloudProvider) => Promise<RemoteSyncPayload | null>;
|
||||
commitRemoteInspection: (provider: CloudProvider, remoteFile: SyncedFile, payload: SyncPayload, opts?: { recordDownload?: boolean }) => Promise<void>;
|
||||
@@ -303,6 +310,39 @@ 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 ==========
|
||||
|
||||
@@ -676,6 +716,18 @@ 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;
|
||||
@@ -734,16 +786,48 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
|
||||
const syncNowWithUnlock = useCallback(async (payload: SyncPayload, opts?: CloudSyncRunOptions) => {
|
||||
await ensureUnlocked();
|
||||
return await manager.syncAllProviders(payload, opts);
|
||||
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);
|
||||
}
|
||||
}, [ensureUnlocked]);
|
||||
|
||||
const syncToProviderWithUnlock = useCallback(async (
|
||||
provider: CloudProvider,
|
||||
payload: SyncPayload,
|
||||
opts?: Pick<CloudSyncRunOptions, 'overrideShrink' | 'applyConvergentPayload'>,
|
||||
opts?: Pick<CloudSyncRunOptions, 'overrideShrink' | 'applyConvergentPayload' | 'signal'>,
|
||||
) => {
|
||||
await ensureUnlocked();
|
||||
return await manager.syncToProvider(provider, payload, opts);
|
||||
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);
|
||||
}
|
||||
}, [ensureUnlocked]);
|
||||
|
||||
const downloadFromProviderWithUnlock = useCallback(async (provider: CloudProvider) => {
|
||||
@@ -785,7 +869,29 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
) => Promise<void>,
|
||||
) => {
|
||||
await ensureUnlocked();
|
||||
return manager.resolveConvergentConflict(addressKey, candidateDot, applyPayload);
|
||||
// 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,
|
||||
});
|
||||
}, [ensureUnlocked]);
|
||||
|
||||
const refreshConvergentSyncConfig = useCallback(() => {
|
||||
@@ -854,6 +960,7 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
connectOneDrive,
|
||||
connectWebDAV,
|
||||
connectS3,
|
||||
connectPluginProvider,
|
||||
completePKCEAuth,
|
||||
cancelOAuthConnect,
|
||||
disconnectProvider,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { buildAITerminalSessionInfo } from '../../components/terminalLayer/TerminalLayerSupport';
|
||||
import { buildAITerminalSessionInfo } from '../../domain/buildAITerminalSessionInfo';
|
||||
import { detectLocalOs } from '../../lib/localShell';
|
||||
import type { Host, PortForwardingRule, TerminalSession } from '../../types';
|
||||
import { STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED } from '../../infrastructure/config/storageKeys';
|
||||
|
||||
@@ -144,12 +144,26 @@ export function readExternalMcpMode(): ExternalMcpMode {
|
||||
return normalizeExternalMcpMode(localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE));
|
||||
}
|
||||
|
||||
export function writeExternalMcpMode(mode: ExternalMcpMode): ExternalMcpMode {
|
||||
const normalized = normalizeExternalMcpMode(mode);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, normalized);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_MODE);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function readExternalMcpIdleTimeoutMinutes(): number {
|
||||
return normalizeExternalMcpIdleTimeoutMinutes(
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES),
|
||||
);
|
||||
}
|
||||
|
||||
export function writeExternalMcpIdleTimeoutMinutes(minutes: number): number {
|
||||
const normalized = normalizeExternalMcpIdleTimeoutMinutes(minutes);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES, normalized);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** Whether host_open should surface/focus the main window. Defaults to true (existing behavior). */
|
||||
export function readExternalMcpFocusOnHostOpen(): boolean {
|
||||
return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN) ?? true;
|
||||
@@ -187,6 +201,13 @@ export function readSessionIdleTimeoutMinutes(): number {
|
||||
);
|
||||
}
|
||||
|
||||
export function writeSessionIdleTimeoutMinutes(minutes: number): number {
|
||||
const normalized = normalizeSessionIdleTimeoutMinutes(minutes);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES, normalized);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function shouldStartExternalMcpOnStartup({
|
||||
enabled,
|
||||
mode,
|
||||
|
||||
@@ -782,6 +782,9 @@ export function useOutputTriggers({
|
||||
resetTriggerScanState();
|
||||
},
|
||||
schedule: (callback) => {
|
||||
// Prefer setTimeout over rAF: terminal output can continue while the
|
||||
// Electron renderer is hidden/minimized, when rAF is throttled or paused.
|
||||
// Pending scan buffers are capped, so delayed flushes can drop matches.
|
||||
const timer = globalThis.setTimeout(callback, OUTPUT_TRIGGER_SCAN_DELAY_MS);
|
||||
return () => globalThis.clearTimeout(timer);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("./usePortForwardHostKeyVerification.ts", import.meta.url), "utf8");
|
||||
|
||||
test("usePortForwardHostKeyVerification stays below the UI layer", () => {
|
||||
assert.doesNotMatch(source, /from ["'].*components\//);
|
||||
assert.match(source, /from ["']\.\.\/\.\.\/domain\/portForwardHostKey["']/);
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { KnownHost } from "../../domain/models";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
import type { HostKeyInfo } from "../../components/terminal/TerminalHostKeyVerification";
|
||||
import type { HostKeyInfo } from "../../domain/hostKey";
|
||||
import {
|
||||
createKnownHostFromPortForwardHostKeyInfo,
|
||||
enqueuePortForwardHostKeyVerification,
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
toPendingPortForwardHostKeyVerification,
|
||||
type PendingPortForwardHostKeyVerification,
|
||||
type PortForwardHostKeyRequest,
|
||||
} from "../../components/port-forwarding/hostKeyVerification";
|
||||
} from "../../domain/portForwardHostKey";
|
||||
|
||||
export interface PortForwardHostKeyVerificationState {
|
||||
hostKeyInfo: HostKeyInfo;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { netcattyBridge } from '../../../infrastructure/services/netcattyBridge';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export interface DiskInfo {
|
||||
capacityKey?: string; // Filesystem or shared-pool identity for deduplication
|
||||
Loaded 100 of 495 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user