mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c849bf6a73 | ||
|
|
0d9c4a6e02 | ||
|
|
acd5700284 | ||
|
|
05b33b5521 | ||
|
|
786e5b48ef | ||
|
|
6414c9e177 | ||
|
|
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 |
No files matched your search
@@ -254,6 +254,24 @@ 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,
|
||||
});
|
||||
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 +292,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 +301,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) {
|
||||
@@ -370,16 +387,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 +411,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 +851,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Research external context for classification
|
||||
id: research
|
||||
if: steps.prepare.outputs.should_run == 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ''
|
||||
@@ -802,6 +961,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 +1017,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 +1059,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 +1116,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 +1963,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 +2059,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);
|
||||
@@ -2084,10 +2289,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 +3027,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 +3209,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 +3332,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);
|
||||
@@ -3170,6 +3389,7 @@ jobs:
|
||||
'
|
||||
|
||||
- name: Upload implement patch
|
||||
id: upload_patch
|
||||
if: steps.patch.outputs.artifact_ready == 'true'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -3203,14 +3423,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 +3445,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 +3484,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 +3580,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 +3608,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 +3626,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 +4676,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 +4775,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);
|
||||
@@ -4544,6 +4822,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:
|
||||
@@ -4578,9 +4857,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 +4886,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({
|
||||
|
||||
@@ -50,7 +50,7 @@ import { TERMINAL_THEMES } from './infrastructure/config/terminalThemes';
|
||||
import { useThemeRuntime, useTerminalAppearanceInjection } from './application/state/useThemeRuntime';
|
||||
import { useCustomThemes } from './application/state/customThemeStore';
|
||||
import type { SyncPayload } from './domain/sync';
|
||||
import { applySyncPayload, buildLocalVaultPayload, hasMeaningfulSyncData } from './application/syncPayload';
|
||||
import { applySyncPayload, buildLocalVaultPayloadAsync, hasMeaningfulSyncData } from './application/syncPayload';
|
||||
import {
|
||||
applyProtectedSyncPayload,
|
||||
ensureVersionChangeBackup,
|
||||
@@ -248,6 +248,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
updateKnownHosts,
|
||||
updateManagedSources,
|
||||
addShellHistoryEntry,
|
||||
removeShellHistoryEntry,
|
||||
addConnectionLog,
|
||||
updateConnectionLog,
|
||||
toggleConnectionLogSaved,
|
||||
@@ -489,7 +490,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 +506,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}
|
||||
}
|
||||
|
||||
return buildLocalVaultPayload(
|
||||
return buildLocalVaultPayloadAsync(
|
||||
{
|
||||
hosts,
|
||||
keys,
|
||||
@@ -548,6 +549,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 +564,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 +591,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
|
||||
@@ -1697,6 +1738,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
const appVaultDomain = useMemo(() => ({
|
||||
addShellHistoryEntry,
|
||||
removeShellHistoryEntry,
|
||||
commitPluginImporterData,
|
||||
commitVaultImportTransaction,
|
||||
connectionLogs,
|
||||
@@ -1742,7 +1784,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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
@@ -113,7 +114,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,
|
||||
@@ -182,6 +183,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]);
|
||||
@@ -272,6 +288,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onRenameSession={startSessionRename}
|
||||
onCopySession={copySessionWithCurrentShell}
|
||||
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
|
||||
onEditHost={handleEditHostFromOverlay}
|
||||
onRenameWorkspace={startWorkspaceRename}
|
||||
onCopyWorkspace={copyWorkspaceWithCurrentShell}
|
||||
onCloseWorkspace={closeWorkspace}
|
||||
@@ -345,6 +362,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onCreateGroup={handleCreateWorkSurfaceHostGroup}
|
||||
onImportOrReuseKey={importOrReuseKey}
|
||||
onUpdateSnippets={updateSnippets}
|
||||
onUpdateHosts={updateHosts}
|
||||
/>
|
||||
|
||||
<VaultViewContainer appThemeStyle={appThemeStyle}>
|
||||
@@ -485,6 +503,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onUpdateHost={handleUpdateHostFromTerminal}
|
||||
onAddKnownHost={handleAddKnownHost}
|
||||
onCommandExecuted={handleTerminalCommandExecuted}
|
||||
onDeleteShellHistoryEntry={removeShellHistoryEntry}
|
||||
onTerminalDataCapture={handleTerminalDataCapture}
|
||||
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
|
||||
onAddSessionToWorkspace={addSessionToWorkspace}
|
||||
@@ -665,6 +684,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);
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -574,6 +574,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 +683,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.',
|
||||
|
||||
@@ -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',
|
||||
@@ -332,6 +333,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',
|
||||
|
||||
@@ -535,6 +535,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 +640,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': 'Как часто обновлять список процессов в боковой панели системного менеджера.',
|
||||
|
||||
@@ -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': 'Настройки терминала',
|
||||
@@ -348,6 +349,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} мин назад',
|
||||
|
||||
@@ -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': '打开方式...',
|
||||
@@ -310,6 +311,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 +414,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': '刷新间隔',
|
||||
|
||||
@@ -659,6 +659,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} 分钟前',
|
||||
|
||||
@@ -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': '開啟方式...',
|
||||
@@ -310,6 +311,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 +414,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': '重新整理間隔',
|
||||
|
||||
@@ -659,6 +659,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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
@@ -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 }>) {
|
||||
|
||||
@@ -66,6 +66,7 @@ interface UseSettingsStorageSyncParams {
|
||||
darkUiThemeId: string;
|
||||
accentMode: 'theme' | 'custom';
|
||||
customAccent: string;
|
||||
customAccentVersion: number;
|
||||
customCSS: string;
|
||||
uiFontFamilyId: string;
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
@@ -109,7 +110,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>>;
|
||||
@@ -160,7 +161,7 @@ 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,
|
||||
@@ -168,7 +169,7 @@ export function useSettingsStorageSync({
|
||||
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,
|
||||
@@ -184,7 +185,7 @@ 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,
|
||||
@@ -194,7 +195,7 @@ export function useSettingsStorageSync({
|
||||
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,
|
||||
@@ -215,7 +216,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) {
|
||||
@@ -505,7 +514,7 @@ export function useSettingsStorageSync({
|
||||
mergeIncomingTerminalSettings,
|
||||
setAccentMode,
|
||||
setAutoUpdateEnabled,
|
||||
setCustomAccent,
|
||||
applyIncomingCustomAccent,
|
||||
setCustomCSS,
|
||||
setDarkUiThemeId,
|
||||
setEditorWordWrapState,
|
||||
|
||||
@@ -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,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 };
|
||||
|
||||
@@ -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" }]),
|
||||
|
||||
@@ -41,6 +41,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 +84,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 +93,7 @@ export async function releaseSftpConnectionMetadata(params: {
|
||||
} catch {
|
||||
// Best-effort: backend owner cleanup remains the final safety net.
|
||||
}
|
||||
params.onRemoteSessionClosed?.(sftpId);
|
||||
}
|
||||
|
||||
export function createSftpConnectionId(
|
||||
@@ -240,7 +244,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 +460,7 @@ export const useSftpConnections = ({
|
||||
connectionCacheKeys: connectionCacheKeyMapRef.current,
|
||||
clearCacheForConnection,
|
||||
closeSftp: async (sftpId) => netcattyBridge.get()?.closeSftp(sftpId),
|
||||
onRemoteSessionClosed: (sftpId) => notifyRemoteSessionClosed(sftpId),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -491,6 +502,7 @@ export const useSftpConnections = ({
|
||||
} catch {
|
||||
// Ignore errors when closing stale SFTP sessions
|
||||
}
|
||||
notifyRemoteSessionClosed(oldSftpId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -966,6 +978,7 @@ export const useSftpConnections = ({
|
||||
} catch {
|
||||
// Ignore errors when closing SFTP session during disconnect
|
||||
}
|
||||
notifyRemoteSessionClosed(sftpId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
cleanupFailedExternalOpenTemp,
|
||||
useExternalFileWatchLifecycle,
|
||||
} from "./externalFileWatchLifecycle";
|
||||
import { createExternalEditTempRetention } from "./externalEditTempRetention";
|
||||
import {
|
||||
cancelExternalUploadRuntime,
|
||||
getExternalUploadController,
|
||||
@@ -163,23 +164,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 +510,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 +518,7 @@ export const useSftpExternalOperations = (
|
||||
|
||||
return { localTempPath, sftpId, externalTransferId };
|
||||
},
|
||||
[getActivePane, isTransferCancelled, ownerId, sftpSessionsRef],
|
||||
[getActivePane, isTransferCancelled, ownerId, rememberExternalEditTemp, sftpSessionsRef],
|
||||
);
|
||||
|
||||
const downloadToTempAndOpen = useCallback(
|
||||
@@ -525,6 +553,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 +584,13 @@ export const useSftpExternalOperations = (
|
||||
|
||||
return { localTempPath, watchId };
|
||||
},
|
||||
[captureExternalFileWatchGeneration, downloadToTemp, getActivePane, rememberExternalFileWatch],
|
||||
[
|
||||
captureExternalFileWatchGeneration,
|
||||
downloadToTemp,
|
||||
forgetExternalEditTemp,
|
||||
getActivePane,
|
||||
rememberExternalFileWatch,
|
||||
],
|
||||
);
|
||||
|
||||
const openWithSystemDefault = useCallback(
|
||||
@@ -590,12 +625,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 +664,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
|
||||
@@ -1491,6 +1534,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",
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
sanitizePortForwardingRulesForSync,
|
||||
shouldPromptCloudVaultRecovery,
|
||||
} from '../syncPayload';
|
||||
import { commitPluginSidecarsAfterSuccessfulSync } from '../pluginSyncSidecarBridge';
|
||||
import { readInterruptedVaultApply } from '../localVaultBackups';
|
||||
import {
|
||||
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
|
||||
@@ -76,6 +77,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 +141,7 @@ const getSyncPayloadDataHash = (payload: SyncPayload): string => {
|
||||
portForwardingRules: sanitizePortForwardingRulesForSync(payload.portForwardingRules),
|
||||
groupConfigs: payload.groupConfigs,
|
||||
settings: payload.settings,
|
||||
pluginSidecars: payload.pluginSidecars,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -188,6 +205,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 +271,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 +383,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
|
||||
@@ -421,6 +458,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);
|
||||
|
||||
@@ -517,13 +558,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 +614,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.
|
||||
@@ -715,6 +760,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);
|
||||
@@ -734,6 +780,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
if (conflictAction === 'upload-local') {
|
||||
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);
|
||||
@@ -781,6 +828,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
try {
|
||||
const roundTripResults = await manager.syncAllProviders(mergeResult.payload);
|
||||
const roundTripResultList = Array.from(roundTripResults.values());
|
||||
commitPluginSidecarsAfterSuccessfulSync(mergeResult.payload, roundTripResultList);
|
||||
const wasShrinkBlocked = roundTripResultList.some((r) => r.shrinkBlocked === true);
|
||||
const roundTripFullySynced = roundTripResultList.length > 0
|
||||
&& roundTripResultList.every((result) => result.success);
|
||||
@@ -828,7 +876,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 +925,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 +972,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 +1048,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
config.settingsVersion,
|
||||
enabled,
|
||||
bookmarksVersion,
|
||||
pluginSidecarsVersion,
|
||||
convergentSyncPaused,
|
||||
syncableSettingsStorageVersion,
|
||||
]);
|
||||
@@ -1002,6 +1065,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,
|
||||
|
||||
@@ -88,7 +88,6 @@ import { resolveSftpTransferConcurrency } from './sftp/transferConcurrency';
|
||||
import { resolveSshTransportIdleTtlMs } from '../../infrastructure/config/sshTransportIdleTtl';
|
||||
import {
|
||||
DEFAULT_ACCENT_MODE,
|
||||
DEFAULT_CUSTOM_ACCENT,
|
||||
DEFAULT_DARK_UI_THEME,
|
||||
DEFAULT_EDITOR_WORD_WRAP,
|
||||
DEFAULT_HOTKEY_SCHEME,
|
||||
@@ -154,6 +153,14 @@ import {
|
||||
type WindowOpacityMutationSource,
|
||||
type WindowOpacityRecord,
|
||||
} from './windowOpacitySync';
|
||||
import {
|
||||
parseCustomAccentRecord,
|
||||
serializeCustomAccentRecord,
|
||||
shouldApplyCustomAccentRecord,
|
||||
shouldBroadcastCustomAccentChange,
|
||||
type CustomAccentMutationSource,
|
||||
type CustomAccentRecord,
|
||||
} from './customAccentSync';
|
||||
import {
|
||||
createLocalTerminalFontSizeRecord,
|
||||
createTerminalFontSizeSyncOrigin,
|
||||
@@ -193,15 +200,47 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const stored = readStoredString(STORAGE_KEY_UI_THEME_DARK);
|
||||
return stored && isValidUiThemeId('dark', stored) ? stored : DEFAULT_DARK_UI_THEME;
|
||||
});
|
||||
const [customAccent, setCustomAccent] = useState<string>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_COLOR);
|
||||
return stored && isValidHslToken(stored) ? stored.trim() : DEFAULT_CUSTOM_ACCENT;
|
||||
const [customAccentRecord, setCustomAccentRecord] = useState<CustomAccentRecord>(() => {
|
||||
return parseCustomAccentRecord(readStoredString(STORAGE_KEY_COLOR));
|
||||
});
|
||||
const customAccent = customAccentRecord.color;
|
||||
const customAccentMutationSourceRef = useRef<CustomAccentMutationSource>('local');
|
||||
const setCustomAccent = useCallback((nextValue: SetStateAction<string>) => {
|
||||
customAccentMutationSourceRef.current = 'local';
|
||||
setCustomAccentRecord((prev) => {
|
||||
const candidate = typeof nextValue === 'function'
|
||||
? (nextValue as (prevState: string) => string)(prev.color)
|
||||
: nextValue;
|
||||
const next = parseCustomAccentRecord(candidate);
|
||||
if (next.color === prev.color) return prev;
|
||||
return { color: next.color, version: prev.version + 1 };
|
||||
});
|
||||
}, []);
|
||||
const applyIncomingCustomAccent = useCallback((raw: unknown) => {
|
||||
const incoming = parseCustomAccentRecord(raw);
|
||||
setCustomAccentRecord((prev) => {
|
||||
if (!shouldApplyCustomAccentRecord(prev, incoming)) {
|
||||
return prev;
|
||||
}
|
||||
customAccentMutationSourceRef.current = 'incoming';
|
||||
return incoming;
|
||||
});
|
||||
}, []);
|
||||
const [accentMode, setAccentMode] = useState<'theme' | 'custom'>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_ACCENT_MODE);
|
||||
if (stored === 'theme' || stored === 'custom') return stored;
|
||||
const legacyColor = readStoredString(STORAGE_KEY_COLOR);
|
||||
return legacyColor && isValidHslToken(legacyColor) ? 'custom' : DEFAULT_ACCENT_MODE;
|
||||
const raw = readStoredString(STORAGE_KEY_COLOR);
|
||||
if (!raw) return DEFAULT_ACCENT_MODE;
|
||||
if (isValidHslToken(raw.trim())) return 'custom';
|
||||
if (raw.trim().startsWith('{')) {
|
||||
try {
|
||||
const obj = JSON.parse(raw.trim()) as { color?: unknown };
|
||||
return typeof obj.color === 'string' && isValidHslToken(obj.color) ? 'custom' : DEFAULT_ACCENT_MODE;
|
||||
} catch {
|
||||
return DEFAULT_ACCENT_MODE;
|
||||
}
|
||||
}
|
||||
return DEFAULT_ACCENT_MODE;
|
||||
});
|
||||
const [uiFontFamilyId, setUiFontFamilyId] = useState<string>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_UI_FONT_FAMILY);
|
||||
@@ -556,6 +595,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
darkUiThemeId,
|
||||
accentMode,
|
||||
customAccent,
|
||||
customAccentVersion: customAccentRecord.version,
|
||||
});
|
||||
appearanceStateRef.current = {
|
||||
theme,
|
||||
@@ -563,6 +603,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
darkUiThemeId,
|
||||
accentMode,
|
||||
customAccent,
|
||||
customAccentVersion: customAccentRecord.version,
|
||||
};
|
||||
|
||||
const setTerminalSettings = useCallback((nextValue: SetStateAction<TerminalSettings>) => {
|
||||
@@ -732,6 +773,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
darkUiThemeId: nextDarkId,
|
||||
accentMode: nextAccentMode,
|
||||
customAccent: nextAccent,
|
||||
customAccentVersion: nextAccentVersion,
|
||||
} = nextAppearance;
|
||||
|
||||
// Fix 2: Skip expensive DOM operations if nothing actually changed
|
||||
@@ -740,18 +782,27 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
nextLightId === current.lightUiThemeId &&
|
||||
nextDarkId === current.darkUiThemeId &&
|
||||
nextAccentMode === current.accentMode &&
|
||||
nextAccent === current.customAccent
|
||||
nextAccent === current.customAccent &&
|
||||
nextAccentVersion === current.customAccentVersion
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Publish synchronously so a later keyed IPC in the same turn composes on top.
|
||||
appearanceStateRef.current = nextAppearance;
|
||||
if (
|
||||
nextAccent !== current.customAccent
|
||||
|| nextAccentVersion !== current.customAccentVersion
|
||||
) {
|
||||
// Mark incoming so the persist effect does not rebroadcast to the peer that
|
||||
// originated this update (color-picker drag ping-pong; see #2743).
|
||||
customAccentMutationSourceRef.current = 'incoming';
|
||||
setCustomAccentRecord({ color: nextAccent, version: nextAccentVersion });
|
||||
}
|
||||
setTheme(nextTheme);
|
||||
setLightUiThemeId(nextLightId);
|
||||
setDarkUiThemeId(nextDarkId);
|
||||
setAccentMode(nextAccentMode);
|
||||
setCustomAccent(nextAccent);
|
||||
|
||||
const effective = nextTheme === 'system' ? getSystemPreference() : nextTheme;
|
||||
const tokens = getUiThemeById(effective, effective === 'dark' ? nextDarkId : nextLightId).tokens;
|
||||
@@ -892,6 +943,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
darkUiThemeId,
|
||||
accentMode,
|
||||
customAccent,
|
||||
customAccentVersion: customAccentRecord.version,
|
||||
};
|
||||
// Capture previous snapshot before overwrite so we can notify only the
|
||||
// appearance fields that actually changed on this render.
|
||||
@@ -913,7 +965,19 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_ACCENT_MODE, accentMode);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_COLOR, customAccent);
|
||||
// Never let a stale effect overwrite a newer revision already on disk.
|
||||
const storedAccent = parseCustomAccentRecord(
|
||||
localStorageAdapter.readString(STORAGE_KEY_COLOR),
|
||||
);
|
||||
if (
|
||||
shouldApplyCustomAccentRecord(storedAccent, customAccentRecord)
|
||||
|| storedAccent.version === customAccentRecord.version
|
||||
) {
|
||||
localStorageAdapter.writeString(
|
||||
STORAGE_KEY_COLOR,
|
||||
serializeCustomAccentRecord(customAccentRecord),
|
||||
);
|
||||
}
|
||||
// Fix 1: Skip IPC broadcast on initial mount (values already match localStorage)
|
||||
if (!persistMountedRef.current) return;
|
||||
// Emit a keyed IPC notification for each changed appearance field so the
|
||||
@@ -932,10 +996,21 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
if (!previousAppearance || previousAppearance.accentMode !== accentMode) {
|
||||
notifySettingsChanged(STORAGE_KEY_ACCENT_MODE, accentMode);
|
||||
}
|
||||
if (!previousAppearance || previousAppearance.customAccent !== customAccent) {
|
||||
notifySettingsChanged(STORAGE_KEY_COLOR, customAccent);
|
||||
if (
|
||||
!previousAppearance
|
||||
|| previousAppearance.customAccent !== customAccent
|
||||
|| previousAppearance.customAccentVersion !== customAccentRecord.version
|
||||
) {
|
||||
const decision = shouldBroadcastCustomAccentChange(
|
||||
customAccentMutationSourceRef.current,
|
||||
true,
|
||||
);
|
||||
customAccentMutationSourceRef.current = decision.nextSource;
|
||||
if (decision.shouldBroadcast) {
|
||||
notifySettingsChanged(STORAGE_KEY_COLOR, customAccentRecord);
|
||||
}
|
||||
}
|
||||
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, notifySettingsChanged]);
|
||||
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentRecord, notifySettingsChanged]);
|
||||
|
||||
// Listen for OS color scheme changes to keep systemPreference in sync
|
||||
useEffect(() => {
|
||||
@@ -1035,6 +1110,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
useSettingsStorageSync({
|
||||
enabled: enableSettingsSync,
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
customAccentVersion: customAccentRecord.version,
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
@@ -1042,7 +1118,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
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: applyIncomingTerminalFontSize,
|
||||
|
||||
@@ -55,6 +55,7 @@ export async function releaseSftpTabConnection(params: {
|
||||
connectionCacheKeys: Map<string, string>;
|
||||
clearCacheForConnection: (connectionId: string) => void;
|
||||
closeSftp: (sftpId: string) => Promise<unknown>;
|
||||
onRemoteSessionClosed?: (sftpId: string) => void;
|
||||
}): Promise<void> {
|
||||
await releaseSftpConnectionMetadata(params);
|
||||
}
|
||||
@@ -66,6 +67,7 @@ export const useSftpState = (
|
||||
options?: SftpStateOptions
|
||||
) => {
|
||||
const transferOwnerIdRef = useRef(options?.transferOwnerId ?? crypto.randomUUID());
|
||||
const forgetExternalEditTempsForSftpRef = useRef<(sftpId: string) => void>(() => {});
|
||||
const createPane = useCallback(
|
||||
(id?: string, showHiddenFiles = options?.defaultShowHiddenFiles ?? false) =>
|
||||
createEmptyPane(id, showHiddenFiles),
|
||||
@@ -241,6 +243,7 @@ export const useSftpState = (
|
||||
connectionCacheKeys: connectionCacheKeyMapRef.current,
|
||||
clearCacheForConnection,
|
||||
closeSftp: async (sftpId) => netcattyBridge.get()?.closeSftp(sftpId),
|
||||
onRemoteSessionClosed: (sftpId) => forgetExternalEditTempsForSftpRef.current(sftpId),
|
||||
});
|
||||
}
|
||||
closeTab(side, tabId);
|
||||
@@ -253,6 +256,7 @@ export const useSftpState = (
|
||||
connectionCacheKeys: connectionCacheKeyMapRef.current,
|
||||
clearCacheForConnection,
|
||||
closeSftp: async (sftpId) => netcattyBridge.get()?.closeSftp(sftpId),
|
||||
onRemoteSessionClosed: (sftpId) => forgetExternalEditTempsForSftpRef.current(sftpId),
|
||||
});
|
||||
}, [clearCacheForConnection]);
|
||||
|
||||
@@ -444,6 +448,7 @@ export const useSftpState = (
|
||||
clearCacheForConnection,
|
||||
createEmptyPane: createPane,
|
||||
autoConnectLocalOnMount: options?.autoConnectLocalOnMount,
|
||||
onRemoteSessionClosed: (sftpId) => forgetExternalEditTempsForSftpRef.current(sftpId),
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -567,6 +572,8 @@ export const useSftpState = (
|
||||
cancelExternalUpload,
|
||||
selectApplication,
|
||||
activeFileWatchCountRef,
|
||||
activeExternalEditCount,
|
||||
forgetExternalEditTempsForSftp,
|
||||
releaseExternalFileWatches,
|
||||
uploadConflicts,
|
||||
resolveUploadConflict,
|
||||
@@ -627,6 +634,7 @@ export const useSftpState = (
|
||||
useCompressedUpload: options?.useCompressedUpload,
|
||||
isTransferCancelled,
|
||||
});
|
||||
forgetExternalEditTempsForSftpRef.current = forgetExternalEditTempsForSftp;
|
||||
|
||||
const conflicts = useMemo(
|
||||
() => [...transferConflicts, ...uploadConflicts],
|
||||
@@ -666,6 +674,7 @@ export const useSftpState = (
|
||||
interactive,
|
||||
browseParked: browseParkedRef.current,
|
||||
activeTransfersCount: Math.max(activeTransfersCount, centerActive ? 1 : 0),
|
||||
activeExternalEditCount,
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
@@ -684,6 +693,7 @@ export const useSftpState = (
|
||||
} catch {
|
||||
// best-effort — session may already be gone
|
||||
}
|
||||
forgetExternalEditTempsForSftpRef.current(sftpId);
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -737,6 +747,7 @@ export const useSftpState = (
|
||||
}, [
|
||||
interactive,
|
||||
activeTransfersCount,
|
||||
activeExternalEditCount,
|
||||
clearCacheForConnection,
|
||||
connect,
|
||||
getActivePane,
|
||||
@@ -911,6 +922,7 @@ export const useSftpState = (
|
||||
rightTabs,
|
||||
transfers,
|
||||
activeTransfersCount,
|
||||
activeExternalEditCount,
|
||||
conflicts,
|
||||
hostKeyVerification,
|
||||
|
||||
@@ -932,6 +944,7 @@ export const useSftpState = (
|
||||
rightTabs,
|
||||
transfers,
|
||||
activeTransfersCount,
|
||||
activeExternalEditCount,
|
||||
conflicts,
|
||||
hostKeyVerification,
|
||||
stableMethods,
|
||||
|
||||
@@ -51,7 +51,11 @@ import {
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
} from "../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter, LOCAL_STORAGE_ADAPTER_CHANGED_EVENT } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import { mergeGlobalHistoryOnAppend, sanitizeGlobalHistoryEntries } from "../../domain/globalHistory";
|
||||
import {
|
||||
mergeGlobalHistoryOnAppend,
|
||||
removeGlobalHistoryEntry,
|
||||
sanitizeGlobalHistoryEntries,
|
||||
} from "../../domain/globalHistory";
|
||||
import {
|
||||
buildTerminalDataMapFromLogs,
|
||||
mergeConnectionLogsFromStorage,
|
||||
@@ -844,6 +848,16 @@ export const useVaultState = () => {
|
||||
publishShellHistorySnapshot([]);
|
||||
}, []);
|
||||
|
||||
const removeShellHistoryEntry = useCallback((entryId: string) => {
|
||||
setShellHistory((prev) => {
|
||||
const updated = removeGlobalHistoryEntry(prev, entryId);
|
||||
if (updated === prev) return prev;
|
||||
localStorageAdapter.write(STORAGE_KEY_SHELL_HISTORY, updated);
|
||||
publishShellHistorySnapshot(updated);
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Connection logs management
|
||||
const addConnectionLog = useCallback(
|
||||
(log: Omit<ConnectionLog, "id">) => {
|
||||
@@ -1506,6 +1520,10 @@ export const useVaultState = () => {
|
||||
latestHosts.length,
|
||||
destination,
|
||||
storedGroups,
|
||||
{
|
||||
identities: latestIdentities.length,
|
||||
keys: latestKeys.length,
|
||||
},
|
||||
);
|
||||
const nextHosts = normalizeVaultOrder(committed.hosts.map((host) => sanitizeHost(host)));
|
||||
const nextKeys = normalizeVaultOrder(committed.keys);
|
||||
@@ -1619,6 +1637,7 @@ export const useVaultState = () => {
|
||||
updateGroupConfigs,
|
||||
addShellHistoryEntry,
|
||||
clearShellHistory,
|
||||
removeShellHistoryEntry,
|
||||
addConnectionLog,
|
||||
updateConnectionLog,
|
||||
toggleConnectionLogSaved,
|
||||
|
||||
@@ -134,15 +134,20 @@ export function useWorkSurfaceHostEditor({
|
||||
|
||||
const save = useCallback((draft: Host) => {
|
||||
if (!target) return;
|
||||
const nextHosts = saveWorkSurfaceHostDraft(hosts, target, draft);
|
||||
if (!nextHosts) {
|
||||
let saved = false;
|
||||
onUpdateHosts((prevHosts) => {
|
||||
const nextHosts = saveWorkSurfaceHostDraft(prevHosts, target, draft);
|
||||
if (!nextHosts) return prevHosts;
|
||||
saved = true;
|
||||
return nextHosts;
|
||||
});
|
||||
if (!saved) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
onUpdateHosts(nextHosts);
|
||||
onSaved?.(target.mode);
|
||||
close();
|
||||
}, [close, hosts, onSaved, onUpdateHosts, target]);
|
||||
}, [close, onSaved, onUpdateHosts, target]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldCloseDeletedWorkSurfaceHost(hosts, target)) {
|
||||
|
||||
@@ -43,13 +43,17 @@ const {
|
||||
applySyncPayload,
|
||||
buildLocalVaultPayload,
|
||||
buildCloudSyncPayload,
|
||||
withPluginSyncSidecars,
|
||||
buildSyncPayload,
|
||||
hasCloudSyncEntityData,
|
||||
hasMeaningfulCloudSyncData,
|
||||
hasMeaningfulSyncData,
|
||||
shouldPromptCloudVaultRecovery,
|
||||
SYNCABLE_SETTING_STORAGE_KEYS,
|
||||
} = await import("./syncPayload.ts");
|
||||
const storageKeys = await import("../infrastructure/config/storageKeys.ts");
|
||||
const { SYNC_STORAGE_KEYS } = await import("../domain/sync.ts");
|
||||
const { localStorageAdapter } = await import("../infrastructure/persistence/localStorageAdapter.ts");
|
||||
|
||||
const knownHost = (id = "kh-1"): KnownHost => ({
|
||||
id,
|
||||
@@ -791,6 +795,164 @@ test("hasMeaningfulCloudSyncData ignores legacy cloud known hosts", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("buildLocalVaultPayload includes last-known plugin sidecars for protective backups", () => {
|
||||
localStorage.setItem(
|
||||
"netcatty_plugin_sidecars_last_known_v1",
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: "com.example.p",
|
||||
kind: "settings",
|
||||
key: "com.example.p.theme\0application\0application",
|
||||
value: "dark",
|
||||
updatedAt: 1,
|
||||
}],
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const payload = buildLocalVaultPayload(vault([]));
|
||||
assert.equal(payload.pluginSidecars?.entries?.length, 1);
|
||||
assert.equal(payload.pluginSidecars?.entries?.[0].value, "dark");
|
||||
assert.equal(hasMeaningfulSyncData(payload), true);
|
||||
} finally {
|
||||
localStorage.removeItem("netcatty_plugin_sidecars_last_known_v1");
|
||||
}
|
||||
});
|
||||
|
||||
test("buildLocalVaultPayloadAsync prefers live empty over last-known for backups", async () => {
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: {
|
||||
netcatty: {
|
||||
collectPluginSyncSidecars: async () => ({ version: 1, entries: [] }),
|
||||
pluginHostReady: () => true,
|
||||
},
|
||||
dispatchEvent: () => true,
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
localStorage.setItem(
|
||||
"netcatty_plugin_sidecars_last_known_v1",
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: "com.example.p",
|
||||
kind: "settings",
|
||||
key: "com.example.p.theme\0application\0application",
|
||||
value: "dark",
|
||||
updatedAt: 1,
|
||||
}],
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const { buildLocalVaultPayloadAsync } = await import("./syncPayload.ts");
|
||||
const payload = await buildLocalVaultPayloadAsync(vault([]));
|
||||
assert.equal(payload.pluginSidecars?.entries?.length, 0);
|
||||
} finally {
|
||||
localStorage.removeItem("netcatty_plugin_sidecars_last_known_v1");
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: previousWindow,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("hasMeaningfulCloudSyncData treats non-empty plugin sidecars as meaningful", () => {
|
||||
assert.equal(
|
||||
hasMeaningfulCloudSyncData({
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
pluginSidecars: {
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: "com.example.p",
|
||||
kind: "settings",
|
||||
key: "com.example.p.theme\0application\0application",
|
||||
value: "dark",
|
||||
updatedAt: 1,
|
||||
}],
|
||||
},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
// Empty bundle alone must not bypass the empty-vault upload guard.
|
||||
assert.equal(
|
||||
hasMeaningfulCloudSyncData({
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
pluginSidecars: { version: 1, entries: [] },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
hasMeaningfulCloudSyncData({
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("hasMeaningfulCloudSyncData does not treat last-known-only empty sidecars as meaningful", () => {
|
||||
const previous = localStorageAdapter.read(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN);
|
||||
try {
|
||||
localStorageAdapter.write(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN, {
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: "com.example.p",
|
||||
kind: "settings",
|
||||
key: "k",
|
||||
value: 1,
|
||||
updatedAt: 1,
|
||||
}],
|
||||
});
|
||||
// Empty vault + empty sidecar entries must stay blocked even when last-known
|
||||
// still remembers prior plugin settings (empty-vault upload guard).
|
||||
assert.equal(
|
||||
hasMeaningfulCloudSyncData({
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
pluginSidecars: { version: 1, entries: [] },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
if (previous == null) localStorageAdapter.remove(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN);
|
||||
else localStorageAdapter.write(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN, previous);
|
||||
}
|
||||
});
|
||||
|
||||
test("plugin sidecar storage keys stay aligned between sync domain and storageKeys registry", () => {
|
||||
assert.equal(
|
||||
storageKeys.STORAGE_KEY_PLUGIN_SIDECARS_LAST_KNOWN,
|
||||
SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN,
|
||||
);
|
||||
assert.equal(
|
||||
storageKeys.STORAGE_KEY_PLUGIN_SIDECARS_PENDING_REMOTE,
|
||||
SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_PENDING_REMOTE,
|
||||
);
|
||||
assert.equal(
|
||||
storageKeys.STORAGE_KEY_AVAILABLE_PLUGIN_SYNC_PROVIDERS,
|
||||
SYNC_STORAGE_KEYS.AVAILABLE_PLUGIN_SYNC_PROVIDERS,
|
||||
);
|
||||
});
|
||||
|
||||
test("hasCloudSyncEntityData ignores settings-only payloads for empty-vault recovery", () => {
|
||||
assert.equal(
|
||||
hasCloudSyncEntityData({
|
||||
@@ -1338,3 +1500,78 @@ test("applyLocalVaultPayload does not commit convergent writes when local import
|
||||
|
||||
assert.equal(committed, false);
|
||||
});
|
||||
|
||||
test("withPluginSyncSidecars attaches non-empty plugin sidecar bundles", () => {
|
||||
const base = buildSyncPayload({
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
});
|
||||
const withEmpty = withPluginSyncSidecars(base, { version: 1, entries: [] });
|
||||
assert.deepEqual(withEmpty.pluginSidecars, { version: 1, entries: [] });
|
||||
|
||||
const withData = withPluginSyncSidecars(base, {
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: "com.example.sync",
|
||||
kind: "settings",
|
||||
key: "com.example.sync.theme\0application\0application",
|
||||
value: "dark",
|
||||
updatedAt: 1,
|
||||
}],
|
||||
});
|
||||
assert.equal(withData.pluginSidecars?.entries.length, 1);
|
||||
assert.equal(withData.pluginSidecars?.entries[0].value, "dark");
|
||||
});
|
||||
|
||||
test("buildCloudSyncPayload includes plugin sidecars from the production collector hook", async () => {
|
||||
const payload = await buildCloudSyncPayload(vault([]), [], {
|
||||
collectPluginSidecars: async () => ({
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: "com.example.sync",
|
||||
kind: "account_baseline",
|
||||
key: "account",
|
||||
value: { id: "acct-1" },
|
||||
updatedAt: 9,
|
||||
}],
|
||||
}),
|
||||
});
|
||||
assert.equal(payload.pluginSidecars?.entries.length, 1);
|
||||
assert.equal(payload.pluginSidecars?.entries[0].kind, "account_baseline");
|
||||
assert.deepEqual(payload.pluginSidecars?.entries[0].value, { id: "acct-1" });
|
||||
});
|
||||
|
||||
test("applySyncPayload applies pluginSidecars through the production applier hook", async () => {
|
||||
let applied: unknown = null;
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
pluginSidecars: {
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: "com.missing.plugin",
|
||||
kind: "crdt_baseline",
|
||||
key: "replica",
|
||||
value: { clock: 3 },
|
||||
updatedAt: 2,
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
await applySyncPayload(payload, {
|
||||
importVaultData: () => {},
|
||||
}, {
|
||||
applyPluginSidecars: async (sidecars) => {
|
||||
applied = sidecars;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal((applied as SyncPayload["pluginSidecars"])?.entries[0].value.clock, 3);
|
||||
});
|
||||
+187
-16
@@ -22,6 +22,7 @@ import type {
|
||||
import {
|
||||
CLOUD_SYNC_PAYLOAD_ENTITY_KEYS,
|
||||
SYNC_PAYLOAD_ENTITY_KEYS,
|
||||
SYNC_STORAGE_KEYS,
|
||||
hasSyncPayloadEntityData,
|
||||
type SyncPayload,
|
||||
} from '../domain/sync';
|
||||
@@ -42,6 +43,10 @@ import {
|
||||
parseTerminalFontSizeRecord,
|
||||
serializeTerminalFontSizeRecord,
|
||||
} from './state/terminalFontSizeSync';
|
||||
import {
|
||||
parseCustomAccentRecord,
|
||||
serializeCustomAccentRecord,
|
||||
} from './state/customAccentSync';
|
||||
import {
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
@@ -122,15 +127,33 @@ export interface SyncableVaultData {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the payload contains any meaningful user data worth
|
||||
* protecting or syncing.
|
||||
* Returns true when the payload carries non-empty plugin sidecar entries.
|
||||
*
|
||||
* An explicit empty `{entries:[]}` bundle is intentionally NOT treated as
|
||||
* meaningful by itself — last-known cache alone must not authorize pushing a
|
||||
* wiped hosts/keys vault to cloud (empty-vault upload guard). Sidecar-only
|
||||
* wipes still sync when the vault also has entities/settings, or via Force Push.
|
||||
*/
|
||||
function hasMeaningfulPluginSidecars(payload: SyncPayload): boolean {
|
||||
return Array.isArray(payload.pluginSidecars?.entries)
|
||||
&& payload.pluginSidecars.entries.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when a payload contains entities, settings, or meaningful
|
||||
* plugin sidecars worth syncing / protecting with empty-vault guards.
|
||||
* Local-only trust records are intentionally ignored by the cloud variant.
|
||||
*/
|
||||
export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
|
||||
if (hasSyncPayloadEntityData(payload, SYNC_PAYLOAD_ENTITY_KEYS)) return true;
|
||||
|
||||
return Boolean(
|
||||
payload.settings && Object.values(payload.settings).some((value) => value !== undefined),
|
||||
);
|
||||
if (
|
||||
payload.settings && Object.values(payload.settings).some((value) => value !== undefined)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return hasMeaningfulPluginSidecars(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,9 +163,14 @@ export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
|
||||
export function hasMeaningfulCloudSyncData(payload: SyncPayload): boolean {
|
||||
if (hasSyncPayloadEntityData(payload, CLOUD_SYNC_PAYLOAD_ENTITY_KEYS)) return true;
|
||||
|
||||
return Boolean(
|
||||
payload.settings && Object.values(payload.settings).some((value) => value !== undefined),
|
||||
);
|
||||
if (
|
||||
payload.settings && Object.values(payload.settings).some((value) => value !== undefined)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Plugin-only profiles (settings/baselines, no vault entities) must still sync.
|
||||
return hasMeaningfulPluginSidecars(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -214,9 +242,10 @@ const SYNCABLE_TERMINAL_KEYS = [
|
||||
'rightClickBehavior', 'showContextMenuOverFullscreenApps', 'middleClickBehavior', 'copyOnSelect', 'normalizeTextOnCopy', 'middleClickPaste', 'wordSeparators',
|
||||
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
||||
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
|
||||
'autoUploadClipboardImageOnPaste',
|
||||
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'dynamicTabTitleMode',
|
||||
'autoCloseOnExit',
|
||||
'showHostInfoBar', 'showServerStats',
|
||||
'showHostInfoBar', 'hostInfoBarTitleMode', 'showServerStats',
|
||||
'serverStatsRefreshInterval',
|
||||
'systemManagerProcessRefreshInterval', 'systemManagerTmuxRefreshInterval',
|
||||
'systemManagerDockerListRefreshInterval', 'systemManagerDockerStatsRefreshInterval',
|
||||
@@ -383,8 +412,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (darkUi) settings.darkUiThemeId = darkUi;
|
||||
const accentMode = localStorageAdapter.readString(STORAGE_KEY_ACCENT_MODE);
|
||||
if (accentMode === 'theme' || accentMode === 'custom') settings.accentMode = accentMode;
|
||||
const accent = localStorageAdapter.readString(STORAGE_KEY_COLOR);
|
||||
if (accent) settings.customAccent = accent;
|
||||
const accentRaw = localStorageAdapter.readString(STORAGE_KEY_COLOR);
|
||||
if (accentRaw) settings.customAccent = parseCustomAccentRecord(accentRaw).color;
|
||||
const uiFont = localStorageAdapter.readString(STORAGE_KEY_UI_FONT_FAMILY);
|
||||
if (uiFont) settings.uiFontFamilyId = uiFont;
|
||||
const lang = localStorageAdapter.readString(STORAGE_KEY_UI_LANGUAGE);
|
||||
@@ -590,7 +619,17 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
|
||||
if (settings.lightUiThemeId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, settings.lightUiThemeId);
|
||||
if (settings.darkUiThemeId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, settings.darkUiThemeId);
|
||||
if (settings.accentMode != null) localStorageAdapter.writeString(STORAGE_KEY_ACCENT_MODE, settings.accentMode);
|
||||
if (settings.customAccent != null) localStorageAdapter.writeString(STORAGE_KEY_COLOR, settings.customAccent);
|
||||
if (settings.customAccent != null) {
|
||||
const existing = parseCustomAccentRecord(localStorageAdapter.readString(STORAGE_KEY_COLOR));
|
||||
localStorageAdapter.writeString(
|
||||
STORAGE_KEY_COLOR,
|
||||
serializeCustomAccentRecord({
|
||||
color: parseCustomAccentRecord(settings.customAccent).color,
|
||||
// Bump so peer windows' version gates accept the synced value.
|
||||
version: Math.max(existing.version, 0) + 1,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (settings.uiFontFamilyId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_FONT_FAMILY, settings.uiFontFamilyId);
|
||||
if (settings.uiLanguage != null) localStorageAdapter.writeString(STORAGE_KEY_UI_LANGUAGE, settings.uiLanguage);
|
||||
if (settings.customCSS != null) localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, settings.customCSS);
|
||||
@@ -877,11 +916,75 @@ export function buildSyncPayload(
|
||||
};
|
||||
}
|
||||
|
||||
export type PluginSyncSidecarCollector = () =>
|
||||
| Promise<SyncPayload['pluginSidecars'] | null | undefined>
|
||||
| SyncPayload['pluginSidecars']
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
export type PluginSyncSidecarApplier = (
|
||||
sidecars: SyncPayload['pluginSidecars'] | null | undefined,
|
||||
) => Promise<void> | void;
|
||||
|
||||
/**
|
||||
* Attach a host-collected plugin sidecar bundle to a cloud payload.
|
||||
* Secrets must already be excluded by the collector; this only drops empty bundles.
|
||||
*/
|
||||
export function withPluginSyncSidecars(
|
||||
payload: SyncPayload,
|
||||
sidecars: SyncPayload['pluginSidecars'] | null | undefined,
|
||||
): SyncPayload {
|
||||
// null/undefined: collector unavailable — leave payload field unchanged
|
||||
// (callers should reattach last-known before upload).
|
||||
if (sidecars == null) {
|
||||
return payload;
|
||||
}
|
||||
// Explicit empty entries is a real reset and must remain on the wire so
|
||||
// apply paths can distinguish it from a legacy payload missing the field.
|
||||
return {
|
||||
...payload,
|
||||
pluginSidecars: {
|
||||
version: 1,
|
||||
entries: Array.isArray(sidecars.entries) ? sidecars.entries : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function defaultCollectPluginSyncSidecars(): Promise<SyncPayload['pluginSidecars'] | null | undefined> {
|
||||
const { collectPluginSyncSidecarsFromHost } = await import('./pluginSyncSidecarBridge');
|
||||
// Let operational failures (DB/runtime) propagate so cloud upload does not
|
||||
// silently strip previously synced sidecars from the remote snapshot.
|
||||
return collectPluginSyncSidecarsFromHost();
|
||||
}
|
||||
|
||||
async function defaultApplyPluginSyncSidecars(
|
||||
sidecars: SyncPayload['pluginSidecars'] | null | undefined,
|
||||
): Promise<void> {
|
||||
const {
|
||||
applyPluginSyncSidecarsFromHost,
|
||||
isPluginSidecarHostUnavailableError,
|
||||
} = await import('./pluginSyncSidecarBridge');
|
||||
try {
|
||||
await applyPluginSyncSidecarsFromHost(sidecars);
|
||||
} catch (error) {
|
||||
// Only tolerate the host-gated-off / manager-unavailable path so a later
|
||||
// plugin-enabled session can still apply from last-known cache. DB/runtime
|
||||
// failures must fail the surrounding sync so the same remote is retried.
|
||||
if (isPluginSidecarHostUnavailableError(error)) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildCloudSyncPayload(
|
||||
vault: SyncableVaultData,
|
||||
portForwardingRules?: PortForwardingRule[],
|
||||
options?: {
|
||||
collectPluginSidecars?: PluginSyncSidecarCollector;
|
||||
},
|
||||
): Promise<SyncPayload> {
|
||||
return {
|
||||
const base: SyncPayload = {
|
||||
hosts: vault.hosts,
|
||||
keys: vault.keys,
|
||||
identities: vault.identities,
|
||||
@@ -896,6 +999,9 @@ export async function buildCloudSyncPayload(
|
||||
settings: await collectCloudSyncableSettings(),
|
||||
syncedAt: Date.now(),
|
||||
};
|
||||
const collect = options?.collectPluginSidecars ?? defaultCollectPluginSyncSidecars;
|
||||
const sidecars = await collect();
|
||||
return withPluginSyncSidecars(base, sidecars);
|
||||
}
|
||||
|
||||
/** Build a local backup/restore payload, including local-only trust records. */
|
||||
@@ -903,11 +1009,60 @@ export function buildLocalVaultPayload(
|
||||
vault: SyncableVaultData,
|
||||
portForwardingRules?: PortForwardingRule[],
|
||||
): SyncPayload {
|
||||
return {
|
||||
const base: SyncPayload = {
|
||||
...buildSyncPayload(vault, portForwardingRules),
|
||||
settings: collectLocalBackupSettings(),
|
||||
knownHosts: vault.knownHosts,
|
||||
};
|
||||
// Protective backups must capture plugin sidecars so restore can recover
|
||||
// plugin settings/baselines. Prefer the last successful host collect cache
|
||||
// (sync). Callers that can await should use buildLocalVaultPayloadAsync.
|
||||
try {
|
||||
const raw = localStorageAdapter.read<{ version?: number; entries?: unknown }>(
|
||||
SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN,
|
||||
);
|
||||
if (raw && Array.isArray(raw.entries)) {
|
||||
return withPluginSyncSidecars(base, {
|
||||
version: 1,
|
||||
entries: raw.entries as NonNullable<SyncPayload['pluginSidecars']>['entries'],
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore cache read failures; backup still carries vault entities
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Async local backup payload that prefers a live host collect when available,
|
||||
* then falls back to last-known cache. Use for protective/version-change backups.
|
||||
*/
|
||||
export async function buildLocalVaultPayloadAsync(
|
||||
vault: SyncableVaultData,
|
||||
portForwardingRules?: PortForwardingRule[],
|
||||
): Promise<SyncPayload> {
|
||||
const base = buildLocalVaultPayload(vault, portForwardingRules);
|
||||
const {
|
||||
collectPluginSyncSidecarsFromHost,
|
||||
isPluginSidecarHostUnavailableError,
|
||||
} = await import('./pluginSyncSidecarBridge');
|
||||
try {
|
||||
const live = await collectPluginSyncSidecarsFromHost();
|
||||
if (live) {
|
||||
// Uploads may still defer wiping last-known until a successful sync
|
||||
// commit, but protective/version-change backups must snapshot the live
|
||||
// authoritative-empty bundle so restore cannot resurrect cleared plugin data.
|
||||
return withPluginSyncSidecars(base, live);
|
||||
}
|
||||
} catch (error) {
|
||||
// Only fall back to last-known when the host is gated off. Operational
|
||||
// failures must abort protective backups so we never apply over data that
|
||||
// was not captured in the safety snapshot.
|
||||
if (!isPluginSidecarHostUnavailableError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -919,7 +1074,10 @@ export function buildLocalVaultPayload(
|
||||
function applyPayload(
|
||||
payload: SyncPayload,
|
||||
importers: SyncPayloadImporters,
|
||||
options: { includeLocalOnlyData: boolean },
|
||||
options: {
|
||||
includeLocalOnlyData: boolean;
|
||||
applyPluginSidecars?: PluginSyncSidecarApplier;
|
||||
},
|
||||
): Promise<void> {
|
||||
const legacyLineTimestampsEnabled = payload.settings?.terminalSettings?.showLineTimestamps === true;
|
||||
// Build the vault import object. Cloud sync intentionally ignores
|
||||
@@ -964,14 +1122,27 @@ function applyPayload(
|
||||
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalSftpBookmarks();
|
||||
importers.onSettingsApplied?.();
|
||||
}
|
||||
|
||||
// Plugin encrypted sidecars: only apply when the field is present so
|
||||
// legacy payloads without pluginSidecars do not wipe installed settings.
|
||||
if (Object.prototype.hasOwnProperty.call(payload, 'pluginSidecars')) {
|
||||
const applySidecars = options.applyPluginSidecars ?? defaultApplyPluginSyncSidecars;
|
||||
await applySidecars(payload.pluginSidecars ?? { version: 1, entries: [] });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function applySyncPayload(
|
||||
payload: SyncPayload,
|
||||
importers: SyncPayloadImporters,
|
||||
options?: {
|
||||
applyPluginSidecars?: PluginSyncSidecarApplier;
|
||||
},
|
||||
): Promise<void> {
|
||||
return applyPayload(payload, importers, { includeLocalOnlyData: false });
|
||||
return applyPayload(payload, importers, {
|
||||
includeLocalOnlyData: false,
|
||||
applyPluginSidecars: options?.applyPluginSidecars,
|
||||
});
|
||||
}
|
||||
|
||||
export async function prepareLocalVaultPayloadApply(
|
||||
|
||||
@@ -136,10 +136,14 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
}
|
||||
sync.cancelOAuthConnect();
|
||||
if (sync.convergentSyncConfig.initialized) return;
|
||||
const providers: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
|
||||
const providers = new Set<CloudProvider>(['github', 'google', 'onedrive', 'webdav', 's3']);
|
||||
for (const id of Object.keys(sync.providers)) {
|
||||
providers.add(id as CloudProvider);
|
||||
}
|
||||
for (const provider of providers) {
|
||||
if (provider === current) continue;
|
||||
if (isProviderReadyForSync(sync.providers[provider])) {
|
||||
const conn = sync.providers[provider];
|
||||
if (conn && isProviderReadyForSync(conn)) {
|
||||
await sync.disconnectProvider(provider);
|
||||
}
|
||||
}
|
||||
@@ -176,18 +180,23 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
);
|
||||
|
||||
const isConnectDisabled = (provider: CloudProvider): boolean => {
|
||||
const connection = sync.providers[provider];
|
||||
if (pendingConnectProvider && pendingConnectProvider !== provider) {
|
||||
return true;
|
||||
}
|
||||
if (pendingConnectProvider === provider) {
|
||||
return true;
|
||||
}
|
||||
if (hasConnectingProvider && sync.providers[provider].status !== 'connecting') {
|
||||
if (hasConnectingProvider && connection?.status !== 'connecting') {
|
||||
return true;
|
||||
}
|
||||
// Never-connected plugin providers still honor the single-provider gate.
|
||||
if (!connection) {
|
||||
return !sync.convergentSyncConfig.initialized && sync.hasAnyConnectedProvider;
|
||||
}
|
||||
return !sync.convergentSyncConfig.initialized
|
||||
&& sync.hasAnyConnectedProvider
|
||||
&& !isProviderReadyForSync(sync.providers[provider]);
|
||||
&& !isProviderReadyForSync(connection);
|
||||
};
|
||||
|
||||
const beginPendingConnect = (provider: CloudProvider): boolean => {
|
||||
@@ -910,6 +919,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
}}
|
||||
onResolveConvergentConflict={handleResolveConvergentConflict}
|
||||
onDowngradeConvergent={handleDowngradeConvergent}
|
||||
disconnectOtherProviders={disconnectOtherProviders}
|
||||
/>
|
||||
|
||||
<CloudSyncDialogs
|
||||
|
||||
@@ -18,11 +18,16 @@ import {
|
||||
RefreshCw,
|
||||
Search,
|
||||
Terminal as TerminalIcon,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { toGlobalHistoryDisplayEntries } from '../domain/globalHistory';
|
||||
import {
|
||||
shouldRemoveAutocompleteHistoryEntry,
|
||||
toGlobalHistoryDisplayEntries,
|
||||
} from '../domain/globalHistory';
|
||||
import type { Host, RemoteHistoryEntry, ShellHistoryEntry } from '../domain/models';
|
||||
import { removeCommandHistoryEntry } from './terminal/autocomplete/commandHistoryStore';
|
||||
import { cn } from '../lib/utils';
|
||||
import type { RemoteHistoryHostState } from '../application/state/useRemoteHistoryState';
|
||||
import {
|
||||
@@ -40,6 +45,7 @@ export interface HistorySidePanelProps {
|
||||
state: RemoteHistoryHostState;
|
||||
globalEntries: ShellHistoryEntry[];
|
||||
onFetch: (sessionId: string, hostId: string) => void;
|
||||
onDeleteGlobalEntry?: (entryId: string) => void;
|
||||
/** Paste into the terminal without executing (no trailing Enter). */
|
||||
onPasteToTerminal: (command: string) => void;
|
||||
/** Write to the terminal and execute (append Enter). */
|
||||
@@ -60,6 +66,7 @@ const DETAIL_ACTIONS_HEIGHT = 24;
|
||||
interface HistoryPanelEntry {
|
||||
id: string;
|
||||
command: string;
|
||||
hostId?: string;
|
||||
timestamp?: number;
|
||||
hostLabel?: string;
|
||||
}
|
||||
@@ -107,6 +114,7 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
|
||||
state,
|
||||
globalEntries,
|
||||
onFetch,
|
||||
onDeleteGlobalEntry,
|
||||
onPasteToTerminal,
|
||||
onRunInTerminal,
|
||||
isVisible = true,
|
||||
@@ -185,6 +193,21 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleDeleteGlobalEntry = useCallback((entryId: string) => {
|
||||
if (scope !== 'global' || !onDeleteGlobalEntry) return;
|
||||
const entry = sourceEntries.find((candidate) => candidate.id === entryId);
|
||||
if (!entry) return;
|
||||
// Always remove the global row; only touch autocomplete when host is known.
|
||||
if (
|
||||
entry.hostId
|
||||
&& shouldRemoveAutocompleteHistoryEntry(globalEntries, entryId)
|
||||
) {
|
||||
removeCommandHistoryEntry(entry.command, entry.hostId);
|
||||
}
|
||||
onDeleteGlobalEntry(entryId);
|
||||
setSelectedEntryId(null);
|
||||
}, [globalEntries, onDeleteGlobalEntry, scope, sourceEntries]);
|
||||
|
||||
const handleRowClick = useCallback((entryId: string) => {
|
||||
setSelectedEntryId((current) => {
|
||||
const next = current === entryId ? null : entryId;
|
||||
@@ -216,6 +239,7 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
|
||||
paste: t('history.action.paste'),
|
||||
run: t('history.action.run'),
|
||||
save: t('history.action.saveAsSnippet'),
|
||||
delete: t('history.action.delete'),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
@@ -358,6 +382,11 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
|
||||
onRun={() => onRunInTerminal(row.entry.command)}
|
||||
onPaste={() => onPasteToTerminal(row.entry.command)}
|
||||
onSave={() => handleSaveAsSnippet(row.entry)}
|
||||
onDelete={
|
||||
scope === 'global' && onDeleteGlobalEntry
|
||||
? () => handleDeleteGlobalEntry(row.entry.id)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -371,6 +400,11 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
|
||||
onRun={() => onRunInTerminal(row.entry.command)}
|
||||
onPaste={() => onPasteToTerminal(row.entry.command)}
|
||||
onSave={() => handleSaveAsSnippet(row.entry)}
|
||||
onDelete={
|
||||
scope === 'global' && onDeleteGlobalEntry
|
||||
? () => handleDeleteGlobalEntry(row.entry.id)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
@@ -416,14 +450,15 @@ const EmptyState: React.FC<{ message: string }> = ({ message }) => (
|
||||
|
||||
interface HistoryDetailStripProps {
|
||||
entry: HistoryPanelEntry;
|
||||
labels: { paste: string; run: string; save: string };
|
||||
labels: { paste: string; run: string; save: string; delete: string };
|
||||
onRun: () => void;
|
||||
onPaste: () => void;
|
||||
onSave: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
const HistoryDetailStrip: React.FC<HistoryDetailStripProps> = memo(
|
||||
({ entry, labels, onRun, onPaste, onSave }) => (
|
||||
({ entry, labels, onRun, onPaste, onSave, onDelete }) => (
|
||||
<div
|
||||
className="border-b border-border/40 bg-muted/20 px-3 py-1.5"
|
||||
data-section="history-detail"
|
||||
@@ -456,6 +491,11 @@ const HistoryDetailStrip: React.FC<HistoryDetailStripProps> = memo(
|
||||
<IconButton title={labels.save} onClick={onSave}>
|
||||
<FileCode size={12} />
|
||||
</IconButton>
|
||||
{onDelete ? (
|
||||
<IconButton title={labels.delete} onClick={onDelete}>
|
||||
<Trash2 size={12} />
|
||||
</IconButton>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
@@ -466,15 +506,16 @@ interface HistoryRowProps {
|
||||
entry: HistoryPanelEntry;
|
||||
isSelected: boolean;
|
||||
showHostLabel: boolean;
|
||||
labels: { paste: string; run: string; save: string };
|
||||
labels: { paste: string; run: string; save: string; delete: string };
|
||||
onSelect: () => void;
|
||||
onRun: () => void;
|
||||
onPaste: () => void;
|
||||
onSave: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
const HistoryRow: React.FC<HistoryRowProps> = memo(
|
||||
({ entry, isSelected, showHostLabel, labels, onSelect, onRun, onPaste, onSave }) => {
|
||||
({ entry, isSelected, showHostLabel, labels, onSelect, onRun, onPaste, onSave, onDelete }) => {
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
@@ -531,6 +572,11 @@ const HistoryRow: React.FC<HistoryRowProps> = memo(
|
||||
<IconButton title={labels.save} onClick={onSave}>
|
||||
<FileCode size={12} />
|
||||
</IconButton>
|
||||
{onDelete ? (
|
||||
<IconButton title={labels.delete} onClick={onDelete}>
|
||||
<Trash2 size={12} />
|
||||
</IconButton>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+106
-30
@@ -9,7 +9,7 @@ import {
|
||||
Tag,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import React, { useEffect, useMemo, useState, useCallback } from "react";
|
||||
import React, { useEffect, useMemo, useState, useCallback, useRef } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useApplicationBackend, type SshAgentStatus } from "../application/state/useApplicationBackend";
|
||||
import { applyGroupDefaults, resolveGroupDefaults, resolveGroupTerminalThemeId } from "../domain/groupConfig";
|
||||
@@ -70,9 +70,7 @@ import {
|
||||
} from "./host-details";
|
||||
import { HostNotesEditor } from "./host/HostNotesEditor";
|
||||
import { HostDetailsScriptsSection } from "./host/HostDetailsScriptsSection";
|
||||
import { ensureHostConnectScriptIds, getHostConnectScriptIds, prepareSnippetForHostConnectQueue } from "@/domain/hostConnectScripts.ts";
|
||||
import { isScriptSnippet } from "@/domain/snippetScript.ts";
|
||||
import { unlinkHostFromScripts } from "@/domain/snippetTargets.ts";
|
||||
import { ensureHostConnectScriptIds, getEditableHostConnectScriptIds, syncSnippetsForHostConnectQueueSave } from "@/domain/hostConnectScripts.ts";
|
||||
import {
|
||||
isPluginHostProtocol,
|
||||
sanitizePluginConnection,
|
||||
@@ -112,6 +110,7 @@ interface HostDetailsPanelProps {
|
||||
onImportKey?: (draft: Partial<SSHKey>) => SSHKey;
|
||||
snippets?: Snippet[];
|
||||
onSnippetsChange?: (snippets: Snippet[]) => void;
|
||||
onHostsChange?: (hosts: Host[] | ((prev: Host[]) => Host[])) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -139,6 +138,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
onImportKey,
|
||||
snippets = [],
|
||||
onSnippetsChange,
|
||||
onHostsChange,
|
||||
className,
|
||||
resizable,
|
||||
persistWidthStorageKey,
|
||||
@@ -151,6 +151,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
resizeAriaLabel,
|
||||
};
|
||||
const { checkSshAgent } = useApplicationBackend();
|
||||
const baselineSnippetsRef = useRef(snippets);
|
||||
const baselineHydratedRef = useRef(snippets.length > 0);
|
||||
const openingQueueIdsRef = useRef<string[]>([]);
|
||||
const [form, setForm] = useState<Host>(
|
||||
() =>
|
||||
(initialData ? normalizePrimaryTelnetState(initialData) : null) ||
|
||||
@@ -238,6 +241,36 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
|
||||
const initialHostId = initialData?.id;
|
||||
|
||||
useEffect(() => {
|
||||
baselineSnippetsRef.current = snippets;
|
||||
baselineHydratedRef.current = snippets.length > 0;
|
||||
if (!initialData) {
|
||||
openingQueueIdsRef.current = [];
|
||||
} else {
|
||||
const normalized = normalizePrimaryTelnetState(initialData);
|
||||
const ensured = snippets.length > 0
|
||||
? ensureHostConnectScriptIds(normalized, snippets)
|
||||
: normalized;
|
||||
openingQueueIdsRef.current = getEditableHostConnectScriptIds(ensured, snippets);
|
||||
}
|
||||
// Snapshot only when opening/reopening a host editor, not on live snippet edits.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialHostId]);
|
||||
|
||||
useEffect(() => {
|
||||
// If the editor opened before snippets hydrated, capture the first non-empty
|
||||
// catalog as baseline so concurrent-edit guards still have a reference.
|
||||
if (baselineHydratedRef.current) return;
|
||||
if (snippets.length === 0) return;
|
||||
baselineSnippetsRef.current = snippets;
|
||||
baselineHydratedRef.current = true;
|
||||
if (initialData) {
|
||||
const normalized = normalizePrimaryTelnetState(initialData);
|
||||
const ensured = ensureHostConnectScriptIds(normalized, snippets);
|
||||
openingQueueIdsRef.current = getEditableHostConnectScriptIds(ensured, snippets);
|
||||
}
|
||||
}, [initialData, snippets]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialData) return;
|
||||
const normalized = normalizePrimaryTelnetState(initialData);
|
||||
@@ -593,37 +626,80 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
if ((cleaned.protocol && cleaned.protocol !== "ssh") || cleaned.moshEnabled || cleaned.etEnabled) {
|
||||
delete cleaned.x11Forwarding;
|
||||
}
|
||||
if (onSnippetsChange && initialData) {
|
||||
if (onSnippetsChange || onHostsChange) {
|
||||
const hostId = cleaned.id;
|
||||
const savedQueueIds = initialData.connectScriptIds ?? getHostConnectScriptIds(initialData, snippets);
|
||||
const finalQueueIds = cleaned.connectScriptIds ?? getHostConnectScriptIds(cleaned, snippets);
|
||||
const savedSet = new Set(savedQueueIds);
|
||||
const finalSet = new Set(finalQueueIds);
|
||||
let nextSnippets = snippets;
|
||||
let changed = false;
|
||||
|
||||
for (const scriptId of finalQueueIds) {
|
||||
if (!savedSet.has(scriptId)) {
|
||||
nextSnippets = nextSnippets.map((item) => (
|
||||
item.id === scriptId && isScriptSnippet(item)
|
||||
? prepareSnippetForHostConnectQueue(item, hostId)
|
||||
: item
|
||||
));
|
||||
changed = true;
|
||||
if (snippets.length === 0 && !baselineHydratedRef.current) {
|
||||
// Vault may publish hosts before snippets hydrate. Do not wipe unresolved
|
||||
// connectScriptIds while the script catalog is still incomplete.
|
||||
} else {
|
||||
const savedQueueIds = openingQueueIdsRef.current;
|
||||
const finalQueueIds = getEditableHostConnectScriptIds(cleaned, snippets);
|
||||
const synced = syncSnippetsForHostConnectQueueSave(
|
||||
snippets,
|
||||
hostId,
|
||||
savedQueueIds,
|
||||
finalQueueIds,
|
||||
{
|
||||
baselineSnippets: baselineSnippetsRef.current,
|
||||
hosts: allHosts,
|
||||
},
|
||||
);
|
||||
cleaned = {
|
||||
...cleaned,
|
||||
connectScriptIds: synced.connectScriptIds.length > 0
|
||||
? synced.connectScriptIds
|
||||
: undefined,
|
||||
// sanitizeHost reconstitutes connectScriptIds from loginScriptId when
|
||||
// the explicit queue is absent; clear the legacy field when emptied.
|
||||
...(synced.connectScriptIds.length === 0
|
||||
? { loginScriptId: undefined }
|
||||
: {}),
|
||||
};
|
||||
if (synced.changed && onSnippetsChange) {
|
||||
onSnippetsChange(synced.snippets);
|
||||
}
|
||||
}
|
||||
for (const scriptId of savedQueueIds) {
|
||||
if (!finalSet.has(scriptId)) {
|
||||
const unlinked = unlinkHostFromScripts(nextSnippets, hostId, scriptId);
|
||||
if (unlinked !== nextSnippets) {
|
||||
nextSnippets = unlinked;
|
||||
changed = true;
|
||||
if (onHostsChange && synced.hosts.length > 0) {
|
||||
const peerChanged = synced.hosts.some((host) => {
|
||||
if (host.id === cleaned.id) return false;
|
||||
const original = allHosts.find((entry) => entry.id === host.id);
|
||||
return original !== undefined && original !== host;
|
||||
});
|
||||
if (peerChanged) {
|
||||
// Append only the script IDs this save newly queued onto each peer.
|
||||
// Leave the edited host alone so onSave can three-way merge it.
|
||||
onHostsChange((prevHosts) => {
|
||||
const peerAdds = new Map<string, string[]>();
|
||||
for (const syncedHost of synced.hosts) {
|
||||
if (syncedHost.id === cleaned.id) continue;
|
||||
const snapshot = allHosts.find((entry) => entry.id === syncedHost.id);
|
||||
if (!snapshot || snapshot === syncedHost) continue;
|
||||
const snapshotIds = snapshot.connectScriptIds ?? [];
|
||||
const addedIds = (syncedHost.connectScriptIds ?? []).filter(
|
||||
(id) => !snapshotIds.includes(id),
|
||||
);
|
||||
if (addedIds.length > 0) peerAdds.set(syncedHost.id, addedIds);
|
||||
}
|
||||
return prevHosts.map((host) => {
|
||||
if (host.id === cleaned.id) return host;
|
||||
const addedIds = peerAdds.get(host.id);
|
||||
if (!addedIds || addedIds.length === 0) return host;
|
||||
const currentIds = host.connectScriptIds ?? [];
|
||||
const mergedIds = [...currentIds];
|
||||
for (const id of addedIds) {
|
||||
if (!mergedIds.includes(id)) mergedIds.push(id);
|
||||
}
|
||||
if (
|
||||
mergedIds.length === currentIds.length
|
||||
&& mergedIds.every((id, index) => id === currentIds[index])
|
||||
) {
|
||||
return host;
|
||||
}
|
||||
return { ...host, connectScriptIds: mergedIds };
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
onSnippetsChange(nextSnippets);
|
||||
}
|
||||
}
|
||||
onSave(stripBuiltInConnectionFieldsForPluginHost(cleaned));
|
||||
};
|
||||
|
||||
@@ -19,6 +19,14 @@ test('keeps the new workspace action outside the scrollable results', () => {
|
||||
assert.match(source, /VariableSizeVirtualList/);
|
||||
});
|
||||
|
||||
test('host rows expose optional edit-host context menu', () => {
|
||||
const source = readFileSync(new URL('./QuickSwitcher.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /onEditHost\?: \(host: Host\) => void/);
|
||||
assert.match(source, /terminal\.layer\.hostTree\.editHost/);
|
||||
assert.match(source, /ContextMenuTrigger asChild/);
|
||||
assert.match(source, /#netcatty-context-menu-root/);
|
||||
});
|
||||
|
||||
test('pointer hover never rewrites the keyboard selection', () => {
|
||||
const quickSwitcherSource = readFileSync(new URL('./QuickSwitcher.tsx', import.meta.url), 'utf8');
|
||||
const workspacePickerSource = readFileSync(new URL('./workspace/AddToWorkspaceDialog.tsx', import.meta.url), 'utf8');
|
||||
|
||||
@@ -101,6 +101,7 @@ export function buildPluginPaletteItems(
|
||||
});
|
||||
}
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from "./ui/context-menu";
|
||||
import { Input } from "./ui/input";
|
||||
import {
|
||||
VariableSizeVirtualList,
|
||||
@@ -137,6 +138,7 @@ interface QuickSwitcherProps {
|
||||
workspaces: Workspace[];
|
||||
onQueryChange: (value: string) => void;
|
||||
onSelect: (host: Host) => void;
|
||||
onEditHost?: (host: Host) => void;
|
||||
onSelectTab: (tabId: string) => void;
|
||||
onClose: () => void;
|
||||
onCreateLocalTerminal?: (shell?: { command: string; args?: string[]; name?: string; icon?: string }) => void;
|
||||
@@ -154,6 +156,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
workspaces,
|
||||
onQueryChange,
|
||||
onSelect,
|
||||
onEditHost,
|
||||
onSelectTab,
|
||||
onClose,
|
||||
onCreateLocalTerminal,
|
||||
@@ -227,12 +230,18 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target;
|
||||
if (!(target instanceof Node)) return;
|
||||
if (containerRef.current?.contains(target)) return;
|
||||
// ContextMenuContent portals to #netcatty-context-menu-root; treat those
|
||||
// clicks as inside so Connect / Edit host can run before unmount.
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
target instanceof Element &&
|
||||
target.closest("#netcatty-context-menu-root")
|
||||
) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
@@ -527,7 +536,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
|
||||
if (item.type === "host") {
|
||||
const host = item.data as Host;
|
||||
return (
|
||||
const hostRow = (
|
||||
<div
|
||||
className={`flex h-full min-h-0 items-center justify-between overflow-hidden px-4 py-2.5 cursor-pointer transition-colors ${getQuickSwitcherRowStateClass(isSelected, isKeyboardNavigating)}`}
|
||||
onClick={() => onSelect(host)}
|
||||
@@ -545,6 +554,20 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
if (!onEditHost) return hostRow;
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{hostRow}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => onSelect(host)}>
|
||||
{t('vault.hosts.connect')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onEditHost(host)}>
|
||||
{t('terminal.layer.hostTree.editHost')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "tab") {
|
||||
|
||||
@@ -10,7 +10,16 @@
|
||||
* Used in TerminalLayer to provide SFTP alongside terminal sessions.
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
|
||||
import React, {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type MutableRefObject,
|
||||
} from "react";
|
||||
import { SftpSidePanelDeferredMount } from "./SftpSidePanelDeferredMount";
|
||||
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from "./terminalLayer/terminalSidePanelChrome";
|
||||
import { formatHostPort } from "../domain/host";
|
||||
@@ -103,6 +112,8 @@ interface SftpSidePanelProps {
|
||||
onInitialLocationApplied?: (location: { hostId: string; path: string }) => void;
|
||||
onCurrentPathChange?: (location: { hostId: string; connectionKey: string; path: string }) => void;
|
||||
onActiveTransfersChange?: (count: number) => void;
|
||||
/** External-editor temps that must keep this owner mounted after panel close. */
|
||||
onActiveExternalEditsChange?: (count: number) => void;
|
||||
showWorkspaceHostHeader?: boolean;
|
||||
isVisible?: boolean;
|
||||
renderOverlays?: boolean;
|
||||
@@ -150,6 +161,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
onInitialLocationApplied,
|
||||
onCurrentPathChange,
|
||||
onActiveTransfersChange,
|
||||
onActiveExternalEditsChange,
|
||||
showWorkspaceHostHeader = false,
|
||||
isVisible = true,
|
||||
renderOverlays = true,
|
||||
@@ -215,6 +227,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
|
||||
const ownedEditorSessionIdsRef = useRef<ReadonlySet<string>>(new Set());
|
||||
const ownedEditorSftpTabIdsRef = useRef<ReadonlySet<string>>(new Set());
|
||||
const activeExternalEditCountRef = useRef(0);
|
||||
// Re-render on tab open/close/session remap only — not on every editor keystroke.
|
||||
useEditorTabPresenceRevision();
|
||||
const hasOwnedEditorTab = editorTabStore.hasOwnedEditorForSftpOwner({
|
||||
@@ -230,9 +243,12 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
surfaceVisible: isVisible,
|
||||
// A promoted editor still saves through this owner after the side panel
|
||||
// becomes hidden, so its browse channel must stay alive until the editor closes.
|
||||
// External editor temps (Notepad++ etc.) likewise need the session: parking
|
||||
// calls closeSftp which deletes those local files.
|
||||
interactive: isBrowseSessionInteractive({
|
||||
surfaceVisible: isVisible,
|
||||
hasOwnedEditorTab,
|
||||
hasActiveExternalEdit: activeExternalEditCountRef.current > 0,
|
||||
}),
|
||||
useCompressedUpload: sftpUseCompressedUpload,
|
||||
defaultShowHiddenFiles: sftpShowHiddenFiles,
|
||||
@@ -257,6 +273,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
]);
|
||||
|
||||
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
|
||||
activeExternalEditCountRef.current = sftp.activeExternalEditCount ?? 0;
|
||||
ownedEditorSessionIdsRef.current = new Set(
|
||||
listRemoteBrowseConnectionIds([
|
||||
...sftp.leftTabs.tabs,
|
||||
@@ -400,6 +417,14 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
onActiveTransfersChange,
|
||||
});
|
||||
|
||||
// Parent retain-on-close only sees transfers unless we also publish external
|
||||
// editor temp activity (closeSftp deletes those files on unmount).
|
||||
const onActiveExternalEditsChangeRef = useRef(onActiveExternalEditsChange);
|
||||
onActiveExternalEditsChangeRef.current = onActiveExternalEditsChange;
|
||||
useLayoutEffect(() => {
|
||||
onActiveExternalEditsChangeRef.current?.(sftp.activeExternalEditCount ?? 0);
|
||||
}, [sftp.activeExternalEditCount]);
|
||||
|
||||
// Register this instance's writeTextFileByConnection with the editor bridge
|
||||
// so editor tabs promoted from SFTP files opened in a terminal side panel
|
||||
// can still route saves through this useSftpState.
|
||||
@@ -474,7 +499,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
|
||||
const s = sftpRef.current;
|
||||
const hasActiveWork = interactiveWorkActive
|
||||
|| (s.activeFileWatchCountRef?.current ?? 0) > 0;
|
||||
|| (s.activeFileWatchCountRef?.current ?? 0) > 0
|
||||
|| (s.activeExternalEditCount ?? 0) > 0;
|
||||
|
||||
const proto = activeHost.protocol;
|
||||
if (proto === 'serial' || activeHost.id?.startsWith('serial-')) {
|
||||
@@ -1159,7 +1185,8 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
}, [followTerminalCwdHost, onGetTerminalCwd]);
|
||||
|
||||
const hasActiveWork = showTextEditor || !!permissionsState || showFileOpenerDialog
|
||||
|| (sftp.activeFileWatchCountRef?.current ?? 0) > 0;
|
||||
|| (sftp.activeFileWatchCountRef?.current ?? 0) > 0
|
||||
|| (sftp.activeExternalEditCount ?? 0) > 0;
|
||||
|
||||
const blockedFollowRef = useRef<SftpFollowTerminalCwdBlock | null>(null);
|
||||
const handledFollowRef = useRef<SftpFollowTerminalCwdBlock | null>(null);
|
||||
@@ -1723,6 +1750,7 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
|
||||
prev.onRequestTerminalFocus === next.onRequestTerminalFocus &&
|
||||
prev.onCurrentPathChange === next.onCurrentPathChange &&
|
||||
prev.onActiveTransfersChange === next.onActiveTransfersChange &&
|
||||
prev.onActiveExternalEditsChange === next.onActiveExternalEditsChange &&
|
||||
prev.initialLocation?.hostId === next.initialLocation?.hostId &&
|
||||
prev.initialLocation?.path === next.initialLocation?.path &&
|
||||
// Only the keepalive fields of terminalSettings affect SFTP connection
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
CloudOff,
|
||||
Github,
|
||||
Loader2,
|
||||
Plug,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
X,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { useCloudSync } from '../application/state/useCloudSync';
|
||||
import { isProviderReadyForSync, type CloudProvider, formatSyncDateTime } from '../domain/sync';
|
||||
import { isPluginCloudProviderId } from '../domain/cloudProviderIds';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Button } from './ui/button';
|
||||
@@ -53,7 +55,7 @@ const OneDriveIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const providerIcons: Record<CloudProvider, React.ReactNode> = {
|
||||
const providerIcons: Partial<Record<CloudProvider, React.ReactNode>> = {
|
||||
github: <Github size={16} />,
|
||||
google: <GoogleDriveIcon className="w-4 h-4" />,
|
||||
onedrive: <OneDriveIcon className="w-4 h-4" />,
|
||||
@@ -61,7 +63,7 @@ const providerIcons: Record<CloudProvider, React.ReactNode> = {
|
||||
s3: <Database size={16} />,
|
||||
};
|
||||
|
||||
const providerNames: Record<CloudProvider, string> = {
|
||||
const providerNames: Partial<Record<CloudProvider, string>> = {
|
||||
github: 'GitHub Gist',
|
||||
google: 'Google Drive',
|
||||
onedrive: 'OneDrive',
|
||||
@@ -69,6 +71,12 @@ const providerNames: Record<CloudProvider, string> = {
|
||||
s3: 'S3 Compatible',
|
||||
};
|
||||
|
||||
const resolveProviderIcon = (provider: CloudProvider): React.ReactNode =>
|
||||
providerIcons[provider] ?? <Plug size={16} />;
|
||||
|
||||
const resolveProviderName = (provider: CloudProvider): string =>
|
||||
providerNames[provider] ?? provider;
|
||||
|
||||
// ============================================================================
|
||||
// Status Dot Component
|
||||
// ============================================================================
|
||||
@@ -123,13 +131,17 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
|
||||
|
||||
// State is now automatically synced via useSyncExternalStore - no manual refresh needed
|
||||
|
||||
// Get connected provider (include syncing status as it's still connected)
|
||||
// Get connected provider (built-ins first, then any namespaced plugin provider).
|
||||
const getConnectedProvider = (): CloudProvider | null => {
|
||||
if (isProviderReadyForSync(sync.providers.github)) return 'github';
|
||||
if (isProviderReadyForSync(sync.providers.google)) return 'google';
|
||||
if (isProviderReadyForSync(sync.providers.onedrive)) return 'onedrive';
|
||||
if (isProviderReadyForSync(sync.providers.webdav)) return 'webdav';
|
||||
if (isProviderReadyForSync(sync.providers.s3)) return 's3';
|
||||
const preferred: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
|
||||
for (const id of preferred) {
|
||||
const conn = sync.providers[id];
|
||||
if (conn && isProviderReadyForSync(conn)) return id;
|
||||
}
|
||||
for (const [id, conn] of Object.entries(sync.providers)) {
|
||||
if (preferred.includes(id as CloudProvider)) continue;
|
||||
if (conn && isProviderReadyForSync(conn)) return id as CloudProvider;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -286,15 +298,17 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
|
||||
{connectedProvider && providerConnection && (
|
||||
<div className="flex items-center gap-3 p-2 rounded-lg bg-muted/30">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 text-primary flex items-center justify-center">
|
||||
{providerIcons[connectedProvider]}
|
||||
{resolveProviderIcon(connectedProvider)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{providerNames[connectedProvider]}</span>
|
||||
<span className="text-sm font-medium">{resolveProviderName(connectedProvider)}</span>
|
||||
<StatusIndicator status={overallStatus} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{providerConnection.account?.avatarUrl && (
|
||||
{providerConnection.account?.avatarUrl
|
||||
&& connectedProvider
|
||||
&& !isPluginCloudProviderId(connectedProvider) && (
|
||||
<img
|
||||
src={providerConnection.account.avatarUrl}
|
||||
alt=""
|
||||
|
||||
@@ -2778,6 +2778,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
clearWipesScrollbackRef.current = terminalSettings?.clearWipesScrollback ?? true;
|
||||
const normalizeTextOnCopyRef = useRef(terminalSettings?.normalizeTextOnCopy ?? true);
|
||||
normalizeTextOnCopyRef.current = terminalSettings?.normalizeTextOnCopy ?? true;
|
||||
const autoUploadClipboardImageOnPasteRef = useRef(terminalSettings?.autoUploadClipboardImageOnPaste ?? false);
|
||||
autoUploadClipboardImageOnPasteRef.current = terminalSettings?.autoUploadClipboardImageOnPaste ?? false;
|
||||
|
||||
const scrollToBottomAfterProgrammaticInput = useCallback((data: string) => {
|
||||
if (!termRef.current) return;
|
||||
@@ -2943,6 +2945,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
passwordPromptActiveRef,
|
||||
isLocalConnection,
|
||||
supportsRemoteImagePaste,
|
||||
autoUploadClipboardImageOnPasteRef,
|
||||
terminalBackend,
|
||||
getRemoteCwd: () => resolveSftpInitialPath({ preferFreshBackend: true }),
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
@@ -3364,6 +3367,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onPasteData: broadcastUserPasteData,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
containerRef,
|
||||
autoUploadClipboardImage:
|
||||
supportsRemoteImagePaste && terminalSettings?.autoUploadClipboardImageOnPaste === true,
|
||||
getRemoteCwd: () => resolveSftpInitialPath({ preferFreshBackend: true }),
|
||||
onClipboardImageUploadResult: handleClipboardImageUploadResult,
|
||||
});
|
||||
|
||||
const handleToggleSessionLog = useCallback(async () => {
|
||||
|
||||
@@ -216,6 +216,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onUpdateHost,
|
||||
onAddKnownHost,
|
||||
onCommandExecuted,
|
||||
onDeleteShellHistoryEntry,
|
||||
onTerminalDataCapture,
|
||||
onCreateWorkspaceFromSessions,
|
||||
onAddSessionToWorkspace,
|
||||
@@ -578,6 +579,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const sftpHostForTabRef = useRef(sftpHostForTab);
|
||||
sftpHostForTabRef.current = sftpHostForTab;
|
||||
const sftpActiveTransfersByTabRef = useRef<Map<string, number>>(new Map());
|
||||
const sftpActiveExternalEditsByTabRef = useRef<Map<string, number>>(new Map());
|
||||
const sftpRetainedAfterCloseTabIdsRef = useRef<Set<string>>(new Set());
|
||||
const sftpOpeningTabIdsRef = useRef<Set<string>>(new Set());
|
||||
const sftpRetainedCleanupTimersRef = useRef<Map<string, number>>(new Map());
|
||||
@@ -612,6 +614,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sftpRetainedCleanupTimersRef.current.delete(tabId);
|
||||
}
|
||||
sftpActiveTransfersByTabRef.current.delete(tabId);
|
||||
sftpActiveExternalEditsByTabRef.current.delete(tabId);
|
||||
sftpRetainedAfterCloseTabIdsRef.current.delete(tabId);
|
||||
sftpOpeningTabIdsRef.current.delete(tabId);
|
||||
setSftpHostForTab(prev => {
|
||||
@@ -644,20 +647,26 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSftpActiveTransfersChange = useCallback((tabId: string, count: number) => {
|
||||
const activeTransfersCount = resolveTabActiveTransfersCount(tabId, Math.max(0, count));
|
||||
if (activeTransfersCount > 0) {
|
||||
const resolveTabActiveExternalEditCount = useCallback((tabId: string, reportedCount?: number) => {
|
||||
return Math.max(0, reportedCount ?? sftpActiveExternalEditsByTabRef.current.get(tabId) ?? 0);
|
||||
}, []);
|
||||
|
||||
const evaluateSftpPanelRetentionAfterActivity = useCallback((tabId: string, params: {
|
||||
activeTransfersCount: number;
|
||||
activeExternalEditCount: number;
|
||||
}) => {
|
||||
const { activeTransfersCount, activeExternalEditCount } = params;
|
||||
if (activeTransfersCount > 0 || activeExternalEditCount > 0) {
|
||||
const cleanupTimer = sftpRetainedCleanupTimersRef.current.get(tabId);
|
||||
if (cleanupTimer !== undefined) {
|
||||
window.clearTimeout(cleanupTimer);
|
||||
sftpRetainedCleanupTimersRef.current.delete(tabId);
|
||||
}
|
||||
sftpActiveTransfersByTabRef.current.set(tabId, activeTransfersCount);
|
||||
return;
|
||||
}
|
||||
sftpActiveTransfersByTabRef.current.delete(tabId);
|
||||
const lifecycle = {
|
||||
activeTransfersCount,
|
||||
activeExternalEditCount,
|
||||
panelOpen: sidePanelOpenTabsRef.current.has(tabId) || sftpOpeningTabIdsRef.current.has(tabId),
|
||||
retainedAfterClose: sftpRetainedAfterCloseTabIdsRef.current.has(tabId),
|
||||
};
|
||||
@@ -668,15 +677,18 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
if (
|
||||
shouldScheduleSftpRetainedPanelCleanup({
|
||||
activeTransfersCount: lifecycle.activeTransfersCount,
|
||||
activeExternalEditCount: lifecycle.activeExternalEditCount,
|
||||
retainedAfterClose: lifecycle.retainedAfterClose,
|
||||
})
|
||||
&& !sftpRetainedCleanupTimersRef.current.has(tabId)
|
||||
) {
|
||||
const cleanupTimer = window.setTimeout(() => {
|
||||
sftpRetainedCleanupTimersRef.current.delete(tabId);
|
||||
const currentCount = resolveTabActiveTransfersCount(tabId);
|
||||
const currentTransfers = resolveTabActiveTransfersCount(tabId);
|
||||
const currentExternalEdits = resolveTabActiveExternalEditCount(tabId);
|
||||
if (
|
||||
currentCount <= 0
|
||||
currentTransfers <= 0
|
||||
&& currentExternalEdits <= 0
|
||||
&& !sidePanelOpenTabsRef.current.has(tabId)
|
||||
&& !sftpOpeningTabIdsRef.current.has(tabId)
|
||||
&& sftpRetainedAfterCloseTabIdsRef.current.has(tabId)
|
||||
@@ -686,7 +698,46 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}, SFTP_TRANSFER_HISTORY_RETENTION_MS);
|
||||
sftpRetainedCleanupTimersRef.current.set(tabId, cleanupTimer);
|
||||
}
|
||||
}, [clearSftpPanelState, resolveTabActiveTransfersCount, sidePanelOpenTabsRef]);
|
||||
}, [
|
||||
clearSftpPanelState,
|
||||
resolveTabActiveExternalEditCount,
|
||||
resolveTabActiveTransfersCount,
|
||||
sidePanelOpenTabsRef,
|
||||
]);
|
||||
|
||||
const handleSftpActiveTransfersChange = useCallback((tabId: string, count: number) => {
|
||||
const activeTransfersCount = resolveTabActiveTransfersCount(tabId, Math.max(0, count));
|
||||
if (activeTransfersCount > 0) {
|
||||
sftpActiveTransfersByTabRef.current.set(tabId, activeTransfersCount);
|
||||
} else {
|
||||
sftpActiveTransfersByTabRef.current.delete(tabId);
|
||||
}
|
||||
evaluateSftpPanelRetentionAfterActivity(tabId, {
|
||||
activeTransfersCount,
|
||||
activeExternalEditCount: resolveTabActiveExternalEditCount(tabId),
|
||||
});
|
||||
}, [
|
||||
evaluateSftpPanelRetentionAfterActivity,
|
||||
resolveTabActiveExternalEditCount,
|
||||
resolveTabActiveTransfersCount,
|
||||
]);
|
||||
|
||||
const handleSftpActiveExternalEditsChange = useCallback((tabId: string, count: number) => {
|
||||
const activeExternalEditCount = resolveTabActiveExternalEditCount(tabId, Math.max(0, count));
|
||||
if (activeExternalEditCount > 0) {
|
||||
sftpActiveExternalEditsByTabRef.current.set(tabId, activeExternalEditCount);
|
||||
} else {
|
||||
sftpActiveExternalEditsByTabRef.current.delete(tabId);
|
||||
}
|
||||
evaluateSftpPanelRetentionAfterActivity(tabId, {
|
||||
activeTransfersCount: resolveTabActiveTransfersCount(tabId),
|
||||
activeExternalEditCount,
|
||||
});
|
||||
}, [
|
||||
evaluateSftpPanelRetentionAfterActivity,
|
||||
resolveTabActiveExternalEditCount,
|
||||
resolveTabActiveTransfersCount,
|
||||
]);
|
||||
|
||||
const closeTerminalSidePanelTab = useCallback((tabId: string) => {
|
||||
setSidePanelOpenTabs((previous) => {
|
||||
@@ -696,9 +747,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
});
|
||||
sftpOpeningTabIdsRef.current.delete(tabId);
|
||||
const activeTransfersCount = resolveTabActiveTransfersCount(tabId);
|
||||
if (shouldKeepSftpMountedAfterClose(activeTransfersCount)) {
|
||||
const activeExternalEditCount = resolveTabActiveExternalEditCount(tabId);
|
||||
if (shouldKeepSftpMountedAfterClose({ activeTransfersCount, activeExternalEditCount })) {
|
||||
sftpRetainedAfterCloseTabIdsRef.current.add(tabId);
|
||||
sftpActiveTransfersByTabRef.current.set(tabId, activeTransfersCount);
|
||||
if (activeTransfersCount > 0) {
|
||||
sftpActiveTransfersByTabRef.current.set(tabId, activeTransfersCount);
|
||||
}
|
||||
if (activeExternalEditCount > 0) {
|
||||
sftpActiveExternalEditsByTabRef.current.set(tabId, activeExternalEditCount);
|
||||
}
|
||||
} else {
|
||||
clearSftpPanelState(tabId);
|
||||
}
|
||||
@@ -713,7 +770,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
return next;
|
||||
});
|
||||
notesReturnTabRef.current.delete(tabId);
|
||||
}, [clearSftpPanelState, resolveTabActiveTransfersCount, setSidePanelOpenTabs]);
|
||||
}, [
|
||||
clearSftpPanelState,
|
||||
resolveTabActiveExternalEditCount,
|
||||
resolveTabActiveTransfersCount,
|
||||
setSidePanelOpenTabs,
|
||||
]);
|
||||
|
||||
const handleToggleWorkspaceComposeBar = useCallback(() => {
|
||||
setIsComposeBarOpen(prev => !prev);
|
||||
@@ -1099,8 +1161,11 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const storeActiveTransferTabIds = listTerminalTabIdsWithRetainingTransfers(
|
||||
sftpTransferCenterStore.getSnapshot().tasks,
|
||||
);
|
||||
// Keep owners with unfinished transfers or external-editor temps mounted
|
||||
// even after the terminal tab id drops out of the valid set.
|
||||
const activeTransferTabIds = new Set([
|
||||
...sftpActiveTransfersByTabRef.current.keys(),
|
||||
...sftpActiveExternalEditsByTabRef.current.keys(),
|
||||
...storeActiveTransferTabIds,
|
||||
]);
|
||||
const invalidTabIds = listInvalidSftpPanelTabIds({
|
||||
@@ -1943,6 +2008,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
handleCloseSession,
|
||||
handleCloseSidePanel,
|
||||
handleCommandExecuted,
|
||||
handleHistoryDelete: onDeleteShellHistoryEntry,
|
||||
handleCommandSubmitted,
|
||||
handleComposeSend,
|
||||
handleHistoryPaste,
|
||||
@@ -1966,6 +2032,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
handleSessionExit,
|
||||
handleSftpCurrentPathChange,
|
||||
handleSftpActiveTransfersChange,
|
||||
handleSftpActiveExternalEditsChange,
|
||||
handleSftpInitialLocationApplied,
|
||||
persistSidePanelWidth,
|
||||
handleSnippetClickForFocusedSession,
|
||||
|
||||
@@ -131,6 +131,7 @@ interface TopTabsProps {
|
||||
onRenameSession: (sessionId: string) => void;
|
||||
onCopySession: (sessionId: string) => void;
|
||||
onCopySessionToNewWindow: (sessionId: string) => void;
|
||||
onEditHost?: (host: Host) => void;
|
||||
onRenameWorkspace: (workspaceId: string) => void;
|
||||
onCopyWorkspace: (workspaceId: string) => void;
|
||||
onCloseWorkspace: (workspaceId: string) => void;
|
||||
@@ -177,6 +178,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onRenameSession,
|
||||
onCopySession,
|
||||
onCopySessionToNewWindow,
|
||||
onEditHost,
|
||||
onRenameWorkspace,
|
||||
onCopyWorkspace,
|
||||
onCloseWorkspace,
|
||||
@@ -804,6 +806,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onRenameSession={onRenameSession}
|
||||
onCopySession={onCopySession}
|
||||
onCopySessionToNewWindow={onCopySessionToNewWindow}
|
||||
onEditHost={onEditHost}
|
||||
renderBulkCloseItems={renderBulkCloseItems}
|
||||
dynamicTabTitleMode={dynamicTabTitleMode}
|
||||
t={t}
|
||||
@@ -1170,6 +1173,7 @@ export const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean
|
||||
prev.isMacClient === next.isMacClient &&
|
||||
prev.onCopySession === next.onCopySession &&
|
||||
prev.onCopySessionToNewWindow === next.onCopySessionToNewWindow &&
|
||||
prev.onEditHost === next.onEditHost &&
|
||||
prev.onCopyWorkspace === next.onCopyWorkspace &&
|
||||
prev.onOpenSettings === next.onOpenSettings &&
|
||||
prev.externalMcpEnabled === next.externalMcpEnabled &&
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import React, { type Dispatch, type RefObject, type SetStateAction } from 'react';
|
||||
import { Database, Github, History, Server, Trash2 } from 'lucide-react';
|
||||
import React, { useEffect, useState, type Dispatch, type RefObject, type SetStateAction } from 'react';
|
||||
import { Database, Github, History, Plug, Server, Trash2 } from 'lucide-react';
|
||||
import type {
|
||||
CloudProvider,
|
||||
ConvergentFieldConflict,
|
||||
ConvergentMigrationPreview,
|
||||
SyncPayload,
|
||||
} from '../../domain/sync';
|
||||
import { isBuiltinCloudProvider } from '../../domain/sync';
|
||||
import { isPluginCloudProviderId } from '../../domain/cloudProviderIds';
|
||||
import { planPluginSyncConnect, hasPluginProviderStoredConfig } from '../../domain/pluginSyncConnect';
|
||||
import { planPluginSyncCredential, syncConfigurationSchemaWithoutSecretRequirements } from '../../domain/pluginSyncCredential';
|
||||
import { pluginConfigurationMatchesSchema } from '../../domain/pluginConfigurationSchema';
|
||||
import type { useCloudSync } from '../../application/state/useCloudSync';
|
||||
import { storePluginSyncSecretsThenConnect } from '../../application/pluginSyncConnectWithSecrets';
|
||||
import { cleanOneDriveErrorMessage, isProviderReadyForSync } from '../../domain/sync';
|
||||
import { pluginExtensionBridge } from '../../application/state/pluginExtensionBridge';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Button } from '../ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '../ui/select';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { toast } from '../ui/toast';
|
||||
import { GoogleDriveIcon, OneDriveIcon, ProviderCard, Toggle } from './CloudSyncControls';
|
||||
import { LocalBackupsPanel } from './CloudSyncLocalBackupsPanel';
|
||||
import { ConvergentSyncPanel } from './ConvergentSyncPanel';
|
||||
@@ -48,6 +57,8 @@ interface CloudSyncDashboardTabsProps {
|
||||
onCancelConvergentMigration: () => void;
|
||||
onResolveConvergentConflict: (addressKey: string, candidateDot: string) => void | Promise<void>;
|
||||
onDowngradeConvergent: () => void | Promise<void>;
|
||||
/** Disconnect other legacy providers before connecting a plugin backend. */
|
||||
disconnectOtherProviders?: (current: CloudProvider) => Promise<void>;
|
||||
}
|
||||
|
||||
export const CloudSyncDashboardTabs: React.FC<CloudSyncDashboardTabsProps> = ({
|
||||
@@ -78,7 +89,201 @@ export const CloudSyncDashboardTabs: React.FC<CloudSyncDashboardTabsProps> = ({
|
||||
onCancelConvergentMigration,
|
||||
onResolveConvergentConflict,
|
||||
onDowngradeConvergent,
|
||||
}) => (
|
||||
disconnectOtherProviders,
|
||||
}) => {
|
||||
const [pluginSyncProviders, setPluginSyncProviders] = useState<Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
configurationSchema?: unknown;
|
||||
}>>([]);
|
||||
const [pluginConnectBusy, setPluginConnectBusy] = useState<string | null>(null);
|
||||
const [pluginConfigDialog, setPluginConfigDialog] = useState<{
|
||||
providerId: string;
|
||||
title: string;
|
||||
configurationSchema?: unknown;
|
||||
} | null>(null);
|
||||
const [pluginConfigText, setPluginConfigText] = useState('{}');
|
||||
const [pluginConfigError, setPluginConfigError] = useState<string | null>(null);
|
||||
const [pluginConfigSaving, setPluginConfigSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const listed = await pluginExtensionBridge.listProviders('sync');
|
||||
if (cancelled) return;
|
||||
setPluginSyncProviders(
|
||||
(listed ?? []).map((entry) => {
|
||||
const nested = (entry as {
|
||||
provider?: {
|
||||
id?: string;
|
||||
label?: string;
|
||||
configurationSchema?: unknown;
|
||||
};
|
||||
pluginDisplayName?: string;
|
||||
}).provider;
|
||||
const id = String(nested?.id ?? '');
|
||||
return {
|
||||
id,
|
||||
title: String(
|
||||
nested?.label
|
||||
?? (entry as { pluginDisplayName?: string }).pluginDisplayName
|
||||
?? id
|
||||
?? 'Plugin sync',
|
||||
),
|
||||
configurationSchema: nested?.configurationSchema,
|
||||
};
|
||||
}).filter((entry) => entry.id.length > 0 && isPluginCloudProviderId(entry.id)),
|
||||
);
|
||||
} catch {
|
||||
if (!cancelled) setPluginSyncProviders([]);
|
||||
}
|
||||
};
|
||||
void refresh();
|
||||
const unsubscribe = pluginExtensionBridge.onContributionsChanged(() => {
|
||||
void refresh();
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const openPluginConfigDialog = (
|
||||
providerId: string,
|
||||
title: string,
|
||||
configurationSchema: unknown | undefined,
|
||||
) => {
|
||||
const connection = sync.providers[providerId];
|
||||
// Include falsy scalars and JSON null so Edit can re-save them.
|
||||
const seed = hasPluginProviderStoredConfig(connection) ? connection!.config : {};
|
||||
setPluginConfigText(JSON.stringify(seed, null, 2));
|
||||
setPluginConfigError(null);
|
||||
setPluginConfigDialog({ providerId, title, configurationSchema });
|
||||
};
|
||||
|
||||
const runPluginConnect = async (providerId: string, configuration: unknown): Promise<boolean> => {
|
||||
setPluginConnectBusy(providerId);
|
||||
try {
|
||||
if (disconnectOtherProviders) {
|
||||
await disconnectOtherProviders(providerId as CloudProvider);
|
||||
}
|
||||
const credentialPlan = planPluginSyncCredential(configuration, {
|
||||
configurationSchema: pluginSyncProviders.find((entry) => entry.id === providerId)
|
||||
?.configurationSchema,
|
||||
});
|
||||
const stored = sync.providers[providerId]?.credential;
|
||||
const existingCredential =
|
||||
stored
|
||||
&& typeof stored === 'object'
|
||||
&& stored.kind === 'secret'
|
||||
&& typeof stored.id === 'string'
|
||||
? stored as { kind: 'secret'; id: string; key: string }
|
||||
: undefined;
|
||||
if (credentialPlan.secrets.length > 0) {
|
||||
const { putPluginSyncSecret, deletePluginSyncSecrets, restorePluginSyncSecrets } = await import(
|
||||
'../../infrastructure/services/adapters/pluginSyncIpcHost'
|
||||
);
|
||||
// Put secrets before connect; roll back just-created keys if connect fails
|
||||
// so a rejected password/token is not left readable in plugin_secrets.
|
||||
// Overwrites restore the previous plaintext from the host stash.
|
||||
await storePluginSyncSecretsThenConnect({
|
||||
providerId,
|
||||
secrets: credentialPlan.secrets.map((secret) => ({
|
||||
secretKey: secret.secretKey,
|
||||
value: secret.value,
|
||||
})),
|
||||
putSecret: putPluginSyncSecret,
|
||||
deleteSecrets: deletePluginSyncSecrets,
|
||||
restoreSecrets: restorePluginSyncSecrets,
|
||||
connect: async (credential) => {
|
||||
await sync.connectPluginProvider(
|
||||
providerId,
|
||||
credentialPlan.configuration,
|
||||
credential,
|
||||
);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await sync.connectPluginProvider(
|
||||
providerId,
|
||||
credentialPlan.configuration,
|
||||
existingCredential,
|
||||
);
|
||||
}
|
||||
toast.success(t('cloudSync.connect.plugin.success'));
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
toast.error(message, t('cloudSync.connect.plugin.failedTitle'));
|
||||
return false;
|
||||
} finally {
|
||||
setPluginConnectBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePluginConnect = async (providerId: string) => {
|
||||
const listed = pluginSyncProviders.find((entry) => entry.id === providerId);
|
||||
const connection = sync.providers[providerId];
|
||||
// Config may be a valid falsy scalar or JSON null — property presence, not truthiness.
|
||||
const hasStoredConfig = hasPluginProviderStoredConfig(connection);
|
||||
const plan = planPluginSyncConnect({
|
||||
configurationSchema: listed?.configurationSchema,
|
||||
storedConfig: connection?.config,
|
||||
hasStoredConfig,
|
||||
});
|
||||
if (plan.action === 'prompt') {
|
||||
openPluginConfigDialog(
|
||||
providerId,
|
||||
listed?.title ?? providerId,
|
||||
listed?.configurationSchema,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await runPluginConnect(providerId, plan.configuration);
|
||||
};
|
||||
|
||||
const handleSavePluginConfig = async () => {
|
||||
if (!pluginConfigDialog) return;
|
||||
let configuration: unknown;
|
||||
try {
|
||||
configuration = JSON.parse(pluginConfigText) as unknown;
|
||||
} catch {
|
||||
setPluginConfigError(t('cloudSync.pluginConfig.invalidJson'));
|
||||
return;
|
||||
}
|
||||
if (pluginConfigDialog.configurationSchema !== undefined) {
|
||||
const connection = sync.providers[pluginConfigDialog.providerId];
|
||||
const hasStoredSecret = connection?.credential != null
|
||||
&& typeof connection.credential === 'object'
|
||||
&& (connection.credential as { kind?: string }).kind === 'secret';
|
||||
// Edit seeds stripped config; required secret fields stay satisfied by the
|
||||
// durable SecretRef until the user re-enters plaintext secrets.
|
||||
const schema = hasStoredSecret
|
||||
? syncConfigurationSchemaWithoutSecretRequirements(pluginConfigDialog.configurationSchema)
|
||||
: pluginConfigDialog.configurationSchema;
|
||||
if (!pluginConfigurationMatchesSchema(schema, configuration)) {
|
||||
setPluginConfigError(t('cloudSync.pluginConfig.schemaInvalid'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
setPluginConfigError(null);
|
||||
setPluginConfigSaving(true);
|
||||
try {
|
||||
const ok = await runPluginConnect(pluginConfigDialog.providerId, configuration);
|
||||
if (ok) setPluginConfigDialog(null);
|
||||
// On failure toast already shown; keep dialog open for correction.
|
||||
} finally {
|
||||
setPluginConfigSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pluginProviderIds = new Set<string>([
|
||||
...pluginSyncProviders.map((entry) => entry.id),
|
||||
...Object.keys(sync.providers).filter((id) => !isBuiltinCloudProvider(id)),
|
||||
]);
|
||||
|
||||
return (
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'providers' | 'status')} className="space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="providers">{t('cloudSync.providers.title')}</TabsTrigger>
|
||||
@@ -187,6 +392,112 @@ export const CloudSyncDashboardTabs: React.FC<CloudSyncDashboardTabsProps> = ({
|
||||
onDisconnect={() => sync.disconnectProvider('s3')}
|
||||
onSync={() => handleSync('s3')}
|
||||
/>
|
||||
|
||||
{[...pluginProviderIds].sort().map((providerId) => {
|
||||
const connection = sync.providers[providerId];
|
||||
const listed = pluginSyncProviders.find((entry) => entry.id === providerId);
|
||||
const title = listed?.title ?? providerId;
|
||||
const connected = connection ? isProviderReadyForSync(connection) : false;
|
||||
const hasStoredConfig = hasPluginProviderStoredConfig(connection);
|
||||
const needsConfig = planPluginSyncConnect({
|
||||
configurationSchema: listed?.configurationSchema,
|
||||
storedConfig: connection?.config,
|
||||
hasStoredConfig,
|
||||
}).action === 'prompt';
|
||||
return (
|
||||
<ProviderCard
|
||||
key={providerId}
|
||||
provider={providerId as CloudProvider}
|
||||
name={title}
|
||||
icon={<Plug size={24} />}
|
||||
isConnected={connected}
|
||||
isSyncing={connection?.status === 'syncing'}
|
||||
isConnecting={
|
||||
connection?.status === 'connecting'
|
||||
|| pluginConnectBusy === providerId
|
||||
}
|
||||
account={connection?.account
|
||||
? {
|
||||
// Never load plugin-supplied avatar URLs in the main
|
||||
// renderer (offline plugin network boundary).
|
||||
id: connection.account.id,
|
||||
name: connection.account.name,
|
||||
email: connection.account.email,
|
||||
}
|
||||
: undefined}
|
||||
lastSync={connection?.lastSync}
|
||||
error={connection?.error}
|
||||
disabled={isConnectDisabled(providerId as CloudProvider)}
|
||||
onEdit={
|
||||
connected || needsConfig || hasStoredConfig
|
||||
? () => openPluginConfigDialog(
|
||||
providerId,
|
||||
title,
|
||||
listed?.configurationSchema,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
onConnect={() => {
|
||||
void handlePluginConnect(providerId);
|
||||
}}
|
||||
onDisconnect={() => sync.disconnectProvider(providerId as CloudProvider)}
|
||||
onSync={() => handleSync(providerId as CloudProvider)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<Dialog
|
||||
open={pluginConfigDialog != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !pluginConfigSaving) setPluginConfigDialog(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t('cloudSync.pluginConfig.title', {
|
||||
name: pluginConfigDialog?.title ?? '',
|
||||
})}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('cloudSync.pluginConfig.desc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<textarea
|
||||
value={pluginConfigText}
|
||||
onChange={(event) => {
|
||||
setPluginConfigText(event.target.value);
|
||||
if (pluginConfigError) setPluginConfigError(null);
|
||||
}}
|
||||
spellCheck={false}
|
||||
disabled={pluginConfigSaving}
|
||||
className="min-h-40 w-full resize-y rounded-md border border-border bg-background px-3 py-2 font-mono text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={t('cloudSync.pluginConfig.label')}
|
||||
/>
|
||||
{pluginConfigError ? (
|
||||
<p role="alert" className="text-xs text-destructive">{pluginConfigError}</p>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={pluginConfigSaving}
|
||||
onClick={() => setPluginConfigDialog(null)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={pluginConfigSaving}
|
||||
onClick={() => { void handleSavePluginConfig(); }}
|
||||
>
|
||||
{pluginConfigSaving
|
||||
? t('cloudSync.provider.connecting')
|
||||
: t('cloudSync.provider.connect')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="status" className="space-y-4">
|
||||
@@ -371,4 +682,5 @@ export const CloudSyncDashboardTabs: React.FC<CloudSyncDashboardTabsProps> = ({
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
);
|
||||
};
|
||||
@@ -38,6 +38,7 @@ test("delete confirmation dialogs constrain long names", () => {
|
||||
const confirmDialogSource = readProjectFile("components/ui/confirm-dialog.tsx");
|
||||
const vaultDeleteDialogSource = readProjectFile("components/vault/VaultDeleteConfirmDialog.tsx");
|
||||
const sftpDialogSource = readProjectFile("components/sftp/SftpPaneDialogs.tsx");
|
||||
const sftpClipboardUploadDialogSource = readProjectFile("components/sftp/SftpClipboardUploadDialog.tsx");
|
||||
const hostTreeGroupDeleteSource = readProjectFile("components/host/HostTreeGroupDeleteDialog.tsx");
|
||||
const vaultViewLayoutSource = readProjectFile("components/vault/VaultViewLayout.tsx");
|
||||
|
||||
@@ -51,6 +52,13 @@ test("delete confirmation dialogs constrain long names", () => {
|
||||
assert.match(sftpDialogSource, /<span className="min-w-0 truncate">\{name\}<\/span>/);
|
||||
assert.match(sftpDialogSource, /overflow-hidden sm:max-w-sm/);
|
||||
|
||||
assert.match(sftpClipboardUploadDialogSource, /overflow-hidden sm:max-w-md/);
|
||||
assert.match(sftpClipboardUploadDialogSource, /<span className="min-w-0 truncate" title=\{file\.name\}>/);
|
||||
assert.match(
|
||||
sftpClipboardUploadDialogSource,
|
||||
/min-w-0 rounded-md border border-border\/60 bg-muted\/30 px-3 py-2 text-sm font-mono break-all \[overflow-wrap:anywhere\]/,
|
||||
);
|
||||
|
||||
assert.match(hostTreeGroupDeleteSource, /overflow-hidden sm:max-w-lg/);
|
||||
assert.match(hostTreeGroupDeleteSource, /break-words text-sm text-muted-foreground \[overflow-wrap:anywhere\]/);
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import React, { useCallback, useMemo, useState, type DragEvent } from 'react';
|
||||
import type { Host, Snippet } from '@/domain/models';
|
||||
import {
|
||||
appendHostConnectScript,
|
||||
getEditableHostConnectScriptIds,
|
||||
getGlobalConnectScripts,
|
||||
getHostConnectScriptIds,
|
||||
removeHostConnectScript,
|
||||
reorderHostConnectScript,
|
||||
} from '@/domain/hostConnectScripts.ts';
|
||||
@@ -60,7 +60,7 @@ export const HostDetailsScriptsSection: React.FC<HostDetailsScriptsSectionProps>
|
||||
[snippets],
|
||||
);
|
||||
const queueIds = useMemo(
|
||||
() => getHostConnectScriptIds(host, snippets),
|
||||
() => getEditableHostConnectScriptIds(host, snippets),
|
||||
[host, snippets],
|
||||
);
|
||||
const queuedScripts = useMemo(
|
||||
@@ -71,15 +71,16 @@ export const HostDetailsScriptsSection: React.FC<HostDetailsScriptsSectionProps>
|
||||
);
|
||||
const linkableScripts = useMemo(
|
||||
() => scripts.filter((script) => {
|
||||
if (!script.id) return false;
|
||||
if (script.targetsAllHosts) return false;
|
||||
if (script.id && queueIds.includes(script.id)) return false;
|
||||
if (queueIds.includes(script.id)) return false;
|
||||
return true;
|
||||
}),
|
||||
[queueIds, scripts],
|
||||
);
|
||||
const linkOptions = useMemo(
|
||||
() => linkableScripts.map((script) => ({
|
||||
value: script.id,
|
||||
value: script.id!,
|
||||
label: script.label || t('scripts.running.unnamed'),
|
||||
})),
|
||||
[linkableScripts, t],
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { PortForwardingRule } from "../../../domain/models";
|
||||
import type { SyncPayload } from "../../../domain/sync";
|
||||
import {
|
||||
buildCloudSyncPayload,
|
||||
buildLocalVaultPayload,
|
||||
applySyncPayload,
|
||||
getEffectivePortForwardingRulesForSync,
|
||||
prepareLocalVaultPayloadApply,
|
||||
@@ -41,10 +40,10 @@ export default function SettingsSyncTab(props: {
|
||||
return buildCloudSyncPayload(vault, getEffectivePortForwardingRules());
|
||||
}, [vault, getEffectivePortForwardingRules]);
|
||||
|
||||
const onBuildLocalPayload = useCallback((): SyncPayload => {
|
||||
const onBuildLocalPayload = useCallback(async (): Promise<SyncPayload> => {
|
||||
const effectiveKnownHosts = getEffectiveKnownHosts(vault.knownHosts);
|
||||
|
||||
return buildLocalVaultPayload(
|
||||
const { buildLocalVaultPayloadAsync } = await import('../../../application/syncPayload');
|
||||
return buildLocalVaultPayloadAsync(
|
||||
{ ...vault, knownHosts: effectiveKnownHosts ?? [] },
|
||||
getEffectivePortForwardingRules(),
|
||||
);
|
||||
|
||||
@@ -35,3 +35,9 @@ test("terminal settings expose the host information bar toggle", () => {
|
||||
assert.match(source, /checked=\{terminalSettings\.showHostInfoBar\}/);
|
||||
assert.match(source, /updateTerminalSetting\("showHostInfoBar", v\)/);
|
||||
});
|
||||
|
||||
test("terminal settings expose host information title mode when the bar is shown", () => {
|
||||
assert.match(source, /terminalSettings\.showHostInfoBar && \(/);
|
||||
assert.match(source, /updateTerminalSetting\("hostInfoBarTitleMode"/);
|
||||
assert.match(source, /settings\.terminal\.hostInfoBar\.titleMode/);
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { AlertCircle, Import, Minus, Palette, Pencil, Plus, Trash2 } from "lucid
|
||||
import type {
|
||||
AutocompleteHistoryScope,
|
||||
CursorShape,
|
||||
HostInfoBarTitleMode,
|
||||
PasswordPromptAssistMode,
|
||||
TerminalEmulationType,
|
||||
TerminalSettings,
|
||||
@@ -924,6 +925,22 @@ function SettingsTerminalTab(props: {
|
||||
onChange={(v) => updateTerminalSetting("showHostInfoBar", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
{terminalSettings.showHostInfoBar && (
|
||||
<SettingRow
|
||||
label={t("settings.terminal.hostInfoBar.titleMode")}
|
||||
description={t("settings.terminal.hostInfoBar.titleMode.desc")}
|
||||
>
|
||||
<Select
|
||||
value={terminalSettings.hostInfoBarTitleMode ?? "address"}
|
||||
options={[
|
||||
{ value: "address", label: t("settings.terminal.hostInfoBar.titleMode.address") },
|
||||
{ value: "label", label: t("settings.terminal.hostInfoBar.titleMode.label") },
|
||||
]}
|
||||
onChange={(v) => updateTerminalSetting("hostInfoBarTitleMode", v as HostInfoBarTitleMode)}
|
||||
className="w-44"
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
<SettingRow
|
||||
label={t("settings.terminal.serverStats.show")}
|
||||
description={t("settings.terminal.serverStats.show.desc")}
|
||||
|
||||
@@ -127,6 +127,16 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
|
||||
<Toggle checked={!terminalSettings.disableBracketedPaste} onChange={(v) => updateTerminalSetting("disableBracketedPaste", !v)} />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.behavior.autoUploadClipboardImage")}
|
||||
description={t("settings.terminal.behavior.autoUploadClipboardImage.desc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={terminalSettings.autoUploadClipboardImageOnPaste ?? false}
|
||||
onChange={(v) => updateTerminalSetting("autoUploadClipboardImageOnPaste", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.behavior.shiftEnterNewline")}
|
||||
description={t("settings.terminal.behavior.shiftEnterNewline.desc")}
|
||||
|
||||
@@ -64,23 +64,28 @@ export const SftpClipboardUploadDialog: React.FC<SftpClipboardUploadDialogProps>
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogContent className="max-w-[calc(100vw-2rem)] overflow-hidden sm:max-w-md">
|
||||
<DialogHeader className="min-w-0 pr-6">
|
||||
<DialogTitle>Upload clipboard files?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Upload {fileCount} item{fileCount === 1 ? "" : "s"} to:
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-md border border-border/60 bg-muted/30 px-3 py-2 text-sm font-mono break-all">
|
||||
<div className="min-w-0 space-y-3">
|
||||
<div className="min-w-0 rounded-md border border-border/60 bg-muted/30 px-3 py-2 text-sm font-mono break-all [overflow-wrap:anywhere]">
|
||||
{request?.targetPath ?? currentPath}
|
||||
</div>
|
||||
{previewFiles.length > 0 && (
|
||||
<div className="max-h-40 overflow-auto rounded-md border border-border/60">
|
||||
<div className="max-h-40 min-w-0 overflow-auto rounded-md border border-border/60">
|
||||
{previewFiles.map((file) => (
|
||||
<div key={file.path} className="px-3 py-2 text-sm border-b border-border/40 last:border-b-0 truncate">
|
||||
{file.name}
|
||||
<div
|
||||
key={file.path}
|
||||
className="flex min-w-0 items-center border-b border-border/40 px-3 py-2 text-sm last:border-b-0"
|
||||
>
|
||||
<span className="min-w-0 truncate" title={file.name}>
|
||||
{file.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{remainingCount > 0 && (
|
||||
|
||||
@@ -3,6 +3,8 @@ import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import {
|
||||
formatTerminalHostInfoBarTitle,
|
||||
formatTerminalHostInfoBarTooltip,
|
||||
formatTerminalTitleConnectionAddress,
|
||||
getLineTimestampToggleHostUpdate,
|
||||
resolveNetworkDeviceTipRightInset,
|
||||
@@ -161,6 +163,56 @@ test("terminal title formats the connection address for remote sessions", () =>
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("host info bar title follows address or label mode", () => {
|
||||
assert.equal(
|
||||
formatTerminalHostInfoBarTitle({
|
||||
serverName: "prod-web",
|
||||
connectionAddress: "root@10.1.2.34:2222",
|
||||
mode: "address",
|
||||
}),
|
||||
"root@10.1.2.34:2222",
|
||||
);
|
||||
assert.equal(
|
||||
formatTerminalHostInfoBarTitle({
|
||||
serverName: "prod-web",
|
||||
connectionAddress: "root@10.1.2.34:2222",
|
||||
mode: "label",
|
||||
}),
|
||||
"prod-web",
|
||||
);
|
||||
assert.equal(
|
||||
formatTerminalHostInfoBarTitle({
|
||||
serverName: " prod-web ",
|
||||
connectionAddress: " root@10.1.2.34:2222 ",
|
||||
mode: "label",
|
||||
}),
|
||||
"prod-web",
|
||||
);
|
||||
assert.equal(
|
||||
formatTerminalHostInfoBarTitle({
|
||||
serverName: "",
|
||||
connectionAddress: "root@10.1.2.34:2222",
|
||||
mode: "label",
|
||||
}),
|
||||
"root@10.1.2.34:2222",
|
||||
);
|
||||
assert.equal(
|
||||
formatTerminalHostInfoBarTitle({
|
||||
serverName: "Local Terminal",
|
||||
connectionAddress: null,
|
||||
mode: "address",
|
||||
}),
|
||||
"Local Terminal",
|
||||
);
|
||||
assert.equal(
|
||||
formatTerminalHostInfoBarTooltip({
|
||||
serverName: "prod-web",
|
||||
connectionAddress: "root@10.1.2.34:2222",
|
||||
}),
|
||||
"prod-web · root@10.1.2.34:2222",
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal title row does not render a status dot beside the address", () => {
|
||||
const source = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
|
||||
const titleStart = source.indexOf("data-terminal-detach-drag-handle");
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ChevronsLeft, GripVertical, Network, X as XIcon } from 'lucide-react';
|
||||
|
||||
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
|
||||
import { classifyDistroId } from '../../domain/host';
|
||||
import type { HostInfoBarTitleMode } from '../../domain/models';
|
||||
import { useNetworkDeviceModeSuggestion } from '../../application/state/useNetworkDeviceModeSuggestion';
|
||||
import { isPluginHostProtocol } from '../../domain/pluginConnection';
|
||||
import { OSC7_SETUP_TARGETS } from './osc7Setup';
|
||||
@@ -167,6 +168,40 @@ export function formatTerminalTitleConnectionAddress(host?: TerminalTitleAddress
|
||||
return `${username}${host.hostname}${port}`;
|
||||
}
|
||||
|
||||
/** Host info bar label: vault name or user@host, based on settings. */
|
||||
export function formatTerminalHostInfoBarTitle({
|
||||
serverName,
|
||||
connectionAddress,
|
||||
mode = "address",
|
||||
}: {
|
||||
serverName?: string | null;
|
||||
connectionAddress?: string | null;
|
||||
mode?: HostInfoBarTitleMode;
|
||||
}): string {
|
||||
const name = (serverName || "").trim();
|
||||
const address = (connectionAddress || "").trim();
|
||||
if (mode === "label") {
|
||||
return name || address;
|
||||
}
|
||||
return address || name;
|
||||
}
|
||||
|
||||
/** Hover tooltip can show both name and address without consuming bar width. */
|
||||
export function formatTerminalHostInfoBarTooltip({
|
||||
serverName,
|
||||
connectionAddress,
|
||||
}: {
|
||||
serverName?: string | null;
|
||||
connectionAddress?: string | null;
|
||||
}): string {
|
||||
const name = (serverName || "").trim();
|
||||
const address = (connectionAddress || "").trim();
|
||||
if (name && address && name !== address) {
|
||||
return `${name} · ${address}`;
|
||||
}
|
||||
return name || address;
|
||||
}
|
||||
|
||||
/** Height (px) of the one-line "enable Network Device Mode" tip strip. */
|
||||
export const NETWORK_DEVICE_TIP_HEIGHT = 28;
|
||||
|
||||
@@ -365,6 +400,18 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
|
||||
});
|
||||
}, [host.id, onUpdateHost, showLineTimestampGutter]);
|
||||
const titleConnectionAddress = formatTerminalTitleConnectionAddress(host);
|
||||
// Prefer vault host.label over sessionDisplayName so dynamic tab titles
|
||||
// (cwd / coding-cli) do not replace the stable server name in this bar.
|
||||
const hostInfoBarServerName = host.label || sessionDisplayName;
|
||||
const hostInfoBarTitle = formatTerminalHostInfoBarTitle({
|
||||
serverName: hostInfoBarServerName,
|
||||
connectionAddress: titleConnectionAddress,
|
||||
mode: terminalSettings?.hostInfoBarTitleMode ?? "address",
|
||||
});
|
||||
const hostInfoBarTooltip = formatTerminalHostInfoBarTooltip({
|
||||
serverName: hostInfoBarServerName,
|
||||
connectionAddress: titleConnectionAddress,
|
||||
});
|
||||
const hasBlockingReconnectOverlay = Boolean(osc52ReadPromptVisible || osc7SetupOpen || scriptExecutionOverlay || zmodem.active || zmodem.overwriteRequest);
|
||||
const showEnterReconnectHint = shouldReconnectTerminalOnEnterKey({
|
||||
key: "Enter",
|
||||
@@ -530,8 +577,8 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
|
||||
data-terminal-detach-drag-handle={inWorkspace && onDetachPointerDown ? "true" : undefined}
|
||||
onPointerDown={onDetachPointerDown}
|
||||
>
|
||||
<span className="whitespace-nowrap truncate min-w-0 max-w-[18rem]" title={titleConnectionAddress || sessionDisplayName || host.label}>
|
||||
{titleConnectionAddress || sessionDisplayName || host.label}
|
||||
<span className="whitespace-nowrap truncate min-w-0 max-w-[18rem]" title={hostInfoBarTooltip || hostInfoBarTitle}>
|
||||
{hostInfoBarTitle}
|
||||
</span>
|
||||
</div>}
|
||||
{host.protocol !== "local" && host.hostname && host.hostname !== "localhost" && (
|
||||
|
||||
@@ -137,6 +137,25 @@ export function recordCommand(
|
||||
saveStore(store);
|
||||
}
|
||||
|
||||
/** Remove one command from autocomplete history for a specific host. */
|
||||
export function removeCommandHistoryEntry(command: string, hostId: string): boolean {
|
||||
const trimmed = command.trim();
|
||||
if (!trimmed) return false;
|
||||
|
||||
const store = loadStore();
|
||||
const nextEntries = store.entries.filter(
|
||||
(entry) => entry.command !== trimmed || entry.hostId !== hostId,
|
||||
);
|
||||
if (nextEntries.length === store.entries.length) return false;
|
||||
|
||||
store.entries = nextEntries;
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = null;
|
||||
}
|
||||
return persistStoreNow(store);
|
||||
}
|
||||
|
||||
/**
|
||||
* Score an entry for ranking at a specific timestamp.
|
||||
* Caches Date.now() at query boundaries to avoid repeated syscalls during sort.
|
||||
|
||||
@@ -2,5 +2,5 @@ export { useTerminalAutocomplete, DEFAULT_AUTOCOMPLETE_SETTINGS } from "./useTer
|
||||
export type { AutocompleteSettings, AutocompleteState, TerminalAutocompleteHandle } from "./useTerminalAutocomplete";
|
||||
export { default as AutocompletePopup } from "./AutocompletePopup";
|
||||
export type { CompletionSuggestion, SuggestionSource } from "./completionEngine";
|
||||
export { recordCommand, clearHistory } from "./commandHistoryStore";
|
||||
export { recordCommand, removeCommandHistoryEntry, clearHistory } from "./commandHistoryStore";
|
||||
export { shellEscape } from "./completionEngine";
|
||||
@@ -239,6 +239,27 @@ test("remote clipboard image upload reports transfer failures without inserting
|
||||
assert.deepEqual(result, { ok: false, reason: "upload-failed" });
|
||||
});
|
||||
|
||||
test("remote clipboard image upload treats clipboard read failures as no image", async () => {
|
||||
const result = await handleRemoteClipboardImageUpload({
|
||||
bridge: {
|
||||
readClipboardImage: async () => {
|
||||
throw new Error("clipboard unavailable");
|
||||
},
|
||||
openSftpForSession: async () => {
|
||||
assert.fail("should not open SFTP without an image");
|
||||
},
|
||||
startStreamTransfer: async (options) => ({ transferId: options.transferId }),
|
||||
},
|
||||
getRemoteCwd: async () => "/home/alice",
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: () => assert.fail("should not paste without an image"),
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { ok: false, reason: "no-image" });
|
||||
});
|
||||
|
||||
test("remote clipboard image upload result maps to user-facing message keys", () => {
|
||||
assert.equal(
|
||||
getRemoteClipboardImageUploadErrorMessageKey({
|
||||
|
||||
@@ -7,7 +7,7 @@ type ClipboardImageFile = {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
type RemoteClipboardImageBridge = Pick<
|
||||
export type RemoteClipboardImageBridge = Pick<
|
||||
NetcattyBridge,
|
||||
"readClipboardImage" | "openSftpForSession" | "startStreamTransfer"
|
||||
> & Pick<Partial<NetcattyBridge>, "closeSftp" | "deleteTempFile">;
|
||||
@@ -94,7 +94,14 @@ export async function handleRemoteClipboardImageUpload({
|
||||
return { ok: false, reason: "unsupported" };
|
||||
}
|
||||
|
||||
const image: ClipboardImageFile | null = await bridge.readClipboardImage();
|
||||
let image: ClipboardImageFile | null;
|
||||
try {
|
||||
image = await bridge.readClipboardImage();
|
||||
} catch {
|
||||
// A clipboard read failure is indistinguishable from an empty clipboard —
|
||||
// treat it as "no image" so callers can fall back to a normal paste.
|
||||
return { ok: false, reason: "no-image" };
|
||||
}
|
||||
if (!image?.path || !image.name) return { ok: false, reason: "no-image" };
|
||||
|
||||
let sftpId: string | undefined;
|
||||
|
||||
@@ -102,7 +102,11 @@ Object.defineProperty(globalThis, "window", {
|
||||
});
|
||||
|
||||
const { getCompletions } = await import("./autocomplete/completionEngine.ts");
|
||||
const { clearHistory, recordCommand } = await import("./autocomplete/commandHistoryStore.ts");
|
||||
const {
|
||||
clearHistory,
|
||||
recordCommand,
|
||||
removeCommandHistoryEntry,
|
||||
} = await import("./autocomplete/commandHistoryStore.ts");
|
||||
const {
|
||||
normalizePathTokenForLookup,
|
||||
shouldPreferRemoteShellCwd,
|
||||
@@ -150,6 +154,51 @@ test("getCompletions does not treat generator-only spec args as path contexts",
|
||||
assert.equal(completions.some((entry) => entry.source === "path"), false);
|
||||
});
|
||||
|
||||
test("removeCommandHistoryEntry removes only the matching host's autocomplete record", async () => {
|
||||
recordCommand("bad-command --flag", "host-1");
|
||||
recordCommand("bad-command --flag", "host-2");
|
||||
|
||||
removeCommandHistoryEntry("bad-command --flag", "host-1");
|
||||
|
||||
const completions = await getCompletions("bad-command", {
|
||||
hostId: "host-1",
|
||||
historyScope: "global",
|
||||
protocol: "local",
|
||||
cwd: "/repo",
|
||||
});
|
||||
assert.equal(
|
||||
completions.some((entry) => entry.source === "history" && entry.text === "bad-command --flag"),
|
||||
true,
|
||||
);
|
||||
|
||||
const hostOnlyCompletions = await getCompletions("bad-command", {
|
||||
hostId: "host-1",
|
||||
historyScope: "host",
|
||||
protocol: "local",
|
||||
cwd: "/repo",
|
||||
});
|
||||
assert.equal(
|
||||
hostOnlyCompletions.some((entry) => entry.source === "history" && entry.text === "bad-command --flag"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("removeCommandHistoryEntry trims command text like recordCommand", async () => {
|
||||
recordCommand("padded-cmd", "host-trim");
|
||||
assert.equal(removeCommandHistoryEntry(" padded-cmd ", "host-trim"), true);
|
||||
|
||||
const hostOnlyCompletions = await getCompletions("padded", {
|
||||
hostId: "host-trim",
|
||||
historyScope: "host",
|
||||
protocol: "local",
|
||||
cwd: "/repo",
|
||||
});
|
||||
assert.equal(
|
||||
hostOnlyCompletions.some((entry) => entry.source === "history" && entry.text === "padded-cmd"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("getCompletions uses the remote shell cwd for relative path arguments instead of stale home", async () => {
|
||||
bridgeState.remoteEntriesByPath.set("~", [{ name: "home-only.txt", type: "file" }]);
|
||||
bridgeState.remoteEntriesByPath.set(".", [{ name: "worktree.txt", type: "file" }]);
|
||||
|
||||
@@ -53,6 +53,7 @@ export const useTerminalContextActions = ({
|
||||
passwordPromptActiveRef,
|
||||
isLocalConnection,
|
||||
supportsRemoteImagePaste,
|
||||
autoUploadClipboardImageOnPasteRef,
|
||||
clearWipesScrollbackRef,
|
||||
normalizeTextOnCopyRef,
|
||||
terminalBackend,
|
||||
@@ -70,6 +71,8 @@ export const useTerminalContextActions = ({
|
||||
passwordPromptActiveRef?: RefObject<boolean | undefined>;
|
||||
isLocalConnection: boolean;
|
||||
supportsRemoteImagePaste: boolean;
|
||||
/** When true, paste auto-uploads a clipboard image (remote sessions only). */
|
||||
autoUploadClipboardImageOnPasteRef?: RefObject<boolean | undefined>;
|
||||
clearWipesScrollbackRef?: RefObject<boolean | undefined>;
|
||||
/** When false, copy uses raw getSelection(). Default true when unset. */
|
||||
normalizeTextOnCopyRef?: RefObject<boolean | undefined>;
|
||||
@@ -110,12 +113,18 @@ export const useTerminalContextActions = ({
|
||||
const bridge = netcattyBridge.get();
|
||||
await handleTerminalClipboardPaste({
|
||||
bridge,
|
||||
autoUploadClipboardImage:
|
||||
supportsRemoteImagePaste && autoUploadClipboardImageOnPasteRef?.current === true,
|
||||
clipboardImageBridge: bridge ?? undefined,
|
||||
getRemoteCwd,
|
||||
isLocalConnection,
|
||||
isSensitiveInput: () => passwordPromptActiveRef?.current === true,
|
||||
onClipboardImageUploadResult,
|
||||
readClipboardText: () => navigator.clipboard.readText(),
|
||||
scrollOnPaste: scrollOnPasteRef?.current ?? false,
|
||||
onPasteData: broadcastUserPasteData,
|
||||
sessionId: sessionRef.current,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
terminalBackend,
|
||||
term,
|
||||
});
|
||||
@@ -123,12 +132,17 @@ export const useTerminalContextActions = ({
|
||||
logger.warn("Failed to paste from clipboard", err);
|
||||
}
|
||||
}, [
|
||||
autoUploadClipboardImageOnPasteRef,
|
||||
broadcastUserPasteData,
|
||||
getRemoteCwd,
|
||||
isLocalConnection,
|
||||
onClipboardImageUploadResult,
|
||||
passwordPromptActiveRef,
|
||||
sessionRef,
|
||||
supportsRemoteImagePaste,
|
||||
termRef,
|
||||
scrollOnPasteRef,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
terminalBackend,
|
||||
]);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect } from "react";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import type { TerminalSession } from "../../../types";
|
||||
import type { RemoteClipboardImageUploadResult } from "../clipboardImagePaste";
|
||||
import { handleTerminalClipboardPaste } from "../terminalClipboardPaste";
|
||||
|
||||
interface UseTerminalFilePasteOptions {
|
||||
@@ -20,6 +21,10 @@ interface UseTerminalFilePasteOptions {
|
||||
onPasteData?: (data: string) => boolean | void;
|
||||
scrollToBottomAfterProgrammaticInput: (data: string) => void;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
/** Remote sessions only: auto-upload a clipboard image on paste. */
|
||||
autoUploadClipboardImage?: boolean;
|
||||
getRemoteCwd?: () => Promise<string | null | undefined>;
|
||||
onClipboardImageUploadResult?: (result: RemoteClipboardImageUploadResult) => void;
|
||||
}
|
||||
|
||||
export function useTerminalFilePaste({
|
||||
@@ -33,6 +38,9 @@ export function useTerminalFilePaste({
|
||||
onPasteData,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
containerRef,
|
||||
autoUploadClipboardImage = false,
|
||||
getRemoteCwd,
|
||||
onClipboardImageUploadResult,
|
||||
}: UseTerminalFilePasteOptions) {
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -43,7 +51,9 @@ export function useTerminalFilePaste({
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
|
||||
if (!isLocalConnection || !bridge?.readClipboardFiles) return;
|
||||
const wantsImageUpload =
|
||||
autoUploadClipboardImage && !isLocalConnection && !!bridge?.readClipboardImage;
|
||||
if (!wantsImageUpload && (!isLocalConnection || !bridge?.readClipboardFiles)) return;
|
||||
|
||||
// ⚡ Must call preventDefault SYNCHRONOUSLY — the event lifecycle
|
||||
// is synchronous; calling it after an await is too late and the
|
||||
@@ -57,8 +67,12 @@ export function useTerminalFilePaste({
|
||||
if (!term) return;
|
||||
await handleTerminalClipboardPaste({
|
||||
bridge,
|
||||
autoUploadClipboardImage: wantsImageUpload,
|
||||
clipboardImageBridge: bridge ?? undefined,
|
||||
getRemoteCwd,
|
||||
isLocalConnection,
|
||||
isSensitiveInput,
|
||||
onClipboardImageUploadResult,
|
||||
readClipboardText: () => navigator.clipboard.readText(),
|
||||
scrollOnPaste: scrollOnPasteRef?.current ?? false,
|
||||
onPasteData,
|
||||
@@ -78,9 +92,12 @@ export function useTerminalFilePaste({
|
||||
container.removeEventListener("paste", handlePaste, true);
|
||||
};
|
||||
}, [
|
||||
autoUploadClipboardImage,
|
||||
containerRef,
|
||||
getRemoteCwd,
|
||||
isLocalConnection,
|
||||
isSensitiveInput,
|
||||
onClipboardImageUploadResult,
|
||||
onPasteData,
|
||||
scrollOnPasteRef,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
|
||||
@@ -1494,6 +1494,15 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
break;
|
||||
}
|
||||
case "paste": {
|
||||
const autoImagePaste =
|
||||
ctx.terminalSettingsRef.current?.autoUploadClipboardImageOnPaste === true;
|
||||
if (autoImagePaste) {
|
||||
// onPaste (shared with the context menu) auto-uploads a
|
||||
// clipboard image via SFTP for remote sessions and falls
|
||||
// back to a regular text paste when there is no image.
|
||||
void ctx.terminalContextActionsRef?.current?.onPaste?.();
|
||||
break;
|
||||
}
|
||||
navigator.clipboard.readText().then((text) => {
|
||||
const id = ctx.sessionRef.current;
|
||||
if (id) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
createRightClickMouseTrackingPressClaim,
|
||||
isMiddleClickContextMenuEvent,
|
||||
isShiftSelectionReplayMouseEvent,
|
||||
markMiddleClickContextMenuEvent,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
resolveMiddleClickBehavior,
|
||||
shouldInterceptMouseTrackingContextMenu,
|
||||
shouldReplayShiftMouseSelectionAsMacOption,
|
||||
shouldStopRightClickMouseTrackingMouseUp,
|
||||
shouldStopShiftRightClickMouseTrackingMouseDown,
|
||||
} from "./middleClickBehavior";
|
||||
|
||||
@@ -299,6 +301,118 @@ test("Shift right-click mousedown capture is limited to connected mouse tracking
|
||||
);
|
||||
});
|
||||
|
||||
test("right-click mouseup reaches mouse-tracking apps when Netcatty did not claim the press", () => {
|
||||
// Herdr / Terminal.app: button-down was delivered, so button-up must be too.
|
||||
// Swallowing mouseup leaves the TUI stuck thinking the right button is held (#2721).
|
||||
const claim = createRightClickMouseTrackingPressClaim();
|
||||
assert.equal(
|
||||
claim.noteMouseDown({
|
||||
event: { button: 2, shiftKey: false } as MouseEvent,
|
||||
mouseTracking: true,
|
||||
status: "connected",
|
||||
rightClickBehavior: "context-menu",
|
||||
forceMenuInAlternateScreen: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldStopRightClickMouseTrackingMouseUp({
|
||||
event: { button: 2, shiftKey: false } as MouseEvent,
|
||||
claimedMatchingMouseDown: claim.consumeMouseUpClaim({ button: 2 } as MouseEvent),
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("right-click mouseup is stopped only when Netcatty claimed the matching mousedown", () => {
|
||||
const shiftClaim = createRightClickMouseTrackingPressClaim();
|
||||
assert.equal(
|
||||
shiftClaim.noteMouseDown({
|
||||
event: { button: 2, shiftKey: true } as MouseEvent,
|
||||
mouseTracking: true,
|
||||
status: "connected",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldStopRightClickMouseTrackingMouseUp({
|
||||
event: { button: 2, shiftKey: true } as MouseEvent,
|
||||
claimedMatchingMouseDown: shiftClaim.consumeMouseUpClaim({ button: 2 } as MouseEvent),
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
const forceMenuClaim = createRightClickMouseTrackingPressClaim();
|
||||
assert.equal(
|
||||
forceMenuClaim.noteMouseDown({
|
||||
event: { button: 2, shiftKey: false } as MouseEvent,
|
||||
mouseTracking: true,
|
||||
status: "connected",
|
||||
rightClickBehavior: "context-menu",
|
||||
forceMenuInAlternateScreen: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldStopRightClickMouseTrackingMouseUp({
|
||||
event: { button: 2, shiftKey: false } as MouseEvent,
|
||||
claimedMatchingMouseDown: forceMenuClaim.consumeMouseUpClaim({ button: 2 } as MouseEvent),
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldStopRightClickMouseTrackingMouseUp({
|
||||
event: { button: 0, shiftKey: false } as MouseEvent,
|
||||
claimedMatchingMouseDown: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("right-click mouseup pairs with the claimed mousedown, not the release modifiers", () => {
|
||||
// Shift+right press claimed by Netcatty, then Shift released before mouseup:
|
||||
// still swallow the release so xterm never sees a lone button-up.
|
||||
const claimedThenShiftReleased = createRightClickMouseTrackingPressClaim();
|
||||
assert.equal(
|
||||
claimedThenShiftReleased.noteMouseDown({
|
||||
event: { button: 2, shiftKey: true } as MouseEvent,
|
||||
mouseTracking: true,
|
||||
status: "connected",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldStopRightClickMouseTrackingMouseUp({
|
||||
event: { button: 2, shiftKey: false } as MouseEvent,
|
||||
claimedMatchingMouseDown: claimedThenShiftReleased.consumeMouseUpClaim(
|
||||
{ button: 2, shiftKey: false } as MouseEvent,
|
||||
),
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
// App-owned press, then Shift held on release: release must still reach xterm.
|
||||
const appOwnedThenShiftAdded = createRightClickMouseTrackingPressClaim();
|
||||
assert.equal(
|
||||
appOwnedThenShiftAdded.noteMouseDown({
|
||||
event: { button: 2, shiftKey: false } as MouseEvent,
|
||||
mouseTracking: true,
|
||||
status: "connected",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldStopRightClickMouseTrackingMouseUp({
|
||||
event: { button: 2, shiftKey: true } as MouseEvent,
|
||||
claimedMatchingMouseDown: appOwnedThenShiftAdded.consumeMouseUpClaim(
|
||||
{ button: 2, shiftKey: true } as MouseEvent,
|
||||
),
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("middle-click terminal mouse down/up events are captured before xterm sees them", () => {
|
||||
const calls: string[] = [];
|
||||
const middleClickEvent = {
|
||||
|
||||
@@ -131,6 +131,48 @@ export const shouldStopShiftRightClickMouseTrackingMouseDown = ({
|
||||
&& event.button === 2
|
||||
&& (event.shiftKey || forcesMenuOverMouseTracking({ rightClickBehavior, forceMenuInAlternateScreen }));
|
||||
|
||||
// Pair mouseup with mousedown ownership. When Netcatty claims the press
|
||||
// (Shift / fullscreen-apps menu), also swallow the release so xterm never
|
||||
// reports a lone button-up. When the TUI owns the press (Herdr, tmux menus,
|
||||
// vim, ...), the release must reach xterm too - otherwise the app stays stuck
|
||||
// with the right button held and mouse UI dies until restart (#2721).
|
||||
// Ownership is remembered from the actual mousedown claim — never re-derived
|
||||
// from mouseup modifiers (Shift may change between press and release).
|
||||
export interface RightClickMouseTrackingPressClaim {
|
||||
/** Evaluate + record whether this right-button mousedown was claimed. */
|
||||
noteMouseDown: (state: ShiftRightClickMouseDownCaptureState) => boolean;
|
||||
/** Consume the pending claim for a right-button mouseup (clears state). */
|
||||
consumeMouseUpClaim: (event: MouseEvent) => boolean;
|
||||
}
|
||||
|
||||
export const createRightClickMouseTrackingPressClaim = (): RightClickMouseTrackingPressClaim => {
|
||||
let claimedMatchingMouseDown = false;
|
||||
|
||||
return {
|
||||
noteMouseDown(state) {
|
||||
const shouldStop = shouldStopShiftRightClickMouseTrackingMouseDown(state);
|
||||
if (state.event.button === 2) {
|
||||
claimedMatchingMouseDown = shouldStop;
|
||||
}
|
||||
return shouldStop;
|
||||
},
|
||||
consumeMouseUpClaim(event) {
|
||||
if (event.button !== 2) return false;
|
||||
const claimed = claimedMatchingMouseDown;
|
||||
claimedMatchingMouseDown = false;
|
||||
return claimed;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const shouldStopRightClickMouseTrackingMouseUp = ({
|
||||
event,
|
||||
claimedMatchingMouseDown,
|
||||
}: {
|
||||
event: MouseEvent;
|
||||
claimedMatchingMouseDown: boolean;
|
||||
}): boolean => event.button === 2 && claimedMatchingMouseDown;
|
||||
|
||||
export const createMacOptionForcedSelectionMouseEvent = (event: MouseEvent): MouseEvent =>
|
||||
markShiftSelectionReplayMouseEvent(new MouseEvent(event.type, {
|
||||
bubbles: event.bubbles,
|
||||
|
||||
@@ -71,3 +71,188 @@ test("terminal user paste still inserts local clipboard file paths", async () =>
|
||||
assert.deepEqual(scrolled, ["/Users/alice/shot.png /Users/alice/report.txt"]);
|
||||
assert.equal(focused, true);
|
||||
});
|
||||
|
||||
test("terminal user paste auto-uploads a clipboard image in remote sessions", async () => {
|
||||
const writes: Array<{ data: string }> = [];
|
||||
const readTextCalls: string[] = [];
|
||||
const image = {
|
||||
path: "/tmp/netcatty/shot.png",
|
||||
name: "shot.png",
|
||||
mediaType: "image/png",
|
||||
size: 12,
|
||||
};
|
||||
|
||||
await handleTerminalClipboardPaste({
|
||||
bridge: {
|
||||
readClipboardFiles: async () => [],
|
||||
},
|
||||
autoUploadClipboardImage: true,
|
||||
clipboardImageBridge: {
|
||||
readClipboardImage: async () => image,
|
||||
openSftpForSession: async () => "sftp-1",
|
||||
startStreamTransfer: async (options) => ({ transferId: options.transferId }),
|
||||
closeSftp: async () => {},
|
||||
deleteTempFile: async () => ({ success: true }),
|
||||
},
|
||||
getRemoteCwd: async () => "/home/alice",
|
||||
isLocalConnection: false,
|
||||
readClipboardText: async () => {
|
||||
readTextCalls.push("read");
|
||||
return "hello";
|
||||
},
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: (_sessionId, data) => writes.push({ data }),
|
||||
},
|
||||
term: {
|
||||
paste: () => assert.fail("image upload should insert the remote path, not paste text"),
|
||||
scrollToBottom: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(writes, [{ data: "/home/alice/.netcatty-paste-images/shot.png" }]);
|
||||
assert.deepEqual(readTextCalls, []);
|
||||
});
|
||||
|
||||
test("terminal user paste falls back to text when the clipboard holds no image", async () => {
|
||||
const pasted: string[] = [];
|
||||
let sftpOpened = false;
|
||||
|
||||
await handleTerminalClipboardPaste({
|
||||
bridge: {
|
||||
readClipboardFiles: async () => [],
|
||||
},
|
||||
autoUploadClipboardImage: true,
|
||||
clipboardImageBridge: {
|
||||
readClipboardImage: async () => null,
|
||||
openSftpForSession: async () => {
|
||||
sftpOpened = true;
|
||||
return "sftp-1";
|
||||
},
|
||||
startStreamTransfer: async (options) => ({ transferId: options.transferId }),
|
||||
},
|
||||
getRemoteCwd: async () => "/home/alice",
|
||||
isLocalConnection: false,
|
||||
readClipboardText: async () => "hello",
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: () => assert.fail("no image should fall back to text paste"),
|
||||
},
|
||||
term: {
|
||||
paste: (text) => pasted.push(text),
|
||||
scrollToBottom: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(sftpOpened, false);
|
||||
assert.deepEqual(pasted, ["hello"]);
|
||||
});
|
||||
|
||||
test("terminal user paste reports failed uploads instead of pasting text", async () => {
|
||||
const results: unknown[] = [];
|
||||
const readTextCalls: string[] = [];
|
||||
const image = {
|
||||
path: "/tmp/netcatty/shot.png",
|
||||
name: "shot.png",
|
||||
mediaType: "image/png",
|
||||
size: 12,
|
||||
};
|
||||
|
||||
await handleTerminalClipboardPaste({
|
||||
bridge: {
|
||||
readClipboardFiles: async () => [],
|
||||
},
|
||||
autoUploadClipboardImage: true,
|
||||
clipboardImageBridge: {
|
||||
readClipboardImage: async () => image,
|
||||
openSftpForSession: async () => "sftp-1",
|
||||
startStreamTransfer: async (options) => ({ transferId: options.transferId, error: "disk full" }),
|
||||
},
|
||||
getRemoteCwd: async () => "/home/alice",
|
||||
isLocalConnection: false,
|
||||
onClipboardImageUploadResult: (result) => results.push(result),
|
||||
readClipboardText: async () => {
|
||||
readTextCalls.push("read");
|
||||
return "hello";
|
||||
},
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: () => assert.fail("failed upload should not paste anything"),
|
||||
},
|
||||
term: {
|
||||
paste: () => assert.fail("failed upload should not paste text"),
|
||||
scrollToBottom: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(results, [{ ok: false, reason: "upload-failed" }]);
|
||||
assert.deepEqual(readTextCalls, []);
|
||||
});
|
||||
|
||||
test("terminal user paste reports thrown upload failures instead of pasting text", async () => {
|
||||
const results: unknown[] = [];
|
||||
const readTextCalls: string[] = [];
|
||||
const image = {
|
||||
path: "/tmp/netcatty/shot.png",
|
||||
name: "shot.png",
|
||||
mediaType: "image/png",
|
||||
size: 12,
|
||||
};
|
||||
|
||||
await handleTerminalClipboardPaste({
|
||||
bridge: {
|
||||
readClipboardFiles: async () => [],
|
||||
},
|
||||
autoUploadClipboardImage: true,
|
||||
clipboardImageBridge: {
|
||||
readClipboardImage: async () => image,
|
||||
openSftpForSession: async () => {
|
||||
throw new Error("SFTP unavailable");
|
||||
},
|
||||
startStreamTransfer: async (options) => ({ transferId: options.transferId }),
|
||||
},
|
||||
getRemoteCwd: async () => "/home/alice",
|
||||
isLocalConnection: false,
|
||||
onClipboardImageUploadResult: (result) => results.push(result),
|
||||
readClipboardText: async () => {
|
||||
readTextCalls.push("read");
|
||||
return "hello";
|
||||
},
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: () => assert.fail("thrown upload failure must not paste anything"),
|
||||
},
|
||||
term: {
|
||||
paste: () => assert.fail("thrown upload failure must not paste text"),
|
||||
scrollToBottom: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(results, [{ ok: false, reason: "upload-failed" }]);
|
||||
assert.deepEqual(readTextCalls, []);
|
||||
});
|
||||
|
||||
test("terminal user paste keeps local file path paste even with auto-upload enabled", async () => {
|
||||
const writes: Array<{ data: string }> = [];
|
||||
|
||||
await handleTerminalClipboardPaste({
|
||||
bridge: {
|
||||
readClipboardFiles: async () => [
|
||||
{ path: "/Users/alice/shot.png", name: "shot.png", isDirectory: false },
|
||||
],
|
||||
},
|
||||
autoUploadClipboardImage: true,
|
||||
isLocalConnection: true,
|
||||
readClipboardText: async () => assert.fail("local file paste should not fall through to text"),
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: (_sessionId, data) => writes.push({ data }),
|
||||
},
|
||||
term: {
|
||||
paste: () => assert.fail("local file paste should write paths directly"),
|
||||
scrollToBottom: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(writes, [{ data: "/Users/alice/shot.png" }]);
|
||||
});
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
|
||||
import {
|
||||
handleRemoteClipboardImageUpload,
|
||||
type RemoteClipboardImageBridge,
|
||||
type RemoteClipboardImageUploadResult,
|
||||
} from "./clipboardImagePaste";
|
||||
import { extractRootPathsFromClipboardFiles } from "./terminalHelpers";
|
||||
import { pasteTextIntoTerminal } from "./runtime/terminalUserPaste";
|
||||
|
||||
@@ -7,8 +12,18 @@ type ClipboardFileBridge = Pick<Partial<NetcattyBridge>, "readClipboardFiles">;
|
||||
|
||||
type TerminalClipboardPasteOptions = {
|
||||
bridge?: ClipboardFileBridge;
|
||||
/**
|
||||
* When true, a clipboard image is uploaded to the remote host (SFTP) and
|
||||
* the remote path is pasted, instead of falling back to a text paste.
|
||||
* Callers should only enable this for connections that support remote
|
||||
* image paste; the upload no-ops unless a session id is provided.
|
||||
*/
|
||||
autoUploadClipboardImage?: boolean;
|
||||
clipboardImageBridge?: RemoteClipboardImageBridge;
|
||||
getRemoteCwd?: () => Promise<string | null | undefined>;
|
||||
isLocalConnection: boolean;
|
||||
isSensitiveInput?: () => boolean;
|
||||
onClipboardImageUploadResult?: (result: RemoteClipboardImageUploadResult) => void;
|
||||
onPasteData?: (data: string) => boolean | void;
|
||||
readClipboardText: () => Promise<string>;
|
||||
scrollOnPaste?: boolean;
|
||||
@@ -22,8 +37,12 @@ type TerminalClipboardPasteOptions = {
|
||||
|
||||
export async function handleTerminalClipboardPaste({
|
||||
bridge,
|
||||
autoUploadClipboardImage = false,
|
||||
clipboardImageBridge,
|
||||
getRemoteCwd,
|
||||
isLocalConnection,
|
||||
isSensitiveInput,
|
||||
onClipboardImageUploadResult,
|
||||
onPasteData,
|
||||
readClipboardText,
|
||||
scrollOnPaste = false,
|
||||
@@ -32,6 +51,38 @@ export async function handleTerminalClipboardPaste({
|
||||
terminalBackend,
|
||||
term,
|
||||
}: TerminalClipboardPasteOptions): Promise<void> {
|
||||
// Image-first: when enabled and a remote session is active, a clipboard
|
||||
// image triggers the SFTP upload flow. "no-image" means the clipboard
|
||||
// holds text/files instead, so we silently continue to the normal paste.
|
||||
if (autoUploadClipboardImage && sessionId && !isLocalConnection) {
|
||||
try {
|
||||
const result = await handleRemoteClipboardImageUpload({
|
||||
bridge: clipboardImageBridge,
|
||||
getRemoteCwd: getRemoteCwd ?? (async () => undefined),
|
||||
isSensitiveInput,
|
||||
sessionId,
|
||||
terminalBackend,
|
||||
term,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
});
|
||||
if (result.ok === true) {
|
||||
onClipboardImageUploadResult?.(result);
|
||||
return;
|
||||
}
|
||||
if (result.reason !== "no-image" && result.reason !== "unsupported") {
|
||||
onClipboardImageUploadResult?.(result);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// The clipboard image was already read successfully above, so any throw
|
||||
// here means the upload itself failed (e.g. SFTP cannot be opened for
|
||||
// the session). Surface the same error as the context-menu action
|
||||
// instead of silently pasting unrelated clipboard content.
|
||||
onClipboardImageUploadResult?.({ ok: false, reason: "upload-failed" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const readClipboardFiles = bridge?.readClipboardFiles;
|
||||
if (isLocalConnection && readClipboardFiles) {
|
||||
try {
|
||||
|
||||
@@ -8,9 +8,10 @@ import { isMacPlatform } from '../../lib/utils';
|
||||
import { resolveXTermScrollback } from '../../infrastructure/config/xtermPerformance';
|
||||
import {
|
||||
createMacOptionForcedSelectionMouseEvent,
|
||||
createRightClickMouseTrackingPressClaim,
|
||||
shouldInterceptMouseTrackingContextMenu,
|
||||
shouldReplayShiftMouseSelectionAsMacOption,
|
||||
shouldStopShiftRightClickMouseTrackingMouseDown,
|
||||
shouldStopRightClickMouseTrackingMouseUp,
|
||||
} from './runtime/middleClickBehavior';
|
||||
import {
|
||||
hasOpenAppDialog,
|
||||
@@ -1553,21 +1554,24 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
}, [sessionId]);
|
||||
|
||||
|
||||
// Prevent xterm.js's built-in rightClickHandler and right-button mouseup
|
||||
// from interfering with tmux/vim popup menus when mouse tracking is active.
|
||||
// On macOS, xterm forces selection with Option, while most terminal users
|
||||
// expect Shift to bypass mouse reporting. Replay Shift+left-click as that
|
||||
// native xterm force-selection gesture before xterm receives the original.
|
||||
// - mousedown (button 2 + Shift): keep Shift+right-click local so the
|
||||
// terminal app does not also receive the right-button press
|
||||
// Prevent xterm.js's built-in rightClickHandler from interfering with
|
||||
// mouse-tracking TUIs. On macOS, xterm forces selection with Option, while
|
||||
// most terminal users expect Shift to bypass mouse reporting. Replay
|
||||
// Shift+left-click as that native xterm force-selection gesture before
|
||||
// xterm receives the original.
|
||||
// - mousedown (button 2 + Shift / fullscreen-apps menu): keep the press
|
||||
// local so the terminal app does not also receive it
|
||||
// - contextmenu: xterm.js calls textarea.select() which steals focus
|
||||
// - mouseup (button 2): tmux interprets the right-button release as a
|
||||
// dismiss action, closing the popup menu immediately after it appears
|
||||
// Both are intercepted at the capture phase before xterm.js's own listeners.
|
||||
// - mouseup (button 2): only stop when we also claimed the matching
|
||||
// mousedown. App-owned right-clicks (Herdr, tmux, vim, ...) must receive
|
||||
// the release or the TUI stays stuck with the button held (#2721).
|
||||
// Intercepted at the capture phase before xterm.js's own listeners.
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const rightClickPressClaim = createRightClickMouseTrackingPressClaim();
|
||||
|
||||
const handleContextMenuCapture = (e: MouseEvent) => {
|
||||
if (!shouldInterceptMouseTrackingContextMenu({
|
||||
event: e,
|
||||
@@ -1601,7 +1605,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
};
|
||||
|
||||
const handleMouseDownCapture = (e: MouseEvent) => {
|
||||
if (shouldStopShiftRightClickMouseTrackingMouseDown({
|
||||
if (rightClickPressClaim.noteMouseDown({
|
||||
event: e,
|
||||
mouseTracking: mouseTrackingRef.current,
|
||||
status: statusRef.current,
|
||||
@@ -1630,7 +1634,10 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
};
|
||||
|
||||
const handleMouseUpCapture = (e: MouseEvent) => {
|
||||
if (e.button === 2 && mouseTrackingRef.current && statusRef.current === 'connected') {
|
||||
if (shouldStopRightClickMouseTrackingMouseUp({
|
||||
event: e,
|
||||
claimedMatchingMouseDown: rightClickPressClaim.consumeMouseUpClaim(e),
|
||||
})) {
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -554,6 +554,7 @@ export interface TerminalLayerProps {
|
||||
onUpdateHost: (host: Host) => void;
|
||||
onAddKnownHost?: (knownHost: KnownHost) => void;
|
||||
onCommandExecuted?: (command: string, hostId: string, hostLabel: string, sessionId: string) => void;
|
||||
onDeleteShellHistoryEntry?: (entryId: string) => void;
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void;
|
||||
onCreateWorkspaceFromSessions: (baseSessionId: string, joiningSessionId: string, hint: Exclude<SplitHint, null>) => void;
|
||||
onAddSessionToWorkspace: (workspaceId: string, sessionId: string, hint: Exclude<SplitHint, null>) => void;
|
||||
|
||||
@@ -431,6 +431,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
FolderTree: s.FolderTree,
|
||||
followAppTerminalTheme: s.followAppTerminalTheme,
|
||||
handleHistoryPaste: s.handleHistoryPaste,
|
||||
handleHistoryDelete: s.handleHistoryDelete,
|
||||
handleHistoryRun: s.handleHistoryRun,
|
||||
handleFocusSidePanelPane: s.handleFocusSidePanelPane,
|
||||
handleSplitSidePanelPane: s.handleSplitSidePanelPane,
|
||||
@@ -474,6 +475,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
handleSessionExit: s.handleSessionExit,
|
||||
handleSftpCurrentPathChange: s.handleSftpCurrentPathChange,
|
||||
handleSftpActiveTransfersChange: s.handleSftpActiveTransfersChange,
|
||||
handleSftpActiveExternalEditsChange: s.handleSftpActiveExternalEditsChange,
|
||||
handleSftpInitialLocationApplied: s.handleSftpInitialLocationApplied,
|
||||
persistSidePanelWidth: s.persistSidePanelWidth,
|
||||
setSidePanelWidth: s.setSidePanelWidth,
|
||||
|
||||
@@ -55,27 +55,54 @@ function task(
|
||||
}
|
||||
|
||||
test("closing the panel keeps SFTP mounted while a transfer is active", () => {
|
||||
assert.equal(shouldKeepSftpMountedAfterClose(1), true);
|
||||
assert.equal(shouldKeepSftpMountedAfterClose(3), true);
|
||||
assert.equal(shouldKeepSftpMountedAfterClose({ activeTransfersCount: 1 }), true);
|
||||
assert.equal(shouldKeepSftpMountedAfterClose({ activeTransfersCount: 3 }), true);
|
||||
});
|
||||
|
||||
test("closing the panel keeps SFTP mounted while an external editor temp is open", () => {
|
||||
assert.equal(shouldKeepSftpMountedAfterClose({
|
||||
activeTransfersCount: 0,
|
||||
activeExternalEditCount: 1,
|
||||
}), true);
|
||||
assert.equal(shouldKeepSftpMountedAfterClose({
|
||||
activeTransfersCount: 0,
|
||||
activeExternalEditCount: 0,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("closing an idle panel still releases its SFTP state", () => {
|
||||
assert.equal(shouldKeepSftpMountedAfterClose(0), false);
|
||||
assert.equal(shouldKeepSftpMountedAfterClose({ activeTransfersCount: 0 }), false);
|
||||
});
|
||||
|
||||
test("a transfer retained by close keeps its history after completion", () => {
|
||||
assert.equal(shouldClearSftpPanelAfterTransferChange({
|
||||
activeTransfersCount: 0,
|
||||
activeExternalEditCount: 0,
|
||||
panelOpen: false,
|
||||
retainedAfterClose: true,
|
||||
}), false);
|
||||
assert.equal(shouldScheduleSftpRetainedPanelCleanup({
|
||||
activeTransfersCount: 0,
|
||||
activeExternalEditCount: 0,
|
||||
retainedAfterClose: true,
|
||||
}), true);
|
||||
assert.ok(SFTP_TRANSFER_HISTORY_RETENTION_MS > 0);
|
||||
});
|
||||
|
||||
test("retained cleanup waits while an external editor temp is still open", () => {
|
||||
assert.equal(shouldClearSftpPanelAfterTransferChange({
|
||||
activeTransfersCount: 0,
|
||||
activeExternalEditCount: 1,
|
||||
panelOpen: false,
|
||||
retainedAfterClose: false,
|
||||
}), false);
|
||||
assert.equal(shouldScheduleSftpRetainedPanelCleanup({
|
||||
activeTransfersCount: 0,
|
||||
activeExternalEditCount: 1,
|
||||
retainedAfterClose: true,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("retained cleanup is scheduled even if close state has not committed yet", () => {
|
||||
assert.equal(shouldScheduleSftpRetainedPanelCleanup({
|
||||
activeTransfersCount: 0,
|
||||
@@ -108,6 +135,7 @@ test("closing a terminal tab keeps its hidden SFTP owner mounted until active tr
|
||||
test("a reopening panel is not cleared before its open state commits", () => {
|
||||
assert.equal(shouldClearSftpPanelAfterTransferChange({
|
||||
activeTransfersCount: 0,
|
||||
activeExternalEditCount: 0,
|
||||
panelOpen: true,
|
||||
retainedAfterClose: false,
|
||||
}), false);
|
||||
@@ -116,6 +144,7 @@ test("a reopening panel is not cleared before its open state commits", () => {
|
||||
test("an unretained hidden idle panel can be released", () => {
|
||||
assert.equal(shouldClearSftpPanelAfterTransferChange({
|
||||
activeTransfersCount: 0,
|
||||
activeExternalEditCount: 0,
|
||||
panelOpen: false,
|
||||
retainedAfterClose: false,
|
||||
}), true);
|
||||
@@ -141,9 +170,13 @@ test("store unfinished tasks retain the owner even when the panel report is stil
|
||||
storeTasks,
|
||||
ownerId,
|
||||
}), 1);
|
||||
assert.equal(shouldKeepSftpMountedAfterClose(
|
||||
resolveSftpActiveTransfersCount({ reportedCount: 0, storeTasks, ownerId }),
|
||||
), true);
|
||||
assert.equal(shouldKeepSftpMountedAfterClose({
|
||||
activeTransfersCount: resolveSftpActiveTransfersCount({
|
||||
reportedCount: 0,
|
||||
storeTasks,
|
||||
ownerId,
|
||||
}),
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("paused and failed top-level tasks still retain the hidden SFTP owner", () => {
|
||||
@@ -183,15 +216,22 @@ test("terminal side panel reports transfer activity and uses store-backed retain
|
||||
assert.match(panelSource, /surfaceVisible:\s*isVisible/);
|
||||
assert.match(panelSource, /useEditorTabPresenceRevision\(\)/);
|
||||
assert.match(panelSource, /hasOwnedEditorTab/);
|
||||
assert.match(panelSource, /hasActiveExternalEdit/);
|
||||
assert.match(slotsSource, /onActiveTransfersChange=\{handleActiveTransfersChange\}/);
|
||||
assert.match(slotsSource, /onActiveExternalEditsChange=\{handleActiveExternalEditsChange\}/);
|
||||
assert.match(panelSource, /onActiveExternalEditsChange/);
|
||||
assert.match(layerSource, /resolveTabActiveTransfersCount/);
|
||||
assert.match(layerSource, /terminalSftpTransferOwnerId/);
|
||||
assert.match(layerSource, /listTerminalTabIdsWithRetainingTransfers/);
|
||||
assert.match(layerSource, /shouldKeepSftpMountedAfterClose\(activeTransfersCount\)/);
|
||||
assert.match(layerSource, /shouldKeepSftpMountedAfterClose\(\{/);
|
||||
assert.match(layerSource, /activeExternalEditCount/);
|
||||
assert.match(layerSource, /sftpActiveExternalEditsByTabRef/);
|
||||
assert.match(layerSource, /sftpRetainedAfterCloseTabIdsRef/);
|
||||
assert.match(layerSource, /sftpRetainedCleanupTimersRef/);
|
||||
// Hidden UI parks browse channels; transfers keep pool / leased sessions.
|
||||
// External editor temps must also block park (closeSftp deletes those files).
|
||||
assert.match(stateSource, /shouldParkBrowseSessions/);
|
||||
assert.match(stateSource, /activeExternalEditCount/);
|
||||
assert.match(stateSource, /takeBrowseSessionsForClose/);
|
||||
assert.match(stateSource, /shouldRestoreBrowseSessions/);
|
||||
});
|
||||
@@ -56,8 +56,13 @@ export function listTerminalTabIdsWithRetainingTransfers(
|
||||
return [...tabIds];
|
||||
}
|
||||
|
||||
export function shouldKeepSftpMountedAfterClose(activeTransfersCount: number): boolean {
|
||||
return activeTransfersCount > 0;
|
||||
export function shouldKeepSftpMountedAfterClose(params: {
|
||||
activeTransfersCount: number;
|
||||
/** External-editor temps still need the browse session (closeSftp deletes them). */
|
||||
activeExternalEditCount?: number;
|
||||
}): boolean {
|
||||
return params.activeTransfersCount > 0
|
||||
|| (params.activeExternalEditCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
export function shouldCloseSftpSidePanel(params: {
|
||||
@@ -74,19 +79,23 @@ export function shouldCloseSftpSidePanel(params: {
|
||||
|
||||
export function shouldClearSftpPanelAfterTransferChange(params: {
|
||||
activeTransfersCount: number;
|
||||
activeExternalEditCount?: number;
|
||||
panelOpen: boolean;
|
||||
retainedAfterClose: boolean;
|
||||
}): boolean {
|
||||
return params.activeTransfersCount <= 0
|
||||
&& (params.activeExternalEditCount ?? 0) <= 0
|
||||
&& !params.panelOpen
|
||||
&& !params.retainedAfterClose;
|
||||
}
|
||||
|
||||
export function shouldScheduleSftpRetainedPanelCleanup(params: {
|
||||
activeTransfersCount: number;
|
||||
activeExternalEditCount?: number;
|
||||
retainedAfterClose: boolean;
|
||||
}): boolean {
|
||||
return params.activeTransfersCount <= 0
|
||||
&& (params.activeExternalEditCount ?? 0) <= 0
|
||||
&& params.retainedAfterClose;
|
||||
}
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ function SidePanelSftpSlotInner({
|
||||
handleSftpInitialLocationApplied,
|
||||
handleSftpCurrentPathChange,
|
||||
handleSftpActiveTransfersChange,
|
||||
handleSftpActiveExternalEditsChange,
|
||||
handlePendingUploadHandled,
|
||||
sftpDoubleClickBehavior,
|
||||
sftpAutoSync,
|
||||
@@ -157,6 +158,13 @@ function SidePanelSftpSlotInner({
|
||||
[handleSftpActiveTransfersChange, tabId],
|
||||
);
|
||||
|
||||
const handleActiveExternalEditsChange = useCallback(
|
||||
(count: number) => {
|
||||
handleSftpActiveExternalEditsChange(tabId, count);
|
||||
},
|
||||
[handleSftpActiveExternalEditsChange, tabId],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={sidePanelHiddenPanelClassName(!isVisible)}>
|
||||
<SftpSidePanel
|
||||
@@ -176,6 +184,7 @@ function SidePanelSftpSlotInner({
|
||||
onInitialLocationApplied={handleInitialLocationApplied}
|
||||
onCurrentPathChange={handleCurrentPathChange}
|
||||
onActiveTransfersChange={handleActiveTransfersChange}
|
||||
onActiveExternalEditsChange={handleActiveExternalEditsChange}
|
||||
showWorkspaceHostHeader={isVisible && !!live.activeWorkspace}
|
||||
isVisible={isVisible}
|
||||
renderOverlays={isVisible}
|
||||
@@ -447,6 +456,7 @@ function SidePanelHistorySlotInner({
|
||||
const {
|
||||
HistorySidePanel,
|
||||
handleHistoryPaste,
|
||||
handleHistoryDelete,
|
||||
handleHistoryRun,
|
||||
} = ctx;
|
||||
|
||||
@@ -460,6 +470,7 @@ function SidePanelHistorySlotInner({
|
||||
state={remoteHistory.getState(live.focusedHost?.id, live.historySessionId)}
|
||||
globalEntries={shellHistory as import('../../domain/models').ShellHistoryEntry[]}
|
||||
onFetch={remoteHistory.fetch}
|
||||
onDeleteGlobalEntry={handleHistoryDelete}
|
||||
onPasteToTerminal={handleHistoryPaste}
|
||||
onRunInTerminal={handleHistoryRun}
|
||||
isVisible
|
||||
|
||||
@@ -232,6 +232,7 @@ const SIDE_PANEL_STABLE_CTX_KEYS = [
|
||||
'systemMountedTabIds',
|
||||
'themeMountedTabIds',
|
||||
'handleHistoryPaste',
|
||||
'handleHistoryDelete',
|
||||
'handleHistoryRun',
|
||||
'handleOpenHistory',
|
||||
'HistorySidePanel',
|
||||
@@ -259,6 +260,7 @@ const SIDE_PANEL_STABLE_CTX_KEYS = [
|
||||
'sftpPendingUploadsForTab',
|
||||
'handleSftpCurrentPathChange',
|
||||
'handleSftpActiveTransfersChange',
|
||||
'handleSftpActiveExternalEditsChange',
|
||||
'sftpDoubleClickBehavior',
|
||||
'sftpAutoSync',
|
||||
'sftpShowHiddenFiles',
|
||||
|
||||
@@ -63,6 +63,7 @@ export const terminalLayerAreEqual = (
|
||||
prev.onUpdateSessionRestoreCwd === next.onUpdateSessionRestoreCwd &&
|
||||
prev.onUpdateHost === next.onUpdateHost &&
|
||||
prev.onAddKnownHost === next.onAddKnownHost &&
|
||||
prev.onDeleteShellHistoryEntry === next.onDeleteShellHistoryEntry &&
|
||||
prev.onToggleWorkspaceViewMode === next.onToggleWorkspaceViewMode &&
|
||||
prev.onSetWorkspaceFocusedSession === next.onSetWorkspaceFocusedSession &&
|
||||
prev.onReorderWorkspaceSessions === next.onReorderWorkspaceSessions &&
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { isSessionReconnectDisabled } from './SessionTabContextMenuContent';
|
||||
|
||||
const source = readFileSync(fileURLToPath(new URL('./SessionTabContextMenuContent.tsx', import.meta.url)), 'utf8');
|
||||
|
||||
test('reconnect is disabled while a session is still connecting', () => {
|
||||
assert.equal(isSessionReconnectDisabled('connecting'), true);
|
||||
assert.equal(isSessionReconnectDisabled('connected'), false);
|
||||
assert.equal(isSessionReconnectDisabled('disconnected'), false);
|
||||
});
|
||||
|
||||
test('session tab menu exposes optional edit-host when a vault host is present', () => {
|
||||
assert.match(source, /editHost\?: Host/);
|
||||
assert.match(source, /onEditHost\?: \(host: Host\) => void/);
|
||||
assert.match(source, /editHost && onEditHost/);
|
||||
assert.match(source, /terminal\.layer\.hostTree\.editHost/);
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { TerminalSession } from '../../types';
|
||||
import type { Host, TerminalSession } from '../../types';
|
||||
import { ContextMenuContent, ContextMenuItem } from '../ui/context-menu';
|
||||
|
||||
type TranslateFn = ReturnType<typeof useI18n>['t'];
|
||||
@@ -15,6 +15,9 @@ interface SessionTabContextMenuContentProps {
|
||||
onReconnectSession: (sessionId: string) => void;
|
||||
sessionStatus: TerminalSession['status'];
|
||||
onRenameSession: (sessionId: string) => void;
|
||||
/** Vault host for this session; omit edit when missing (e.g. local shell). */
|
||||
editHost?: Host;
|
||||
onEditHost?: (host: Host) => void;
|
||||
renderBulkCloseItems?: (anchorId: string) => React.ReactNode;
|
||||
t: TranslateFn;
|
||||
}
|
||||
@@ -28,6 +31,8 @@ export function SessionTabContextMenuContent({
|
||||
onReconnectSession,
|
||||
sessionStatus,
|
||||
onRenameSession,
|
||||
editHost,
|
||||
onEditHost,
|
||||
renderBulkCloseItems,
|
||||
t,
|
||||
}: SessionTabContextMenuContentProps) {
|
||||
@@ -42,6 +47,11 @@ export function SessionTabContextMenuContent({
|
||||
<ContextMenuItem onClick={() => onRenameSession(sessionId)}>
|
||||
{t('common.rename')}
|
||||
</ContextMenuItem>
|
||||
{editHost && onEditHost && (
|
||||
<ContextMenuItem onClick={() => onEditHost(editHost)}>
|
||||
{t('terminal.layer.hostTree.editHost')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{onCopySession && (
|
||||
<ContextMenuItem onClick={() => onCopySession(sessionId)}>
|
||||
{t('tabs.copyTab')}
|
||||
|
||||
@@ -683,6 +683,7 @@ interface SessionTopTabProps {
|
||||
onRenameSession: (sessionId: string) => void;
|
||||
onCopySession: (sessionId: string) => void;
|
||||
onCopySessionToNewWindow: (sessionId: string) => void;
|
||||
onEditHost?: (host: Host) => void;
|
||||
renderBulkCloseItems: RenderBulkCloseItems;
|
||||
dynamicTabTitleMode?: DynamicTabTitleMode;
|
||||
t: TranslateFn;
|
||||
@@ -706,6 +707,7 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
|
||||
onRenameSession,
|
||||
onCopySession,
|
||||
onCopySessionToNewWindow,
|
||||
onEditHost,
|
||||
renderBulkCloseItems,
|
||||
dynamicTabTitleMode,
|
||||
t,
|
||||
@@ -833,6 +835,8 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
|
||||
onReconnectSession={terminalReconnectRegistry.request}
|
||||
sessionStatus={session.status}
|
||||
onRenameSession={onRenameSession}
|
||||
editHost={host}
|
||||
onEditHost={onEditHost}
|
||||
renderBulkCloseItems={renderBulkCloseItems}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
@@ -1373,6 +1373,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
groupConfigs={groupConfigs}
|
||||
snippets={snippets}
|
||||
onSnippetsChange={onUpdateSnippets}
|
||||
onHostsChange={onUpdateHosts}
|
||||
onImportKey={onImportOrReuseKey}
|
||||
onSave={(host) => {
|
||||
const latestHost = hosts.find(
|
||||
@@ -1383,7 +1384,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
openedHost: editingHost,
|
||||
latestHost,
|
||||
});
|
||||
onUpdateHosts(upsertHostById(hosts, nextHost));
|
||||
onUpdateHosts((prevHosts) => upsertHostById(prevHosts, nextHost));
|
||||
setIsHostPanelOpen(false);
|
||||
setEditingHost(null);
|
||||
setNewHostGroupPath(null);
|
||||
|
||||
@@ -28,6 +28,8 @@ import { sanitizeHost } from "../../domain/host";
|
||||
import {
|
||||
applyVaultImportDestination,
|
||||
applyVaultHostImport,
|
||||
buildVaultHostEndpointKey,
|
||||
buildVaultHostMergeKey,
|
||||
filterVaultImportKeyPassphrasesAgainstExisting,
|
||||
mergeVaultImportIssues,
|
||||
resolveVaultImportKeyPassphraseConflicts,
|
||||
@@ -179,6 +181,9 @@ export function useVaultImportHandlers({
|
||||
result = applyVaultImportDestination(
|
||||
result,
|
||||
options?.destination ?? { mode: "preserve" },
|
||||
// SecureCRT keeps distinct session files that share an endpoint;
|
||||
// only rewrite their group when the user picks an import location.
|
||||
{ collapseDuplicateEndpoints: format !== "securecrt" },
|
||||
);
|
||||
}
|
||||
updateProgress({ stage: "preparing", percent: 70 });
|
||||
@@ -308,15 +313,16 @@ export function useVaultImportHandlers({
|
||||
}
|
||||
}
|
||||
|
||||
const makeKey = (h: Host) =>
|
||||
`${(h.protocol ?? "ssh").toLowerCase()}|${h.hostname.toLowerCase()}|${h.port}|${(h.username ?? "").toLowerCase()}`;
|
||||
|
||||
// Managed ssh_config rematch is endpoint-only; CSV/other imports treat
|
||||
// group as part of session identity so direct vs proxy copies can coexist.
|
||||
const makeKey = isManaged ? buildVaultHostEndpointKey : buildVaultHostMergeKey;
|
||||
|
||||
const existingKeys = new Set(currentHosts.map(makeKey));
|
||||
// Filter out duplicates for both managed and non-managed imports
|
||||
let newHosts = format === "securecrt"
|
||||
? result.hosts
|
||||
: result.hosts.filter((h) => !existingKeys.has(makeKey(h)));
|
||||
|
||||
|
||||
// For managed imports, also update existing hosts to be managed
|
||||
let updatedExistingHosts: Host[] = [];
|
||||
if (isManaged) {
|
||||
|
||||
@@ -413,6 +413,18 @@ connection draft may reference an identity or key draft from the same import by
|
||||
its source ID; Netcatty maps that reference to the new host-owned credential ID
|
||||
before encrypted persistence and rejects unresolved or ambiguous references.
|
||||
|
||||
PR 8 adds sync Providers as another operation-keyed map under permission
|
||||
`provider.sync`. Plugins implement only encrypted object storage: `connect`,
|
||||
`disconnect`, `getAccount`, `getCapabilities`, `readObject`, `writeObject`, and
|
||||
`deleteObject`. Capability reporting covers revisions, conditional writes,
|
||||
atomic replacement, and size limits (`SyncLimits`). Large objects leave the
|
||||
JSON control plane on the existing stream seam; plugins never receive the cloud
|
||||
master key or plaintext vault. Netcatty continues to own encryption, CRDT merge,
|
||||
migrations, protection snapshots, conflict handling, and read-merge-write-verify.
|
||||
WebDAV is adapted through the shared encrypted-object storage interface so the
|
||||
same path can exercise configuration, secret handles, upload/download,
|
||||
verification, and recovery.
|
||||
|
||||
`PluginSecretStore.get()` never returns plaintext. It returns a host-issued
|
||||
`SecretRef`. Its random ID stays opaque; its non-secret `key` binds later lease
|
||||
authorization to the same manifest resource used by `get()`/`set()`. `set()`
|
||||
@@ -575,7 +587,7 @@ The cross-phase contract was checked against every planned consumer:
|
||||
| PR 5 terminal providers | namespaced provider IDs, provider request/result envelopes and bounded streams |
|
||||
| PR 6 data pipeline | direct MessagePort transfer envelopes, sequence, per-chunk credit, and bounded receive-window fields |
|
||||
| PR 7 connection/auth/import | provider kinds and configuration schemas, platform-specific companion variants, framing, stable failures and progress |
|
||||
| PR 8 sync | namespaced sync providers and JSON/binary bounded transport |
|
||||
| PR 8 sync | implemented: namespaced `sync` Providers, `SyncLimits`, operation-keyed connect/read/write/delete/capabilities, inline or streamed encrypted objects |
|
||||
| PR 9 rollout | schema/API selection, compatibility reporting and the final API 1.0 freeze |
|
||||
|
||||
The contract tests construct representative manifests for the contribution UI,
|
||||
|
||||
@@ -225,7 +225,7 @@ new protocol route. Plugin packages still cannot add mappings themselves.
|
||||
| PR 5 terminal Providers | validated host requests, cancellation, lifecycle events | Provider ranking, deadlines, snapshots, built-in highlighter/autocomplete adapters |
|
||||
| PR 6 terminal pipeline | runtime identity and placement policy | direct MessagePort fast path, sensitive-input bypass, circuit breaker |
|
||||
| PR 7 connection/auth/import | activation-owned Provider requests, exact result validators, bounded streams, diagnostics, secret leases, credential refs | implemented: connection sessions, authentication challenges, importer preview/commit |
|
||||
| PR 8 sync | streams, lifecycle identity, namespaced storage boundary | dynamic providers, encrypted sidecar, CRDT state and account baselines |
|
||||
| PR 8 sync | streams, lifecycle identity, namespaced storage boundary | implemented: `kind: "sync"` Providers with `provider.sync`, encrypted-object connect/read/write/delete/capabilities, WebDAV through shared storage surface, non-cascade sidecars for `sync: true` settings and account/CRDT baselines |
|
||||
| PR 9 distribution | retained immutable versions, compare-and-set restore, placement resolver, module resources | signatures, trust, health checks, audited update and user rollback policy, API 1.0, and the reproducible terminal benchmark harness/environment/release gate for the 1% throughput and 4 ms p95 / 8 ms p99 input-latency targets |
|
||||
|
||||
## Data-model decisions that must remain explicit
|
||||
@@ -249,10 +249,11 @@ writing the active-version pointer directly.
|
||||
|
||||
`plugin_kv` is runtime-owned local data and is removed by explicit uninstall.
|
||||
Phase-3 encrypted secrets, persistent grants and security audit, plus phase-4
|
||||
settings and view state, use separate non-cascade tables. Future connection
|
||||
profiles and CRDT sidecar baselines are the same user-owned class and must not
|
||||
be added to the `plugin_kv` cascade when missing plugin code must preserve user
|
||||
or synchronized data.
|
||||
settings and view state, use separate non-cascade tables. PR 8 stores encrypted
|
||||
sync sidecars (`plugin_sync_sidecars`) in the same non-cascade class for
|
||||
`sync: true` settings plus plugin sync account/CRDT baselines. Missing or
|
||||
uninstalled plugin code must not cascade-delete those rows or coerce related
|
||||
sync configuration away.
|
||||
|
||||
Activation events are declared by the public manifest. Phase 4 now starts only
|
||||
`onStartupFinished` plugins during contribution initialization; commands, views,
|
||||
|
||||
@@ -219,8 +219,10 @@ after durable user data can exist.
|
||||
a post-permission lookup. Importers use bounded streams, not directory walks,
|
||||
and their draft schemas reject executable startup commands and hidden
|
||||
plaintext built-in credential fields before Vault persistence.
|
||||
- PR 8 stores encrypted sync sidecars separately from package cascade storage
|
||||
and transports larger encrypted objects over the existing stream seam.
|
||||
- PR 8 stores encrypted sync sidecars in `plugin_sync_sidecars` (no package
|
||||
cascade) for non-secret `sync: true` settings plus account/CRDT baselines, and
|
||||
transports larger encrypted objects over the existing stream seam. Secret
|
||||
settings never enter cloud sidecars.
|
||||
- PR 9 supplies signed publisher principals and trust policy through placement;
|
||||
signed identity changes force a fresh grant instead of widening an unsigned
|
||||
grant silently.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Sync providers
|
||||
|
||||
Netcatty cloud sync providers are dynamic and namespaced. Built-in providers
|
||||
(`github`, `google`, `onedrive`, `webdav`, `s3`) stay compatible; plugins
|
||||
register additional IDs under their plugin namespace with `kind: "sync"` and
|
||||
permission `provider.sync`.
|
||||
|
||||
## Boundary
|
||||
|
||||
Plugins implement **encrypted object storage only**:
|
||||
|
||||
- `connect` / `disconnect` / `getAccount`
|
||||
- `getCapabilities` (`revisions`, `conditionalWrites`, `atomicReplacement`, size limits)
|
||||
- `readObject` / `writeObject` / `deleteObject`
|
||||
|
||||
Netcatty owns encryption, the master key, CRDT merge, migrations, protection
|
||||
snapshots, conflict handling, and read-merge-write-verify. Plugin providers
|
||||
never receive the vault master key or plaintext sync payloads.
|
||||
|
||||
## Secrets
|
||||
|
||||
Only non-secret configuration marked for sync enters cloud payloads. Plugin
|
||||
connect secrets (`password`, `token`, `secret`, `apiKey`, `accessToken`) are
|
||||
stripped from configuration, stored in the OS-backed plugin secret store, and
|
||||
passed to `SyncConnectPayload.credential` as opaque `{ kind: "secret", id, key }`
|
||||
references. Additional extracted secrets are stored under `sync-credential:<field>`
|
||||
keys so plugins can `secrets.get` / `credentials.createLease` them.
|
||||
|
||||
Durable reconnects persist an opaque SecretRef (`{ kind, id, key }`), not
|
||||
plaintext. The host injects Authorization only after consuming an
|
||||
operation-bound lease whose `operationId` matches `network:<origin>`.
|
||||
|
||||
**Using a SecretRef from a sandbox plugin:** create an operation-bound lease via
|
||||
`credentials.createLease` with `operationId` set to `network:<origin>` (same
|
||||
origin the request will call), then call `network.request` with:
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "https://example.com/…",
|
||||
"credentialLease": { "kind": "secret-lease", "id": "…", "operationId": "network:https://example.com", "expiresAt": 0 },
|
||||
"authorization": { "scheme": "Bearer" }
|
||||
}
|
||||
```
|
||||
|
||||
The host consumes the lease (bound to the request origin, not a plugin-echoed
|
||||
id) and injects `Authorization` (Bearer or Basic). Plaintext never returns to
|
||||
the plugin. Companion `credentialLeases` remains available for node-only
|
||||
companions.
|
||||
|
||||
WebDAV continues to exercise the shared EncryptedObjectStorage path for
|
||||
configuration, proxy behavior, upload/download, and recovery. Write verification
|
||||
on WebDAV is performed by the adapter's pad+verify upload (not a second host
|
||||
byte re-read). Credentials remain field-encrypted at rest via the secure field
|
||||
adapter.
|
||||
|
||||
## Streams and SyncLimits
|
||||
|
||||
Public `SyncLimits` (see plugin contract) define:
|
||||
|
||||
- `maxObjectBytes` — hard ciphertext cap
|
||||
- `inlineObjectBytes` — maximum size that may travel inline on the control plane
|
||||
- key / revision length bounds
|
||||
|
||||
Above `inlineObjectBytes`, main↔plugin uses credit-window streams
|
||||
(`STREAM_WINDOW_BYTES` = 256 KiB). Renderer↔main uses structured-clone
|
||||
`Uint8Array` for inline payloads and pull/chunked IPC (`sync-write-begin` /
|
||||
`sync-write-chunk` / `sync-write-commit`, `sync-read-chunk`) for larger objects.
|
||||
Transfers are per-sender, TTL-bounded, capped, and cancelled via
|
||||
`cancelPluginExtensionRequest(requestId)` / `AbortSignal`.
|
||||
|
||||
## Sidecars (non-cascade)
|
||||
|
||||
Missing or disabled plugins must not delete synced settings or connection
|
||||
baselines. Host-owned `plugin_sync_sidecars` carry `sync:true` non-secret
|
||||
settings and account/CRDT baselines through collect/apply with last-known and
|
||||
prefer-cloud merge semantics. Device-local baselines survive remote settings
|
||||
wipes; empty-vault upload guards ignore last-known-only evidence.
|
||||
|
||||
## WebDAV
|
||||
|
||||
The production WebDAV adapter is wrapped as EncryptedObjectStorage so it shares
|
||||
the same encrypt→write / read→decrypt surface as plugin providers. WebDAV's
|
||||
native pad+verify upload already satisfies write verification and may leave
|
||||
trailing padding on the remote object; the shared bridge therefore skips a
|
||||
full byte re-read on that path (`assumeVerifiedWrites`) — without that flag the
|
||||
host compare would false-fail on padded bodies. Plugin providers keep
|
||||
host-owned byte compare after write.
|
||||
@@ -283,5 +283,6 @@ runtime identity, cancellation, progress, permission names, and stream
|
||||
protocol. PR 6 added its direct interceptor transport without changing the
|
||||
ordinary registry. PR 7 reused that registration and runtime lifecycle for
|
||||
connection, authentication, and importer Providers, with operation-specific
|
||||
result validators and bounded stream consumers. PR 8 sync Providers and PR 9
|
||||
result validators and bounded stream consumers. PR 8 sync Providers (implemented)
|
||||
and PR 9
|
||||
rollout can reuse the same boundaries.
|
||||
@@ -64,8 +64,8 @@ platform is unreleased, so both tables are part of the complete schema at
|
||||
Secret settings never enter the settings table or a renderer snapshot. The
|
||||
host stores plaintext only through the phase-3 safeStorage-backed secret store,
|
||||
shows a configured indicator, and exposes an opaque `SecretRef` to the owning
|
||||
runtime. Only non-secret fields explicitly declaring `sync: true` may be used by
|
||||
the future encrypted sidecar sync phase.
|
||||
runtime. Only non-secret fields explicitly declaring `sync: true` enter the
|
||||
encrypted sidecar sync path (PR 8).
|
||||
|
||||
## Commands, menus, and Context Keys
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import {
|
||||
BUILTIN_CLOUD_PROVIDERS,
|
||||
assertCloudProviderId,
|
||||
isBuiltinCloudProvider,
|
||||
isPluginCloudProviderId,
|
||||
providerConnectionStorageKey,
|
||||
} from './cloudProviderIds';
|
||||
|
||||
describe('cloudProviderIds', () => {
|
||||
it('recognizes built-in providers', () => {
|
||||
for (const id of BUILTIN_CLOUD_PROVIDERS) {
|
||||
assert.equal(isBuiltinCloudProvider(id), true);
|
||||
assert.equal(isPluginCloudProviderId(id), false);
|
||||
assert.equal(providerConnectionStorageKey(id), `netcatty_provider_${id}_v1`);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts namespaced plugin provider IDs without coercing them to built-ins', () => {
|
||||
const id = 'com.example.backup.sync';
|
||||
assert.equal(isPluginCloudProviderId(id), true);
|
||||
assert.equal(isBuiltinCloudProvider(id), false);
|
||||
assert.equal(providerConnectionStorageKey(id), `netcatty_provider_plugin_v1:${id}`);
|
||||
assert.equal(assertCloudProviderId(id), id);
|
||||
});
|
||||
|
||||
it('rejects empty or NUL-containing provider IDs', () => {
|
||||
assert.throws(() => assertCloudProviderId(''), /invalid/i);
|
||||
assert.throws(() => assertCloudProviderId('bad\0id'), /invalid/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Cloud provider identity helpers.
|
||||
*
|
||||
* Built-in providers keep their stable short IDs. Plugin sync Providers use
|
||||
* namespaced contribution IDs (e.g. com.example.backup.sync). The manager
|
||||
* boundary accepts both without coercing missing plugins away.
|
||||
*/
|
||||
|
||||
export const BUILTIN_CLOUD_PROVIDERS = [
|
||||
'github',
|
||||
'google',
|
||||
'onedrive',
|
||||
'webdav',
|
||||
's3',
|
||||
] as const;
|
||||
|
||||
export type BuiltinCloudProvider = (typeof BUILTIN_CLOUD_PROVIDERS)[number];
|
||||
|
||||
/** Built-in short IDs or namespaced plugin contribution IDs. */
|
||||
export type CloudProviderId = BuiltinCloudProvider | (string & {});
|
||||
|
||||
const BUILTIN_SET = new Set<string>(BUILTIN_CLOUD_PROVIDERS);
|
||||
|
||||
export function isBuiltinCloudProvider(provider: string): provider is BuiltinCloudProvider {
|
||||
return BUILTIN_SET.has(provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the provider ID looks like a namespaced plugin contribution.
|
||||
* Contribution IDs are reverse-DNS style and always contain a dot.
|
||||
*/
|
||||
export function isPluginCloudProviderId(provider: string): boolean {
|
||||
if (isBuiltinCloudProvider(provider)) return false;
|
||||
if (typeof provider !== 'string' || provider.length < 3 || provider.length > 256) return false;
|
||||
if (provider.includes('\0') || provider.includes('/') || provider.includes('\\')) return false;
|
||||
return provider.includes('.');
|
||||
}
|
||||
|
||||
export function assertCloudProviderId(provider: string): CloudProviderId {
|
||||
if (typeof provider !== 'string' || provider.length < 1 || provider.length > 256) {
|
||||
throw new TypeError('Cloud provider ID is invalid');
|
||||
}
|
||||
if (provider.includes('\0')) {
|
||||
throw new TypeError('Cloud provider ID is invalid');
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
/** localStorage / encrypted local key for a provider connection record. */
|
||||
export function providerConnectionStorageKey(provider: CloudProviderId): string {
|
||||
if (isBuiltinCloudProvider(provider)) {
|
||||
return `netcatty_provider_${provider}_v1`;
|
||||
}
|
||||
// Plugin IDs may contain characters unsafe for ad-hoc key templates.
|
||||
return `netcatty_provider_plugin_v1:${provider}`;
|
||||
}
|
||||
|
||||
/** Registry of dynamic plugin provider IDs that have been connected on this device. */
|
||||
export const PLUGIN_CLOUD_PROVIDER_REGISTRY_KEY = 'netcatty_plugin_cloud_providers_v1';
|
||||
@@ -125,6 +125,9 @@ export function cloudSyncPayloadsEqual(left: SyncPayload, right: SyncPayload): b
|
||||
.map((key) => [key, (payload as unknown as Record<string, unknown>)[key] ?? []]),
|
||||
),
|
||||
settings: payload.settings ?? {},
|
||||
// Plugin sidecars are host-owned opaque data on the encrypted blob and
|
||||
// must participate in migration freshness / equality checks.
|
||||
pluginSidecars: payload.pluginSidecars ?? { version: 1, entries: [] },
|
||||
});
|
||||
return fingerprint(project(left)) === fingerprint(project(right));
|
||||
}
|
||||
|
||||
@@ -25,6 +25,58 @@ import {
|
||||
materializeSyncPayloadFromConvergentState,
|
||||
withConvergentSyncEnvelope,
|
||||
} from './payload';
|
||||
import {
|
||||
mergePluginSyncSidecars,
|
||||
mergePluginSyncSidecarsThreeWay,
|
||||
} from '../pluginSyncSidecar';
|
||||
|
||||
/** LWW-union plugin sidecars from every migration input that carries them. */
|
||||
function mergeMigrationSidecars(
|
||||
...bundles: Array<SyncPayload['pluginSidecars'] | null | undefined>
|
||||
): SyncPayload['pluginSidecars'] | undefined {
|
||||
let entries: NonNullable<SyncPayload['pluginSidecars']>['entries'] = [];
|
||||
let sawAny = false;
|
||||
for (const bundle of bundles) {
|
||||
if (!bundle || !Array.isArray(bundle.entries)) continue;
|
||||
sawAny = true;
|
||||
entries = mergePluginSyncSidecars({ local: entries, remote: bundle });
|
||||
}
|
||||
return sawAny ? { version: 1, entries } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Three-way merge local sidecars against each remote source using that
|
||||
* source's trusted baseline (falls back to local baseline). Preserves
|
||||
* explicit local deletions instead of resurrecting them via LWW union.
|
||||
*/
|
||||
function mergeMigrationSidecarsWithBaselines(options: {
|
||||
local: SyncPayload['pluginSidecars'] | null | undefined;
|
||||
localBaseline: SyncPayload['pluginSidecars'] | null | undefined;
|
||||
sources: Array<{
|
||||
remote: SyncPayload['pluginSidecars'] | null | undefined;
|
||||
baseline: SyncPayload['pluginSidecars'] | null | undefined;
|
||||
}>;
|
||||
}): SyncPayload['pluginSidecars'] | undefined {
|
||||
let entries = Array.isArray(options.local?.entries) ? [...options.local.entries] : [];
|
||||
const localBase = Array.isArray(options.localBaseline?.entries)
|
||||
? options.localBaseline.entries
|
||||
: [];
|
||||
let sawAny = Array.isArray(options.local?.entries);
|
||||
for (const source of options.sources) {
|
||||
if (!source.remote || !Array.isArray(source.remote.entries)) continue;
|
||||
sawAny = true;
|
||||
const base = Array.isArray(source.baseline?.entries)
|
||||
? source.baseline.entries
|
||||
: localBase;
|
||||
entries = mergePluginSyncSidecarsThreeWay({
|
||||
base,
|
||||
local: entries,
|
||||
remote: source.remote.entries,
|
||||
});
|
||||
}
|
||||
if (!sawAny) return undefined;
|
||||
return { version: 1, entries };
|
||||
}
|
||||
|
||||
/*
|
||||
* A local snapshot with no cloud entities and no trusted base is a fresh
|
||||
@@ -203,14 +255,43 @@ export function planConvergentSyncMigration(options: {
|
||||
}
|
||||
if (blockedReasons.length === 0) {
|
||||
state = createConvergentSyncStateFromPayload(merged, options.deviceId, options.now);
|
||||
// Prefer the three-way merge result already on `merged` (preserves
|
||||
// explicit sidecar deletions). Do not re-LWW raw provider bundles —
|
||||
// that would resurrect entries three-way merge correctly removed.
|
||||
const migrationSidecars = Object.prototype.hasOwnProperty.call(merged, 'pluginSidecars')
|
||||
? merged.pluginSidecars
|
||||
: mergeMigrationSidecars(
|
||||
options.localPayload.pluginSidecars,
|
||||
...v1Inputs.map((input) => input.payload.pluginSidecars),
|
||||
);
|
||||
materialized = materializeSyncPayloadFromConvergentState(state, {
|
||||
syncedAt: options.now,
|
||||
syncMeta: merged.syncMeta,
|
||||
...(migrationSidecars ? { pluginSidecars: migrationSidecars } : {}),
|
||||
});
|
||||
}
|
||||
} else if (blockedReasons.length === 0) {
|
||||
state = v2Inputs.map((input) => input.state).reduce(mergeConvergentSyncStates);
|
||||
const joinedPayload = materializeSyncPayloadFromConvergentState(state, { syncedAt: options.now });
|
||||
// Three-way per provider so local resets are not resurrected from a
|
||||
// still-stale remote entry during convergent enablement.
|
||||
const joinedSidecars = mergeMigrationSidecarsWithBaselines({
|
||||
local: options.localPayload.pluginSidecars,
|
||||
localBaseline: options.localTrustedBaseline?.pluginSidecars,
|
||||
sources: [
|
||||
...v2Inputs.map((input) => ({
|
||||
remote: input.payload.pluginSidecars,
|
||||
baseline: input.trustedBaseline?.pluginSidecars,
|
||||
})),
|
||||
...v1Inputs.map((input) => ({
|
||||
remote: input.payload.pluginSidecars,
|
||||
baseline: input.trustedBaseline?.pluginSidecars,
|
||||
})),
|
||||
],
|
||||
});
|
||||
const joinedPayload = materializeSyncPayloadFromConvergentState(state, {
|
||||
syncedAt: options.now,
|
||||
...(joinedSidecars ? { pluginSidecars: joinedSidecars } : {}),
|
||||
});
|
||||
const legacySources: Array<{
|
||||
id: string;
|
||||
payload: SyncPayload;
|
||||
@@ -257,7 +338,13 @@ export function planConvergentSyncMigration(options: {
|
||||
}
|
||||
if (blockedReasons.length === 0) {
|
||||
state = branches.reduce(mergeConvergentSyncStates, state);
|
||||
materialized = materializeSyncPayloadFromConvergentState(state, { syncedAt: options.now });
|
||||
// joinedSidecars already unions local + all provider inputs. Re-LWW-ing
|
||||
// raw sources again cannot add unique entries and can confuse future
|
||||
// three-way paths that expect the joined set to be final.
|
||||
materialized = materializeSyncPayloadFromConvergentState(state, {
|
||||
syncedAt: options.now,
|
||||
...(joinedSidecars ? { pluginSidecars: joinedSidecars } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,7 +352,13 @@ export function planConvergentSyncMigration(options: {
|
||||
if (conflicts.length > 0) blockedReasons.push('The convergent state contains unresolved field conflicts');
|
||||
const canInitialize = blockedReasons.length === 0 && state !== null && materialized !== null;
|
||||
const payload = canInitialize && state
|
||||
? withConvergentSyncEnvelope(state, { syncedAt: options.now, syncMeta: materialized?.syncMeta })
|
||||
? withConvergentSyncEnvelope(state, {
|
||||
syncedAt: options.now,
|
||||
syncMeta: materialized?.syncMeta,
|
||||
...(materialized?.pluginSidecars
|
||||
? { pluginSidecars: materialized.pluginSidecars }
|
||||
: {}),
|
||||
})
|
||||
: null;
|
||||
const previewPayload = materialized ?? options.localPayload;
|
||||
const entityCounts = Object.fromEntries(
|
||||
|
||||
@@ -202,6 +202,8 @@ export function materializeSyncPayloadFromConvergentState(
|
||||
options: {
|
||||
syncedAt: number;
|
||||
syncMeta?: SyncReliabilityMeta;
|
||||
/** Opaque plugin sidecars travel with the encrypted blob outside CRDT fields. */
|
||||
pluginSidecars?: SyncPayload['pluginSidecars'];
|
||||
},
|
||||
): SyncPayload {
|
||||
requireKnownCollections(state);
|
||||
@@ -228,6 +230,17 @@ export function materializeSyncPayloadFromConvergentState(
|
||||
settings,
|
||||
syncedAt: options.syncedAt,
|
||||
...(options.syncMeta ? { syncMeta: options.syncMeta } : {}),
|
||||
// Preserve explicit empty bundles so lifecycle materializations (conflict
|
||||
// resolve / downgrade) can clear or re-upload sidecars rather than omit
|
||||
// the field and look like a legacy payload.
|
||||
...(options.pluginSidecars && Array.isArray(options.pluginSidecars.entries)
|
||||
? {
|
||||
pluginSidecars: {
|
||||
version: 1 as const,
|
||||
entries: options.pluginSidecars.entries,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -513,7 +526,11 @@ export function hydrateConvergentSyncEnvelope(
|
||||
|
||||
export function withConvergentSyncEnvelope(
|
||||
state: ConvergentSyncStateV2,
|
||||
options: { syncedAt: number; syncMeta?: SyncReliabilityMeta },
|
||||
options: {
|
||||
syncedAt: number;
|
||||
syncMeta?: SyncReliabilityMeta;
|
||||
pluginSidecars?: SyncPayload['pluginSidecars'];
|
||||
},
|
||||
): SyncPayload {
|
||||
const payload = materializeSyncPayloadFromConvergentState(state, options);
|
||||
return {
|
||||
|
||||
Loaded 100 of 186 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user