mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-31 19:02:31 +00:00
Compare commits
171
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 | ||
|
|
e35e2b23ea | ||
|
|
005739230c | ||
|
|
a4ef55f174 | ||
|
|
2f8f7daa56 | ||
|
|
ad8b5aa2a7 | ||
|
|
3e54c585af | ||
|
|
55b2dacbcc | ||
|
|
f200aafc8f | ||
|
|
9d876cf6d5 | ||
|
|
29df6216ed | ||
|
|
c8c24a5c5c | ||
|
|
f35f578cae | ||
|
|
d44af5f4a5 | ||
|
|
b261869acc | ||
|
|
d7ec779a7d | ||
|
|
af1e0ce5ad | ||
|
|
8b40c187f7 | ||
|
|
1961e87ff9 | ||
|
|
04479d1c4f | ||
|
|
db24fd115e | ||
|
|
39458a84f0 | ||
|
|
b60fad2080 | ||
|
|
072bbf24ad | ||
|
|
5d8409580a | ||
|
|
11850a0dac | ||
|
|
8b7d1d7647 | ||
|
|
a3d071f398 | ||
|
|
83cd1890ac | ||
|
|
38bce21a94 | ||
|
|
6e73cec109 | ||
|
|
aef1678af8 | ||
|
|
bed3e03fe2 | ||
|
|
74a23c335b | ||
|
|
ac7288d65f | ||
|
|
9a5ef5c84c | ||
|
|
9400e3731b | ||
|
|
cb6c65fd37 | ||
|
|
28738881ff | ||
|
|
13aa4a8c67 | ||
|
|
3b6ca6f803 | ||
|
|
121a1ed8be | ||
|
|
8ac48c3e51 | ||
|
|
76c2463b3d | ||
|
|
56bef275f5 | ||
|
|
3dbd2213fa | ||
|
|
8ef87f2f16 | ||
|
|
84125bcdcb | ||
|
|
c6313b8b02 | ||
|
|
bbef1d2d99 | ||
|
|
0cff7a3e52 | ||
|
|
21022d6b92 | ||
|
|
a50222dc07 | ||
|
|
ce0b8f85c3 | ||
|
|
764c7ea4c7 | ||
|
|
1a61ec86ef | ||
|
|
228d992671 | ||
|
|
523dcd80c8 | ||
|
|
093728c31f | ||
|
|
d11fcd80ca | ||
|
|
0950efb41b | ||
|
|
7cfe82aacc | ||
|
|
97890d6f84 | ||
|
|
33a1e5852c | ||
|
|
9639a26568 | ||
|
|
fb3cb2a263 | ||
|
|
add0f33cd5 | ||
|
|
1289383169 | ||
|
|
4974e22879 | ||
|
|
61bc1f1102 | ||
|
|
0d7b9839bc | ||
|
|
7657de7c01 | ||
|
|
925be73b1e | ||
|
|
3dfb100c50 | ||
|
|
259a5af6f6 | ||
|
|
953ff61328 | ||
|
|
e014098695 | ||
|
|
be9f53bf3f | ||
|
|
e9de0b1548 | ||
|
|
604cbbdf20 | ||
|
|
a109c3409b | ||
|
|
a010454e10 | ||
|
|
f8939decd7 | ||
|
|
ed2fa70f5c | ||
|
|
efd92fdbe4 | ||
|
|
b893ed2b06 | ||
|
|
e7d41c13c8 | ||
|
|
41344d83f7 | ||
|
|
eb79e97626 | ||
|
|
e3241f8b92 | ||
|
|
71e7c60aaa | ||
|
|
2f6b395cf3 | ||
|
|
49065c1b5e | ||
|
|
b7359fefa6 | ||
|
|
4d612668d5 | ||
|
|
b4d40ab67a | ||
|
|
2d20fe9834 | ||
|
|
072af9caa3 | ||
|
|
b965a031f2 | ||
|
|
df540cca4f | ||
|
|
8533b3f4c0 | ||
|
|
d6d268e5c7 | ||
|
|
3dbc4bb9bb | ||
|
|
61281d79d8 | ||
|
|
3e347ccb41 | ||
|
|
506769c421 | ||
|
|
fab439a646 | ||
|
|
1d879443ec | ||
|
|
8174971c3c | ||
|
|
421dcb8230 | ||
|
|
b4fbedf827 | ||
|
|
406fe2135a | ||
|
|
a2b922eb92 | ||
|
|
fbb09d0100 | ||
|
|
f17d96ba1b | ||
|
|
97b773ef89 | ||
|
|
0436dc9556 | ||
|
|
5ee42cf34b | ||
|
|
72b4f2319b | ||
|
|
47b2094d8b | ||
|
|
73780dddab | ||
|
|
2453b9f1a8 | ||
|
|
ea8ccce690 | ||
|
|
6888a79b03 | ||
|
|
ae5b648fdc | ||
|
|
8aace6c1ce | ||
|
|
77fed032f5 | ||
|
|
b2a379a368 | ||
|
|
129aaddb40 | ||
|
|
97b0dd4ebc | ||
|
|
3ea4286ed5 | ||
|
|
76ce594fbd | ||
|
|
6404c6b209 | ||
|
|
e8769cd590 | ||
|
|
cd32635f44 | ||
|
|
c46247e996 | ||
|
|
a6a54e705f | ||
|
|
c456a97224 |
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({
|
||||
|
||||
@@ -142,6 +142,14 @@ smoke coverage. Packaged-resource changes must also pass `npm run pack:dir`.
|
||||
- Seed data: `config/defaultData.ts`; terminal themes: `config/terminalThemes.ts`.
|
||||
- **Temporary files**: All temporary files (e.g., SFTP downloaded files for external editing) must be written to Netcatty's dedicated temp directory via `tempDirBridge.getTempFilePath(fileName)`. Do not write directly to `os.tmpdir()`. This ensures proper cleanup and user visibility in Settings > System.
|
||||
|
||||
## Terminal Side Panel Splits
|
||||
|
||||
- `domain/sidePanelLayout.ts` owns the pure pane/split tree operations, including focus, unique tools, close collapse, resize, and the pane limit.
|
||||
- `application/state/useTerminalSidePanelLayoutState.ts` owns per-terminal layouts and keeps the legacy focused-tool map compatible with external open paths.
|
||||
- `TerminalLayerSidePanelSection.tsx` renders one shared toolbar plus the nested pane chrome. Each pane content host must remain `overflow-hidden` and layout-contained.
|
||||
- Mounted tool panels use a stable portal node that moves between their pane host and the hidden parking host. Never fall back to an `absolute inset-0` side-panel root when a pane host is not ready.
|
||||
- Closing the whole side panel clears that terminal's split tree. Switching terminal tabs must not reuse another tab's layout.
|
||||
|
||||
## Testing & Safety
|
||||
- Favor unit tests for domain helpers (e.g., `workspace.ts`, `host.ts`) and hook-level tests for application state.
|
||||
- When changing storage keys or schema, provide migration or backward-compat handling.
|
||||
|
||||
@@ -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 下载
|
||||
|
||||
+1
-1
@@ -216,7 +216,7 @@ Windows リリースは未署名の場合があります。
|
||||
|
||||
> **Windows のポータブルデータ:** Netcatty を終了し、`Netcatty.exe`(zip 版)またはポータブル版ランチャーと同じ場所に `data` フォルダーを作成してください。次回起動時から、Netcatty はデータをこのフォルダーに保存します。保存済みのパスワードと秘密鍵は、作成した Windows ユーザーによって引き続き保護されます。別のコンピューターまたは Windows ユーザーへ移動した場合は、これらの機密情報を再入力する必要があります。
|
||||
|
||||
> **Windows でフォルダーを Netcatty で開く:** インストール版では、エクスプローラーのフォルダーおよびフォルダー背景のコンテキストメニューに **Open in Netcatty** が追加されます。選択すると、そのフォルダーでローカルターミナルが開きます。Windows 11 では、先に **その他のオプションを表示** を選択してください。ZIP 版とポータブル版では、このメニューは追加されません。
|
||||
> **Windows でフォルダーを Netcatty で開く:** インストール版では、エクスプローラーのフォルダーおよびフォルダー背景のコンテキストメニューに **Open in Netcatty** が追加されます。選択すると、そのフォルダーでローカルターミナルが開きます。Windows 11 では、先に **その他のオプションを表示** を選択してください。**設定 → システム → Windows Explorer** からメニューの表示/非表示を切り替えられます。ZIP 版とポータブル版では、既定ではこのメニューは追加されません。
|
||||
|
||||
> **macOS ユーザーへ:** 現在のリリースはコード署名と notarization が行われている想定です。Gatekeeper の警告が出る場合は、GitHub Releases から最新版の公式ビルドを取得しているか確認してください。
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ application and integration are complete.
|
||||
|
||||
> **Windows portable data:** Exit Netcatty, then create a folder named `data` beside `Netcatty.exe` (zip build) or beside the portable launcher. Netcatty will store its profile there on the next launch. Saved passwords and private keys remain protected by the Windows user account that created them, so they must be re-entered after moving the folder to another computer or Windows account.
|
||||
|
||||
> **Open folders in Netcatty on Windows:** The installed version adds **Open in Netcatty** to Explorer's folder context menu and folder-background context menu. It opens a local terminal in that folder. On Windows 11, choose **Show more options** first. ZIP and portable versions do not add this menu.
|
||||
> **Open folders in Netcatty on Windows:** The installed version adds **Open in Netcatty** to Explorer's folder context menu and folder-background context menu. It opens a local terminal in that folder. On Windows 11, choose **Show more options** first. Turn the menu off or on in **Settings → System → Windows Explorer**. ZIP and portable versions do not add this menu by default.
|
||||
|
||||
> **macOS Users:** Current releases are expected to be code-signed and notarized. If Gatekeeper still warns, make sure you downloaded the latest official build from GitHub Releases.
|
||||
|
||||
|
||||
+1
-1
@@ -215,7 +215,7 @@ Windows 发布文件可能仍未签名。
|
||||
|
||||
> **Windows 便携数据:** 退出 Netcatty,在 `Netcatty.exe`(zip 版)或便携版启动文件旁创建名为 `data` 的文件夹。下次启动后,Netcatty 会把数据保存在这里。已保存的密码和私钥仍受创建它们的 Windows 用户保护;将该文件夹移到其他电脑或 Windows 用户后,需要重新输入这些敏感信息。
|
||||
|
||||
> **在 Windows 中用 Netcatty 打开文件夹:** 安装版会在资源管理器的文件夹右键菜单和文件夹空白处右键菜单中加入 **Open in Netcatty**。点击后会直接在该目录打开本地终端;Windows 11 需要先选择 **显示更多选项**。ZIP 版和便携版不会添加此菜单。
|
||||
> **在 Windows 中用 Netcatty 打开文件夹:** 安装版会在资源管理器的文件夹右键菜单和文件夹空白处右键菜单中加入 **Open in Netcatty**。点击后会直接在该目录打开本地终端;Windows 11 需要先选择 **显示更多选项**。可在 **设置 → 系统 → Windows 资源管理器** 中随时关闭或重新开启。ZIP 版和便携版默认不会添加此菜单。
|
||||
|
||||
> **macOS 用户注意:** 当前发布版本应已完成代码签名和公证。如果 Gatekeeper 仍然提示风险,请确认您下载的是 GitHub Releases 中的最新官方构建。
|
||||
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -258,6 +258,24 @@ export const enAiMessages: Messages = {
|
||||
'ai.opencode.check': 'Check',
|
||||
'ai.opencode.resetPath': 'Reset',
|
||||
|
||||
// AI Grok Build (in-app managed agent — distinct from External MCP client install)
|
||||
'ai.grok.title': 'Grok Build',
|
||||
'ai.grok.description': "xAI's Grok Build coding agent CLI. Install the Grok CLI, sign in with `grok login` or set XAI_API_KEY, then select it as an external agent.",
|
||||
'ai.grok.detecting': 'Detecting...',
|
||||
'ai.grok.detected': 'Detected',
|
||||
'ai.grok.notFound': 'Not found',
|
||||
'ai.grok.path': 'Path:',
|
||||
'ai.grok.notFoundHint': 'Could not find grok in PATH. Install Grok Build CLI or specify the executable path below.',
|
||||
'ai.grok.customPathPlaceholder': 'e.g. /usr/local/bin/grok',
|
||||
'ai.grok.check': 'Check',
|
||||
'ai.grok.resetPath': 'Reset',
|
||||
'ai.grok.runtime.acp.title': 'Use Grok ACP (agent stdio)',
|
||||
'ai.grok.runtime.acp.default': 'Default',
|
||||
'ai.grok.runtime.acp.description':
|
||||
'Talk to Grok over Agent Client Protocol (grok agent stdio). Injects Netcatty MCP on session/new. Turn off to use the original headless streaming-json CLI path.',
|
||||
'ai.grok.runtime.streamingJson.hint':
|
||||
'Using headless streaming-json (grok -p --output-format streaming-json). Project .grok/config.toml is used for MCP injection.',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': 'Default Agent',
|
||||
'ai.defaultAgent.description': 'Agent to use when starting a new AI session',
|
||||
@@ -511,6 +529,11 @@ export const enAiMessages: Messages = {
|
||||
'terminal.layer.movePanelLeft': 'Move panel to left',
|
||||
'terminal.layer.movePanelRight': 'Move panel to right',
|
||||
'terminal.layer.closePanel': 'Close panel',
|
||||
'terminal.layer.closePane': 'Close split',
|
||||
'terminal.layer.resizeSplit': 'Resize split',
|
||||
'terminal.layer.splitHorizontal': 'Split top and bottom',
|
||||
'terminal.layer.splitVertical': 'Split left and right',
|
||||
'terminal.layer.openInNewSplit': 'Open in new split',
|
||||
'terminal.layer.hostTree.search': 'Search hosts...',
|
||||
'terminal.layer.hostTree.searchButton': 'Search',
|
||||
'terminal.layer.hostTree.tagsButton': 'Filter by tags',
|
||||
@@ -547,6 +570,7 @@ export const enAiMessages: Messages = {
|
||||
'topTabs.windowOpacity': 'Window opacity',
|
||||
'topTabs.openSettings': 'Open Settings',
|
||||
'ai.chat.sessionHistory': 'Session history',
|
||||
'ai.chat.resizeInput': 'Drag to resize the message input',
|
||||
'ai.chat.attach': 'Attach',
|
||||
'ai.chat.terminalSelectionAttachment': 'Terminal selection',
|
||||
'ai.chat.terminalSelectionLines': 'lines: {count}',
|
||||
|
||||
@@ -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',
|
||||
@@ -680,6 +688,10 @@ export const enTerminalMessages: Messages = {
|
||||
'snippets.selection.selectVisible': 'Select visible',
|
||||
'snippets.selection.deselectAll': 'Deselect all',
|
||||
'snippets.selection.exportSelected': 'Export selected ({count})',
|
||||
'snippets.selection.deleteSelected': 'Delete ({count})',
|
||||
'snippets.selection.deleteConfirmTitle': 'Delete selected items ({count})?',
|
||||
'snippets.selection.deleteConfirmDesc': 'The selected items will be permanently deleted. This action cannot be undone.',
|
||||
'snippets.selection.deleteSuccess': 'Deleted selected items: {count}.',
|
||||
'snippets.export.snippet': 'Export snippet',
|
||||
'snippets.export.package': 'Export script package',
|
||||
'snippets.export.toast.empty': 'No snippets to export.',
|
||||
|
||||
@@ -487,6 +487,9 @@ export const enVaultMessages: Messages = {
|
||||
'settings.sshDeepLink.title': 'SSH and Telnet links',
|
||||
'settings.sshDeepLink.enable': 'Open ssh:// and telnet:// links with Netcatty',
|
||||
'settings.sshDeepLink.enableDesc': 'Allow Netcatty to handle ssh:// and telnet:// links from browsers and other apps.',
|
||||
'settings.explorerContextMenu.title': 'Windows Explorer',
|
||||
'settings.explorerContextMenu.enable': 'Show “Open in Netcatty” in folder context menus',
|
||||
'settings.explorerContextMenu.enableDesc': 'Adds a right-click item for folders and folder backgrounds so you can open a local terminal there. Applies immediately; no reinstall needed.',
|
||||
'settings.jmsDeepLink.title': 'JumpServer Links',
|
||||
'settings.jmsDeepLink.enable': 'Handle jms:// links',
|
||||
'settings.jmsDeepLink.enableDesc': 'Register Netcatty as the handler for JumpServer jms:// client links. May conflict with the official JumpServer client.',
|
||||
|
||||
@@ -211,6 +211,24 @@ export const ruAiMessages: Messages = {
|
||||
'ai.codebuddy.elicitation.validation.format': 'Поле «{field}» должно соответствовать формату {format}.',
|
||||
'ai.codebuddy.elicitation.validation.option': 'Выберите допустимый вариант для «{field}».',
|
||||
|
||||
// AI Grok Build (in-app managed agent — distinct from External MCP client install)
|
||||
'ai.grok.title': 'Grok Build',
|
||||
'ai.grok.description': 'Агент программирования Grok Build от xAI (CLI). Установите Grok CLI, выполните `grok login` или задайте XAI_API_KEY, затем выберите его как внешнего агента.',
|
||||
'ai.grok.detecting': 'Обнаружение...',
|
||||
'ai.grok.detected': 'Обнаружен',
|
||||
'ai.grok.notFound': 'Не найден',
|
||||
'ai.grok.path': 'Путь:',
|
||||
'ai.grok.notFoundHint': 'Не удалось найти grok в PATH. Установите Grok Build CLI или укажите путь к исполняемому файлу ниже.',
|
||||
'ai.grok.customPathPlaceholder': 'например, /usr/local/bin/grok',
|
||||
'ai.grok.check': 'Проверить',
|
||||
'ai.grok.resetPath': 'Сбросить',
|
||||
'ai.grok.runtime.acp.title': 'Использовать Grok ACP (agent stdio)',
|
||||
'ai.grok.runtime.acp.default': 'По умолчанию',
|
||||
'ai.grok.runtime.acp.description':
|
||||
'Подключение к Grok через Agent Client Protocol (grok agent stdio). Netcatty MCP внедряется в session/new. Выключите, чтобы использовать исходный headless streaming-json CLI.',
|
||||
'ai.grok.runtime.streamingJson.hint':
|
||||
'Режим headless streaming-json (grok -p --output-format streaming-json). MCP внедряется через .grok/config.toml проекта.',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': 'Агент по умолчанию',
|
||||
'ai.defaultAgent.description': 'Агент, который будет использоваться при запуске новой AI-сессии',
|
||||
@@ -463,6 +481,11 @@ export const ruAiMessages: Messages = {
|
||||
'terminal.layer.movePanelLeft': 'Переместить панель влево',
|
||||
'terminal.layer.movePanelRight': 'Переместить панель вправо',
|
||||
'terminal.layer.closePanel': 'Закрыть панель',
|
||||
'terminal.layer.closePane': 'Закрыть область',
|
||||
'terminal.layer.resizeSplit': 'Изменить размер области',
|
||||
'terminal.layer.splitHorizontal': 'Разделить сверху и снизу',
|
||||
'terminal.layer.splitVertical': 'Разделить слева и справа',
|
||||
'terminal.layer.openInNewSplit': 'Открыть в новой области',
|
||||
'terminal.layer.hostTree.search': 'Поиск хостов...',
|
||||
'terminal.layer.hostTree.searchButton': 'Поиск',
|
||||
'terminal.layer.hostTree.tagsButton': 'Фильтр по тегам',
|
||||
@@ -492,6 +515,7 @@ export const ruAiMessages: Messages = {
|
||||
'topTabs.windowOpacity': 'Прозрачность окна',
|
||||
'topTabs.openSettings': 'Открыть настройки',
|
||||
'ai.chat.sessionHistory': 'История сессий',
|
||||
'ai.chat.resizeInput': 'Перетащите, чтобы изменить высоту поля ввода',
|
||||
'ai.chat.attach': 'Прикрепить',
|
||||
'ai.chat.terminalSelectionAttachment': 'Выделение терминала',
|
||||
'ai.chat.terminalSelectionLines': 'строк: {count}',
|
||||
|
||||
@@ -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} мин назад',
|
||||
@@ -697,6 +705,10 @@ export const ruTerminalMessages: Messages = {
|
||||
'snippets.selection.selectVisible': 'Выбрать видимые',
|
||||
'snippets.selection.deselectAll': 'Снять выбор',
|
||||
'snippets.selection.exportSelected': 'Экспорт выбранных ({count})',
|
||||
'snippets.selection.deleteSelected': 'Удалить ({count})',
|
||||
'snippets.selection.deleteConfirmTitle': 'Удалить выбранные элементы ({count})?',
|
||||
'snippets.selection.deleteConfirmDesc': 'Выбранные элементы будут удалены без возможности восстановления.',
|
||||
'snippets.selection.deleteSuccess': 'Удалено выбранных элементов: {count}.',
|
||||
'snippets.export.snippet': 'Экспорт сниппета',
|
||||
'snippets.export.package': 'Экспорт пакета',
|
||||
'snippets.export.toast.empty': 'Нет сниппетов для экспорта.',
|
||||
|
||||
@@ -524,6 +524,9 @@ export const ruVaultMessages: Messages = {
|
||||
'settings.sshDeepLink.title': 'SSH- и Telnet-ссылки',
|
||||
'settings.sshDeepLink.enable': 'Открывать ssh:// и telnet:// ссылки в Netcatty',
|
||||
'settings.sshDeepLink.enableDesc': 'Разрешить Netcatty обрабатывать ssh:// и telnet:// ссылки из браузеров и других приложений.',
|
||||
'settings.explorerContextMenu.title': 'Проводник Windows',
|
||||
'settings.explorerContextMenu.enable': 'Показывать «Open in Netcatty» в контекстном меню папок',
|
||||
'settings.explorerContextMenu.enableDesc': 'Добавляет пункт в меню папок и фона папки, чтобы открыть локальный терминал в этом каталоге. Применяется сразу, переустановка не нужна.',
|
||||
'settings.jmsDeepLink.title': 'Ссылки JumpServer',
|
||||
'settings.jmsDeepLink.enable': 'Обрабатывать ссылки jms://',
|
||||
'settings.jmsDeepLink.enableDesc': 'Зарегистрировать Netcatty как обработчик клиентских ссылок JumpServer jms://. Может конфликтовать с официальным клиентом JumpServer.',
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import en from './en.ts';
|
||||
import ru from './ru.ts';
|
||||
import zhCN from './zh-CN.ts';
|
||||
import zhTW from './zh-TW.ts';
|
||||
|
||||
const KEYS = [
|
||||
'snippets.selection.deleteSelected',
|
||||
'snippets.selection.deleteConfirmTitle',
|
||||
'snippets.selection.deleteConfirmDesc',
|
||||
'snippets.selection.deleteSuccess',
|
||||
] as const;
|
||||
|
||||
test('snippet bulk-delete copy exists in every locale', () => {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, zhCN, zhTW })) {
|
||||
const missing = KEYS.filter((key) => !messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale} is missing snippet bulk-delete copy`);
|
||||
}
|
||||
});
|
||||
|
||||
test('English bulk-delete copy is entity-neutral and grammatical for one item', () => {
|
||||
assert.equal(
|
||||
en['snippets.selection.deleteConfirmTitle'].replace('{count}', '1'),
|
||||
'Delete selected items (1)?',
|
||||
);
|
||||
assert.equal(
|
||||
en['snippets.selection.deleteSuccess'].replace('{count}', '1'),
|
||||
'Deleted selected items: 1.',
|
||||
);
|
||||
});
|
||||
@@ -258,6 +258,24 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.opencode.check': '检查',
|
||||
'ai.opencode.resetPath': '重置',
|
||||
|
||||
// AI Grok Build(应用内托管 Agent,与 External MCP「添加到 Grok」不同)
|
||||
'ai.grok.title': 'Grok Build',
|
||||
'ai.grok.description': 'xAI 的 Grok Build 编程 Agent CLI。安装 Grok CLI,使用 `grok login` 登录或设置 XAI_API_KEY 后,即可作为外部 Agent 选择。',
|
||||
'ai.grok.detecting': '检测中...',
|
||||
'ai.grok.detected': '已检测到',
|
||||
'ai.grok.notFound': '未找到',
|
||||
'ai.grok.path': '路径:',
|
||||
'ai.grok.notFoundHint': '在 PATH 中未找到 grok。请安装 Grok Build CLI 或在下方指定可执行文件路径。',
|
||||
'ai.grok.customPathPlaceholder': '例如 /usr/local/bin/grok',
|
||||
'ai.grok.check': '检查',
|
||||
'ai.grok.resetPath': '重置',
|
||||
'ai.grok.runtime.acp.title': '使用 Grok ACP(agent stdio)',
|
||||
'ai.grok.runtime.acp.default': '默认',
|
||||
'ai.grok.runtime.acp.description':
|
||||
'通过 Agent Client Protocol(grok agent stdio)接入 Grok。在 session/new 注入 Netcatty MCP。关闭后使用原始 headless streaming-json CLI 路径。',
|
||||
'ai.grok.runtime.streamingJson.hint':
|
||||
'当前为 headless streaming-json(grok -p --output-format streaming-json)。MCP 通过项目 .grok/config.toml 注入。',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': '默认 Agent',
|
||||
'ai.defaultAgent.description': '创建新 AI 会话时使用的 Agent',
|
||||
@@ -511,6 +529,11 @@ export const zhCNAiMessages: Messages = {
|
||||
'terminal.layer.movePanelLeft': '面板移至左侧',
|
||||
'terminal.layer.movePanelRight': '面板移至右侧',
|
||||
'terminal.layer.closePanel': '关闭面板',
|
||||
'terminal.layer.closePane': '关闭分屏',
|
||||
'terminal.layer.resizeSplit': '调整分屏大小',
|
||||
'terminal.layer.splitHorizontal': '上下分屏',
|
||||
'terminal.layer.splitVertical': '左右分屏',
|
||||
'terminal.layer.openInNewSplit': '在新分屏中打开',
|
||||
'terminal.layer.hostTree.search': '搜索主机...',
|
||||
'terminal.layer.hostTree.searchButton': '搜索',
|
||||
'terminal.layer.hostTree.tagsButton': '按标签筛选',
|
||||
@@ -547,6 +570,7 @@ export const zhCNAiMessages: Messages = {
|
||||
'topTabs.windowOpacity': '窗口透明度',
|
||||
'topTabs.openSettings': '打开设置',
|
||||
'ai.chat.sessionHistory': '会话历史',
|
||||
'ai.chat.resizeInput': '拖动调整消息输入框高度',
|
||||
'ai.chat.attach': '附件',
|
||||
'ai.chat.terminalSelectionAttachment': '终端选区',
|
||||
'ai.chat.terminalSelectionLines': '{count} 行',
|
||||
|
||||
@@ -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': '打开方式...',
|
||||
@@ -131,6 +132,9 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.sshDeepLink.title': 'SSH 和 Telnet 链接',
|
||||
'settings.sshDeepLink.enable': '用 Netcatty 打开 ssh:// 和 telnet:// 链接',
|
||||
'settings.sshDeepLink.enableDesc': '允许 Netcatty 接管来自浏览器和其他应用的 ssh://、telnet:// 链接。',
|
||||
'settings.explorerContextMenu.title': 'Windows 资源管理器',
|
||||
'settings.explorerContextMenu.enable': '在文件夹右键菜单中显示 “Open in Netcatty”',
|
||||
'settings.explorerContextMenu.enableDesc': '为文件夹和文件夹空白处添加右键项,可直接在该目录打开本地终端。开关立即生效,无需重装。',
|
||||
'settings.jmsDeepLink.title': 'JumpServer 链接',
|
||||
'settings.jmsDeepLink.enable': '处理 jms:// 链接',
|
||||
'settings.jmsDeepLink.enableDesc': '将 Netcatty 注册为 JumpServer jms:// 客户端链接的处理程序,可能与官方 JumpServer 客户端冲突。',
|
||||
@@ -307,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 时发送配置的文本,而不是普通回车。',
|
||||
@@ -407,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': '刷新间隔',
|
||||
@@ -761,6 +772,10 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'snippets.selection.selectVisible': '选择当前显示',
|
||||
'snippets.selection.deselectAll': '取消选择',
|
||||
'snippets.selection.exportSelected': '导出选中({count})',
|
||||
'snippets.selection.deleteSelected': '删除({count})',
|
||||
'snippets.selection.deleteConfirmTitle': '删除选中的 {count} 项?',
|
||||
'snippets.selection.deleteConfirmDesc': '所选项目将被永久删除,此操作无法撤销。',
|
||||
'snippets.selection.deleteSuccess': '已删除 {count} 个所选项目。',
|
||||
'snippets.export.snippet': '导出代码片段',
|
||||
'snippets.export.package': '导出脚本包',
|
||||
'snippets.export.toast.empty': '没有可导出的代码片段。',
|
||||
|
||||
@@ -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} 分钟前',
|
||||
|
||||
@@ -258,6 +258,24 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.opencode.check': '檢查',
|
||||
'ai.opencode.resetPath': '重置',
|
||||
|
||||
// AI Grok Build(應用內託管 Agent,與 External MCP「新增到 Grok」不同)
|
||||
'ai.grok.title': 'Grok Build',
|
||||
'ai.grok.description': 'xAI 的 Grok Build 程式設計 Agent CLI。安裝 Grok CLI,使用 `grok login` 登入或設定 XAI_API_KEY 後,即可作為外部 Agent 選擇。',
|
||||
'ai.grok.detecting': '偵測中...',
|
||||
'ai.grok.detected': '已偵測到',
|
||||
'ai.grok.notFound': '未找到',
|
||||
'ai.grok.path': '路徑:',
|
||||
'ai.grok.notFoundHint': '在 PATH 中未找到 grok。請安裝 Grok Build CLI 或在下方指定執行檔路徑。',
|
||||
'ai.grok.customPathPlaceholder': '例如 /usr/local/bin/grok',
|
||||
'ai.grok.check': '檢查',
|
||||
'ai.grok.resetPath': '重置',
|
||||
'ai.grok.runtime.acp.title': '使用 Grok ACP(agent stdio)',
|
||||
'ai.grok.runtime.acp.default': '預設',
|
||||
'ai.grok.runtime.acp.description':
|
||||
'透過 Agent Client Protocol(grok agent stdio)接入 Grok。在 session/new 注入 Netcatty MCP。關閉後使用原始 headless streaming-json CLI 路徑。',
|
||||
'ai.grok.runtime.streamingJson.hint':
|
||||
'目前為 headless streaming-json(grok -p --output-format streaming-json)。MCP 透過專案 .grok/config.toml 注入。',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': '預設 Agent',
|
||||
'ai.defaultAgent.description': '建立新 AI 工作階段時使用的 Agent',
|
||||
@@ -510,6 +528,11 @@ export const zhTWAiMessages: Messages = {
|
||||
'terminal.layer.movePanelLeft': '面板移至左側',
|
||||
'terminal.layer.movePanelRight': '面板移至右側',
|
||||
'terminal.layer.closePanel': '關閉面板',
|
||||
'terminal.layer.closePane': '關閉分割',
|
||||
'terminal.layer.resizeSplit': '調整分割大小',
|
||||
'terminal.layer.splitHorizontal': '上下分割',
|
||||
'terminal.layer.splitVertical': '左右分割',
|
||||
'terminal.layer.openInNewSplit': '在新分割中開啟',
|
||||
'terminal.layer.hostTree.search': '搜尋主機...',
|
||||
'terminal.layer.hostTree.searchButton': '搜尋',
|
||||
'terminal.layer.hostTree.tagsButton': '按標籤篩選',
|
||||
@@ -546,6 +569,7 @@ export const zhTWAiMessages: Messages = {
|
||||
'topTabs.windowOpacity': '視窗透明度',
|
||||
'topTabs.openSettings': '開啟設定',
|
||||
'ai.chat.sessionHistory': '工作階段歷史',
|
||||
'ai.chat.resizeInput': '拖曳調整訊息輸入框高度',
|
||||
'ai.chat.attach': '附件',
|
||||
'ai.chat.terminalSelectionAttachment': '終端選區',
|
||||
'ai.chat.terminalSelectionLines': '{count} 行',
|
||||
|
||||
@@ -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': '開啟方式...',
|
||||
@@ -131,6 +132,9 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.sshDeepLink.title': 'SSH 和 Telnet 連結',
|
||||
'settings.sshDeepLink.enable': '用 Netcatty 開啟 ssh:// 和 telnet:// 連結',
|
||||
'settings.sshDeepLink.enableDesc': '允許 Netcatty 接管來自瀏覽器和其他應用程式的 ssh://、telnet:// 連結。',
|
||||
'settings.explorerContextMenu.title': 'Windows 檔案總管',
|
||||
'settings.explorerContextMenu.enable': '在資料夾右鍵選單中顯示 “Open in Netcatty”',
|
||||
'settings.explorerContextMenu.enableDesc': '為資料夾與資料夾空白處加入右鍵項目,可直接在該目錄開啟本機終端機。開關立即生效,無需重裝。',
|
||||
'settings.jmsDeepLink.title': 'JumpServer 連結',
|
||||
'settings.jmsDeepLink.enable': '處理 jms:// 連結',
|
||||
'settings.jmsDeepLink.enableDesc': '將 Netcatty 註冊為 JumpServer jms:// 用戶端連結的處理程式,可能與官方 JumpServer 用戶端衝突。',
|
||||
@@ -307,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。',
|
||||
@@ -407,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': '重新整理間隔',
|
||||
@@ -761,6 +772,10 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'snippets.selection.selectVisible': '選擇目前顯示',
|
||||
'snippets.selection.deselectAll': '取消選擇',
|
||||
'snippets.selection.exportSelected': '匯出選取({count})',
|
||||
'snippets.selection.deleteSelected': '刪除({count})',
|
||||
'snippets.selection.deleteConfirmTitle': '刪除選取的 {count} 項?',
|
||||
'snippets.selection.deleteConfirmDesc': '所選項目將被永久刪除,此操作無法復原。',
|
||||
'snippets.selection.deleteSuccess': '已刪除 {count} 個所選項目。',
|
||||
'snippets.export.snippet': '匯出程式碼片段',
|
||||
'snippets.export.package': '匯出程式碼包',
|
||||
'snippets.export.toast.empty': '沒有可匯出的程式碼片段。',
|
||||
|
||||
@@ -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 }>) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
|
||||
STORAGE_KEY_SSH_DEEP_LINK_ENABLED,
|
||||
STORAGE_KEY_JMS_DEEP_LINK_ENABLED,
|
||||
STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
|
||||
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
|
||||
@@ -72,7 +73,7 @@ interface UseSettingsIpcSyncParams {
|
||||
setTerminalThemeLightId: Dispatch<SetStateAction<string>>;
|
||||
setFollowAppTerminalThemeState: Dispatch<SetStateAction<boolean>>;
|
||||
setTerminalFontFamilyId: Dispatch<SetStateAction<string>>;
|
||||
setTerminalFontSize: Dispatch<SetStateAction<number>>;
|
||||
setTerminalFontSize: (raw: unknown) => void;
|
||||
mergeIncomingTerminalSettings: (incoming: Partial<TerminalSettings>) => void;
|
||||
setEditorWordWrapState: Dispatch<SetStateAction<boolean>>;
|
||||
setSessionLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -82,6 +83,7 @@ interface UseSettingsIpcSyncParams {
|
||||
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSshDeepLinkEnabledState: (enabled: boolean) => void;
|
||||
setJmsDeepLinkEnabledState: (enabled: boolean) => void;
|
||||
setExplorerContextMenuEnabledState: (enabled: boolean) => void;
|
||||
setHotkeyScheme: Dispatch<SetStateAction<HotkeyScheme>>;
|
||||
applyIncomingCustomKeyBindings: (incoming: { bindings: CustomKeyBindings; version: number; origin: string }) => void;
|
||||
setIsHotkeyRecordingState: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -125,6 +127,7 @@ export function useSettingsIpcSync({
|
||||
setSshDebugLogsEnabled,
|
||||
setSshDeepLinkEnabledState,
|
||||
setJmsDeepLinkEnabledState,
|
||||
setExplorerContextMenuEnabledState,
|
||||
setHotkeyScheme,
|
||||
applyIncomingCustomKeyBindings,
|
||||
setIsHotkeyRecordingState,
|
||||
@@ -193,7 +196,7 @@ export function useSettingsIpcSync({
|
||||
const migrated = migrateIncomingTerminalFontId(value);
|
||||
if (migrated) setTerminalFontFamilyId(migrated);
|
||||
}
|
||||
if (key === STORAGE_KEY_TERM_FONT_SIZE && typeof value === 'number') {
|
||||
if (key === STORAGE_KEY_TERM_FONT_SIZE) {
|
||||
setTerminalFontSize(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_TERM_SETTINGS) {
|
||||
@@ -235,6 +238,9 @@ export function useSettingsIpcSync({
|
||||
if (key === STORAGE_KEY_JMS_DEEP_LINK_ENABLED && typeof value === 'boolean') {
|
||||
setJmsDeepLinkEnabledState(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED && typeof value === 'boolean') {
|
||||
setExplorerContextMenuEnabledState(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_HOTKEY_SCHEME && (value === 'disabled' || value === 'mac' || value === 'pc')) {
|
||||
setHotkeyScheme(value);
|
||||
}
|
||||
@@ -331,6 +337,7 @@ export function useSettingsIpcSync({
|
||||
setSessionLogsTimestampsEnabled,
|
||||
setSshDeepLinkEnabledState,
|
||||
setJmsDeepLinkEnabledState,
|
||||
setExplorerContextMenuEnabledState,
|
||||
setSshDebugLogsEnabled,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
|
||||
@@ -95,6 +95,8 @@ export const DEFAULT_SESSION_LOGS_TIMESTAMPS_ENABLED = false;
|
||||
export const DEFAULT_SSH_DEBUG_LOGS_ENABLED = false;
|
||||
export const DEFAULT_SSH_DEEP_LINK_ENABLED = true;
|
||||
export const DEFAULT_JMS_DEEP_LINK_ENABLED = false;
|
||||
/** Windows Explorer context menu; true matches NSIS installer default. */
|
||||
export const DEFAULT_EXPLORER_CONTEXT_MENU_ENABLED = true;
|
||||
|
||||
export const readStoredString = (key: string): string | null => {
|
||||
const raw = localStorageAdapter.readString(key);
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
|
||||
STORAGE_KEY_SSH_DEEP_LINK_ENABLED,
|
||||
STORAGE_KEY_JMS_DEEP_LINK_ENABLED,
|
||||
STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED,
|
||||
STORAGE_KEY_RESTORE_PREVIOUS_SESSION,
|
||||
STORAGE_KEY_RESTORE_TERMINAL_CWD,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
@@ -65,6 +66,7 @@ interface UseSettingsStorageSyncParams {
|
||||
darkUiThemeId: string;
|
||||
accentMode: 'theme' | 'custom';
|
||||
customAccent: string;
|
||||
customAccentVersion: number;
|
||||
customCSS: string;
|
||||
uiFontFamilyId: string;
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
@@ -99,6 +101,7 @@ interface UseSettingsStorageSyncParams {
|
||||
sshDebugLogsEnabled: boolean;
|
||||
sshDeepLinkEnabled: boolean;
|
||||
jmsDeepLinkEnabled: boolean;
|
||||
explorerContextMenuEnabled: boolean;
|
||||
globalHotkeyEnabled: boolean;
|
||||
autoUpdateEnabled: boolean;
|
||||
windowOpacity: number;
|
||||
@@ -107,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>>;
|
||||
@@ -117,7 +120,7 @@ interface UseSettingsStorageSyncParams {
|
||||
setTerminalThemeLightId: Dispatch<SetStateAction<string>>;
|
||||
setFollowAppTerminalThemeState: Dispatch<SetStateAction<boolean>>;
|
||||
setTerminalFontFamilyId: Dispatch<SetStateAction<string>>;
|
||||
setTerminalFontSize: Dispatch<SetStateAction<number>>;
|
||||
setTerminalFontSize: (raw: unknown) => void;
|
||||
setSftpDoubleClickBehavior: Dispatch<SetStateAction<'open' | 'transfer'>>;
|
||||
setSftpAutoSync: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpShowHiddenFiles: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -144,6 +147,7 @@ interface UseSettingsStorageSyncParams {
|
||||
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSshDeepLinkEnabledState: (enabled: boolean) => void;
|
||||
setJmsDeepLinkEnabledState: (enabled: boolean) => void;
|
||||
setExplorerContextMenuEnabledState: (enabled: boolean) => void;
|
||||
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setWindowOpacity: (raw: unknown) => void;
|
||||
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
|
||||
@@ -157,22 +161,22 @@ interface UseSettingsStorageSyncParams {
|
||||
|
||||
export function useSettingsStorageSync({
|
||||
enabled = true,
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentVersion,
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent,
|
||||
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
|
||||
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
|
||||
applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
@@ -181,23 +185,23 @@ export function useSettingsStorageSync({
|
||||
// can compare without capturing 25+ state variables in its closure / dep array.
|
||||
// This avoids constant listener detach/reattach on every state change.
|
||||
const settingsSnapshotRef = useRef({
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentVersion,
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
});
|
||||
settingsSnapshotRef.current = {
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentVersion,
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
};
|
||||
|
||||
@@ -212,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) {
|
||||
@@ -283,10 +295,7 @@ export function useSettingsStorageSync({
|
||||
}
|
||||
// Sync terminal font size from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_FONT_SIZE && e.newValue) {
|
||||
const newSize = parseInt(e.newValue, 10);
|
||||
if (!isNaN(newSize) && newSize !== s.terminalFontSize) {
|
||||
setTerminalFontSize(newSize);
|
||||
}
|
||||
setTerminalFontSize(e.newValue);
|
||||
}
|
||||
// Sync SFTP double-click behavior from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR && e.newValue) {
|
||||
@@ -357,6 +366,12 @@ export function useSettingsStorageSync({
|
||||
setJmsDeepLinkEnabledState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.explorerContextMenuEnabled) {
|
||||
setExplorerContextMenuEnabledState(newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP compressed upload setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true' || e.newValue === 'enabled';
|
||||
@@ -499,7 +514,7 @@ export function useSettingsStorageSync({
|
||||
mergeIncomingTerminalSettings,
|
||||
setAccentMode,
|
||||
setAutoUpdateEnabled,
|
||||
setCustomAccent,
|
||||
applyIncomingCustomAccent,
|
||||
setCustomCSS,
|
||||
setDarkUiThemeId,
|
||||
setEditorWordWrapState,
|
||||
@@ -516,6 +531,7 @@ export function useSettingsStorageSync({
|
||||
setSessionLogsTimestampsEnabled,
|
||||
setSshDeepLinkEnabledState,
|
||||
setJmsDeepLinkEnabledState,
|
||||
setExplorerContextMenuEnabledState,
|
||||
setSshDebugLogsEnabled,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -664,10 +664,13 @@ async function resumeSingleFileWithDedicatedSession(
|
||||
: null;
|
||||
if (!sourceStat) throw new Error("Source is unavailable");
|
||||
{
|
||||
// Remote download sources may grow append-only (live logs); keep the
|
||||
// planned snapshot size and existing checkpoint instead of restarting.
|
||||
const allowSourceGrowth = sourceType === "sftp";
|
||||
const validationError = validateTransferResumeSource(task, {
|
||||
size: sourceStat.size,
|
||||
lastModified: sourceStat.lastModified,
|
||||
});
|
||||
}, { allowSourceGrowth });
|
||||
const classified = classifyResumeSourceValidationError(validationError);
|
||||
if (classified.kind === "modified") {
|
||||
const err = new Error(classified.message || validationError || "Source modified");
|
||||
@@ -693,7 +696,9 @@ async function resumeSingleFileWithDedicatedSession(
|
||||
targetSftpId,
|
||||
sourceHostId: endpoints.sourceHost?.id,
|
||||
targetHostId: endpoints.targetHost?.id,
|
||||
totalBytes: task.totalBytes || undefined,
|
||||
totalBytes: Number.isFinite(task.totalBytes)
|
||||
? task.totalBytes
|
||||
: undefined,
|
||||
resumable: task.resumable !== false,
|
||||
checkpointBytes: task.checkpointBytes ?? task.transferredBytes ?? 0,
|
||||
resumeStage: task.resumeStage,
|
||||
@@ -1032,6 +1037,9 @@ async function resumeDirectoryWithDedicatedSession(
|
||||
const planned = traversal.files;
|
||||
totalFiles = planned.length;
|
||||
const hasCompactCheckpoint = isValidDirectoryResumeCheckpoint(parent.directoryResumeCheckpoint);
|
||||
// Compact identities keep size+mtime so a rewrite/truncate fails closed.
|
||||
// Append-only growth of an already-covered remote file invalidates the
|
||||
// prefix and retransfers those entries — safer than path-only proofs.
|
||||
const compactCheckpointValid = hasCompactCheckpoint
|
||||
&& await validateDirectoryResumeCheckpoint(parent, planned);
|
||||
if (
|
||||
@@ -1110,6 +1118,15 @@ async function resumeDirectoryWithDedicatedSession(
|
||||
const childId = persisted?.id ?? crypto.randomUUID();
|
||||
const resetPersistedCheckpoint = resetReplaceStage
|
||||
|| (hasCompactCheckpoint && !compactCheckpointValid);
|
||||
// Prefer the original planned snapshot size for interrupted download
|
||||
// children. Fresh traversal `file.size` may already include appends
|
||||
// (live logs); re-planning to the grown size breaks growth-aware
|
||||
// resume validation and restarts the file incorrectly.
|
||||
const plannedTotalBytes = !resetPersistedCheckpoint
|
||||
&& endpoints.isDownload
|
||||
&& Number.isFinite(persisted?.totalBytes)
|
||||
? Math.max(0, Number(persisted?.totalBytes) || 0)
|
||||
: (file.size || persisted?.totalBytes || 0);
|
||||
let childBase: TransferTask = {
|
||||
...parent,
|
||||
...persisted,
|
||||
@@ -1123,7 +1140,7 @@ async function resumeDirectoryWithDedicatedSession(
|
||||
progressMode: "bytes",
|
||||
ownerId: "dedicated-resume",
|
||||
status: "transferring",
|
||||
totalBytes: file.size || persisted?.totalBytes || 0,
|
||||
totalBytes: plannedTotalBytes,
|
||||
transferredBytes: resetPersistedCheckpoint
|
||||
? 0
|
||||
: (persisted?.checkpointBytes ?? persisted?.transferredBytes ?? 0),
|
||||
@@ -1168,10 +1185,11 @@ async function resumeDirectoryWithDedicatedSession(
|
||||
if (!sourceStat) {
|
||||
throw new Error("Source is unavailable");
|
||||
}
|
||||
const allowSourceGrowth = sourceType === "sftp";
|
||||
const validationError = validateTransferResumeSource(childBase, {
|
||||
size: sourceStat.size,
|
||||
lastModified: sourceStat.lastModified,
|
||||
});
|
||||
}, { allowSourceGrowth });
|
||||
const classified = classifyResumeSourceValidationError(validationError);
|
||||
if (classified.kind === "restart") {
|
||||
childBase = {
|
||||
@@ -1196,10 +1214,21 @@ async function resumeDirectoryWithDedicatedSession(
|
||||
} else if (classified.kind === "fatal") {
|
||||
throw new Error(classified.message || validationError || "Resume validation failed");
|
||||
} else {
|
||||
// Keep the planned snapshot size for growing remote downloads so
|
||||
// we do not re-plan the whole file mid-resume. Preserve explicit
|
||||
// zero-byte plans (`||` would incorrectly promote to grown size).
|
||||
const plannedBytes = Number(childBase.totalBytes);
|
||||
const hasPlannedBytes = Number.isFinite(plannedBytes) && plannedBytes >= 0;
|
||||
childBase = {
|
||||
...childBase,
|
||||
totalBytes: sourceStat.size || childBase.totalBytes,
|
||||
sourceLastModified: sourceStat.lastModified ?? childBase.sourceLastModified,
|
||||
totalBytes: allowSourceGrowth
|
||||
? (hasPlannedBytes ? plannedBytes : sourceStat.size)
|
||||
: (sourceStat.size || childBase.totalBytes),
|
||||
sourceLastModified: allowSourceGrowth
|
||||
&& hasPlannedBytes
|
||||
&& sourceStat.size > plannedBytes
|
||||
? (childBase.sourceLastModified ?? sourceStat.lastModified)
|
||||
: (sourceStat.lastModified ?? childBase.sourceLastModified),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1217,7 +1246,9 @@ async function resumeDirectoryWithDedicatedSession(
|
||||
targetSftpId,
|
||||
sourceHostId: endpoints.sourceHost?.id,
|
||||
targetHostId: endpoints.targetHost?.id,
|
||||
totalBytes: childBase.totalBytes || file.size || undefined,
|
||||
totalBytes: Number.isFinite(childBase.totalBytes)
|
||||
? childBase.totalBytes
|
||||
: (file.size || undefined),
|
||||
resumable: parent.resumable !== false,
|
||||
checkpointBytes: childBase.checkpointBytes ?? 0,
|
||||
resumeStage: childBase.resumeStage,
|
||||
@@ -1235,7 +1266,9 @@ async function resumeDirectoryWithDedicatedSession(
|
||||
options?.onChildUpdate?.({
|
||||
...childBase,
|
||||
status: "completed",
|
||||
transferredBytes: childBase.totalBytes || file.size || childBase.transferredBytes,
|
||||
transferredBytes: Number.isFinite(childBase.totalBytes)
|
||||
? childBase.totalBytes
|
||||
: (file.size || childBase.transferredBytes),
|
||||
speed: 0,
|
||||
endTime: Date.now(),
|
||||
error: undefined,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createExternalEditTempRetention } from "./externalEditTempRetention";
|
||||
|
||||
test("remembers distinct temps per sftp session and forgets one session without touching others", () => {
|
||||
const retention = createExternalEditTempRetention();
|
||||
|
||||
assert.equal(retention.remember("sftp-a", "/tmp/a.txt"), true);
|
||||
assert.equal(retention.remember("sftp-a", "/tmp/a.txt"), false);
|
||||
assert.equal(retention.remember("sftp-b", "/tmp/b.txt"), true);
|
||||
assert.equal(retention.size, 2);
|
||||
|
||||
assert.equal(retention.forgetSftp("sftp-a"), true);
|
||||
assert.equal(retention.size, 1);
|
||||
assert.equal(retention.forgetSftp("sftp-a"), false);
|
||||
assert.equal(retention.forgetPath("/tmp/b.txt"), true);
|
||||
assert.equal(retention.size, 0);
|
||||
});
|
||||
|
||||
test("clear drops every retainer after session-wide cleanup", () => {
|
||||
const retention = createExternalEditTempRetention();
|
||||
retention.remember("sftp-1", "/tmp/one.txt");
|
||||
retention.remember("sftp-2", "/tmp/two.txt");
|
||||
|
||||
assert.equal(retention.clear(), true);
|
||||
assert.equal(retention.size, 0);
|
||||
assert.equal(retention.clear(), false);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Renderer-side retainers for remote temps opened in external editors.
|
||||
* closeSftp deletes those files; forget matching retainers so park/auto-connect
|
||||
* checks do not keep treating a deleted temp as active work.
|
||||
*/
|
||||
export type ExternalEditTempRetention = {
|
||||
remember(sftpId: string, localPath: string): boolean;
|
||||
forgetPath(localPath: string): boolean;
|
||||
forgetSftp(sftpId: string): boolean;
|
||||
clear(): boolean;
|
||||
readonly size: number;
|
||||
};
|
||||
|
||||
export function createExternalEditTempRetention(): ExternalEditTempRetention {
|
||||
const bySftp = new Map<string, Set<string>>();
|
||||
|
||||
const recount = (): number => {
|
||||
let total = 0;
|
||||
for (const paths of bySftp.values()) total += paths.size;
|
||||
return total;
|
||||
};
|
||||
|
||||
return {
|
||||
remember(sftpId, localPath) {
|
||||
if (!sftpId || !localPath) return false;
|
||||
let paths = bySftp.get(sftpId);
|
||||
if (!paths) {
|
||||
paths = new Set();
|
||||
bySftp.set(sftpId, paths);
|
||||
}
|
||||
if (paths.has(localPath)) return false;
|
||||
paths.add(localPath);
|
||||
return true;
|
||||
},
|
||||
forgetPath(localPath) {
|
||||
if (!localPath) return false;
|
||||
let changed = false;
|
||||
for (const [sftpId, paths] of bySftp) {
|
||||
if (!paths.delete(localPath)) continue;
|
||||
changed = true;
|
||||
if (paths.size === 0) bySftp.delete(sftpId);
|
||||
}
|
||||
return changed;
|
||||
},
|
||||
forgetSftp(sftpId) {
|
||||
if (!sftpId || !bySftp.has(sftpId)) return false;
|
||||
bySftp.delete(sftpId);
|
||||
return true;
|
||||
},
|
||||
clear() {
|
||||
if (bySftp.size === 0) return false;
|
||||
bySftp.clear();
|
||||
return true;
|
||||
},
|
||||
get size() {
|
||||
return recount();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -138,9 +138,13 @@ test("release invalidates pending watch starts while a new generation remains us
|
||||
|
||||
test("backend session cleanup removes a stopped watch from renderer ownership", async () => {
|
||||
const stopped: Array<{ watchId: string; cleanupTempFile: boolean }> = [];
|
||||
let onBackendStopped: ((payload: { watchId: string }) => void) | null = null;
|
||||
let onBackendStopped: ((payload: {
|
||||
watchId: string;
|
||||
localPath?: string;
|
||||
}) => void) | null = null;
|
||||
let lifecycle: ExternalFileWatchLifecycle | null = null;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const forgottenPaths: string[] = [];
|
||||
|
||||
function Probe() {
|
||||
lifecycle = useExternalFileWatchLifecycle(
|
||||
@@ -151,6 +155,7 @@ test("backend session cleanup removes a stopped watch from renderer ownership",
|
||||
onBackendStopped = callback;
|
||||
return () => { onBackendStopped = null; };
|
||||
},
|
||||
(localPath) => { forgottenPaths.push(localPath); },
|
||||
);
|
||||
return null;
|
||||
}
|
||||
@@ -162,9 +167,13 @@ test("backend session cleanup removes a stopped watch from renderer ownership",
|
||||
assert.equal(lifecycle!.activeCountRef.current, 1);
|
||||
|
||||
await act(async () => {
|
||||
onBackendStopped?.({ watchId: "watch-closed-with-session" });
|
||||
onBackendStopped?.({
|
||||
watchId: "watch-closed-with-session",
|
||||
localPath: "/tmp/externally-deleted.txt",
|
||||
});
|
||||
});
|
||||
assert.equal(lifecycle!.activeCountRef.current, 0);
|
||||
assert.deepEqual(forgottenPaths, ["/tmp/externally-deleted.txt"]);
|
||||
|
||||
await act(async () => renderer!.unmount());
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
@@ -19,7 +19,12 @@ export type StopExternalFileWatch = (
|
||||
) => void | Promise<unknown>;
|
||||
|
||||
export type SubscribeExternalFileWatchStopped = (
|
||||
callback: (payload: { watchId: string }) => void,
|
||||
callback: (payload: {
|
||||
watchId: string;
|
||||
localPath?: string;
|
||||
remotePath?: string;
|
||||
sftpId?: string;
|
||||
}) => void,
|
||||
) => (() => void) | void;
|
||||
|
||||
export interface ExternalFileWatchLifecycle {
|
||||
@@ -32,16 +37,19 @@ export interface ExternalFileWatchLifecycle {
|
||||
export function useExternalFileWatchLifecycle(
|
||||
stopWatch: StopExternalFileWatch,
|
||||
subscribeStopped?: SubscribeExternalFileWatchStopped,
|
||||
onStoppedLocalPath?: (localPath: string) => void,
|
||||
): ExternalFileWatchLifecycle {
|
||||
const watchIdsRef = useRef<Set<string>>(new Set());
|
||||
const activeCountRef = useRef(0);
|
||||
const stopWatchRef = useRef(stopWatch);
|
||||
const subscribeStoppedRef = useRef(subscribeStopped);
|
||||
const onStoppedLocalPathRef = useRef(onStoppedLocalPath);
|
||||
const disposedRef = useRef(false);
|
||||
const generationRef = useRef(0);
|
||||
const invalidatedCleanupTempFilesRef = useRef(false);
|
||||
stopWatchRef.current = stopWatch;
|
||||
subscribeStoppedRef.current = subscribeStopped;
|
||||
onStoppedLocalPathRef.current = onStoppedLocalPath;
|
||||
|
||||
const captureGeneration = useCallback(() => generationRef.current, []);
|
||||
|
||||
@@ -86,9 +94,10 @@ export function useExternalFileWatchLifecycle(
|
||||
};
|
||||
}, [releaseAll]);
|
||||
|
||||
useEffect(() => subscribeStoppedRef.current?.(({ watchId }) => {
|
||||
useEffect(() => subscribeStoppedRef.current?.(({ watchId, localPath }) => {
|
||||
if (!watchId || !watchIdsRef.current.delete(watchId)) return;
|
||||
activeCountRef.current = watchIdsRef.current.size;
|
||||
if (localPath) onStoppedLocalPathRef.current?.(localPath);
|
||||
}), []);
|
||||
|
||||
return { activeCountRef, captureGeneration, remember, releaseAll };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -568,6 +580,10 @@ export const useSftpConnections = ({
|
||||
sharedHostCache?.path,
|
||||
);
|
||||
|
||||
// Sudo never reuses a terminal shell; normalize before UI state so
|
||||
// reusedConnection (spinner/overlay) matches the actual open path.
|
||||
const sourceSessionId = host.sftpSudo ? undefined : options?.sourceSessionId;
|
||||
|
||||
const connection: SftpConnection = {
|
||||
id: connectionId,
|
||||
hostId: host.id,
|
||||
@@ -579,7 +595,7 @@ export const useSftpConnections = ({
|
||||
// If the backend falls back to a fresh connection, the pane stays
|
||||
// non-interactive (loading=true) with stale cached files visible —
|
||||
// no worse than the previous UX of always showing a spinner.
|
||||
reusedConnection: !!options?.sourceSessionId,
|
||||
reusedConnection: !!sourceSessionId,
|
||||
fileProtocol: host.sftpFileProtocol ?? 'auto',
|
||||
};
|
||||
|
||||
@@ -652,7 +668,7 @@ export const useSftpConnections = ({
|
||||
// immediately repeated after a shared-channel attempt fails.
|
||||
const sftpId = await openSftpConnectionOnce({
|
||||
bridge,
|
||||
sourceSessionId: !host.sftpSudo ? options?.sourceSessionId : undefined,
|
||||
sourceSessionId,
|
||||
openOptions: {
|
||||
sessionId: sftpSessionId,
|
||||
...credentials,
|
||||
@@ -962,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",
|
||||
|
||||
@@ -178,10 +178,12 @@ export const useSftpTransfers = ({
|
||||
const surfaceVisibleRef = useRef(surfaceVisible);
|
||||
surfaceVisibleRef.current = surfaceVisible;
|
||||
const [transfers, setTransfersState] = useState<TransferTask[]>(() => sftpTransferCenterStore.getOwnerTasks(ownerId));
|
||||
// Only re-surface live attention conflicts. Terminal rows may still carry a
|
||||
// stale conflict payload after older Skip paths — those must not reopen the dialog.
|
||||
const [conflicts, setConflicts] = useState<FileConflict[]>(() => sftpTransferCenterStore
|
||||
.getOwnerTasks(ownerId)
|
||||
.map((task) => task.conflict)
|
||||
.filter((conflict): conflict is FileConflict => !!conflict));
|
||||
.filter((task) => task.status === "attention" && !!task.conflict)
|
||||
.map((task) => task.conflict as FileConflict));
|
||||
|
||||
// Track cancelled task IDs. has() also sees process-global cancel flags set by
|
||||
// the transfer center after the React owner unmounts.
|
||||
@@ -1393,8 +1395,20 @@ export const useSftpTransfers = ({
|
||||
|
||||
const task = transfersRef.current.find((t) => t.id === conflictId);
|
||||
if (!task) {
|
||||
// Dialog can show a store-owned conflict before the panel adopts the row.
|
||||
// Clearing only React state leaves attention+conflict in localStorage and
|
||||
// the prompt returns on the next SFTP open (issue #2636).
|
||||
conflictsRef.current = conflictsRef.current.filter((c) => c.transferId !== conflictId);
|
||||
setConflicts(conflictsRef.current);
|
||||
if (action === "stop" || action === "skip") {
|
||||
sftpTransferCenterStore.patchTask(conflictId, {
|
||||
status: "cancelled",
|
||||
conflict: undefined,
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
error: undefined,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1436,9 +1450,18 @@ export const useSftpTransfers = ({
|
||||
for (const affectedTask of affectedTasks) {
|
||||
cancelledTasksRef.current.add(affectedTask.id);
|
||||
}
|
||||
// Drop conflict so remount / restart cannot rehydrate the dialog from a
|
||||
// cancelled row that still carries conflict metadata.
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => affectedConflictIds.has(t.id)
|
||||
? { ...t, status: "cancelled" as TransferStatus, endTime: Date.now() }
|
||||
? {
|
||||
...t,
|
||||
status: "cancelled" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
conflict: undefined,
|
||||
error: undefined,
|
||||
speed: 0,
|
||||
}
|
||||
: t,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
createLocalTerminalFontSizeRecord,
|
||||
parseTerminalFontSizeRecord,
|
||||
resolveAuthoritativeTerminalFontSizeStorage,
|
||||
resolveIncomingTerminalFontSize,
|
||||
resolveTerminalFontSizeStorage,
|
||||
serializeTerminalFontSizeRecord,
|
||||
shouldApplyTerminalFontSizeRecord,
|
||||
shouldBroadcastTerminalFontSizeChange,
|
||||
type TerminalFontSizeMutationSource,
|
||||
type TerminalFontSizeRecord,
|
||||
} from './terminalFontSizeSync.ts';
|
||||
|
||||
/**
|
||||
* Minimal model of the settings ↔ main font-size sync loop that caused #2689.
|
||||
* Models both IPC rebroadcast and stale localStorage overwrites.
|
||||
*/
|
||||
function simulateFontSizeClicks(options: {
|
||||
shouldBroadcast: (
|
||||
source: TerminalFontSizeMutationSource,
|
||||
persistMounted: boolean,
|
||||
) => { shouldBroadcast: boolean; nextSource: TerminalFontSizeMutationSource };
|
||||
shouldApply: (current: TerminalFontSizeRecord, incoming: TerminalFontSizeRecord) => boolean;
|
||||
versioned: boolean;
|
||||
}): { settingsValues: number[]; mainValues: number[]; storageWrites: string[] } {
|
||||
let settings: TerminalFontSizeRecord = { fontSize: 16, version: 0, origin: 'legacy' };
|
||||
let main: TerminalFontSizeRecord = { fontSize: 16, version: 0, origin: 'legacy' };
|
||||
let settingsSource: TerminalFontSizeMutationSource = 'local';
|
||||
let mainSource: TerminalFontSizeMutationSource = 'local';
|
||||
let storage = options.versioned
|
||||
? serializeTerminalFontSizeRecord(settings)
|
||||
: String(settings.fontSize);
|
||||
const settingsValues: number[] = [];
|
||||
const mainValues: number[] = [];
|
||||
const storageWrites: string[] = [];
|
||||
|
||||
const pendingIpc: Array<{ to: 'settings' | 'main'; record: TerminalFontSizeRecord }> = [];
|
||||
|
||||
const writeStorage = (record: TerminalFontSizeRecord) => {
|
||||
const next = options.versioned
|
||||
? serializeTerminalFontSizeRecord(record)
|
||||
: String(record.fontSize);
|
||||
if (next === storage) return;
|
||||
storage = next;
|
||||
storageWrites.push(next);
|
||||
};
|
||||
|
||||
const applyLocal = (window: 'settings' | 'main', fontSize: number) => {
|
||||
const bump = (
|
||||
prev: TerminalFontSizeRecord,
|
||||
origin: string,
|
||||
): TerminalFontSizeRecord => (
|
||||
options.versioned
|
||||
? { fontSize, version: prev.version + 1, origin }
|
||||
: { fontSize, version: 0, origin: 'legacy' }
|
||||
);
|
||||
|
||||
if (window === 'settings') {
|
||||
settingsSource = 'local';
|
||||
settings = bump(settings, 'settings-window');
|
||||
settingsValues.push(settings.fontSize);
|
||||
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, 'main-window');
|
||||
mainValues.push(main.fontSize);
|
||||
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: TerminalFontSizeRecord) => {
|
||||
if (window === 'settings') {
|
||||
if (!options.shouldApply(settings, record)) return;
|
||||
settingsSource = 'incoming';
|
||||
settings = { ...record };
|
||||
settingsValues.push(settings.fontSize);
|
||||
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.fontSize);
|
||||
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 = parseTerminalFontSizeRecord(storage);
|
||||
applyIncoming(window, record);
|
||||
};
|
||||
|
||||
// Match reporter steps: + (16→17) then - while a delayed peer echo of 17
|
||||
// can still race with a later local 15.
|
||||
applyLocal('settings', 17);
|
||||
deliverStorageTo('main');
|
||||
applyLocal('settings', 15);
|
||||
const delayed = pendingIpc.shift();
|
||||
flushIpc();
|
||||
if (delayed) applyIncoming(delayed.to, delayed.record);
|
||||
deliverStorageTo('settings');
|
||||
flushIpc();
|
||||
|
||||
return { settingsValues, mainValues, storageWrites };
|
||||
}
|
||||
|
||||
test('parseTerminalFontSizeRecord accepts legacy plain numbers and versioned records', () => {
|
||||
assert.deepEqual(
|
||||
parseTerminalFontSizeRecord('16'),
|
||||
{ fontSize: 16, version: 0, origin: 'legacy' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseTerminalFontSizeRecord(14),
|
||||
{ fontSize: 14, version: 0, origin: 'legacy' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseTerminalFontSizeRecord({ fontSize: 18, version: 3, origin: 'window-a' }),
|
||||
{ fontSize: 18, version: 3, origin: 'window-a' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseTerminalFontSizeRecord('{"fontSize":15,"version":9,"origin":"window-b"}'),
|
||||
{ fontSize: 15, version: 9, origin: 'window-b' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseTerminalFontSizeRecord('17|12|settings-window'),
|
||||
{ fontSize: 17, version: 12, origin: 'settings-window' },
|
||||
);
|
||||
assert.equal(parseTerminalFontSizeRecord('bad').fontSize, 14);
|
||||
});
|
||||
|
||||
test('shouldApplyTerminalFontSizeRecord ignores stale revisions', () => {
|
||||
const current = { fontSize: 15, version: 2, origin: 'window-a' };
|
||||
assert.equal(
|
||||
shouldApplyTerminalFontSizeRecord(
|
||||
current,
|
||||
{ fontSize: 17, version: 1, origin: 'window-b' },
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldApplyTerminalFontSizeRecord(
|
||||
current,
|
||||
{ fontSize: 18, version: 3, origin: 'window-b' },
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(shouldApplyTerminalFontSizeRecord(current, { ...current }), false);
|
||||
});
|
||||
|
||||
test('simultaneous writers converge through storage in either persistence order', () => {
|
||||
const run = (order: Array<'settings' | 'main'>) => {
|
||||
const initial: TerminalFontSizeRecord = {
|
||||
fontSize: 16,
|
||||
version: 1,
|
||||
origin: 'initial-window',
|
||||
};
|
||||
let storage = serializeTerminalFontSizeRecord(initial);
|
||||
let settings = createLocalTerminalFontSizeRecord(
|
||||
initial,
|
||||
storage,
|
||||
17,
|
||||
'settings-window',
|
||||
10,
|
||||
);
|
||||
let main = createLocalTerminalFontSizeRecord(
|
||||
initial,
|
||||
storage,
|
||||
15,
|
||||
'main-window',
|
||||
10,
|
||||
);
|
||||
|
||||
for (const writer of order) {
|
||||
const current = writer === 'settings' ? settings : main;
|
||||
const resolution = resolveTerminalFontSizeStorage(current, storage);
|
||||
if (resolution.shouldAdopt) {
|
||||
if (writer === 'settings') settings = resolution.record;
|
||||
else main = resolution.record;
|
||||
} else if (resolution.shouldPersist) {
|
||||
storage = resolution.serializedRecord;
|
||||
}
|
||||
}
|
||||
|
||||
const settingsResolution = resolveTerminalFontSizeStorage(settings, storage);
|
||||
const mainResolution = resolveTerminalFontSizeStorage(main, storage);
|
||||
settings = settingsResolution.record;
|
||||
main = mainResolution.record;
|
||||
|
||||
assert.equal(settingsResolution.shouldPersist, false);
|
||||
assert.equal(mainResolution.shouldPersist, false);
|
||||
assert.deepEqual(settings, main);
|
||||
assert.equal(resolveTerminalFontSizeStorage(settings, storage).shouldAdopt, false);
|
||||
assert.equal(resolveTerminalFontSizeStorage(settings, storage).shouldPersist, false);
|
||||
return settings;
|
||||
};
|
||||
|
||||
assert.deepEqual(run(['settings', 'main']), run(['main', 'settings']));
|
||||
});
|
||||
|
||||
test('a stale window creates and persists a revision newer than shared storage', () => {
|
||||
const stored = serializeTerminalFontSizeRecord({
|
||||
fontSize: 18,
|
||||
version: 7,
|
||||
origin: 'main-window',
|
||||
});
|
||||
const local = createLocalTerminalFontSizeRecord(
|
||||
{ fontSize: 15, version: 2, origin: 'settings-window' },
|
||||
stored,
|
||||
16,
|
||||
'settings-window',
|
||||
5,
|
||||
);
|
||||
assert.equal(local.version, 8);
|
||||
const resolution = resolveTerminalFontSizeStorage(local, stored);
|
||||
assert.equal(resolution.shouldAdopt, false);
|
||||
assert.equal(resolution.shouldPersist, true);
|
||||
assert.deepEqual(parseTerminalFontSizeRecord(resolution.serializedRecord), local);
|
||||
});
|
||||
|
||||
test('a rejected losing record repairs shared storage back to the current winner', () => {
|
||||
const winner: TerminalFontSizeRecord = {
|
||||
fontSize: 17,
|
||||
version: 10,
|
||||
origin: 'settings-window',
|
||||
};
|
||||
const loser: TerminalFontSizeRecord = {
|
||||
fontSize: 15,
|
||||
version: 10,
|
||||
origin: 'main-window',
|
||||
};
|
||||
const loserRaw = serializeTerminalFontSizeRecord(loser);
|
||||
|
||||
const rejected = resolveIncomingTerminalFontSize(winner, loser, loserRaw);
|
||||
assert.equal(rejected.shouldUpdate, false);
|
||||
assert.deepEqual(rejected.record, winner);
|
||||
assert.equal(
|
||||
rejected.repairSerializedRecord,
|
||||
serializeTerminalFontSizeRecord(winner),
|
||||
);
|
||||
|
||||
const stable = resolveIncomingTerminalFontSize(
|
||||
winner,
|
||||
loser,
|
||||
rejected.repairSerializedRecord,
|
||||
);
|
||||
assert.equal(stable.shouldUpdate, false);
|
||||
assert.equal(stable.repairSerializedRecord, null);
|
||||
});
|
||||
|
||||
test('a delayed persistence effect arbitrates from the latest authoritative record', () => {
|
||||
const renderedWhenEffectWasScheduled: TerminalFontSizeRecord = {
|
||||
fontSize: 14,
|
||||
version: 9,
|
||||
origin: 'old-window',
|
||||
};
|
||||
const winner: TerminalFontSizeRecord = {
|
||||
fontSize: 17,
|
||||
version: 10,
|
||||
origin: 'settings-window',
|
||||
};
|
||||
const loser: TerminalFontSizeRecord = {
|
||||
fontSize: 15,
|
||||
version: 10,
|
||||
origin: 'main-window',
|
||||
};
|
||||
const currentRef = { current: renderedWhenEffectWasScheduled };
|
||||
currentRef.current = winner;
|
||||
|
||||
const resolution = resolveAuthoritativeTerminalFontSizeStorage(
|
||||
currentRef,
|
||||
serializeTerminalFontSizeRecord(loser),
|
||||
);
|
||||
assert.equal(resolution.shouldAdopt, false);
|
||||
assert.equal(resolution.shouldPersist, true);
|
||||
assert.deepEqual(resolution.record, winner);
|
||||
assert.deepEqual(parseTerminalFontSizeRecord(resolution.serializedRecord), winner);
|
||||
});
|
||||
|
||||
test('shouldBroadcastTerminalFontSizeChange suppresses incoming rebroadcasts', () => {
|
||||
assert.deepEqual(
|
||||
shouldBroadcastTerminalFontSizeChange('incoming', true),
|
||||
{ shouldBroadcast: false, nextSource: 'local' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
shouldBroadcastTerminalFontSizeChange('local', true),
|
||||
{ shouldBroadcast: true, nextSource: 'local' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
shouldBroadcastTerminalFontSizeChange('local', false),
|
||||
{ shouldBroadcast: false, nextSource: 'local' },
|
||||
);
|
||||
});
|
||||
|
||||
test('legacy unversioned always-broadcast font size sync oscillates during rapid +/- clicks', () => {
|
||||
const alwaysBroadcast = (
|
||||
_source: TerminalFontSizeMutationSource,
|
||||
persistMounted: boolean,
|
||||
) => ({
|
||||
shouldBroadcast: persistMounted,
|
||||
nextSource: 'local' as const,
|
||||
});
|
||||
const alwaysApply = () => true;
|
||||
|
||||
const { settingsValues } = simulateFontSizeClicks({
|
||||
shouldBroadcast: alwaysBroadcast,
|
||||
shouldApply: alwaysApply,
|
||||
versioned: false,
|
||||
});
|
||||
const unique = new Set(settingsValues);
|
||||
assert.ok(
|
||||
unique.has(17) && unique.has(15) && settingsValues.length > 2,
|
||||
`expected oscillation between 17 and 15, got ${settingsValues.join(',')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('versioned font size sync ignores stale peer echoes during rapid +/- clicks', () => {
|
||||
const { settingsValues, mainValues } = simulateFontSizeClicks({
|
||||
shouldBroadcast: shouldBroadcastTerminalFontSizeChange,
|
||||
shouldApply: shouldApplyTerminalFontSizeRecord,
|
||||
versioned: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(settingsValues, [17, 15]);
|
||||
assert.ok(mainValues.includes(15));
|
||||
assert.equal(mainValues.includes(17) && mainValues[mainValues.length - 1] === 17, false);
|
||||
});
|
||||
|
||||
test('serializeTerminalFontSizeRecord round-trips through parse', () => {
|
||||
const record = { fontSize: 18, version: 9, origin: 'window|with delimiter' };
|
||||
const raw = serializeTerminalFontSizeRecord(record);
|
||||
assert.deepEqual(parseTerminalFontSizeRecord(raw), record);
|
||||
});
|
||||
|
||||
test('serialized records remain readable by the old parseInt storage reader', () => {
|
||||
const raw = serializeTerminalFontSizeRecord({
|
||||
fontSize: 18,
|
||||
version: 9,
|
||||
origin: 'settings-window',
|
||||
});
|
||||
assert.equal(parseInt(raw, 10), 18);
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
import {
|
||||
DEFAULT_FONT_SIZE,
|
||||
MAX_FONT_SIZE,
|
||||
MIN_FONT_SIZE,
|
||||
} from '../../infrastructure/config/fonts';
|
||||
|
||||
export type TerminalFontSizeMutationSource = 'local' | 'incoming';
|
||||
|
||||
export type TerminalFontSizeRecord = {
|
||||
fontSize: number;
|
||||
version: number;
|
||||
origin: string;
|
||||
};
|
||||
|
||||
const LEGACY_TERMINAL_FONT_SIZE_ORIGIN = 'legacy';
|
||||
|
||||
function normalizeTerminalFontSizeOrigin(origin: unknown): string {
|
||||
return typeof origin === 'string' && origin.length > 0
|
||||
? origin
|
||||
: LEGACY_TERMINAL_FONT_SIZE_ORIGIN;
|
||||
}
|
||||
|
||||
export function clampTerminalFontSizeValue(fontSize: unknown): number {
|
||||
const value = typeof fontSize === 'number' ? fontSize : Number(fontSize);
|
||||
if (!Number.isFinite(value)) return DEFAULT_FONT_SIZE;
|
||||
return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, Math.round(value)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse persisted / IPC terminal-font-size payloads.
|
||||
* Accepts legacy plain numbers ("16") and versioned records.
|
||||
*/
|
||||
export function parseTerminalFontSizeRecord(raw: unknown): TerminalFontSizeRecord {
|
||||
if (typeof raw === 'number' || typeof raw === 'string') {
|
||||
const trimmed = typeof raw === 'string' ? raw.trim() : raw;
|
||||
if (typeof trimmed === 'string' && trimmed.startsWith('{')) {
|
||||
try {
|
||||
return parseTerminalFontSizeRecord(JSON.parse(trimmed));
|
||||
} catch {
|
||||
// fall through to Number()
|
||||
}
|
||||
}
|
||||
if (typeof trimmed === 'string') {
|
||||
const [fontSizePart, versionPart, encodedOrigin, ...rest] = trimmed.split('|');
|
||||
if (encodedOrigin && rest.length === 0) {
|
||||
const fontSize = Number(fontSizePart);
|
||||
const version = Number(versionPart);
|
||||
if (Number.isFinite(fontSize) && Number.isFinite(version)) {
|
||||
let origin = encodedOrigin;
|
||||
try {
|
||||
origin = decodeURIComponent(encodedOrigin);
|
||||
} catch {
|
||||
// Keep the encoded value as a stable tie-breaker.
|
||||
}
|
||||
return {
|
||||
fontSize: clampTerminalFontSizeValue(fontSize),
|
||||
version: version > 0 ? Math.floor(version) : 0,
|
||||
origin: normalizeTerminalFontSizeOrigin(origin),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
const asNumber = Number(trimmed);
|
||||
if (Number.isFinite(asNumber)) {
|
||||
return {
|
||||
fontSize: clampTerminalFontSizeValue(asNumber),
|
||||
version: 0,
|
||||
origin: LEGACY_TERMINAL_FONT_SIZE_ORIGIN,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (raw && typeof raw === 'object') {
|
||||
const record = raw as { fontSize?: unknown; version?: unknown; origin?: unknown };
|
||||
const fontSize = clampTerminalFontSizeValue(record.fontSize);
|
||||
const version = Number(record.version);
|
||||
return {
|
||||
fontSize,
|
||||
version: Number.isFinite(version) && version > 0 ? Math.floor(version) : 0,
|
||||
origin: normalizeTerminalFontSizeOrigin(record.origin),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
fontSize: DEFAULT_FONT_SIZE,
|
||||
version: 0,
|
||||
origin: LEGACY_TERMINAL_FONT_SIZE_ORIGIN,
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeTerminalFontSizeRecord(record: TerminalFontSizeRecord): string {
|
||||
const fontSize = clampTerminalFontSizeValue(record.fontSize);
|
||||
const version = Math.max(0, Math.floor(record.version) || 0);
|
||||
const origin = encodeURIComponent(normalizeTerminalFontSizeOrigin(record.origin));
|
||||
// Keep the font size first so older Netcatty versions using parseInt() can
|
||||
// still read the preference after a downgrade.
|
||||
return `${fontSize}|${version}|${origin}`;
|
||||
}
|
||||
|
||||
export function createTerminalFontSizeSyncOrigin(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
export function nextTerminalFontSizeSyncVersion(
|
||||
currentVersion: number,
|
||||
persistedVersion: number,
|
||||
now: number = Date.now(),
|
||||
): number {
|
||||
return Math.max(
|
||||
Math.floor(now),
|
||||
Math.floor(currentVersion) + 1,
|
||||
Math.floor(persistedVersion) + 1,
|
||||
);
|
||||
}
|
||||
|
||||
export function createLocalTerminalFontSizeRecord(
|
||||
current: TerminalFontSizeRecord,
|
||||
persistedRaw: unknown,
|
||||
nextFontSizeValue: unknown,
|
||||
origin: string,
|
||||
now: number = Date.now(),
|
||||
): TerminalFontSizeRecord {
|
||||
const nextFontSize = clampTerminalFontSizeValue(nextFontSizeValue);
|
||||
if (nextFontSize === current.fontSize) return current;
|
||||
const persisted = parseTerminalFontSizeRecord(persistedRaw);
|
||||
return {
|
||||
fontSize: nextFontSize,
|
||||
version: nextTerminalFontSizeSyncVersion(current.version, persisted.version, now),
|
||||
origin: normalizeTerminalFontSizeOrigin(origin),
|
||||
};
|
||||
}
|
||||
|
||||
export function areTerminalFontSizeRecordsEqual(
|
||||
left: TerminalFontSizeRecord,
|
||||
right: TerminalFontSizeRecord,
|
||||
): boolean {
|
||||
return left.fontSize === right.fontSize
|
||||
&& left.version === right.version
|
||||
&& left.origin === right.origin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Incoming peer updates must not clobber a newer local revision. A stable
|
||||
* origin tie-break makes simultaneous writers converge on the same record.
|
||||
*/
|
||||
export function shouldApplyTerminalFontSizeRecord(
|
||||
current: TerminalFontSizeRecord,
|
||||
incoming: TerminalFontSizeRecord,
|
||||
): boolean {
|
||||
if (incoming.version > current.version) return true;
|
||||
if (incoming.version < current.version) return false;
|
||||
if (
|
||||
incoming.version === 0
|
||||
&& incoming.origin === LEGACY_TERMINAL_FONT_SIZE_ORIGIN
|
||||
&& current.origin === LEGACY_TERMINAL_FONT_SIZE_ORIGIN
|
||||
) {
|
||||
return incoming.fontSize !== current.fontSize;
|
||||
}
|
||||
if (incoming.origin !== current.origin) return incoming.origin > current.origin;
|
||||
return incoming.fontSize > current.fontSize;
|
||||
}
|
||||
|
||||
export type TerminalFontSizeStorageResolution = {
|
||||
record: TerminalFontSizeRecord;
|
||||
serializedRecord: string;
|
||||
shouldAdopt: boolean;
|
||||
shouldPersist: boolean;
|
||||
};
|
||||
|
||||
export type TerminalFontSizeIncomingResolution = {
|
||||
record: TerminalFontSizeRecord;
|
||||
shouldUpdate: boolean;
|
||||
repairSerializedRecord: string | null;
|
||||
};
|
||||
|
||||
export function resolveTerminalFontSizeStorage(
|
||||
current: TerminalFontSizeRecord,
|
||||
storedRaw: unknown,
|
||||
): TerminalFontSizeStorageResolution {
|
||||
const stored = parseTerminalFontSizeRecord(storedRaw);
|
||||
if (shouldApplyTerminalFontSizeRecord(current, stored)) {
|
||||
return {
|
||||
record: stored,
|
||||
serializedRecord: serializeTerminalFontSizeRecord(stored),
|
||||
shouldAdopt: true,
|
||||
shouldPersist: false,
|
||||
};
|
||||
}
|
||||
|
||||
const serializedRecord = serializeTerminalFontSizeRecord(current);
|
||||
return {
|
||||
record: current,
|
||||
serializedRecord,
|
||||
shouldAdopt: false,
|
||||
shouldPersist: storedRaw !== serializedRecord,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAuthoritativeTerminalFontSizeStorage(
|
||||
currentRef: Readonly<{ current: TerminalFontSizeRecord }>,
|
||||
storedRaw: unknown,
|
||||
): TerminalFontSizeStorageResolution {
|
||||
return resolveTerminalFontSizeStorage(currentRef.current, storedRaw);
|
||||
}
|
||||
|
||||
export function resolveIncomingTerminalFontSize(
|
||||
current: TerminalFontSizeRecord,
|
||||
incomingRaw: unknown,
|
||||
storedRaw: unknown,
|
||||
): TerminalFontSizeIncomingResolution {
|
||||
const incoming = parseTerminalFontSizeRecord(incomingRaw);
|
||||
if (shouldApplyTerminalFontSizeRecord(current, incoming)) {
|
||||
return {
|
||||
record: incoming,
|
||||
shouldUpdate: true,
|
||||
repairSerializedRecord: null,
|
||||
};
|
||||
}
|
||||
|
||||
const storageResolution = resolveTerminalFontSizeStorage(current, storedRaw);
|
||||
if (storageResolution.shouldAdopt) {
|
||||
return {
|
||||
record: storageResolution.record,
|
||||
shouldUpdate: true,
|
||||
repairSerializedRecord: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
record: current,
|
||||
shouldUpdate: false,
|
||||
repairSerializedRecord: storageResolution.shouldPersist
|
||||
? storageResolution.serializedRecord
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a terminal-font-size state change should be rebroadcast to
|
||||
* peer windows. Incoming IPC/storage updates must not notify again —
|
||||
* otherwise +/- clicks in the Settings window ping-pong with the main window
|
||||
* and terminals oscillate between the last two sizes (see #2689, same class
|
||||
* as window-opacity #2018).
|
||||
*/
|
||||
export function shouldBroadcastTerminalFontSizeChange(
|
||||
mutationSource: TerminalFontSizeMutationSource,
|
||||
persistMounted: boolean,
|
||||
): { shouldBroadcast: boolean; nextSource: TerminalFontSizeMutationSource } {
|
||||
if (mutationSource === 'incoming') {
|
||||
return { shouldBroadcast: false, nextSource: 'local' };
|
||||
}
|
||||
return { shouldBroadcast: persistMounted, nextSource: 'local' };
|
||||
}
|
||||
@@ -13,15 +13,9 @@ import {
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { useToolbarItemLayout } from './useToolbarItemLayout';
|
||||
import type { SidePanelTool } from '../../domain/sidePanelLayout';
|
||||
|
||||
export type TerminalSidePanelTabId =
|
||||
| 'sftp'
|
||||
| 'scripts'
|
||||
| 'history'
|
||||
| 'theme'
|
||||
| 'system'
|
||||
| 'notes'
|
||||
| 'ai';
|
||||
export type TerminalSidePanelTabId = SidePanelTool;
|
||||
|
||||
export const TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER: TerminalSidePanelTabId[] = [
|
||||
'sftp',
|
||||
@@ -64,6 +58,30 @@ export function reorderTerminalSidePanelTab(
|
||||
.order as TerminalSidePanelTabId[];
|
||||
}
|
||||
|
||||
export function fitTerminalSidePanelTabs(params: {
|
||||
shown: TerminalSidePanelTabId[];
|
||||
collapsed: TerminalSidePanelTabId[];
|
||||
active: TerminalSidePanelTabId | null;
|
||||
maxShown: number;
|
||||
}): { shown: TerminalSidePanelTabId[]; collapsed: TerminalSidePanelTabId[] } {
|
||||
if (params.shown.length <= params.maxShown) {
|
||||
return { shown: params.shown, collapsed: params.collapsed };
|
||||
}
|
||||
|
||||
const kept = new Set(params.shown.slice(0, Math.max(1, params.maxShown)));
|
||||
if (params.active && params.shown.includes(params.active) && !kept.has(params.active)) {
|
||||
const lastKept = [...kept].at(-1);
|
||||
if (lastKept) kept.delete(lastKept);
|
||||
kept.add(params.active);
|
||||
}
|
||||
const shown = params.shown.filter((id) => kept.has(id));
|
||||
const autoCollapsed = params.shown.filter((id) => !kept.has(id));
|
||||
return {
|
||||
shown,
|
||||
collapsed: [...autoCollapsed, ...params.collapsed.filter((id) => !autoCollapsed.includes(id))],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the modern layout storage from the legacy order-only key when needed.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
clampTerminalSidePanelWidth,
|
||||
getTerminalSidePanelAvailableWidth,
|
||||
getTerminalSidePanelMaxShownTools,
|
||||
getTerminalSidePanelMaxWidth,
|
||||
TERMINAL_SIDE_PANEL_MAX_WIDTH,
|
||||
TERMINAL_SIDE_PANEL_MIN_WIDTH,
|
||||
} from './terminalSidePanelWidth.ts';
|
||||
|
||||
test('terminal side panel can expand to the wider maximum', () => {
|
||||
assert.equal(getTerminalSidePanelMaxWidth(2000), TERMINAL_SIDE_PANEL_MAX_WIDTH);
|
||||
assert.equal(clampTerminalSidePanelWidth(1400, 2000), TERMINAL_SIDE_PANEL_MAX_WIDTH);
|
||||
});
|
||||
|
||||
test('terminal side panel collapses tool buttons before shared actions are clipped', () => {
|
||||
assert.equal(getTerminalSidePanelMaxShownTools(280), 2);
|
||||
assert.equal(getTerminalSidePanelMaxShownTools(320), 4);
|
||||
assert.ok(getTerminalSidePanelMaxShownTools(420) >= 7);
|
||||
});
|
||||
|
||||
test('terminal side panel uses the actual surface left after fixed sibling panels', () => {
|
||||
assert.equal(getTerminalSidePanelAvailableWidth(780, 220), 560);
|
||||
assert.equal(getTerminalSidePanelMaxWidth(560), TERMINAL_SIDE_PANEL_MIN_WIDTH);
|
||||
assert.equal(clampTerminalSidePanelWidth(680, 560), TERMINAL_SIDE_PANEL_MIN_WIDTH);
|
||||
});
|
||||
|
||||
test('terminal side panel keeps usable terminal space in smaller windows', () => {
|
||||
assert.equal(getTerminalSidePanelMaxWidth(1000), 680);
|
||||
assert.equal(clampTerminalSidePanelWidth(900, 1000), 680);
|
||||
assert.equal(clampTerminalSidePanelWidth(100, 1000), TERMINAL_SIDE_PANEL_MIN_WIDTH);
|
||||
assert.equal(clampTerminalSidePanelWidth(280, 1000, 401), 401);
|
||||
assert.equal(clampTerminalSidePanelWidth(280, 600, 401), TERMINAL_SIDE_PANEL_MIN_WIDTH);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
export const TERMINAL_SIDE_PANEL_MIN_WIDTH = 280;
|
||||
export const TERMINAL_SIDE_PANEL_MAX_WIDTH = 1200;
|
||||
export const TERMINAL_SIDE_PANEL_MIN_TERMINAL_WIDTH = 320;
|
||||
export const TERMINAL_SIDE_PANEL_TOOL_BUTTON_WIDTH = 28;
|
||||
export const TERMINAL_SIDE_PANEL_TOOLBAR_RESERVED_WIDTH = 200;
|
||||
|
||||
export function getTerminalSidePanelAvailableWidth(
|
||||
terminalLayerWidth: number,
|
||||
occupiedSiblingWidth: number,
|
||||
): number {
|
||||
return Math.max(0, terminalLayerWidth - occupiedSiblingWidth);
|
||||
}
|
||||
|
||||
export function getTerminalSidePanelMaxWidth(availableSurfaceWidth: number): number {
|
||||
const availableWidth = availableSurfaceWidth - TERMINAL_SIDE_PANEL_MIN_TERMINAL_WIDTH;
|
||||
return Math.max(
|
||||
TERMINAL_SIDE_PANEL_MIN_WIDTH,
|
||||
Math.min(TERMINAL_SIDE_PANEL_MAX_WIDTH, availableWidth),
|
||||
);
|
||||
}
|
||||
|
||||
export function clampTerminalSidePanelWidth(
|
||||
width: number,
|
||||
availableSurfaceWidth: number,
|
||||
contentMinimumWidth = TERMINAL_SIDE_PANEL_MIN_WIDTH,
|
||||
): number {
|
||||
const maximumWidth = getTerminalSidePanelMaxWidth(availableSurfaceWidth);
|
||||
const minimumWidth = Math.min(
|
||||
maximumWidth,
|
||||
Math.max(TERMINAL_SIDE_PANEL_MIN_WIDTH, contentMinimumWidth),
|
||||
);
|
||||
return Math.max(minimumWidth, Math.min(maximumWidth, width));
|
||||
}
|
||||
|
||||
export function getTerminalSidePanelMaxShownTools(panelWidth: number): number {
|
||||
return Math.max(
|
||||
1,
|
||||
Math.floor(
|
||||
(panelWidth - TERMINAL_SIDE_PANEL_TOOLBAR_RESERVED_WIDTH)
|
||||
/ TERMINAL_SIDE_PANEL_TOOL_BUTTON_WIDTH,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ export const useApplicationBackend = () => {
|
||||
|
||||
const checkSshAgent = useCallback(async (options?: {
|
||||
identityAgent?: string;
|
||||
agentForwarding?: boolean;
|
||||
hostname?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
|
||||
STORAGE_KEY_SSH_DEEP_LINK_ENABLED,
|
||||
STORAGE_KEY_JMS_DEEP_LINK_ENABLED,
|
||||
STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED,
|
||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
STORAGE_KEY_HTTP_NETWORK_PROXY,
|
||||
@@ -87,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,
|
||||
@@ -112,6 +112,7 @@ import {
|
||||
DEFAULT_SSH_DEBUG_LOGS_ENABLED,
|
||||
DEFAULT_SSH_DEEP_LINK_ENABLED,
|
||||
DEFAULT_JMS_DEEP_LINK_ENABLED,
|
||||
DEFAULT_EXPLORER_CONTEXT_MENU_ENABLED,
|
||||
DEFAULT_TERMINAL_THEME,
|
||||
DEFAULT_THEME,
|
||||
DEFAULT_WINDOW_OPACITY,
|
||||
@@ -152,6 +153,24 @@ import {
|
||||
type WindowOpacityMutationSource,
|
||||
type WindowOpacityRecord,
|
||||
} from './windowOpacitySync';
|
||||
import {
|
||||
parseCustomAccentRecord,
|
||||
serializeCustomAccentRecord,
|
||||
shouldApplyCustomAccentRecord,
|
||||
shouldBroadcastCustomAccentChange,
|
||||
type CustomAccentMutationSource,
|
||||
type CustomAccentRecord,
|
||||
} from './customAccentSync';
|
||||
import {
|
||||
createLocalTerminalFontSizeRecord,
|
||||
createTerminalFontSizeSyncOrigin,
|
||||
parseTerminalFontSizeRecord,
|
||||
resolveAuthoritativeTerminalFontSizeStorage,
|
||||
resolveIncomingTerminalFontSize,
|
||||
shouldBroadcastTerminalFontSizeChange,
|
||||
type TerminalFontSizeMutationSource,
|
||||
type TerminalFontSizeRecord,
|
||||
} from './terminalFontSizeSync';
|
||||
import {
|
||||
hasPersistedAppearanceChanged,
|
||||
resolveAppearanceSyncState,
|
||||
@@ -181,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);
|
||||
@@ -218,7 +269,51 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY);
|
||||
return migrateIncomingTerminalFontId(stored) ?? TERMINAL_FONT_AUTO;
|
||||
});
|
||||
const [terminalFontSize, setTerminalFontSize] = useState<number>(() => localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE) || DEFAULT_FONT_SIZE);
|
||||
const [terminalFontSizeRecord, setTerminalFontSizeRecord] = useState<TerminalFontSizeRecord>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_TERM_FONT_SIZE);
|
||||
if (stored === null) {
|
||||
return { fontSize: DEFAULT_FONT_SIZE, version: 0, origin: 'legacy' };
|
||||
}
|
||||
return parseTerminalFontSizeRecord(stored);
|
||||
});
|
||||
const terminalFontSize = terminalFontSizeRecord.fontSize;
|
||||
const terminalFontSizeRecordRef = useRef(terminalFontSizeRecord);
|
||||
const terminalFontSizeMutationSourceRef = useRef<TerminalFontSizeMutationSource>('local');
|
||||
const terminalFontSizeLocalOriginRef = useRef(createTerminalFontSizeSyncOrigin());
|
||||
const setTerminalFontSize = useCallback((nextValue: SetStateAction<number>) => {
|
||||
terminalFontSizeMutationSourceRef.current = 'local';
|
||||
const persistedRaw = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_SIZE);
|
||||
const current = terminalFontSizeRecordRef.current;
|
||||
const candidate = typeof nextValue === 'function'
|
||||
? (nextValue as (prevState: number) => number)(current.fontSize)
|
||||
: nextValue;
|
||||
const next = createLocalTerminalFontSizeRecord(
|
||||
current,
|
||||
persistedRaw,
|
||||
candidate,
|
||||
terminalFontSizeLocalOriginRef.current,
|
||||
);
|
||||
if (next === current) return;
|
||||
terminalFontSizeRecordRef.current = next;
|
||||
setTerminalFontSizeRecord(next);
|
||||
}, []);
|
||||
const applyIncomingTerminalFontSize = useCallback((raw: unknown) => {
|
||||
const resolution = resolveIncomingTerminalFontSize(
|
||||
terminalFontSizeRecordRef.current,
|
||||
raw,
|
||||
localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_SIZE),
|
||||
);
|
||||
if (resolution.repairSerializedRecord !== null) {
|
||||
localStorageAdapter.writeString(
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
resolution.repairSerializedRecord,
|
||||
);
|
||||
}
|
||||
if (!resolution.shouldUpdate) return;
|
||||
terminalFontSizeMutationSourceRef.current = 'incoming';
|
||||
terminalFontSizeRecordRef.current = resolution.record;
|
||||
setTerminalFontSizeRecord(resolution.record);
|
||||
}, []);
|
||||
const [uiLanguage, setUiLanguage] = useState<UILanguage>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_UI_LANGUAGE);
|
||||
return resolveSupportedLocale(stored || DEFAULT_UI_LOCALE);
|
||||
@@ -379,6 +474,14 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_JMS_DEEP_LINK_ENABLED);
|
||||
return stored ?? DEFAULT_JMS_DEEP_LINK_ENABLED;
|
||||
});
|
||||
const [explorerContextMenuEnabled, setExplorerContextMenuEnabledState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED);
|
||||
return stored ?? DEFAULT_EXPLORER_CONTEXT_MENU_ENABLED;
|
||||
});
|
||||
const [explorerContextMenuSupported, setExplorerContextMenuSupported] = useState<boolean>(() => {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
return /Win/i.test(navigator.platform) || /Windows/i.test(navigator.userAgent);
|
||||
});
|
||||
|
||||
// Global Toggle Window Settings (Quake Mode)
|
||||
const [toggleWindowHotkey, setToggleWindowHotkey] = useState<string>(() => {
|
||||
@@ -475,6 +578,9 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const jmsDeepLinkMutationSourceRef = useRef<'local' | 'incoming'>('local');
|
||||
const jmsDeepLinkEnabledRef = useRef(jmsDeepLinkEnabled);
|
||||
const jmsDeepLinkSetRequestIdRef = useRef(0);
|
||||
const explorerContextMenuMutationSourceRef = useRef<'local' | 'incoming'>('local');
|
||||
const explorerContextMenuEnabledRef = useRef(explorerContextMenuEnabled);
|
||||
const explorerContextMenuSetRequestIdRef = useRef(0);
|
||||
|
||||
// Fix 1: Mount guard — skip redundant IPC broadcasts & localStorage writes on initial mount.
|
||||
// Set to true by the LAST useEffect declaration; all persist effects see false on first render.
|
||||
@@ -489,6 +595,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
darkUiThemeId,
|
||||
accentMode,
|
||||
customAccent,
|
||||
customAccentVersion: customAccentRecord.version,
|
||||
});
|
||||
appearanceStateRef.current = {
|
||||
theme,
|
||||
@@ -496,6 +603,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
darkUiThemeId,
|
||||
accentMode,
|
||||
customAccent,
|
||||
customAccentVersion: customAccentRecord.version,
|
||||
};
|
||||
|
||||
const setTerminalSettings = useCallback((nextValue: SetStateAction<TerminalSettings>) => {
|
||||
@@ -594,6 +702,15 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
});
|
||||
}, []);
|
||||
|
||||
const applyIncomingExplorerContextMenuEnabled = useCallback((enabled: boolean) => {
|
||||
explorerContextMenuSetRequestIdRef.current += 1;
|
||||
setExplorerContextMenuEnabledState((prev) => {
|
||||
if (prev === enabled) return prev;
|
||||
explorerContextMenuMutationSourceRef.current = 'incoming';
|
||||
return enabled;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Helper to notify other windows about settings changes via IPC
|
||||
const notifySettingsChanged = useCallback((key: string, value: unknown) => {
|
||||
if (!enableSettingsSync) return;
|
||||
@@ -656,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
|
||||
@@ -664,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;
|
||||
@@ -713,8 +840,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const storedTermFont = readStoredString(STORAGE_KEY_TERM_FONT_FAMILY);
|
||||
const migratedTermFont = migrateIncomingTerminalFontId(storedTermFont);
|
||||
if (migratedTermFont) setTerminalFontFamilyId(migratedTermFont);
|
||||
const storedTermSize = localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE);
|
||||
if (storedTermSize != null) setTerminalFontSize(storedTermSize);
|
||||
const storedTermSize = readStoredString(STORAGE_KEY_TERM_FONT_SIZE);
|
||||
if (storedTermSize != null) applyIncomingTerminalFontSize(storedTermSize);
|
||||
const storedTermSettings = readStoredString(STORAGE_KEY_TERM_SETTINGS);
|
||||
if (storedTermSettings) {
|
||||
try {
|
||||
@@ -744,6 +871,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
applyIncomingSshDeepLinkEnabled(storedSshDeepLinkEnabled ?? DEFAULT_SSH_DEEP_LINK_ENABLED);
|
||||
const storedJmsDeepLinkEnabled = localStorageAdapter.readBoolean(STORAGE_KEY_JMS_DEEP_LINK_ENABLED);
|
||||
applyIncomingJmsDeepLinkEnabled(storedJmsDeepLinkEnabled ?? DEFAULT_JMS_DEEP_LINK_ENABLED);
|
||||
const storedExplorerContextMenuEnabled = localStorageAdapter.readBoolean(STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED);
|
||||
applyIncomingExplorerContextMenuEnabled(storedExplorerContextMenuEnabled ?? DEFAULT_EXPLORER_CONTEXT_MENU_ENABLED);
|
||||
|
||||
// SFTP
|
||||
const storedDblClick = readStoredString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR);
|
||||
@@ -804,7 +933,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
|
||||
// Custom terminal themes
|
||||
customThemeStore.loadFromStorage();
|
||||
}, [applyIncomingCustomKeyBindings, applyIncomingJmsDeepLinkEnabled, applyIncomingSshDeepLinkEnabled, syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
|
||||
}, [applyIncomingCustomKeyBindings, applyIncomingExplorerContextMenuEnabled, applyIncomingJmsDeepLinkEnabled, applyIncomingSshDeepLinkEnabled, applyIncomingTerminalFontSize, syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const appearanceRender: AppearanceRenderSnapshot = {
|
||||
@@ -814,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.
|
||||
@@ -835,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
|
||||
@@ -854,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(() => {
|
||||
@@ -903,7 +1056,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setTerminalThemeLightId,
|
||||
setFollowAppTerminalThemeState,
|
||||
setTerminalFontFamilyId,
|
||||
setTerminalFontSize,
|
||||
setTerminalFontSize: applyIncomingTerminalFontSize,
|
||||
mergeIncomingTerminalSettings,
|
||||
setEditorWordWrapState,
|
||||
setSessionLogsEnabled,
|
||||
@@ -913,6 +1066,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setSshDebugLogsEnabled,
|
||||
setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled,
|
||||
setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled,
|
||||
setExplorerContextMenuEnabledState: applyIncomingExplorerContextMenuEnabled,
|
||||
setHotkeyScheme,
|
||||
applyIncomingCustomKeyBindings,
|
||||
setIsHotkeyRecordingState,
|
||||
@@ -956,21 +1110,23 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
useSettingsStorageSync({
|
||||
enabled: enableSettingsSync,
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
customAccentVersion: customAccentRecord.version,
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled,
|
||||
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,
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize: applyIncomingTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled, setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled, setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled, setExplorerContextMenuEnabledState: applyIncomingExplorerContextMenuEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
|
||||
applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
@@ -1007,10 +1163,32 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
}, [terminalFontFamilyId, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_TERM_FONT_SIZE, terminalFontSize);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TERM_FONT_SIZE, terminalFontSize);
|
||||
}, [terminalFontSize, notifySettingsChanged]);
|
||||
const resolution = resolveAuthoritativeTerminalFontSizeStorage(
|
||||
terminalFontSizeRecordRef,
|
||||
localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_SIZE),
|
||||
);
|
||||
// If another window already published a newer winner, adopt it instead of
|
||||
// displaying and broadcasting a local value that peers will reject.
|
||||
if (resolution.shouldAdopt) {
|
||||
terminalFontSizeMutationSourceRef.current = 'incoming';
|
||||
terminalFontSizeRecordRef.current = resolution.record;
|
||||
setTerminalFontSizeRecord(resolution.record);
|
||||
return;
|
||||
}
|
||||
if (resolution.shouldPersist) {
|
||||
localStorageAdapter.writeString(
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
resolution.serializedRecord,
|
||||
);
|
||||
}
|
||||
const decision = shouldBroadcastTerminalFontSizeChange(
|
||||
terminalFontSizeMutationSourceRef.current,
|
||||
persistMountedRef.current,
|
||||
);
|
||||
terminalFontSizeMutationSourceRef.current = decision.nextSource;
|
||||
if (!decision.shouldBroadcast) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TERM_FONT_SIZE, resolution.record);
|
||||
}, [terminalFontSizeRecord, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.write(STORAGE_KEY_TERM_SETTINGS, terminalSettings);
|
||||
@@ -1346,6 +1524,74 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
notifySettingsChanged(STORAGE_KEY_JMS_DEEP_LINK_ENABLED, jmsDeepLinkEnabled);
|
||||
}, [jmsDeepLinkEnabled, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
explorerContextMenuEnabledRef.current = explorerContextMenuEnabled;
|
||||
}, [explorerContextMenuEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const requestIdAtStart = explorerContextMenuSetRequestIdRef.current;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getExplorerContextMenuEnabled) return;
|
||||
void bridge.getExplorerContextMenuEnabled().then((result) => {
|
||||
if (cancelled) return;
|
||||
if (explorerContextMenuSetRequestIdRef.current !== requestIdAtStart) return;
|
||||
const enabled = typeof result === 'object' && result
|
||||
? result.enabled
|
||||
: result;
|
||||
const supported = typeof result === 'object' && result
|
||||
? result.supported === true
|
||||
: false;
|
||||
setExplorerContextMenuSupported(supported);
|
||||
if (typeof enabled !== 'boolean') return;
|
||||
explorerContextMenuMutationSourceRef.current = 'incoming';
|
||||
setExplorerContextMenuEnabledState((prev) => (prev === enabled ? prev : enabled));
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED, enabled);
|
||||
}).catch(() => {
|
||||
// Keep the cached renderer value when the bridge is unavailable.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setExplorerContextMenuEnabled = useCallback((enabled: boolean) => {
|
||||
const previous = explorerContextMenuEnabledRef.current;
|
||||
const requestId = explorerContextMenuSetRequestIdRef.current + 1;
|
||||
explorerContextMenuSetRequestIdRef.current = requestId;
|
||||
explorerContextMenuMutationSourceRef.current = 'local';
|
||||
setExplorerContextMenuEnabledState(enabled);
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.setExplorerContextMenuEnabled) return;
|
||||
void bridge.setExplorerContextMenuEnabled(enabled).then((result) => {
|
||||
if (explorerContextMenuSetRequestIdRef.current !== requestId) return;
|
||||
const success = typeof result === 'object' ? result.success : result;
|
||||
if (success !== false) return;
|
||||
const finalEnabled = typeof result === 'object' && typeof result.enabled === 'boolean'
|
||||
? result.enabled
|
||||
: previous;
|
||||
explorerContextMenuMutationSourceRef.current = 'incoming';
|
||||
setExplorerContextMenuEnabledState(finalEnabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED, finalEnabled);
|
||||
}).catch(() => {
|
||||
if (explorerContextMenuSetRequestIdRef.current !== requestId) return;
|
||||
explorerContextMenuMutationSourceRef.current = 'incoming';
|
||||
setExplorerContextMenuEnabledState(previous);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED, previous);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED, explorerContextMenuEnabled);
|
||||
if (explorerContextMenuMutationSourceRef.current === 'incoming') {
|
||||
explorerContextMenuMutationSourceRef.current = 'local';
|
||||
return;
|
||||
}
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED, explorerContextMenuEnabled);
|
||||
}, [explorerContextMenuEnabled, notifySettingsChanged]);
|
||||
|
||||
useSystemSettingsEffects({
|
||||
enabled: enableSystemEffects,
|
||||
toggleWindowHotkey,
|
||||
@@ -1545,6 +1791,9 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setSshDeepLinkEnabled,
|
||||
jmsDeepLinkEnabled,
|
||||
setJmsDeepLinkEnabled,
|
||||
explorerContextMenuEnabled,
|
||||
setExplorerContextMenuEnabled,
|
||||
explorerContextMenuSupported,
|
||||
// Global Toggle Window (Quake Mode)
|
||||
toggleWindowHotkey,
|
||||
setToggleWindowHotkey,
|
||||
@@ -1574,7 +1823,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
|
||||
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled,
|
||||
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
]),
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
|
||||
import { collectSidePanelPanes } from '../../domain/sidePanelLayout.ts';
|
||||
import { useTerminalSidePanelLayoutState } from './useTerminalSidePanelLayoutState.ts';
|
||||
|
||||
test('terminal side panel layouts stay isolated and keep one pane per tool', async () => {
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let state: ReturnType<typeof useTerminalSidePanelLayoutState> | null = null;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
|
||||
const Probe = () => {
|
||||
state = useTerminalSidePanelLayoutState();
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
await act(async () => {
|
||||
state?.setSidePanelOpenTabs(new Map([
|
||||
['terminal-a', 'notes'],
|
||||
['terminal-b', 'scripts'],
|
||||
]));
|
||||
});
|
||||
|
||||
const layoutA = state?.sidePanelLayouts.get('terminal-a');
|
||||
const layoutB = state?.sidePanelLayouts.get('terminal-b');
|
||||
assert.ok(layoutA);
|
||||
assert.ok(layoutB);
|
||||
|
||||
await act(async () => {
|
||||
state?.splitPane('terminal-a', 'ai', 'vertical', {
|
||||
paneId: 'pane-ai',
|
||||
splitId: 'split-a',
|
||||
}, 400);
|
||||
});
|
||||
assert.deepEqual(
|
||||
collectSidePanelPanes(state!.sidePanelLayouts.get('terminal-a')!.root).map((pane) => pane.tool),
|
||||
['notes', 'ai'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
collectSidePanelPanes(state!.sidePanelLayouts.get('terminal-b')!.root).map((pane) => pane.tool),
|
||||
['scripts'],
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
state?.splitPane('terminal-a', 'notes', 'horizontal', {
|
||||
paneId: 'unused-pane',
|
||||
splitId: 'unused-split',
|
||||
}, 400);
|
||||
});
|
||||
const focused = state!.sidePanelLayouts.get('terminal-a')!;
|
||||
assert.equal(collectSidePanelPanes(focused.root).length, 2);
|
||||
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'notes');
|
||||
|
||||
// External open paths still write the focused-tool map. They must replace
|
||||
// only the focused pane, then focus an existing pane without duplicating it.
|
||||
await act(async () => {
|
||||
state?.setSidePanelOpenTabs((current) => new Map(current).set('terminal-a', 'system'));
|
||||
});
|
||||
assert.deepEqual(
|
||||
collectSidePanelPanes(state!.sidePanelLayouts.get('terminal-a')!.root).map((pane) => pane.tool),
|
||||
['system', 'ai'],
|
||||
);
|
||||
await act(async () => {
|
||||
state?.setSidePanelOpenTabs((current) => new Map(current).set('terminal-a', 'ai'));
|
||||
});
|
||||
const externallyFocused = state!.sidePanelLayouts.get('terminal-a')!;
|
||||
assert.deepEqual(collectSidePanelPanes(externallyFocused.root).map((pane) => pane.tool), ['system', 'ai']);
|
||||
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'ai');
|
||||
assert.equal(
|
||||
collectSidePanelPanes(externallyFocused.root).find((pane) => pane.id === externallyFocused.focusedPaneId)?.tool,
|
||||
'ai',
|
||||
);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
});
|
||||
|
||||
test('closing the final pane removes only that terminal side panel state', async () => {
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let state: ReturnType<typeof useTerminalSidePanelLayoutState> | null = null;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
|
||||
const Probe = () => {
|
||||
state = useTerminalSidePanelLayoutState();
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
await act(async () => {
|
||||
state?.setSidePanelOpenTabs(new Map([
|
||||
['terminal-a', 'notes'],
|
||||
['terminal-b', 'scripts'],
|
||||
]));
|
||||
});
|
||||
const paneId = state!.sidePanelLayouts.get('terminal-a')!.focusedPaneId;
|
||||
|
||||
let closedLast = false;
|
||||
await act(async () => {
|
||||
closedLast = state!.closePane('terminal-a', paneId);
|
||||
});
|
||||
|
||||
assert.equal(closedLast, true);
|
||||
assert.equal(state!.sidePanelLayouts.has('terminal-a'), false);
|
||||
assert.equal(state!.sidePanelOpenTabs.has('terminal-a'), false);
|
||||
assert.equal(state!.sidePanelLayouts.has('terminal-b'), true);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
closeSidePanelPane,
|
||||
createSidePanelLayout,
|
||||
focusSidePanelPane,
|
||||
getFocusedSidePanelPane,
|
||||
resizeSidePanelSplit,
|
||||
selectSidePanelTool,
|
||||
splitSidePanelPane,
|
||||
type SidePanelLayout,
|
||||
type SidePanelSplitDirection,
|
||||
type SidePanelTool,
|
||||
} from '../../domain/sidePanelLayout';
|
||||
|
||||
export function useTerminalSidePanelLayoutState() {
|
||||
const [sidePanelOpenTabs, setSidePanelOpenTabs] = useState<Map<string, SidePanelTool>>(new Map());
|
||||
const [sidePanelLayouts, setSidePanelLayouts] = useState<Map<string, SidePanelLayout>>(new Map());
|
||||
const sidePanelOpenTabsRef = useRef(sidePanelOpenTabs);
|
||||
const sidePanelLayoutsRef = useRef(sidePanelLayouts);
|
||||
sidePanelOpenTabsRef.current = sidePanelOpenTabs;
|
||||
sidePanelLayoutsRef.current = sidePanelLayouts;
|
||||
|
||||
// Legacy and external open paths write the focused-tool map. Reconcile that
|
||||
// public application boundary into the per-tab tree without disturbing the
|
||||
// other panes in an existing layout.
|
||||
useEffect(() => {
|
||||
setSidePanelLayouts((previous) => {
|
||||
let changed = false;
|
||||
const next = new Map(previous);
|
||||
|
||||
for (const tabId of previous.keys()) {
|
||||
if (!sidePanelOpenTabs.has(tabId)) {
|
||||
next.delete(tabId);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [tabId, tool] of sidePanelOpenTabs) {
|
||||
const current = next.get(tabId);
|
||||
const updated = current
|
||||
? selectSidePanelTool(current, tool)
|
||||
: createSidePanelLayout(tool, crypto.randomUUID());
|
||||
if (updated !== current) {
|
||||
next.set(tabId, updated);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) sidePanelLayoutsRef.current = next;
|
||||
return changed ? next : previous;
|
||||
});
|
||||
}, [sidePanelOpenTabs]);
|
||||
|
||||
const commitLayout = useCallback((tabId: string, layout: SidePanelLayout) => {
|
||||
const layouts = new Map(sidePanelLayoutsRef.current).set(tabId, layout);
|
||||
const focusedTool = getFocusedSidePanelPane(layout).tool;
|
||||
const openTabs = new Map(sidePanelOpenTabsRef.current).set(tabId, focusedTool);
|
||||
sidePanelLayoutsRef.current = layouts;
|
||||
sidePanelOpenTabsRef.current = openTabs;
|
||||
setSidePanelLayouts(layouts);
|
||||
setSidePanelOpenTabs(openTabs);
|
||||
}, []);
|
||||
|
||||
const focusPane = useCallback((tabId: string, paneId: string) => {
|
||||
const layout = sidePanelLayoutsRef.current.get(tabId);
|
||||
if (!layout) return;
|
||||
const nextLayout = focusSidePanelPane(layout, paneId);
|
||||
if (nextLayout !== layout) commitLayout(tabId, nextLayout);
|
||||
}, [commitLayout]);
|
||||
|
||||
const splitPane = useCallback((
|
||||
tabId: string,
|
||||
tool: SidePanelTool,
|
||||
direction: SidePanelSplitDirection,
|
||||
ids: { paneId: string; splitId: string },
|
||||
availableAxisLength: number,
|
||||
) => {
|
||||
const layout = sidePanelLayoutsRef.current.get(tabId);
|
||||
if (!layout) return;
|
||||
commitLayout(tabId, splitSidePanelPane(
|
||||
layout,
|
||||
layout.focusedPaneId,
|
||||
tool,
|
||||
direction,
|
||||
ids,
|
||||
availableAxisLength,
|
||||
));
|
||||
}, [commitLayout]);
|
||||
|
||||
const closePane = useCallback((tabId: string, paneId: string): boolean => {
|
||||
const layout = sidePanelLayoutsRef.current.get(tabId);
|
||||
if (!layout) return false;
|
||||
const nextLayout = closeSidePanelPane(layout, paneId);
|
||||
if (nextLayout) {
|
||||
commitLayout(tabId, nextLayout);
|
||||
return false;
|
||||
}
|
||||
|
||||
const layouts = new Map(sidePanelLayoutsRef.current);
|
||||
const openTabs = new Map(sidePanelOpenTabsRef.current);
|
||||
layouts.delete(tabId);
|
||||
openTabs.delete(tabId);
|
||||
sidePanelLayoutsRef.current = layouts;
|
||||
sidePanelOpenTabsRef.current = openTabs;
|
||||
setSidePanelLayouts(layouts);
|
||||
setSidePanelOpenTabs(openTabs);
|
||||
return true;
|
||||
}, [commitLayout]);
|
||||
|
||||
const resizeSplit = useCallback((tabId: string, splitId: string, sizes: number[]) => {
|
||||
const layout = sidePanelLayoutsRef.current.get(tabId);
|
||||
if (!layout) return;
|
||||
const nextLayout = resizeSidePanelSplit(layout, splitId, sizes);
|
||||
if (nextLayout === layout) return;
|
||||
const layouts = new Map(sidePanelLayoutsRef.current).set(tabId, nextLayout);
|
||||
sidePanelLayoutsRef.current = layouts;
|
||||
setSidePanelLayouts(layouts);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
sidePanelOpenTabs,
|
||||
setSidePanelOpenTabs,
|
||||
sidePanelOpenTabsRef,
|
||||
sidePanelLayouts,
|
||||
sidePanelLayoutsRef,
|
||||
focusPane,
|
||||
splitPane,
|
||||
closePane,
|
||||
resizeSplit,
|
||||
};
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import assert from "node:assert/strict";
|
||||
import type { SyncPayload } from "../domain/sync.ts";
|
||||
import type { KnownHost } from "../domain/models.ts";
|
||||
import type { SyncableVaultData } from "./syncPayload.ts";
|
||||
import { parseTerminalFontSizeRecord } from "./state/terminalFontSizeSync.ts";
|
||||
|
||||
type LocalStorageMock = {
|
||||
clear(): void;
|
||||
@@ -42,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,
|
||||
@@ -234,6 +239,42 @@ test("buildSyncPayload includes host tree sidebar visibility setting", () => {
|
||||
assert.equal(payload.settings?.terminalSidePanelAutoOpenTab, "scripts");
|
||||
});
|
||||
|
||||
test("buildSyncPayload reads the versioned terminal font size record", () => {
|
||||
localStorage.setItem(
|
||||
storageKeys.STORAGE_KEY_TERM_FONT_SIZE,
|
||||
"18|9|settings-window",
|
||||
);
|
||||
|
||||
const payload = buildSyncPayload(vault([]));
|
||||
|
||||
assert.equal(payload.settings?.terminalFontSize, 18);
|
||||
});
|
||||
|
||||
test("applySyncPayload writes a newer terminal font size that old versions can read", async () => {
|
||||
localStorage.setItem(
|
||||
storageKeys.STORAGE_KEY_TERM_FONT_SIZE,
|
||||
"16|7|main-window",
|
||||
);
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
settings: { terminalFontSize: 19 },
|
||||
};
|
||||
|
||||
await applySyncPayload(payload, { importVaultData: () => {} });
|
||||
|
||||
const raw = localStorage.getItem(storageKeys.STORAGE_KEY_TERM_FONT_SIZE);
|
||||
assert.ok(raw);
|
||||
assert.equal(parseInt(raw, 10), 19);
|
||||
const record = parseTerminalFontSizeRecord(raw);
|
||||
assert.equal(record.fontSize, 19);
|
||||
assert.ok(record.version > 7);
|
||||
assert.equal(record.origin, "sync-payload");
|
||||
});
|
||||
|
||||
test("buildSyncPayload excludes externalAgents (device-local OS-bound config)", () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_AGENTS, JSON.stringify([
|
||||
{ id: "codex", name: "Codex", command: "/opt/homebrew/bin/codex", enabled: true },
|
||||
@@ -754,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({
|
||||
@@ -1301,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);
|
||||
});
|
||||
+210
-19
@@ -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';
|
||||
@@ -37,6 +38,15 @@ import { decryptField, encryptField } from '../infrastructure/persistence/secure
|
||||
import { sanitizeQuickMessages } from '../infrastructure/ai/quickMessages';
|
||||
import { emitAIStateChanged } from './state/aiStateEvents';
|
||||
import { rehydrateGlobalSftpBookmarks } from './state/sftp/globalSftpBookmarks';
|
||||
import {
|
||||
nextTerminalFontSizeSyncVersion,
|
||||
parseTerminalFontSizeRecord,
|
||||
serializeTerminalFontSizeRecord,
|
||||
} from './state/terminalFontSizeSync';
|
||||
import {
|
||||
parseCustomAccentRecord,
|
||||
serializeCustomAccentRecord,
|
||||
} from './state/customAccentSync';
|
||||
import {
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
@@ -117,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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -209,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',
|
||||
@@ -378,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);
|
||||
@@ -400,8 +434,10 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (termThemeLight) settings.terminalThemeLight = termThemeLight;
|
||||
const termFont = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY);
|
||||
if (termFont) settings.terminalFontFamily = termFont;
|
||||
const termSize = localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE);
|
||||
if (termSize != null) settings.terminalFontSize = termSize;
|
||||
const termSizeRaw = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_SIZE);
|
||||
if (termSizeRaw != null && termSizeRaw !== '') {
|
||||
settings.terminalFontSize = parseTerminalFontSizeRecord(termSizeRaw).fontSize;
|
||||
}
|
||||
const terminalSidePanelAutoOpen = localStorageAdapter.readBoolean(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN);
|
||||
if (terminalSidePanelAutoOpen != null) settings.terminalSidePanelAutoOpen = terminalSidePanelAutoOpen;
|
||||
const terminalSidePanelAutoOpenTab = localStorageAdapter.readString(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB);
|
||||
@@ -583,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);
|
||||
@@ -596,7 +642,20 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
|
||||
if (settings.terminalThemeDark != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_DARK, settings.terminalThemeDark);
|
||||
if (settings.terminalThemeLight != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_LIGHT, settings.terminalThemeLight);
|
||||
if (settings.terminalFontFamily != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, settings.terminalFontFamily);
|
||||
if (settings.terminalFontSize != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_SIZE, String(settings.terminalFontSize));
|
||||
if (settings.terminalFontSize != null) {
|
||||
const existing = parseTerminalFontSizeRecord(
|
||||
localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_SIZE),
|
||||
);
|
||||
localStorageAdapter.writeString(
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
serializeTerminalFontSizeRecord({
|
||||
fontSize: settings.terminalFontSize,
|
||||
// Bump so peer windows' version gates accept the synced value.
|
||||
version: nextTerminalFontSizeSyncVersion(existing.version, existing.version),
|
||||
origin: 'sync-payload',
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (settings.terminalSidePanelAutoOpen != null) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN, settings.terminalSidePanelAutoOpen);
|
||||
}
|
||||
@@ -857,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,
|
||||
@@ -876,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. */
|
||||
@@ -883,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -899,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
|
||||
@@ -944,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(
|
||||
|
||||
+5
-2
@@ -1,13 +1,16 @@
|
||||
!macro customInstall
|
||||
; Open a selected folder in a new Netcatty local terminal.
|
||||
; Args go after `--` so Electron/Chromium does not swallow them. Use
|
||||
; `--open-terminal-path="%1."` so paths with spaces stay one token and drive
|
||||
; roots (`C:\`) do not break Windows quote parsing.
|
||||
WriteRegStr SHCTX "Software\Classes\Directory\shell\Netcatty" "MUIVerb" "Open in Netcatty"
|
||||
WriteRegStr SHCTX "Software\Classes\Directory\shell\Netcatty" "Icon" "$INSTDIR\${APP_EXECUTABLE_FILENAME},0"
|
||||
WriteRegStr SHCTX "Software\Classes\Directory\shell\Netcatty\command" "" '"$INSTDIR\${APP_EXECUTABLE_FILENAME}" --open-terminal-path "%1"'
|
||||
WriteRegStr SHCTX "Software\Classes\Directory\shell\Netcatty\command" "" '"$INSTDIR\${APP_EXECUTABLE_FILENAME}" -- --open-terminal-path="%1."'
|
||||
|
||||
; Open the directory currently displayed by Explorer.
|
||||
WriteRegStr SHCTX "Software\Classes\Directory\Background\shell\Netcatty" "MUIVerb" "Open in Netcatty"
|
||||
WriteRegStr SHCTX "Software\Classes\Directory\Background\shell\Netcatty" "Icon" "$INSTDIR\${APP_EXECUTABLE_FILENAME},0"
|
||||
WriteRegStr SHCTX "Software\Classes\Directory\Background\shell\Netcatty\command" "" '"$INSTDIR\${APP_EXECUTABLE_FILENAME}" --open-terminal-path "%V"'
|
||||
WriteRegStr SHCTX "Software\Classes\Directory\Background\shell\Netcatty\command" "" '"$INSTDIR\${APP_EXECUTABLE_FILENAME}" -- --open-terminal-path="%V."'
|
||||
!macroend
|
||||
|
||||
!macro customUnInstall
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const panelSource = readFileSync(new URL('./AIChatPanelContent.tsx', import.meta.url), 'utf8');
|
||||
const selectorSource = readFileSync(new URL('./ai/AgentSelector.tsx', import.meta.url), 'utf8');
|
||||
const preparingSource = readFileSync(new URL('./AIChatSidePanel.tsx', import.meta.url), 'utf8');
|
||||
|
||||
const primaryToolbarSources = [
|
||||
panelSource,
|
||||
readFileSync(new URL('./ScriptsSidePanel.tsx', import.meta.url), 'utf8'),
|
||||
readFileSync(new URL('./HistorySidePanel.tsx', import.meta.url), 'utf8'),
|
||||
readFileSync(new URL('./SftpSidePanel.tsx', import.meta.url), 'utf8'),
|
||||
readFileSync(new URL('./notes/NotesManager.tsx', import.meta.url), 'utf8'),
|
||||
readFileSync(new URL('./systemManager/SystemManagerSidePanel.tsx', import.meta.url), 'utf8'),
|
||||
readFileSync(new URL('./terminal/ThemeSidePanel.tsx', import.meta.url), 'utf8'),
|
||||
];
|
||||
|
||||
test('AI chat header matches the compact system panel toolbar height', () => {
|
||||
assert.match(panelSource, /TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS/);
|
||||
assert.match(selectorSource, /className="group flex h-6/);
|
||||
assert.match(selectorSource, /truncate text-\[11px\]/);
|
||||
assert.match(panelSource, /className="h-6 w-6 rounded-md/);
|
||||
});
|
||||
|
||||
test('AI chat preparing state keeps the same compact header height', () => {
|
||||
assert.match(preparingSource, /TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS/);
|
||||
assert.match(preparingSource, /className="h-6 w-32/);
|
||||
});
|
||||
|
||||
test('agent selector menu stays compact with restrained corners', () => {
|
||||
assert.match(selectorSource, /w-\[256px\].*rounded-md/);
|
||||
assert.match(selectorSource, /flex h-9 w-full items-center gap-2\.5 px-3/);
|
||||
assert.doesNotMatch(selectorSource, /rounded-lg/);
|
||||
assert.doesNotMatch(selectorSource, /rounded-2xl/);
|
||||
});
|
||||
|
||||
test('terminal side panel tools share one primary toolbar height', () => {
|
||||
for (const source of primaryToolbarSources) {
|
||||
assert.match(source, /TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS/);
|
||||
}
|
||||
});
|
||||
@@ -11,6 +11,8 @@ import ChatInput from './ai/ChatInput';
|
||||
import ChatMessageList from './ai/ChatMessageList';
|
||||
import ConversationExport from './ai/ConversationExport';
|
||||
import { SessionHistoryDrawer, formatRelativeTime } from './AIChatSessionHistoryDrawer';
|
||||
import { cn } from '../lib/utils';
|
||||
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from './terminalLayer/terminalSidePanelChrome';
|
||||
import {
|
||||
getAIPanelDiagnosticHiddenParts,
|
||||
getAIPanelProfilerProps,
|
||||
@@ -163,7 +165,10 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
{/* ── Header ── */}
|
||||
{!hideHeader && (
|
||||
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Header')}>
|
||||
<div className="px-2.5 py-1.5 flex items-center justify-between border-b border-border/50 shrink-0">
|
||||
<div className={cn(
|
||||
TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS,
|
||||
'px-2 flex items-center justify-between border-b border-border/50',
|
||||
)}>
|
||||
<AgentSelector
|
||||
currentAgentId={currentAgentId}
|
||||
externalAgents={externalAgents}
|
||||
@@ -178,16 +183,17 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
<ConversationExport
|
||||
session={activeSession}
|
||||
onExport={handleExport}
|
||||
className="h-6 w-6 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground [&_svg]:size-3"
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
|
||||
className="h-6 w-6 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
>
|
||||
<History size={14} />
|
||||
<History size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.sessionHistory')}</TooltipContent>
|
||||
@@ -197,10 +203,10 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md text-primary/82 hover:bg-primary/[0.10] hover:text-primary"
|
||||
className="h-6 w-6 rounded-md text-primary/82 hover:bg-primary/[0.10] hover:text-primary"
|
||||
onClick={handleNewChat}
|
||||
>
|
||||
<Plus size={15} />
|
||||
<Plus size={13} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.newChat')}</TooltipContent>
|
||||
|
||||
@@ -74,6 +74,7 @@ import {
|
||||
type SdkRuntimeModelCatalog,
|
||||
} from './AIChatSidePanelHelpers';
|
||||
import { AIChatPanelContent } from './AIChatPanelContent';
|
||||
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from './terminalLayer/terminalSidePanelChrome';
|
||||
import {
|
||||
getAIPanelProfilerProps,
|
||||
profileAIPanelCalculation,
|
||||
@@ -230,8 +231,8 @@ const AIChatSidePanelPreparing = React.memo(function AIChatSidePanelPreparing()
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-background" data-section="ai-chat-panel-preparing">
|
||||
<div className="shrink-0 border-b border-border/50 px-2.5 py-1.5">
|
||||
<div className="h-8 w-36 rounded-md bg-muted/45" />
|
||||
<div className={`${TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS} border-b border-border/50 px-2 flex items-center`}>
|
||||
<div className="h-6 w-32 rounded-md bg-muted/45" />
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -91,6 +91,7 @@ test('shouldLoadSdkRuntimeModels includes SDK agents with model catalogs', () =>
|
||||
assert.equal(shouldLoadSdkRuntimeModels(agent('cursor')), true);
|
||||
assert.equal(shouldLoadSdkRuntimeModels(agent('codebuddy')), true);
|
||||
assert.equal(shouldLoadSdkRuntimeModels(agent('opencode')), true);
|
||||
assert.equal(shouldLoadSdkRuntimeModels(agent('grok')), true);
|
||||
assert.equal(shouldLoadSdkRuntimeModels(agent('codex')), false);
|
||||
assert.equal(shouldLoadSdkRuntimeModels({ ...agent('codex'), codexRuntime: 'app-server' }), true);
|
||||
assert.equal(shouldLoadSdkRuntimeModels(undefined), false);
|
||||
|
||||
@@ -83,6 +83,7 @@ export function buildSdkRuntimeModelCacheKey(agent: {
|
||||
acpCommand?: string;
|
||||
env?: Record<string, string>;
|
||||
codexRuntime?: 'sdk' | 'app-server';
|
||||
grokRuntime?: 'acp' | 'streaming-json';
|
||||
cursorAuthMode?: 'cli-login' | 'api-key';
|
||||
}): string {
|
||||
const sdkBackend = agent.sdkBackend || agent.acpCommand || '';
|
||||
@@ -92,7 +93,10 @@ export function buildSdkRuntimeModelCacheKey(agent: {
|
||||
const cursorAuth = sdkBackend === 'cursor'
|
||||
? (agent.cursorAuthMode === 'cli-login' ? 'cli-login' : 'api-key')
|
||||
: '';
|
||||
return [agent.id, sdkBackend, agent.command ?? '', agent.codexRuntime ?? 'sdk', cursorAuth, ...envHints].join('\u0000');
|
||||
const grokRuntime = sdkBackend === 'grok'
|
||||
? (agent.grokRuntime === 'streaming-json' ? 'streaming-json' : 'acp')
|
||||
: '';
|
||||
return [agent.id, sdkBackend, agent.command ?? '', agent.codexRuntime ?? 'sdk', grokRuntime, cursorAuth, ...envHints].join('\u0000');
|
||||
}
|
||||
|
||||
export function createSdkRuntimeModelCache(options: SdkRuntimeModelCacheOptions = {}) {
|
||||
@@ -189,7 +193,8 @@ export function shouldLoadSdkRuntimeModels(agent?: ExternalAgentConfig): boolean
|
||||
|| sdkBackend === 'copilot'
|
||||
|| sdkBackend === 'cursor'
|
||||
|| sdkBackend === 'codebuddy'
|
||||
|| sdkBackend === 'opencode';
|
||||
|| sdkBackend === 'opencode'
|
||||
|| sdkBackend === 'grok';
|
||||
}
|
||||
|
||||
export function shouldAdoptSdkCurrentModel(
|
||||
|
||||
@@ -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 {
|
||||
@@ -30,6 +35,7 @@ import {
|
||||
type VariableSizeVirtualListHandle,
|
||||
} from './ui/VariableSizeVirtualList';
|
||||
import { Input } from './ui/input';
|
||||
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from './terminalLayer/terminalSidePanelChrome';
|
||||
|
||||
export type HistoryPanelScope = 'host' | 'global';
|
||||
|
||||
@@ -39,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). */
|
||||
@@ -59,6 +66,7 @@ const DETAIL_ACTIONS_HEIGHT = 24;
|
||||
interface HistoryPanelEntry {
|
||||
id: string;
|
||||
command: string;
|
||||
hostId?: string;
|
||||
timestamp?: number;
|
||||
hostLabel?: string;
|
||||
}
|
||||
@@ -106,6 +114,7 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
|
||||
state,
|
||||
globalEntries,
|
||||
onFetch,
|
||||
onDeleteGlobalEntry,
|
||||
onPasteToTerminal,
|
||||
onRunInTerminal,
|
||||
isVisible = true,
|
||||
@@ -184,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;
|
||||
@@ -215,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],
|
||||
);
|
||||
@@ -241,7 +266,10 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
|
||||
data-section="history-panel"
|
||||
data-history-scope={scope}
|
||||
>
|
||||
<div className="shrink-0 px-2 py-1.5 border-b border-border/50 flex items-center gap-1.5">
|
||||
<div className={cn(
|
||||
TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS,
|
||||
'px-2 border-b border-border/50 flex items-center gap-1.5',
|
||||
)}>
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<Search
|
||||
size={12}
|
||||
@@ -251,7 +279,7 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('history.searchPlaceholder')}
|
||||
className="h-7 pl-7 text-xs bg-muted/30 border-none"
|
||||
className="h-6 pl-7 text-xs bg-muted/30 border-none"
|
||||
/>
|
||||
</div>
|
||||
{scope === 'host' && (
|
||||
@@ -261,7 +289,7 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
|
||||
disabled={!isSupportedSession || state.loading}
|
||||
title={t('history.action.refresh')}
|
||||
aria-label={t('history.action.refresh')}
|
||||
className="shrink-0 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors disabled:opacity-40 disabled:hover:text-muted-foreground disabled:hover:bg-transparent"
|
||||
className="shrink-0 h-6 w-6 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors disabled:opacity-40 disabled:hover:text-muted-foreground disabled:hover:bg-transparent"
|
||||
>
|
||||
<RefreshCw size={14} className={cn(state.loading && 'animate-spin')} />
|
||||
</button>
|
||||
@@ -354,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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -367,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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
@@ -412,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"
|
||||
@@ -452,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>
|
||||
),
|
||||
@@ -462,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;
|
||||
@@ -527,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>
|
||||
);
|
||||
|
||||
@@ -29,3 +29,8 @@ test('editing enabled SSH agent controls persists the enabled state', () => {
|
||||
test('enabling SSH agent login clears an imported none sentinel', () => {
|
||||
assert.match(source, /resolveSshAgentToggleUpdate/);
|
||||
});
|
||||
|
||||
test('login and forwarding warnings use independent agent status', () => {
|
||||
assert.match(source, /systemSshAgentEnabled && sshAgentStatus && !sshAgentStatus\.running/);
|
||||
assert.match(source, /form\.agentForwarding && sshForwardingAgentStatus && !sshForwardingAgentStatus\.running/);
|
||||
});
|
||||
@@ -41,6 +41,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
effectiveFontSize,
|
||||
hasEffectiveFontSizeOverride,
|
||||
sshAgentStatus,
|
||||
sshForwardingAgentStatus,
|
||||
effectiveGroupDefaults,
|
||||
effectiveAuthMethod,
|
||||
showAlgorithmOverrides,
|
||||
@@ -319,7 +320,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
enabled={!!form.agentForwarding}
|
||||
onToggle={() => update("agentForwarding", !form.agentForwarding)}
|
||||
/>
|
||||
{form.agentForwarding && sshAgentStatus && !sshAgentStatus.running && (
|
||||
{form.agentForwarding && sshForwardingAgentStatus && !sshForwardingAgentStatus.running && (
|
||||
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
|
||||
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -4,10 +4,14 @@ import test from 'node:test';
|
||||
|
||||
const source = readFileSync(new URL('./HostDetailsPanel.tsx', import.meta.url), 'utf8');
|
||||
|
||||
test('SSH agent availability ignores stale async responses', () => {
|
||||
assert.match(source, /let cancelled = false/);
|
||||
test('login and forwarding agent availability are checked independently', () => {
|
||||
assert.equal(source.match(/let cancelled = false/g)?.length, 2);
|
||||
assert.match(source, /if \(!cancelled\) setSshAgentStatus\(status\)/);
|
||||
assert.match(source, /return \(\) => \{\s*cancelled = true/);
|
||||
assert.match(source, /if \(!cancelled\) setSshForwardingAgentStatus\(status\)/);
|
||||
assert.match(source, /if \(form\.agentForwarding \|\| form\.useSshAgent === true\) \{\s*void checkSshAgent\(\{\s*identityAgent: form\.useSshAgent === true \? form\.identityAgent : undefined,\s*hostname:/);
|
||||
assert.match(source, /if \(form\.agentForwarding\) \{\s*void checkSshAgent\(\{\s*identityAgent: form\.identityAgent,\s*agentForwarding: true,/);
|
||||
assert.equal(source.match(/return \(\) => \{\s*cancelled = true/g)?.length, 2);
|
||||
assert.match(source, /sshForwardingAgentStatus=\{sshForwardingAgentStatus\}/);
|
||||
});
|
||||
|
||||
test('inherited identity actions use the resolved authentication username', () => {
|
||||
|
||||
+130
-36
@@ -9,9 +9,9 @@ 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 } from "../application/state/useApplicationBackend";
|
||||
import { useApplicationBackend, type SshAgentStatus } from "../application/state/useApplicationBackend";
|
||||
import { applyGroupDefaults, resolveGroupDefaults, resolveGroupTerminalThemeId } from "../domain/groupConfig";
|
||||
import {
|
||||
getEffectiveHostDistro,
|
||||
@@ -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) ||
|
||||
@@ -192,11 +195,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
const [newGroupName, setNewGroupName] = useState("");
|
||||
const [newGroupParent, setNewGroupParent] = useState("");
|
||||
|
||||
const [sshAgentStatus, setSshAgentStatus] = useState<{
|
||||
running: boolean;
|
||||
startupType: string | null;
|
||||
error: string | null;
|
||||
} | null>(null);
|
||||
const [sshAgentStatus, setSshAgentStatus] = useState<SshAgentStatus | null>(null);
|
||||
const [sshForwardingAgentStatus, setSshForwardingAgentStatus] = useState<SshAgentStatus | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -217,10 +217,60 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
};
|
||||
}, [form.agentForwarding, form.useSshAgent, form.identityAgent, form.hostname, form.port, form.username, checkSshAgent]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (form.agentForwarding) {
|
||||
void checkSshAgent({
|
||||
identityAgent: form.identityAgent,
|
||||
agentForwarding: true,
|
||||
hostname: form.hostname,
|
||||
port: form.port,
|
||||
username: form.username,
|
||||
}).then((status) => {
|
||||
if (!cancelled) setSshForwardingAgentStatus(status);
|
||||
});
|
||||
} else {
|
||||
setSshForwardingAgentStatus(null);
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [form.agentForwarding, form.identityAgent, form.hostname, form.port, form.username, checkSshAgent]);
|
||||
|
||||
const [groupInputValue, setGroupInputValue] = useState(form.group || "");
|
||||
|
||||
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);
|
||||
@@ -576,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));
|
||||
};
|
||||
@@ -1050,6 +1143,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
effectiveFontSize={effectiveFontSize}
|
||||
hasEffectiveFontSizeOverride={hasEffectiveFontSizeOverride}
|
||||
sshAgentStatus={sshAgentStatus}
|
||||
sshForwardingAgentStatus={sshForwardingAgentStatus}
|
||||
effectiveGroupDefaults={effectiveGroupDefaults}
|
||||
effectiveAuthMethod={effectiveAuthMethod}
|
||||
showAlgorithmOverrides={showAlgorithmOverrides}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -30,6 +30,7 @@ import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { SnippetCommandTooltipContent } from './snippets/SnippetCommandTooltipContent';
|
||||
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from './terminalLayer/terminalSidePanelChrome';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
|
||||
|
||||
const SCRIPT_ROW_HEIGHT = 34;
|
||||
@@ -719,11 +720,14 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
data-section="snippets-panel"
|
||||
>
|
||||
{/* Sub view tabs */}
|
||||
<div className="shrink-0 px-2 py-1 border-b border-border/50 flex items-center gap-1">
|
||||
<div className={cn(
|
||||
TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS,
|
||||
'px-2 border-b border-border/50 flex items-center gap-1',
|
||||
)}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex-1 h-7 rounded-md text-xs',
|
||||
'flex-1 h-6 rounded-md text-[11px]',
|
||||
subView === 'library' ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:bg-muted/50',
|
||||
)}
|
||||
onClick={() => setSubView('library')}
|
||||
@@ -733,7 +737,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex-1 h-7 rounded-md text-xs',
|
||||
'flex-1 h-6 rounded-md text-[11px]',
|
||||
subView === 'running' ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:bg-muted/50',
|
||||
)}
|
||||
onClick={() => setSubView('running')}
|
||||
|
||||
@@ -570,6 +570,9 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
setSshDeepLinkEnabled={settings.setSshDeepLinkEnabled}
|
||||
jmsDeepLinkEnabled={settings.jmsDeepLinkEnabled}
|
||||
setJmsDeepLinkEnabled={settings.setJmsDeepLinkEnabled}
|
||||
explorerContextMenuEnabled={settings.explorerContextMenuEnabled}
|
||||
setExplorerContextMenuEnabled={settings.setExplorerContextMenuEnabled}
|
||||
explorerContextMenuSupported={settings.explorerContextMenuSupported}
|
||||
restorePreviousSession={settings.restorePreviousSession}
|
||||
setRestorePreviousSession={settings.setRestorePreviousSession}
|
||||
restoreTerminalCwd={settings.restoreTerminalCwd}
|
||||
|
||||
@@ -10,8 +10,18 @@
|
||||
* 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";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useSftpState } from "../application/state/useSftpState";
|
||||
@@ -102,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;
|
||||
@@ -149,6 +161,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
onInitialLocationApplied,
|
||||
onCurrentPathChange,
|
||||
onActiveTransfersChange,
|
||||
onActiveExternalEditsChange,
|
||||
showWorkspaceHostHeader = false,
|
||||
isVisible = true,
|
||||
renderOverlays = true,
|
||||
@@ -214,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({
|
||||
@@ -229,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,
|
||||
@@ -256,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,
|
||||
@@ -399,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.
|
||||
@@ -473,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-')) {
|
||||
@@ -1158,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);
|
||||
@@ -1572,7 +1600,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
>
|
||||
{showWorkspaceHostHeader && displayHost && (
|
||||
<div
|
||||
className="shrink-0 border-b border-border/50 bg-muted/20 px-3 py-1.5"
|
||||
className={`${TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS} border-b border-border/50 bg-muted/20 px-3 flex items-center`}
|
||||
data-section="terminal-sftp-host-header"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
@@ -1722,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
|
||||
|
||||
@@ -54,3 +54,9 @@ test('script code editor restores Electron clipboard paste for Cmd/Ctrl+V and na
|
||||
assert.match(codeEditorSource, /contextmenu:\s*false/);
|
||||
assert.match(codeEditorSource, /editor\.action\.clipboardPasteAction/);
|
||||
});
|
||||
|
||||
test('script code editor pastes into Monaco find widget instead of script body', () => {
|
||||
assert.match(codeEditorSource, /pasteForMonacoEditorCommand/);
|
||||
assert.match(codeEditorSource, /activeElement:\s*document\.activeElement/);
|
||||
assert.match(codeEditorSource, /pasteIntoEditor:\s*\(\)\s*=>\s*handlePasteRef\.current\(\)/);
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { getRunnableHostsForSnippet, snippetHasRunTargets } from '../domain/snippetTargets.ts';
|
||||
import { removeHostConnectScript, syncHostsForSnippetTargetChange } from '../domain/hostConnectScripts.ts';
|
||||
import { flattenSnippetCommandPreview } from '../domain/snippetPreview.ts';
|
||||
import { deleteSelectedSnippetsFromVault } from '../domain/snippetSelection.ts';
|
||||
import { DEFAULT_SCRIPT_TEMPLATE, isScriptSnippet } from '../domain/snippetScript.ts';
|
||||
import { reorderVaultItems, reorderVaultStrings, sortByVaultOrder } from '../domain/vaultOrder';
|
||||
import { Button } from './ui/button';
|
||||
@@ -510,11 +511,11 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
const [draggingPackagePath, setDraggingPackagePath] = useState<string | null>(null);
|
||||
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
|
||||
const [selectedSnippetIds, setSelectedSnippetIds] = useState<Set<string>>(new Set());
|
||||
const [deleteTarget, setDeleteTarget] = useState<{
|
||||
type: 'snippet' | 'package';
|
||||
id: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<
|
||||
| { type: 'snippet' | 'package'; id: string; name: string }
|
||||
| { type: 'selection'; ids: string[] }
|
||||
| null
|
||||
>(null);
|
||||
const [isSnippetImportDialogOpen, setIsSnippetImportDialogOpen] = useState(false);
|
||||
const [pendingImport, setPendingImport] = useState<PendingSnippetImport | null>(null);
|
||||
const prepareGridLayoutAnimation = useVaultGridLayoutAnimation(listRef);
|
||||
@@ -1252,11 +1253,38 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
});
|
||||
};
|
||||
|
||||
const requestDeleteSelectedSnippets = () => {
|
||||
const ids = snippets
|
||||
.filter((snippet) => selectedSnippetIds.has(snippet.id))
|
||||
.map((snippet) => snippet.id);
|
||||
if (ids.length === 0) return;
|
||||
setDeleteTarget({ type: 'selection', ids });
|
||||
};
|
||||
|
||||
const existingDeleteSelectionIds = deleteTarget?.type === 'selection'
|
||||
? deleteTarget.ids.filter((id) => snippets.some((snippet) => snippet.id === id))
|
||||
: [];
|
||||
|
||||
const confirmDeleteTarget = () => {
|
||||
if (!deleteTarget) return;
|
||||
|
||||
if (deleteTarget.type === 'package') {
|
||||
performDeletePackage(deleteTarget.id);
|
||||
} else if (deleteTarget.type === 'selection') {
|
||||
const deletedIds = new Set(deleteTarget.ids);
|
||||
const result = deleteSelectedSnippetsFromVault(snippets, hosts, deletedIds);
|
||||
if (result.deletedCount === 0) {
|
||||
clearSnippetSelection();
|
||||
setDeleteTarget(null);
|
||||
return;
|
||||
}
|
||||
onBulkSave(result.snippets);
|
||||
onUpdateHosts?.(result.hosts);
|
||||
if (editingSnippet.id && deletedIds.has(editingSnippet.id)) {
|
||||
handleClosePanel();
|
||||
}
|
||||
toast.success(t('snippets.selection.deleteSuccess', { count: result.deletedCount }));
|
||||
clearSnippetSelection();
|
||||
} else {
|
||||
onDelete(deleteTarget.id);
|
||||
setSelectedSnippetIds((prev) => {
|
||||
@@ -1820,6 +1848,16 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
<Download size={12} className="mr-1" />
|
||||
{t('snippets.selection.exportSelected', { count: selectedSnippetIds.size })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
disabled={selectedSnippetIds.size === 0}
|
||||
onClick={requestDeleteSelectedSnippets}
|
||||
>
|
||||
<Trash2 size={12} className="mr-1" />
|
||||
{t('snippets.selection.deleteSelected', { count: selectedSnippetIds.size })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -2138,12 +2176,14 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
|
||||
<VaultDeleteConfirmDialog
|
||||
open={Boolean(deleteTarget)}
|
||||
title={t('vault.deleteConfirm.title', {
|
||||
name: deleteTarget?.name ?? '',
|
||||
})}
|
||||
title={deleteTarget?.type === 'selection'
|
||||
? t('snippets.selection.deleteConfirmTitle', { count: existingDeleteSelectionIds.length })
|
||||
: t('vault.deleteConfirm.title', { name: deleteTarget?.name ?? '' })}
|
||||
description={
|
||||
deleteTarget?.type === 'package'
|
||||
? t('vault.deleteConfirm.packageDesc')
|
||||
: deleteTarget?.type === 'selection'
|
||||
? t('snippets.selection.deleteConfirmDesc')
|
||||
: t('vault.deleteConfirm.desc')
|
||||
}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
@@ -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=""
|
||||
|
||||
+81
-19
@@ -108,7 +108,12 @@ import {
|
||||
resumeScriptRun,
|
||||
stopScriptRun,
|
||||
} from "@/application/state/scriptAutomationCoordinator.ts";
|
||||
import { resolveConnectScriptsForHost, hasUnresolvedConnectScriptBindings } from "@/domain/hostConnectScripts.ts";
|
||||
import {
|
||||
hasUnresolvedConnectScriptBindings,
|
||||
resolveConnectScriptsForHost,
|
||||
shouldMarkConnectAutomationConsumed,
|
||||
shouldUseFreshSshConnectionForAutomation,
|
||||
} from "@/domain/hostConnectScripts.ts";
|
||||
import { isVaultInitialized } from "@/application/state/vaultInitStore.ts";
|
||||
import { netcattyBridge } from "@/infrastructure/services/netcattyBridge.ts";
|
||||
import { ScriptExecutionOverlay } from "./terminal/ScriptExecutionOverlay";
|
||||
@@ -352,6 +357,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const connectScriptsInFlightRef = useRef(false);
|
||||
const pendingScriptRunIdRef = useRef<string | null>(null);
|
||||
const pendingScriptHandledRef = useRef<Snippet | null>(null);
|
||||
const pendingScriptRef = useRef(pendingScript);
|
||||
const pendingScriptIdRef = useRef(pendingScriptId);
|
||||
pendingScriptRef.current = pendingScript;
|
||||
pendingScriptIdRef.current = pendingScriptId;
|
||||
const isPendingScriptAlreadyHandled = useCallback((snippet: Snippet) => {
|
||||
if (snippet.id) {
|
||||
return pendingScriptRunIdRef.current === snippet.id;
|
||||
}
|
||||
return pendingScriptHandledRef.current === snippet;
|
||||
}, []);
|
||||
// Mosh marks status=connected during the SSH handshake so interactive
|
||||
// prompts remain reachable. Connect/pending scripts must wait until
|
||||
// mosh-client is ready (#2199). closeSession clears preload ready
|
||||
@@ -724,6 +739,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const [progressValue, setProgressValue] = useState(15);
|
||||
const [isDisconnectedDialogDismissed, setIsDisconnectedDialogDismissed] = useState(false);
|
||||
const [connectionReuseFellBack, setConnectionReuseFellBack] = useState(false);
|
||||
const [connectionReuseAttemptSourceId, setConnectionReuseAttemptSourceId] = useState(
|
||||
reuseConnectionFromSessionId,
|
||||
);
|
||||
|
||||
const statusRef = useRef<TerminalSession["status"]>(status);
|
||||
statusRef.current = status;
|
||||
@@ -1042,6 +1060,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const passwordPickerEmptyText = t("terminal.passwordPicker.empty");
|
||||
const sudoHintText = t("terminal.sudoHint.pressEnter");
|
||||
const sessionStartersRef = useRef<ReturnType<typeof createTerminalSessionStarters> | null>(null);
|
||||
const reuseConnectionSourceRef = useRef(reuseConnectionFromSessionId);
|
||||
const reuseConnectionSourceAttemptedRef = useRef(false);
|
||||
const previousReuseConnectionSourcePropRef = useRef(reuseConnectionFromSessionId);
|
||||
if (previousReuseConnectionSourcePropRef.current !== reuseConnectionFromSessionId) {
|
||||
previousReuseConnectionSourcePropRef.current = reuseConnectionFromSessionId;
|
||||
reuseConnectionSourceRef.current = reuseConnectionFromSessionId;
|
||||
reuseConnectionSourceAttemptedRef.current = false;
|
||||
}
|
||||
const auth = useTerminalAuthState({
|
||||
host,
|
||||
pendingAuthRef,
|
||||
@@ -2137,7 +2163,29 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
knownHosts,
|
||||
resolvedChainHosts,
|
||||
sessionId,
|
||||
reuseConnectionFromSessionId,
|
||||
reuseConnectionFromSessionIdRef: reuseConnectionSourceRef,
|
||||
reuseConnectionSourceAttemptedRef,
|
||||
setConnectionReuseAttemptSourceId,
|
||||
shouldUseFreshSshConnection: () => {
|
||||
const currentPendingScript = pendingScriptRef.current;
|
||||
const currentPendingScriptId = pendingScriptIdRef.current;
|
||||
const hasUnhandledPendingScript = currentPendingScript && isScriptSnippet(currentPendingScript)
|
||||
? !isPendingScriptAlreadyHandled(currentPendingScript)
|
||||
: Boolean(
|
||||
currentPendingScriptId
|
||||
&& pendingScriptRunIdRef.current !== currentPendingScriptId,
|
||||
);
|
||||
return shouldUseFreshSshConnectionForAutomation({
|
||||
host: hostRef.current,
|
||||
snippets: snippetsRef.current,
|
||||
vaultInitialized: isVaultInitialized(),
|
||||
hasPendingScript: hasUnhandledPendingScript,
|
||||
connectAutomationConsumed: connectScriptsConsumedRef.current,
|
||||
});
|
||||
},
|
||||
onConnectAutomationSnapshotCommitted: () => {
|
||||
connectScriptsConsumedRef.current = true;
|
||||
},
|
||||
isNetworkDevice,
|
||||
startupCommand,
|
||||
noAutoRun,
|
||||
@@ -2314,13 +2362,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
pendingScriptHandledRef.current = null;
|
||||
}, [pendingScript?.id, pendingScriptId]);
|
||||
|
||||
const isPendingScriptAlreadyHandled = useCallback((snippet: Snippet) => {
|
||||
if (snippet.id) {
|
||||
return pendingScriptRunIdRef.current === snippet.id;
|
||||
}
|
||||
return pendingScriptHandledRef.current === snippet;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== 'connected') return;
|
||||
if (effectiveTerminalProtocol === 'mosh' && !moshShellReady) return;
|
||||
@@ -2337,6 +2378,22 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedConnectScripts = resolveConnectScriptsForHost(host, snippets);
|
||||
// An empty hydrated vault is a final decision for this connection. Lock it
|
||||
// before the output-settling delay so a sync arriving during that window
|
||||
// cannot inject a new connect script into an already-reused SSH session.
|
||||
if (
|
||||
!connectScriptsConsumedRef.current
|
||||
&& resolvedConnectScripts.length === 0
|
||||
&& shouldMarkConnectAutomationConsumed({
|
||||
allConnectScriptsDone: true,
|
||||
vaultInitialized: isVaultInitialized(),
|
||||
hasUnresolvedBindings: hasUnresolvedConnectScriptBindings(host, snippets),
|
||||
})
|
||||
) {
|
||||
connectScriptsConsumedRef.current = true;
|
||||
}
|
||||
|
||||
const shouldEvaluateConnect = !connectScriptsConsumedRef.current;
|
||||
const hasPendingWork = Boolean(pendingOne);
|
||||
if (!shouldEvaluateConnect && !hasPendingWork) return;
|
||||
@@ -2348,7 +2405,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const runPending = Boolean(pendingOne);
|
||||
const connectQueueNow = connectScriptsConsumedRef.current
|
||||
? []
|
||||
: resolveConnectScriptsForHost(host, snippets).filter(
|
||||
: resolvedConnectScripts.filter(
|
||||
(item) => item.id && !connectScriptsCompletedIdsRef.current.has(item.id),
|
||||
);
|
||||
|
||||
@@ -2360,20 +2417,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
scriptsToRun.push(pendingOne);
|
||||
}
|
||||
|
||||
const resolvedConnectScripts = resolveConnectScriptsForHost(host, snippets);
|
||||
const allConnectScriptsDone = resolvedConnectScripts.length === 0
|
||||
|| resolvedConnectScripts.every(
|
||||
(item) => item.id && connectScriptsCompletedIdsRef.current.has(item.id),
|
||||
);
|
||||
|
||||
if (scriptsToRun.length === 0) {
|
||||
if (
|
||||
!connectScriptsConsumedRef.current
|
||||
&& allConnectScriptsDone
|
||||
&& isVaultInitialized()
|
||||
&& snippets.length > 0
|
||||
&& !hasUnresolvedConnectScriptBindings(host, snippets)
|
||||
) {
|
||||
if (!connectScriptsConsumedRef.current && shouldMarkConnectAutomationConsumed({
|
||||
allConnectScriptsDone,
|
||||
vaultInitialized: isVaultInitialized(),
|
||||
hasUnresolvedBindings: hasUnresolvedConnectScriptBindings(host, snippets),
|
||||
})) {
|
||||
connectScriptsConsumedRef.current = true;
|
||||
}
|
||||
return;
|
||||
@@ -2724,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;
|
||||
@@ -2889,6 +2945,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
passwordPromptActiveRef,
|
||||
isLocalConnection,
|
||||
supportsRemoteImagePaste,
|
||||
autoUploadClipboardImageOnPasteRef,
|
||||
terminalBackend,
|
||||
getRemoteCwd: () => resolveSftpInitialPath({ preferFreshBackend: true }),
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
@@ -3181,6 +3238,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
setIsDisconnectedDialogDismissed(false);
|
||||
setConnectionReuseFellBack(false);
|
||||
setConnectionReuseAttemptSourceId(undefined);
|
||||
updateStatus("connecting");
|
||||
setError(null);
|
||||
setProgressLogs((prev) => (
|
||||
@@ -3270,7 +3328,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
isSerialConnection,
|
||||
isDisconnectedDialogDismissed,
|
||||
hideConnectingDialogForConnectionReuse: shouldHideConnectingDialogForConnectionReuse({
|
||||
reuseConnectionFromSessionId,
|
||||
reuseConnectionFromSessionId: connectionReuseAttemptSourceId,
|
||||
host,
|
||||
connectionReuseFellBack,
|
||||
}),
|
||||
@@ -3309,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 () => {
|
||||
|
||||
+222
-92
@@ -90,6 +90,7 @@ import {
|
||||
listInvalidSftpPanelTabIds,
|
||||
listTerminalTabIdsWithRetainingTransfers,
|
||||
resolveSftpActiveTransfersCount,
|
||||
shouldCloseSftpSidePanel,
|
||||
shouldClearSftpPanelAfterTransferChange,
|
||||
shouldKeepSftpMountedAfterClose,
|
||||
shouldScheduleSftpRetainedPanelCleanup,
|
||||
@@ -106,6 +107,16 @@ import {
|
||||
import { shouldProbeCommandCwd } from './terminalLayer/commandCwdProbe';
|
||||
import { resolvePreferredTerminalCwd, scheduleBackendCwdProbeAfterCommand } from './terminal/sftpCwd';
|
||||
import { classifyDistroId, shouldProbeSessionCwd } from '../domain/host';
|
||||
import {
|
||||
collectSidePanelPanes,
|
||||
sidePanelLayoutHasTool,
|
||||
type SidePanelSplitDirection,
|
||||
} from '../domain/sidePanelLayout';
|
||||
import { useTerminalSidePanelLayoutState } from '../application/state/useTerminalSidePanelLayoutState';
|
||||
import {
|
||||
TERMINAL_SIDE_PANEL_MAX_WIDTH,
|
||||
TERMINAL_SIDE_PANEL_MIN_WIDTH,
|
||||
} from '../application/state/terminalSidePanelWidth';
|
||||
|
||||
import {
|
||||
AIChatPanelsHost,
|
||||
@@ -205,6 +216,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onUpdateHost,
|
||||
onAddKnownHost,
|
||||
onCommandExecuted,
|
||||
onDeleteShellHistoryEntry,
|
||||
onTerminalDataCapture,
|
||||
onCreateWorkspaceFromSessions,
|
||||
onAddSessionToWorkspace,
|
||||
@@ -253,6 +265,19 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onRemoveSessionFromWorkspace,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
// Side panel state must be initialized before any callbacks reference its
|
||||
// setters or refs in their dependency arrays.
|
||||
const {
|
||||
sidePanelOpenTabs,
|
||||
setSidePanelOpenTabs,
|
||||
sidePanelOpenTabsRef,
|
||||
sidePanelLayouts,
|
||||
sidePanelLayoutsRef,
|
||||
focusPane: focusSidePanelPaneForTab,
|
||||
splitPane: splitSidePanelPaneForTab,
|
||||
closePane: closeSidePanelPaneForTab,
|
||||
resizeSplit: resizeSidePanelSplitForTab,
|
||||
} = useTerminalSidePanelLayoutState();
|
||||
const terminalRendererCwdBySessionRef = useRef<Map<string, string>>(new Map());
|
||||
const stableRef = useRef<Record<string, unknown>>({});
|
||||
const activeTabIdRef = useRef(activeTabStore.getActiveTabId());
|
||||
@@ -429,7 +454,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
next.set(tabId, targetPanel);
|
||||
return next;
|
||||
});
|
||||
}, [onUpdateSessionStatus]);
|
||||
}, [onUpdateSessionStatus, setSidePanelOpenTabs, sidePanelOpenTabsRef]);
|
||||
|
||||
const handleSessionExit = useCallback((sessionId: string, evt: TerminalSessionExitEvent) => {
|
||||
const intent = resolveTerminalSessionExitIntent(
|
||||
@@ -518,9 +543,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const onSetWorkspaceFocusedSessionRef = useRef(onSetWorkspaceFocusedSession);
|
||||
onSetWorkspaceFocusedSessionRef.current = onSetWorkspaceFocusedSession;
|
||||
|
||||
// Side panel state - per-tab tracking of which sub-panel is active
|
||||
// Maps tab IDs to the active sub-panel type (sftp/scripts/theme), absent = closed
|
||||
const [sidePanelOpenTabs, setSidePanelOpenTabs] = useState<Map<string, SidePanelTab>>(new Map());
|
||||
// Keep AI/scripts/theme panels mounted while switching sub-tabs (like SFTP).
|
||||
const [aiMountedTabIds, setAiMountedTabIds] = useState<string[]>([]);
|
||||
const [scriptsMountedTabIds, setScriptsMountedTabIds] = useState<string[]>([]);
|
||||
@@ -528,16 +550,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const [themeMountedTabIds, setThemeMountedTabIds] = useState<string[]>([]);
|
||||
const [notesMountedTabIds, setNotesMountedTabIds] = useState<string[]>([]);
|
||||
const [sidePanelWidth, setSidePanelWidth, persistSidePanelWidth] = useStoredNumber(
|
||||
STORAGE_KEY_SIDE_PANEL_WIDTH, 420, { min: 280, max: 800 },
|
||||
STORAGE_KEY_SIDE_PANEL_WIDTH,
|
||||
420,
|
||||
{ min: TERMINAL_SIDE_PANEL_MIN_WIDTH, max: TERMINAL_SIDE_PANEL_MAX_WIDTH },
|
||||
);
|
||||
const [sidePanelPosition, setSidePanelPosition] = useStoredString<'left' | 'right'>(
|
||||
'netcatty_side_panel_position',
|
||||
'left',
|
||||
(v): v is 'left' | 'right' => v === 'left' || v === 'right',
|
||||
);
|
||||
const sidePanelOpenTabsRef = useRef(sidePanelOpenTabs);
|
||||
sidePanelOpenTabsRef.current = sidePanelOpenTabs;
|
||||
|
||||
// Remember the last sub-panel shown per tab so the toggle shortcut can
|
||||
// restore it after a close. Overwritten on open, never cleared on close.
|
||||
const lastSidePanelTabRef = useRef<Map<string, SidePanelTab>>(new Map());
|
||||
@@ -558,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());
|
||||
@@ -592,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 => {
|
||||
@@ -624,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),
|
||||
};
|
||||
@@ -648,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)
|
||||
@@ -666,7 +698,84 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}, SFTP_TRANSFER_HISTORY_RETENTION_MS);
|
||||
sftpRetainedCleanupTimersRef.current.set(tabId, cleanupTimer);
|
||||
}
|
||||
}, [clearSftpPanelState, resolveTabActiveTransfersCount]);
|
||||
}, [
|
||||
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) => {
|
||||
const next = new Map(previous);
|
||||
next.delete(tabId);
|
||||
return next;
|
||||
});
|
||||
sftpOpeningTabIdsRef.current.delete(tabId);
|
||||
const activeTransfersCount = resolveTabActiveTransfersCount(tabId);
|
||||
const activeExternalEditCount = resolveTabActiveExternalEditCount(tabId);
|
||||
if (shouldKeepSftpMountedAfterClose({ activeTransfersCount, activeExternalEditCount })) {
|
||||
sftpRetainedAfterCloseTabIdsRef.current.add(tabId);
|
||||
if (activeTransfersCount > 0) {
|
||||
sftpActiveTransfersByTabRef.current.set(tabId, activeTransfersCount);
|
||||
}
|
||||
if (activeExternalEditCount > 0) {
|
||||
sftpActiveExternalEditsByTabRef.current.set(tabId, activeExternalEditCount);
|
||||
}
|
||||
} else {
|
||||
clearSftpPanelState(tabId);
|
||||
}
|
||||
setAiMountedTabIds((previous) => removeMountedSidePanelTabId(previous, tabId));
|
||||
setScriptsMountedTabIds((previous) => removeMountedSidePanelTabId(previous, tabId));
|
||||
setThemeMountedTabIds((previous) => removeMountedSidePanelTabId(previous, tabId));
|
||||
setSystemMountedTabIds((previous) => removeMountedSidePanelTabId(previous, tabId));
|
||||
setNotesMountedTabIds((previous) => removeMountedSidePanelTabId(previous, tabId));
|
||||
setNotesOpenNoteByTab((previous) => {
|
||||
const next = new Map(previous);
|
||||
next.delete(tabId);
|
||||
return next;
|
||||
});
|
||||
notesReturnTabRef.current.delete(tabId);
|
||||
}, [
|
||||
clearSftpPanelState,
|
||||
resolveTabActiveExternalEditCount,
|
||||
resolveTabActiveTransfersCount,
|
||||
setSidePanelOpenTabs,
|
||||
]);
|
||||
|
||||
const handleToggleWorkspaceComposeBar = useCallback(() => {
|
||||
setIsComposeBarOpen(prev => !prev);
|
||||
@@ -702,45 +811,38 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
&& currentHost.sftpSudo === host.sftpSudo
|
||||
&& (currentHost.sftpFileProtocol || "auto") === (host.sftpFileProtocol || "auto");
|
||||
|
||||
const isClosing = !shouldKeepOpen && isOpen && isSameEndpoint;
|
||||
const activeTransfersCount = resolveTabActiveTransfersCount(tabId);
|
||||
const keepSftpMounted = isClosing
|
||||
&& shouldKeepSftpMountedAfterClose(activeTransfersCount);
|
||||
const currentLayout = sidePanelLayoutsRef.current.get(tabId);
|
||||
const paneCount = currentLayout
|
||||
? collectSidePanelPanes(currentLayout.root).length
|
||||
: 1;
|
||||
const isClosing = shouldCloseSftpSidePanel({
|
||||
shouldKeepOpen,
|
||||
isOpen,
|
||||
isSameEndpoint: !!isSameEndpoint,
|
||||
paneCount,
|
||||
});
|
||||
if (isClosing) {
|
||||
sftpOpeningTabIdsRef.current.delete(tabId);
|
||||
closeTerminalSidePanelTab(tabId);
|
||||
return;
|
||||
}
|
||||
if (keepSftpMounted) {
|
||||
sftpRetainedAfterCloseTabIdsRef.current.add(tabId);
|
||||
sftpActiveTransfersByTabRef.current.set(tabId, activeTransfersCount);
|
||||
} else if (!isClosing) {
|
||||
sftpOpeningTabIdsRef.current.add(tabId);
|
||||
const cleanupTimer = sftpRetainedCleanupTimersRef.current.get(tabId);
|
||||
if (cleanupTimer !== undefined) {
|
||||
window.clearTimeout(cleanupTimer);
|
||||
sftpRetainedCleanupTimersRef.current.delete(tabId);
|
||||
}
|
||||
sftpRetainedAfterCloseTabIdsRef.current.delete(tabId);
|
||||
|
||||
sftpOpeningTabIdsRef.current.add(tabId);
|
||||
const cleanupTimer = sftpRetainedCleanupTimersRef.current.get(tabId);
|
||||
if (cleanupTimer !== undefined) {
|
||||
window.clearTimeout(cleanupTimer);
|
||||
sftpRetainedCleanupTimersRef.current.delete(tabId);
|
||||
}
|
||||
sftpRetainedAfterCloseTabIdsRef.current.delete(tabId);
|
||||
|
||||
setSidePanelOpenTabs(prev => {
|
||||
const next = new Map(prev);
|
||||
if (isClosing) {
|
||||
next.delete(tabId);
|
||||
} else {
|
||||
next.set(tabId, 'sftp');
|
||||
}
|
||||
next.set(tabId, 'sftp');
|
||||
return next;
|
||||
});
|
||||
|
||||
// Store or remove the host for this tab.
|
||||
// Removing on close unmounts the panel so SFTP sessions are cleaned up.
|
||||
setSftpHostForTab(prev => {
|
||||
const next = new Map(prev);
|
||||
if (isClosing && !keepSftpMounted) {
|
||||
next.delete(tabId);
|
||||
} else {
|
||||
next.set(tabId, host);
|
||||
}
|
||||
next.set(tabId, host);
|
||||
return next;
|
||||
});
|
||||
|
||||
@@ -764,8 +866,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
|
||||
setSftpPendingUploadsForTab(prev => {
|
||||
const next = new Map(prev);
|
||||
if (isClosing || !pendingUploadEntries?.length) {
|
||||
// Clear any stale pending upload on close or when opening without new files
|
||||
if (!pendingUploadEntries?.length) {
|
||||
// Clear any stale pending upload when opening without new files.
|
||||
next.delete(tabId);
|
||||
} else {
|
||||
next.set(tabId, {
|
||||
@@ -778,7 +880,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [resolveSftpOpenTarget, resolveTabActiveTransfersCount]);
|
||||
}, [closeTerminalSidePanelTab, resolveSftpOpenTarget, setSidePanelOpenTabs, sidePanelLayoutsRef, sidePanelOpenTabsRef]);
|
||||
|
||||
const handlePendingUploadHandled = useCallback((tabId: string, requestId: string) => {
|
||||
setSftpPendingUploadsForTab(prev => {
|
||||
@@ -938,7 +1040,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const session = sessionsRef.current.find((candidate) => candidate.id === sessionId);
|
||||
if (!session || !canReuseTerminalConnection(session)) return;
|
||||
const sessionHost = sessionHostsMapRef.current.get(sessionId);
|
||||
const visibleSftpHost = tabId && sidePanelOpenTabsRef.current.get(tabId) === 'sftp'
|
||||
const visibleSftpHost = tabId && sidePanelLayoutHasTool(sidePanelLayoutsRef.current.get(tabId), 'sftp')
|
||||
? sftpHostForTabRef.current.get(tabId) ?? null
|
||||
: null;
|
||||
if (!shouldProbeCommandCwd({
|
||||
@@ -978,7 +1080,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
},
|
||||
});
|
||||
cwdProbeCancelersRef.current.set(sessionId, cancelProbe);
|
||||
}, [codingCliSignalController, handleTerminalCwdChange, restoreTerminalCwd, terminalBackend]);
|
||||
}, [codingCliSignalController, handleTerminalCwdChange, restoreTerminalCwd, sidePanelLayoutsRef, terminalBackend]);
|
||||
|
||||
const handleCommandExecuted = useCallback((command: string, hostId: string, hostLabel: string, sessionId: string) => {
|
||||
onCommandExecuted?.(command, hostId, hostLabel, sessionId);
|
||||
@@ -1059,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({
|
||||
@@ -1196,33 +1301,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
if (!activeTabId) return;
|
||||
const sessionIdToRefocus = getActiveTerminalSessionId();
|
||||
syncWorkspaceFocusIfNeeded(sessionIdToRefocus);
|
||||
setSidePanelOpenTabs(prev => {
|
||||
const next = new Map(prev);
|
||||
next.delete(activeTabId);
|
||||
return next;
|
||||
});
|
||||
sftpOpeningTabIdsRef.current.delete(activeTabId);
|
||||
const activeTransfersCount = resolveTabActiveTransfersCount(activeTabId);
|
||||
if (shouldKeepSftpMountedAfterClose(activeTransfersCount)) {
|
||||
sftpRetainedAfterCloseTabIdsRef.current.add(activeTabId);
|
||||
// Seed the ref so subsequent zero reports cannot drop a live store owner.
|
||||
sftpActiveTransfersByTabRef.current.set(activeTabId, activeTransfersCount);
|
||||
} else {
|
||||
clearSftpPanelState(activeTabId);
|
||||
}
|
||||
setAiMountedTabIds((prev) => removeMountedSidePanelTabId(prev, activeTabId));
|
||||
setScriptsMountedTabIds((prev) => removeMountedSidePanelTabId(prev, activeTabId));
|
||||
setThemeMountedTabIds((prev) => removeMountedSidePanelTabId(prev, activeTabId));
|
||||
setSystemMountedTabIds((prev) => removeMountedSidePanelTabId(prev, activeTabId));
|
||||
setNotesMountedTabIds((prev) => removeMountedSidePanelTabId(prev, activeTabId));
|
||||
setNotesOpenNoteByTab((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(activeTabId);
|
||||
return next;
|
||||
});
|
||||
notesReturnTabRef.current.delete(activeTabId);
|
||||
closeTerminalSidePanelTab(activeTabId);
|
||||
refocusTerminalSession(sessionIdToRefocus);
|
||||
}, [clearSftpPanelState, getActiveTerminalSessionId, refocusTerminalSession, resolveTabActiveTransfersCount, syncWorkspaceFocusIfNeeded]);
|
||||
}, [closeTerminalSidePanelTab, getActiveTerminalSessionId, refocusTerminalSession, syncWorkspaceFocusIfNeeded]);
|
||||
|
||||
// Resolve the SFTP host for a tab: a previously-stored host, otherwise the
|
||||
// host of the workspace's focused session or the active session. null = none.
|
||||
@@ -1242,15 +1323,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
// Switch side panel to a specific tab (or toggle if already on that tab)
|
||||
const handleSwitchSidePanelTab = useCallback((tab: SidePanelTab) => {
|
||||
const tabId = activeTabIdRef.current;
|
||||
if (!tabId) return;
|
||||
const currentPanel = sidePanelOpenTabsRef.current.get(tabId);
|
||||
|
||||
// If already on this tab, do nothing — user must click X to close
|
||||
if (currentPanel === tab) return;
|
||||
|
||||
const prepareSidePanelTool = useCallback((tabId: string, tab: SidePanelTab): boolean => {
|
||||
if (tab === 'sftp') {
|
||||
sftpOpeningTabIdsRef.current.add(tabId);
|
||||
const cleanupTimer = sftpRetainedCleanupTimersRef.current.get(tabId);
|
||||
@@ -1290,8 +1363,21 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
// Keep the hidden SFTP panel mounted while switching sub-panels. A panel
|
||||
// closed during a transfer is also retained until the user reopens it and
|
||||
// explicitly closes the now-idle panel, preserving the completed history.
|
||||
|
||||
markSidePanelSubTabOpened(tabId, tab);
|
||||
return true;
|
||||
}, [getActiveTerminalSessionId, markSidePanelSubTabOpened, resolveSftpHostForTab, resolveSftpOpenTarget]);
|
||||
|
||||
// Switch only the focused pane. If the tool is already open elsewhere in
|
||||
// the tree, the domain helper focuses that pane instead of duplicating it.
|
||||
const handleSwitchSidePanelTab = useCallback((tab: SidePanelTab) => {
|
||||
const tabId = activeTabIdRef.current;
|
||||
if (!tabId) return;
|
||||
const currentPanel = sidePanelOpenTabsRef.current.get(tabId);
|
||||
|
||||
// If already on this tab, do nothing — user must click X to close
|
||||
if (currentPanel === tab) return;
|
||||
if (!prepareSidePanelTool(tabId, tab)) return;
|
||||
|
||||
startTransition(() => {
|
||||
setSidePanelOpenTabs(prev => {
|
||||
const next = new Map(prev);
|
||||
@@ -1299,7 +1385,44 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
return next;
|
||||
});
|
||||
});
|
||||
}, [getActiveTerminalSessionId, markSidePanelSubTabOpened, resolveSftpHostForTab, resolveSftpOpenTarget]);
|
||||
}, [prepareSidePanelTool, setSidePanelOpenTabs, sidePanelOpenTabsRef]);
|
||||
|
||||
const handleFocusSidePanelPane = useCallback((paneId: string) => {
|
||||
const tabId = activeTabIdRef.current;
|
||||
if (!tabId) return;
|
||||
focusSidePanelPaneForTab(tabId, paneId);
|
||||
}, [focusSidePanelPaneForTab]);
|
||||
|
||||
const handleSplitSidePanelPane = useCallback((
|
||||
tool: SidePanelTab,
|
||||
direction: SidePanelSplitDirection,
|
||||
availableAxisLength: number,
|
||||
) => {
|
||||
const tabId = activeTabIdRef.current;
|
||||
if (!tabId) return;
|
||||
if (!sidePanelLayoutsRef.current.has(tabId) || !prepareSidePanelTool(tabId, tool)) return;
|
||||
splitSidePanelPaneForTab(
|
||||
tabId,
|
||||
tool,
|
||||
direction,
|
||||
{ paneId: crypto.randomUUID(), splitId: crypto.randomUUID() },
|
||||
availableAxisLength,
|
||||
);
|
||||
}, [prepareSidePanelTool, sidePanelLayoutsRef, splitSidePanelPaneForTab]);
|
||||
|
||||
const handleCloseSidePanelPane = useCallback((paneId: string) => {
|
||||
const tabId = activeTabIdRef.current;
|
||||
if (!tabId) return;
|
||||
if (closeSidePanelPaneForTab(tabId, paneId)) {
|
||||
handleCloseSidePanel();
|
||||
}
|
||||
}, [closeSidePanelPaneForTab, handleCloseSidePanel]);
|
||||
|
||||
const handleResizeSidePanelSplit = useCallback((splitId: string, sizes: number[]) => {
|
||||
const tabId = activeTabIdRef.current;
|
||||
if (!tabId) return;
|
||||
resizeSidePanelSplitForTab(tabId, splitId, sizes);
|
||||
}, [resizeSidePanelSplitForTab]);
|
||||
|
||||
// Toggle SFTP from activity bar header
|
||||
const handleToggleSftpFromBar = useCallback(() => {
|
||||
@@ -1332,7 +1455,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
return next;
|
||||
});
|
||||
});
|
||||
}, [handleCloseSidePanel, markSidePanelSubTabOpened]);
|
||||
}, [handleCloseSidePanel, markSidePanelSubTabOpened, setSidePanelOpenTabs, sidePanelOpenTabsRef]);
|
||||
|
||||
// Toggle the whole side panel (new ⌘/Ctrl+\ shortcut). Close if open; if
|
||||
// closed, reopen the tab's last sub-panel, defaulting to SFTP (when a host is
|
||||
@@ -1352,7 +1475,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
// If the remembered panel is SFTP but no host is resolvable, use scripts.
|
||||
const target: SidePanelTab = intent.tab === 'sftp' && !sftpAvailable ? 'scripts' : intent.tab;
|
||||
handleSwitchSidePanelTab(target);
|
||||
}, [handleCloseSidePanel, handleSwitchSidePanelTab, resolveSftpHostForTab]);
|
||||
}, [handleCloseSidePanel, handleSwitchSidePanelTab, resolveSftpHostForTab, sidePanelOpenTabsRef]);
|
||||
|
||||
// Open theme side panel (called from Terminal toolbar)
|
||||
const handleOpenTheme = useCallback(() => {
|
||||
@@ -1381,7 +1504,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
notesReturnTabRef.current.set(tabId, currentPanel);
|
||||
}
|
||||
handleSwitchSidePanelTab('notes');
|
||||
}, [handleSwitchSidePanelTab]);
|
||||
}, [handleSwitchSidePanelTab, sidePanelOpenTabsRef]);
|
||||
|
||||
const handleBackFromNotes = useCallback(() => {
|
||||
const tabId = activeTabIdRef.current;
|
||||
@@ -1408,7 +1531,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
next.set(tabId, 'notes');
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
}, [setSidePanelOpenTabs]);
|
||||
|
||||
const handleOpenHostFromNotes = useCallback((host: Host, source?: { noteId?: string }) => {
|
||||
const sourceNoteId = source?.noteId;
|
||||
@@ -1460,7 +1583,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
notesReturnTabRef.current.set(intent.tabId, intent.returnPanel);
|
||||
}
|
||||
openNotesPanelForSourceNote(intent.tabId, intent.noteId);
|
||||
}, [onOpenVaultNoteFromChat, openNotesPanelForSourceNote]);
|
||||
}, [onOpenVaultNoteFromChat, openNotesPanelForSourceNote, sidePanelOpenTabsRef]);
|
||||
|
||||
const handleAddSelectionToAI = useCallback((sourceSessionId: string, selection: string) => {
|
||||
const text = selection.trim();
|
||||
@@ -1506,7 +1629,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}
|
||||
|
||||
handleSwitchSidePanelTab('ai');
|
||||
}, [handleCloseSidePanel, handleSwitchSidePanelTab]);
|
||||
}, [handleCloseSidePanel, handleSwitchSidePanelTab, sidePanelOpenTabsRef]);
|
||||
|
||||
// Execute snippet on the focused terminal session
|
||||
const handleSnippetClickForFocusedSession = useCallback((
|
||||
@@ -1885,6 +2008,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
handleCloseSession,
|
||||
handleCloseSidePanel,
|
||||
handleCommandExecuted,
|
||||
handleHistoryDelete: onDeleteShellHistoryEntry,
|
||||
handleCommandSubmitted,
|
||||
handleComposeSend,
|
||||
handleHistoryPaste,
|
||||
@@ -1896,6 +2020,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
handleOpenAI,
|
||||
handleOpenSystem,
|
||||
handleOpenNotes,
|
||||
handleFocusSidePanelPane,
|
||||
handleSplitSidePanelPane,
|
||||
handleCloseSidePanelPane,
|
||||
handleResizeSidePanelSplit,
|
||||
handleBackFromNotes,
|
||||
handleOpenHostFromNotes,
|
||||
handleOsDetected,
|
||||
@@ -1904,6 +2032,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
handleSessionExit,
|
||||
handleSftpCurrentPathChange,
|
||||
handleSftpActiveTransfersChange,
|
||||
handleSftpActiveExternalEditsChange,
|
||||
handleSftpInitialLocationApplied,
|
||||
persistSidePanelWidth,
|
||||
handleSnippetClickForFocusedSession,
|
||||
@@ -2018,6 +2147,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
setSftpPendingUploadsForTab,
|
||||
showHostTreeSidebar,
|
||||
sidePanelOpenTabs,
|
||||
sidePanelLayouts,
|
||||
sidePanelPosition,
|
||||
sidePanelWidth,
|
||||
sftpAutoSync,
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -31,13 +31,15 @@ export interface PromptInputProps extends HTMLAttributes<HTMLFormElement> {
|
||||
onSubmit: (text: string, event: FormEvent<HTMLFormElement>) => void | Promise<void>;
|
||||
/** Allow attachments that carry their own prompt context to submit without textarea text. */
|
||||
allowEmptySubmit?: boolean;
|
||||
/** Optional styling for the bordered input group inside the form. */
|
||||
inputGroupClassName?: string;
|
||||
}
|
||||
|
||||
export const shouldSubmitPromptInput = (text: string, allowEmptySubmit = false): boolean =>
|
||||
Boolean(text.trim()) || allowEmptySubmit;
|
||||
|
||||
export const PromptInput = forwardRef<HTMLFormElement, PromptInputProps>(
|
||||
({ className, onSubmit, allowEmptySubmit = false, children, ...props }, ref) => {
|
||||
({ className, onSubmit, allowEmptySubmit = false, inputGroupClassName, children, ...props }, ref) => {
|
||||
const handleSubmit = useCallback(
|
||||
(e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
@@ -58,7 +60,7 @@ export const PromptInput = forwardRef<HTMLFormElement, PromptInputProps>(
|
||||
data-allow-empty-submit={allowEmptySubmit ? 'true' : undefined}
|
||||
{...props}
|
||||
>
|
||||
<InputGroup>{children}</InputGroup>
|
||||
<InputGroup className={inputGroupClassName}>{children}</InputGroup>
|
||||
</form>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -45,7 +45,7 @@ const BUILTIN_AGENTS: AgentInfo[] = [
|
||||
];
|
||||
|
||||
const SectionLabel: React.FC<{ children: React.ReactNode; action?: React.ReactNode }> = ({ children, action }) => (
|
||||
<div className="px-4 pb-2 pt-2 flex items-center justify-between">
|
||||
<div className="flex items-center justify-between px-3 pb-1.5 pt-1.5">
|
||||
<span className="text-[10px] font-medium tracking-wide text-muted-foreground/52">
|
||||
{children}
|
||||
</span>
|
||||
@@ -63,7 +63,7 @@ const AgentMenuRow: React.FC<{
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center gap-3 px-4 text-left text-[13px] text-foreground/86 transition-colors cursor-pointer hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/30',
|
||||
'flex h-9 w-full items-center gap-2.5 px-3 text-left text-xs text-foreground/86 transition-colors cursor-pointer hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/30',
|
||||
isActive && 'bg-muted',
|
||||
)}
|
||||
>
|
||||
@@ -93,7 +93,7 @@ const DiscoveredAgentRow: React.FC<{
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-10 w-full items-center gap-3 rounded-md px-4 text-[13px]">
|
||||
<div className="flex h-9 w-full items-center gap-2.5 rounded px-3 text-xs">
|
||||
<AgentIconBadge agent={agentLike} size="xs" variant="plain" className="opacity-78" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block truncate text-foreground/86">{agent.name}</span>
|
||||
@@ -200,19 +200,19 @@ const AgentSelector: React.FC<AgentSelectorProps> = ({
|
||||
<DropdownTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="group flex h-8 min-w-0 max-w-[170px] items-center gap-2 rounded-md px-2 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/28"
|
||||
className="group flex h-6 min-w-0 max-w-[170px] items-center gap-1.5 rounded-md px-1.5 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/28"
|
||||
>
|
||||
<AgentIconBadge
|
||||
agent={currentAgent}
|
||||
size="xs"
|
||||
variant="plain"
|
||||
className="opacity-78"
|
||||
className="h-3 w-3 opacity-78"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground/90">
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] font-medium text-foreground/90">
|
||||
{currentAgent.name}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={12}
|
||||
size={10}
|
||||
className={cn(
|
||||
'shrink-0 text-muted-foreground/60 transition-transform',
|
||||
open && 'rotate-180',
|
||||
@@ -224,7 +224,7 @@ const AgentSelector: React.FC<AgentSelectorProps> = ({
|
||||
<DropdownContent
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="w-[288px] overflow-hidden rounded-2xl border border-border/50 bg-popover p-0 text-foreground shadow-lg supports-[backdrop-filter]:backdrop-blur-sm"
|
||||
className="w-[256px] overflow-hidden rounded-md border border-border/50 bg-popover p-0 text-foreground shadow-lg supports-[backdrop-filter]:backdrop-blur-sm"
|
||||
>
|
||||
{BUILTIN_AGENTS.map((agent) => (
|
||||
<AgentMenuRow
|
||||
@@ -287,9 +287,9 @@ const AgentSelector: React.FC<AgentSelectorProps> = ({
|
||||
<div className="mx-0 my-1 border-t border-border/50" />
|
||||
<button
|
||||
onClick={handleManageAgents}
|
||||
className="flex h-10 w-full items-center gap-3 px-4 text-left text-[13px] text-foreground/82 transition-colors cursor-pointer hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/30"
|
||||
className="flex h-9 w-full items-center gap-2.5 px-3 text-left text-xs text-foreground/82 transition-colors cursor-pointer hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/30"
|
||||
>
|
||||
<Settings size={16} className="opacity-72 shrink-0" />
|
||||
<Settings size={14} className="opacity-72 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">{t('ai.agentSettings')}</span>
|
||||
</button>
|
||||
</DropdownContent>
|
||||
|
||||
@@ -5,8 +5,70 @@ import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import ChatInput from './ChatInput';
|
||||
import {
|
||||
CHAT_INPUT_MAX_HEIGHT,
|
||||
CHAT_INPUT_MIN_HEIGHT,
|
||||
resolveChatInputAriaHeight,
|
||||
resolveChatInputMaxHeight,
|
||||
resolveChatInputResizeHeight,
|
||||
resolveVisibleChatInputHeight,
|
||||
resolveVisibleChatInputMaxHeight,
|
||||
} from './chatInputResize';
|
||||
import { TooltipProvider } from '../ui/tooltip';
|
||||
|
||||
test('clamps composer dragging to the usable pane height', () => {
|
||||
assert.equal(resolveChatInputMaxHeight(900), CHAT_INPUT_MAX_HEIGHT);
|
||||
assert.equal(resolveChatInputMaxHeight(180), CHAT_INPUT_MIN_HEIGHT);
|
||||
assert.equal(resolveChatInputResizeHeight(128, 500, 420, 360), 208);
|
||||
assert.equal(resolveChatInputResizeHeight(128, 500, 700, 360), CHAT_INPUT_MIN_HEIGHT);
|
||||
assert.equal(resolveChatInputResizeHeight(300, 500, 300, 360), 360);
|
||||
});
|
||||
|
||||
test('keeps the requested composer height while the pane is temporarily hidden or constrained', () => {
|
||||
assert.equal(resolveVisibleChatInputMaxHeight(0), null);
|
||||
assert.equal(resolveVisibleChatInputMaxHeight(Number.NaN), null);
|
||||
assert.equal(resolveVisibleChatInputHeight(360, 220), 220);
|
||||
assert.equal(resolveVisibleChatInputHeight(360, 400), 360);
|
||||
assert.equal(resolveVisibleChatInputHeight(null, 400), null);
|
||||
});
|
||||
|
||||
test('reports an accessible composer height inside the available range', () => {
|
||||
assert.equal(resolveChatInputAriaHeight(null, CHAT_INPUT_MIN_HEIGHT), CHAT_INPUT_MIN_HEIGHT);
|
||||
assert.equal(resolveChatInputAriaHeight(360, 220), 220);
|
||||
assert.equal(resolveChatInputAriaHeight(160, 220), 160);
|
||||
});
|
||||
|
||||
test('renders an accessible composer resize handle', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<TooltipProvider>
|
||||
<ChatInput value="" onChange={() => {}} onSend={() => {}} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
assert.match(html, /role="separator"/);
|
||||
assert.match(html, /aria-orientation="horizontal"/);
|
||||
assert.match(html, /aria-label="ai\.chat\.resizeInput"/);
|
||||
assert.match(html, /cursor-ns-resize/);
|
||||
});
|
||||
|
||||
test('expanded composer grows the text area while keeping controls at the bottom', () => {
|
||||
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(source, /data-section="ai-chat-input-body"/);
|
||||
assert.match(source, /composerHeight != null \? 'flex min-h-0 flex-1 flex-col'/);
|
||||
assert.match(source, /data-section="ai-chat-input-footer"/);
|
||||
assert.match(source, /className="shrink-0/);
|
||||
assert.doesNotMatch(source, /<Expand/);
|
||||
assert.doesNotMatch(source, /setExpanded/);
|
||||
});
|
||||
|
||||
test('composer resizing also ends when pointer capture is unexpectedly lost', () => {
|
||||
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(source, /resizeStartRef\.current = null;[\s\S]*releasePointerCapture/);
|
||||
assert.match(source, /onLostPointerCapture=\{handleComposerResizeEnd\}/);
|
||||
});
|
||||
|
||||
test('virtualizes the host mention list without changing its option contract', () => {
|
||||
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
|
||||
|
||||
|
||||
+183
-19
@@ -6,7 +6,7 @@
|
||||
* and a bottom toolbar with muted controls + subtle send button.
|
||||
*/
|
||||
|
||||
import { ArrowUp, AtSign, Check, ChevronDown, ChevronRight, Cpu, Expand, Eye, FileText, ImageIcon, Loader2, MessageSquare, Package, Plus, ShieldCheck, SquareTerminal, X, Zap } from 'lucide-react';
|
||||
import { ArrowUp, AtSign, Check, ChevronDown, ChevronRight, Cpu, Eye, FileText, ImageIcon, Loader2, MessageSquare, Package, Plus, ShieldCheck, SquareTerminal, X, Zap } from 'lucide-react';
|
||||
import {
|
||||
buildSlashCommandItems,
|
||||
filterQuickMessages,
|
||||
@@ -38,6 +38,17 @@ import { ProviderIconBadge } from '../settings/tabs/ai/ProviderIconBadge';
|
||||
import { VariableSizeVirtualList, type VariableSizeVirtualListHandle } from '../ui/VariableSizeVirtualList';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import type { AgentContextUsage } from './hooks/useAgentCompactionUi';
|
||||
import {
|
||||
CHAT_INPUT_DEFAULT_HEIGHT,
|
||||
CHAT_INPUT_MAX_HEIGHT,
|
||||
CHAT_INPUT_MIN_HEIGHT,
|
||||
CHAT_INPUT_PANEL_RESERVE,
|
||||
resolveChatInputAriaHeight,
|
||||
resolveChatInputMaxHeight,
|
||||
resolveChatInputResizeHeight,
|
||||
resolveVisibleChatInputHeight,
|
||||
resolveVisibleChatInputMaxHeight,
|
||||
} from './chatInputResize';
|
||||
|
||||
// Keep in sync with the popover's Tailwind max-width below.
|
||||
const MODEL_PICKER_MAX_WIDTH = 360;
|
||||
@@ -169,7 +180,9 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
const { t } = useI18n();
|
||||
const hasTerminalSelectionAttachment = files.some((file) => file.terminalSelection);
|
||||
const composerDisabled = disabled || isSteering;
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [composerHeight, setComposerHeight] = useState<number | null>(null);
|
||||
const [composerMaxHeight, setComposerMaxHeight] = useState(CHAT_INPUT_MAX_HEIGHT);
|
||||
const composerDesiredHeightRef = useRef<number | null>(null);
|
||||
// Consolidate menu state into a single discriminated union to prevent multiple menus open simultaneously
|
||||
type ActiveMenu = 'model' | 'attach' | 'atMention' | 'slashCommand' | 'perm' | null;
|
||||
const [activeMenu, setActiveMenu] = useState<ActiveMenu>(null);
|
||||
@@ -198,6 +211,15 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
}, []);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const inputShellRef = useRef<HTMLDivElement>(null);
|
||||
const resizeStartRef = useRef<{
|
||||
pointerId: number;
|
||||
startY: number;
|
||||
startHeight: number;
|
||||
maxHeight: number;
|
||||
previousUserSelect: string;
|
||||
pendingY: number | null;
|
||||
frame: number | null;
|
||||
} | null>(null);
|
||||
const modelBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const permBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const attachBtnRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -205,6 +227,116 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
const atMentionListRef = useRef<VariableSizeVirtualListHandle>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const getComposerMaxHeight = useCallback(() => {
|
||||
const panel = inputShellRef.current?.closest<HTMLElement>('[data-section="ai-chat-panel"]');
|
||||
return resolveChatInputMaxHeight(
|
||||
panel?.clientHeight ?? CHAT_INPUT_MAX_HEIGHT + CHAT_INPUT_PANEL_RESERVE,
|
||||
);
|
||||
}, []);
|
||||
|
||||
const applyPendingComposerResize = useCallback(() => {
|
||||
const resizeStart = resizeStartRef.current;
|
||||
if (!resizeStart) return;
|
||||
resizeStart.frame = null;
|
||||
if (resizeStart.pendingY == null) return;
|
||||
const nextHeight = resolveChatInputResizeHeight(
|
||||
resizeStart.startHeight,
|
||||
resizeStart.startY,
|
||||
resizeStart.pendingY,
|
||||
resizeStart.maxHeight,
|
||||
);
|
||||
resizeStart.pendingY = null;
|
||||
composerDesiredHeightRef.current = nextHeight;
|
||||
setComposerHeight(nextHeight);
|
||||
}, []);
|
||||
|
||||
const finishComposerResize = useCallback((target?: HTMLDivElement, pointerId?: number) => {
|
||||
const resizeStart = resizeStartRef.current;
|
||||
if (!resizeStart) return;
|
||||
if (resizeStart.frame !== null) {
|
||||
cancelAnimationFrame(resizeStart.frame);
|
||||
applyPendingComposerResize();
|
||||
}
|
||||
resizeStartRef.current = null;
|
||||
document.body.style.userSelect = resizeStart.previousUserSelect;
|
||||
if (target && pointerId != null && target.hasPointerCapture(pointerId)) {
|
||||
target.releasePointerCapture(pointerId);
|
||||
}
|
||||
}, [applyPendingComposerResize]);
|
||||
|
||||
const handleComposerResizeStart = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0 || !inputShellRef.current) return;
|
||||
event.preventDefault();
|
||||
closeAllMenus();
|
||||
const maxHeight = getComposerMaxHeight();
|
||||
resizeStartRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startY: event.clientY,
|
||||
startHeight: inputShellRef.current.getBoundingClientRect().height,
|
||||
maxHeight,
|
||||
previousUserSelect: document.body.style.userSelect,
|
||||
pendingY: null,
|
||||
frame: null,
|
||||
};
|
||||
setComposerMaxHeight(maxHeight);
|
||||
document.body.style.userSelect = 'none';
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}, [closeAllMenus, getComposerMaxHeight]);
|
||||
|
||||
const handleComposerResizeMove = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const resizeStart = resizeStartRef.current;
|
||||
if (!resizeStart || resizeStart.pointerId !== event.pointerId) return;
|
||||
resizeStart.pendingY = event.clientY;
|
||||
if (resizeStart.frame === null) {
|
||||
resizeStart.frame = requestAnimationFrame(applyPendingComposerResize);
|
||||
}
|
||||
}, [applyPendingComposerResize]);
|
||||
|
||||
const handleComposerResizeEnd = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||||
finishComposerResize(event.currentTarget, event.pointerId);
|
||||
}, [finishComposerResize]);
|
||||
|
||||
const handleComposerResizeKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;
|
||||
event.preventDefault();
|
||||
const maxHeight = getComposerMaxHeight();
|
||||
const currentHeight = inputShellRef.current?.getBoundingClientRect().height
|
||||
?? composerHeight
|
||||
?? CHAT_INPUT_DEFAULT_HEIGHT;
|
||||
const nextHeight = resolveChatInputResizeHeight(
|
||||
currentHeight,
|
||||
0,
|
||||
event.key === 'ArrowUp' ? -16 : 16,
|
||||
maxHeight,
|
||||
);
|
||||
setComposerMaxHeight(maxHeight);
|
||||
composerDesiredHeightRef.current = nextHeight;
|
||||
setComposerHeight(nextHeight);
|
||||
}, [composerHeight, getComposerMaxHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
const panel = inputShellRef.current?.closest<HTMLElement>('[data-section="ai-chat-panel"]');
|
||||
if (!panel || typeof ResizeObserver === 'undefined') return undefined;
|
||||
const observer = new ResizeObserver(() => {
|
||||
const maxHeight = resolveVisibleChatInputMaxHeight(panel.clientHeight);
|
||||
if (maxHeight == null) return;
|
||||
setComposerMaxHeight(maxHeight);
|
||||
setComposerHeight(resolveVisibleChatInputHeight(
|
||||
composerDesiredHeightRef.current,
|
||||
maxHeight,
|
||||
));
|
||||
});
|
||||
observer.observe(panel);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
const resizeStart = resizeStartRef.current;
|
||||
if (!resizeStart) return;
|
||||
if (resizeStart.frame !== null) cancelAnimationFrame(resizeStart.frame);
|
||||
document.body.style.userSelect = resizeStart.previousUserSelect;
|
||||
}, []);
|
||||
|
||||
const findSlashTrigger = useCallback((text: string, caretPosition: number) => {
|
||||
const beforeCaret = text.slice(0, caretPosition);
|
||||
const match = /(^|\s)\/([a-z0-9-]*)$/i.exec(beforeCaret);
|
||||
@@ -645,10 +777,42 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
|
||||
return (
|
||||
<div className="shrink-0 px-4 pb-4">
|
||||
<div ref={inputShellRef} className="relative">
|
||||
<div
|
||||
ref={inputShellRef}
|
||||
className="relative"
|
||||
style={composerHeight == null ? undefined : { height: composerHeight }}
|
||||
>
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label={t('ai.chat.resizeInput')}
|
||||
aria-valuemin={CHAT_INPUT_MIN_HEIGHT}
|
||||
aria-valuemax={composerMaxHeight}
|
||||
aria-valuenow={Math.round(resolveChatInputAriaHeight(
|
||||
composerHeight,
|
||||
composerMaxHeight,
|
||||
))}
|
||||
tabIndex={0}
|
||||
title={t('ai.chat.resizeInput')}
|
||||
onPointerDown={handleComposerResizeStart}
|
||||
onPointerMove={handleComposerResizeMove}
|
||||
onPointerUp={handleComposerResizeEnd}
|
||||
onPointerCancel={handleComposerResizeEnd}
|
||||
onLostPointerCapture={handleComposerResizeEnd}
|
||||
onKeyDown={handleComposerResizeKeyDown}
|
||||
onDoubleClick={() => {
|
||||
composerDesiredHeightRef.current = null;
|
||||
setComposerHeight(null);
|
||||
}}
|
||||
className="group/resize absolute inset-x-2 -top-1 z-20 flex h-2 cursor-ns-resize touch-none items-start justify-center outline-none"
|
||||
>
|
||||
<span className="mt-[3px] h-0.5 w-10 rounded-full bg-border opacity-0 transition-opacity group-hover/resize:opacity-80 group-focus-visible/resize:opacity-80" />
|
||||
</div>
|
||||
<PromptInput
|
||||
onSubmit={handleSubmit}
|
||||
allowEmptySubmit={hasTerminalSelectionAttachment}
|
||||
className={composerHeight == null ? undefined : 'h-full'}
|
||||
inputGroupClassName={composerHeight == null ? undefined : 'h-full'}
|
||||
>
|
||||
{/* File attachment chips */}
|
||||
{files.length > 0 && (
|
||||
@@ -705,8 +869,17 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Textarea with expand toggle */}
|
||||
<div className="relative" onPaste={handlePaste} onDrop={handleDrop} onDragOver={(e) => e.preventDefault()}>
|
||||
{/* Resizable textarea */}
|
||||
<div
|
||||
data-section="ai-chat-input-body"
|
||||
className={[
|
||||
'relative',
|
||||
composerHeight != null ? 'flex min-h-0 flex-1 flex-col' : undefined,
|
||||
].filter(Boolean).join(' ')}
|
||||
onPaste={handlePaste}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
>
|
||||
{selectedUserSkills.length > 0 && (
|
||||
<div className="px-3 pt-3 pb-1.5">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -746,22 +919,10 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
disabled={composerDisabled}
|
||||
className={[
|
||||
selectedUserSkills.length > 0 ? 'pt-1.5' : undefined,
|
||||
expanded ? 'max-h-[220px]' : undefined,
|
||||
composerHeight != null ? 'min-h-0 max-h-none flex-1' : undefined,
|
||||
].filter(Boolean).join(' ')}
|
||||
maxLength={100000}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
className="absolute top-3.5 right-3 rounded-md p-1 text-muted-foreground/38 hover:text-muted-foreground/72 hover:bg-muted/25 transition-colors cursor-pointer"
|
||||
>
|
||||
<Expand size={12} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{expanded ? t('ai.chat.collapse') : t('ai.chat.expand')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* @ mention popover */}
|
||||
@@ -866,7 +1027,10 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
)}
|
||||
|
||||
{/* Footer toolbar */}
|
||||
<PromptInputFooter className="gap-1.5 border-t-0 bg-transparent px-3 pb-2 pt-0">
|
||||
<PromptInputFooter
|
||||
data-section="ai-chat-input-footer"
|
||||
className="shrink-0 gap-1.5 border-t-0 bg-transparent px-3 pb-2 pt-0"
|
||||
>
|
||||
<PromptInputTools className="gap-1 min-w-0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
@@ -37,22 +38,16 @@ test("resolved approval state retains only tool calls still present in messages"
|
||||
assert.deepEqual([...next], [["live-call", false]]);
|
||||
});
|
||||
|
||||
test("assistant content stays plain while streaming or when markdown is hidden", () => {
|
||||
test("assistant content stays plain only when markdown is hidden", () => {
|
||||
assert.equal(shouldRenderAssistantAsPlainText({
|
||||
hideMarkdown: false,
|
||||
isStreamingMessage: true,
|
||||
}), true);
|
||||
}), false);
|
||||
assert.equal(shouldRenderAssistantAsPlainText({
|
||||
hideMarkdown: true,
|
||||
isStreamingMessage: false,
|
||||
}), true);
|
||||
assert.equal(shouldRenderAssistantAsPlainText({
|
||||
hideMarkdown: false,
|
||||
isStreamingMessage: false,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("ChatMessageList uses plain text for the streaming assistant message", () => {
|
||||
test("ChatMessageList renders markdown for the streaming assistant message", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
id: "user-1",
|
||||
@@ -80,9 +75,15 @@ test("ChatMessageList uses plain text for the streaming assistant message", () =
|
||||
),
|
||||
);
|
||||
|
||||
assert.match(markup, /data-ai-content="plain"/);
|
||||
assert.match(markup, /data-ai-content="markdown"/);
|
||||
assert.match(markup, /streaming-body/);
|
||||
assert.doesNotMatch(markup, /data-ai-content="markdown"/);
|
||||
assert.doesNotMatch(markup, /data-ai-content="plain"/);
|
||||
});
|
||||
|
||||
test("streaming markdown keeps the renderer in its animating mode", () => {
|
||||
const source = readFileSync(new URL("./ChatMessageList.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /LazyMessageResponse isAnimating=\{!!isThisStreaming\}/);
|
||||
});
|
||||
|
||||
test("ChatMessageList hydrates markdown after streaming settles", () => {
|
||||
|
||||
@@ -105,16 +105,10 @@ export function shouldProvideVaultArtifactNavigation({
|
||||
return Boolean(onOpenVaultNote || onOpenVaultHost || onOpenVaultSnippet || onOpenVaultSection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Streamdown + shiki re-parse is too expensive for per-frame streaming updates.
|
||||
* Use plain text while the assistant message is still animating; hydrate markdown
|
||||
* once the turn settles.
|
||||
*/
|
||||
export function shouldRenderAssistantAsPlainText(options: {
|
||||
hideMarkdown: boolean;
|
||||
isStreamingMessage: boolean;
|
||||
}): boolean {
|
||||
return options.hideMarkdown || options.isStreamingMessage;
|
||||
return options.hideMarkdown;
|
||||
}
|
||||
|
||||
const ASSISTANT_PLAIN_TEXT_CLASS = 'whitespace-pre-wrap break-words text-[13px] leading-[1.45]';
|
||||
@@ -655,7 +649,6 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
? <div className={ASSISTANT_PLAIN_TEXT_CLASS}>{message.content}</div>
|
||||
: shouldRenderAssistantAsPlainText({
|
||||
hideMarkdown,
|
||||
isStreamingMessage: !!isThisStreaming,
|
||||
})
|
||||
? (
|
||||
<div
|
||||
@@ -668,7 +661,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
: (
|
||||
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Markdown')}>
|
||||
<div data-ai-content="markdown">
|
||||
<LazyMessageResponse>
|
||||
<LazyMessageResponse isAnimating={!!isThisStreaming}>
|
||||
{message.content}
|
||||
</LazyMessageResponse>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
export const CHAT_INPUT_MIN_HEIGHT = 112;
|
||||
export const CHAT_INPUT_MAX_HEIGHT = 420;
|
||||
export const CHAT_INPUT_DEFAULT_HEIGHT = 128;
|
||||
export const CHAT_INPUT_PANEL_RESERVE = 112;
|
||||
|
||||
export function resolveChatInputMaxHeight(panelHeight: number): number {
|
||||
return Math.max(
|
||||
CHAT_INPUT_MIN_HEIGHT,
|
||||
Math.min(CHAT_INPUT_MAX_HEIGHT, panelHeight - CHAT_INPUT_PANEL_RESERVE),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveVisibleChatInputMaxHeight(panelHeight: number): number | null {
|
||||
if (!Number.isFinite(panelHeight) || panelHeight <= 0) return null;
|
||||
return resolveChatInputMaxHeight(panelHeight);
|
||||
}
|
||||
|
||||
export function resolveVisibleChatInputHeight(
|
||||
desiredHeight: number | null,
|
||||
maxHeight: number,
|
||||
): number | null {
|
||||
return desiredHeight == null ? null : Math.min(desiredHeight, maxHeight);
|
||||
}
|
||||
|
||||
export function resolveChatInputAriaHeight(
|
||||
height: number | null,
|
||||
maxHeight: number,
|
||||
): number {
|
||||
return Math.max(
|
||||
CHAT_INPUT_MIN_HEIGHT,
|
||||
Math.min(maxHeight, height ?? CHAT_INPUT_DEFAULT_HEIGHT),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveChatInputResizeHeight(
|
||||
startHeight: number,
|
||||
startPointerY: number,
|
||||
pointerY: number,
|
||||
maxHeight: number,
|
||||
): number {
|
||||
return Math.min(
|
||||
maxHeight,
|
||||
Math.max(CHAT_INPUT_MIN_HEIGHT, startHeight + startPointerY - pointerY),
|
||||
);
|
||||
}
|
||||
@@ -126,6 +126,26 @@ test('buildManagedAgentState preserves the experimental Codex runtime across pat
|
||||
assert.equal(state.agents[0].command, '/new/codex');
|
||||
});
|
||||
|
||||
test('buildManagedAgentState preserves Grok runtime across path refreshes', () => {
|
||||
const state = buildManagedAgentState(
|
||||
[{
|
||||
id: 'discovered_grok',
|
||||
name: 'Grok Build',
|
||||
command: '/old/grok',
|
||||
enabled: true,
|
||||
sdkBackend: 'grok',
|
||||
grokRuntime: 'streaming-json',
|
||||
}],
|
||||
'discovered_grok',
|
||||
'grok',
|
||||
{ path: '/new/grok', version: '0.2.118', available: true },
|
||||
);
|
||||
|
||||
assert.equal(state.agents[0].grokRuntime, 'streaming-json');
|
||||
assert.equal(state.agents[0].command, '/new/grok');
|
||||
assert.equal(state.agents[0].sdkBackend, 'grok');
|
||||
});
|
||||
|
||||
test('getInitialManagedAgentPaths ignores auto-detected command paths', () => {
|
||||
const state = buildManagedAgentState(
|
||||
[],
|
||||
|
||||
@@ -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\]/);
|
||||
|
||||
|
||||
Loaded 100 of 319 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user