mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-11 17:00:22 +00:00
Compare commits
166
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cda4a39b7 | ||
|
|
90494c0c06 | ||
|
|
1478316806 | ||
|
|
74ae5e0a48 | ||
|
|
cba714f31c | ||
|
|
532cf6cc7d | ||
|
|
747847e71a | ||
|
|
af9cef2ee9 | ||
|
|
a7f96c9412 | ||
|
|
a3b111377c | ||
|
|
50ef8fac57 | ||
|
|
d855e69c23 | ||
|
|
2c369403db | ||
|
|
10ff5fe94d | ||
|
|
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.
|
||||
|
||||
+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 中的最新官方构建。
|
||||
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -680,6 +680,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}',
|
||||
|
||||
@@ -697,6 +697,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} 行',
|
||||
|
||||
@@ -131,6 +131,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 客户端冲突。',
|
||||
@@ -761,6 +764,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': '没有可导出的代码片段。',
|
||||
|
||||
@@ -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} 行',
|
||||
|
||||
@@ -131,6 +131,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 用戶端衝突。',
|
||||
@@ -761,6 +764,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': '沒有可匯出的程式碼片段。',
|
||||
|
||||
@@ -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,
|
||||
@@ -99,6 +100,7 @@ interface UseSettingsStorageSyncParams {
|
||||
sshDebugLogsEnabled: boolean;
|
||||
sshDeepLinkEnabled: boolean;
|
||||
jmsDeepLinkEnabled: boolean;
|
||||
explorerContextMenuEnabled: boolean;
|
||||
globalHotkeyEnabled: boolean;
|
||||
autoUpdateEnabled: boolean;
|
||||
windowOpacity: number;
|
||||
@@ -117,7 +119,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 +146,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>>;
|
||||
@@ -163,7 +166,7 @@ export function useSettingsStorageSync({
|
||||
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,
|
||||
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
|
||||
@@ -172,7 +175,7 @@ export function useSettingsStorageSync({
|
||||
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,
|
||||
@@ -187,7 +190,7 @@ export function useSettingsStorageSync({
|
||||
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 = {
|
||||
@@ -197,7 +200,7 @@ export function useSettingsStorageSync({
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -283,10 +286,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 +357,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';
|
||||
@@ -516,6 +522,7 @@ export function useSettingsStorageSync({
|
||||
setSessionLogsTimestampsEnabled,
|
||||
setSshDeepLinkEnabledState,
|
||||
setJmsDeepLinkEnabledState,
|
||||
setExplorerContextMenuEnabledState,
|
||||
setSshDebugLogsEnabled,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -568,6 +568,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 +583,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 +656,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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
@@ -112,6 +113,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 +154,16 @@ import {
|
||||
type WindowOpacityMutationSource,
|
||||
type WindowOpacityRecord,
|
||||
} from './windowOpacitySync';
|
||||
import {
|
||||
createLocalTerminalFontSizeRecord,
|
||||
createTerminalFontSizeSyncOrigin,
|
||||
parseTerminalFontSizeRecord,
|
||||
resolveAuthoritativeTerminalFontSizeStorage,
|
||||
resolveIncomingTerminalFontSize,
|
||||
shouldBroadcastTerminalFontSizeChange,
|
||||
type TerminalFontSizeMutationSource,
|
||||
type TerminalFontSizeRecord,
|
||||
} from './terminalFontSizeSync';
|
||||
import {
|
||||
hasPersistedAppearanceChanged,
|
||||
resolveAppearanceSyncState,
|
||||
@@ -218,7 +230,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 +435,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 +539,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.
|
||||
@@ -594,6 +661,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;
|
||||
@@ -713,8 +789,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 +820,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 +882,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 = {
|
||||
@@ -903,7 +981,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setTerminalThemeLightId,
|
||||
setFollowAppTerminalThemeState,
|
||||
setTerminalFontFamilyId,
|
||||
setTerminalFontSize,
|
||||
setTerminalFontSize: applyIncomingTerminalFontSize,
|
||||
mergeIncomingTerminalSettings,
|
||||
setEditorWordWrapState,
|
||||
setSessionLogsEnabled,
|
||||
@@ -913,6 +991,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setSshDebugLogsEnabled,
|
||||
setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled,
|
||||
setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled,
|
||||
setExplorerContextMenuEnabledState: applyIncomingExplorerContextMenuEnabled,
|
||||
setHotkeyScheme,
|
||||
applyIncomingCustomKeyBindings,
|
||||
setIsHotkeyRecordingState,
|
||||
@@ -961,16 +1040,16 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
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,
|
||||
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 +1086,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 +1447,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 +1714,9 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setSshDeepLinkEnabled,
|
||||
jmsDeepLinkEnabled,
|
||||
setJmsDeepLinkEnabled,
|
||||
explorerContextMenuEnabled,
|
||||
setExplorerContextMenuEnabled,
|
||||
explorerContextMenuSupported,
|
||||
// Global Toggle Window (Quake Mode)
|
||||
toggleWindowHotkey,
|
||||
setToggleWindowHotkey,
|
||||
@@ -1574,7 +1746,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,
|
||||
]),
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -234,6 +235,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 },
|
||||
|
||||
@@ -37,6 +37,11 @@ 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 {
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
@@ -400,8 +405,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);
|
||||
@@ -596,7 +603,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);
|
||||
}
|
||||
|
||||
+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(
|
||||
|
||||
@@ -30,6 +30,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';
|
||||
|
||||
@@ -241,7 +242,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 +255,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 +265,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>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import React, { useEffect, useMemo, useState, useCallback } 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,
|
||||
@@ -192,11 +192,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,6 +214,26 @@ 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;
|
||||
@@ -1050,6 +1067,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
effectiveFontSize={effectiveFontSize}
|
||||
hasEffectiveFontSizeOverride={hasEffectiveFontSizeOverride}
|
||||
sshAgentStatus={sshAgentStatus}
|
||||
sshForwardingAgentStatus={sshForwardingAgentStatus}
|
||||
effectiveGroupDefaults={effectiveGroupDefaults}
|
||||
effectiveAuthMethod={effectiveAuthMethod}
|
||||
showAlgorithmOverrides={showAlgorithmOverrides}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
|
||||
import { SftpSidePanelDeferredMount } from "./SftpSidePanelDeferredMount";
|
||||
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from "./terminalLayer/terminalSidePanelChrome";
|
||||
import { formatHostPort } from "../domain/host";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useSftpState } from "../application/state/useSftpState";
|
||||
@@ -1572,7 +1573,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">
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
+74
-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;
|
||||
@@ -3181,6 +3235,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
setIsDisconnectedDialogDismissed(false);
|
||||
setConnectionReuseFellBack(false);
|
||||
setConnectionReuseAttemptSourceId(undefined);
|
||||
updateStatus("connecting");
|
||||
setError(null);
|
||||
setProgressLogs((prev) => (
|
||||
@@ -3270,7 +3325,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
isSerialConnection,
|
||||
isDisconnectedDialogDismissed,
|
||||
hideConnectingDialogForConnectionReuse: shouldHideConnectingDialogForConnectionReuse({
|
||||
reuseConnectionFromSessionId,
|
||||
reuseConnectionFromSessionId: connectionReuseAttemptSourceId,
|
||||
host,
|
||||
connectionReuseFellBack,
|
||||
}),
|
||||
|
||||
+148
-85
@@ -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,
|
||||
@@ -253,6 +264,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 +453,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 +542,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 +549,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());
|
||||
@@ -666,7 +686,34 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}, SFTP_TRANSFER_HISTORY_RETENTION_MS);
|
||||
sftpRetainedCleanupTimersRef.current.set(tabId, cleanupTimer);
|
||||
}
|
||||
}, [clearSftpPanelState, resolveTabActiveTransfersCount]);
|
||||
}, [clearSftpPanelState, resolveTabActiveTransfersCount, sidePanelOpenTabsRef]);
|
||||
|
||||
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);
|
||||
if (shouldKeepSftpMountedAfterClose(activeTransfersCount)) {
|
||||
sftpRetainedAfterCloseTabIdsRef.current.add(tabId);
|
||||
sftpActiveTransfersByTabRef.current.set(tabId, activeTransfersCount);
|
||||
} 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, resolveTabActiveTransfersCount, setSidePanelOpenTabs]);
|
||||
|
||||
const handleToggleWorkspaceComposeBar = useCallback(() => {
|
||||
setIsComposeBarOpen(prev => !prev);
|
||||
@@ -702,45 +749,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 +804,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 +818,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 +978,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 +1018,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);
|
||||
@@ -1196,33 +1236,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 +1258,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 +1298,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 +1320,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 +1390,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 +1410,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 +1439,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 +1466,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 +1518,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 +1564,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}
|
||||
|
||||
handleSwitchSidePanelTab('ai');
|
||||
}, [handleCloseSidePanel, handleSwitchSidePanelTab]);
|
||||
}, [handleCloseSidePanel, handleSwitchSidePanelTab, sidePanelOpenTabsRef]);
|
||||
|
||||
// Execute snippet on the focused terminal session
|
||||
const handleSnippetClickForFocusedSession = useCallback((
|
||||
@@ -1896,6 +1954,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
handleOpenAI,
|
||||
handleOpenSystem,
|
||||
handleOpenNotes,
|
||||
handleFocusSidePanelPane,
|
||||
handleSplitSidePanelPane,
|
||||
handleCloseSidePanelPane,
|
||||
handleResizeSidePanelSplit,
|
||||
handleBackFromNotes,
|
||||
handleOpenHostFromNotes,
|
||||
handleOsDetected,
|
||||
@@ -2018,6 +2080,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
setSftpPendingUploadsForTab,
|
||||
showHostTreeSidebar,
|
||||
sidePanelOpenTabs,
|
||||
sidePanelLayouts,
|
||||
sidePanelPosition,
|
||||
sidePanelWidth,
|
||||
sftpAutoSync,
|
||||
|
||||
@@ -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(
|
||||
[],
|
||||
|
||||
@@ -25,6 +25,7 @@ loader.config({ paths: { vs: monacoBasePath } });
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { useClipboardBackend } from '../../application/state/useClipboardBackend';
|
||||
import { HotkeyScheme, KeyBinding, matchesKeyBinding } from '../../domain/models';
|
||||
import { pasteForMonacoEditorCommand } from '../../infrastructure/monaco/monacoClipboardPaste';
|
||||
import { useNetcattyMonacoTheme } from '../../infrastructure/monaco/useNetcattyMonacoTheme';
|
||||
import { getLanguageName, getSupportedLanguages } from '../../lib/sftpFileUtils';
|
||||
import { Button } from '../ui/button';
|
||||
@@ -277,31 +278,13 @@ const TextEditorPaneInner: React.FC<TextEditorPaneProps> = ({
|
||||
});
|
||||
|
||||
// Fallback paste path for Electron environments where Monaco paste can fail.
|
||||
// Skip custom paste when focus is inside the find/replace widget so that
|
||||
// its input fields receive the pasted text via default browser behavior.
|
||||
// When focus is in the find/replace widget, paste into that input instead of the body.
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyV, () => {
|
||||
const active = document.activeElement;
|
||||
if (active?.closest('.find-widget')) {
|
||||
// Read clipboard and insert into the find/replace input field.
|
||||
void (async () => {
|
||||
try {
|
||||
const text = await readClipboardTextRef.current();
|
||||
if (!text) return;
|
||||
// Monaco find widget inputs are <textarea> elements inside .monaco-inputbox
|
||||
if (active instanceof HTMLTextAreaElement || active instanceof HTMLInputElement) {
|
||||
const start = active.selectionStart ?? active.value.length;
|
||||
const end = active.selectionEnd ?? active.value.length;
|
||||
active.focus();
|
||||
active.setSelectionRange(start, end);
|
||||
document.execCommand('insertText', false, text);
|
||||
}
|
||||
} catch {
|
||||
// Ignore – paste simply won't work
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
void handlePasteRef.current();
|
||||
void pasteForMonacoEditorCommand({
|
||||
activeElement: document.activeElement,
|
||||
readClipboardText: () => readClipboardTextRef.current(),
|
||||
pasteIntoEditor: () => handlePasteRef.current(),
|
||||
});
|
||||
});
|
||||
|
||||
editor.focus();
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
} from "../../infrastructure/config/storageKeys";
|
||||
import { logger } from "../../lib/logger";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from "../terminalLayer/terminalSidePanelChrome";
|
||||
import { readTextFile } from "../../lib/readTextFile";
|
||||
import { buildTextFilesZipBlob } from "../../lib/textZip";
|
||||
import type { Host, VaultNote } from "../../types";
|
||||
@@ -90,7 +91,7 @@ interface NoteFolderNode {
|
||||
|
||||
type NotesToolbarPanel = "search" | null;
|
||||
|
||||
const toolbarIconButtonClass = "netcatty-tab h-7 w-7 shrink-0 rounded-md p-0 hover:bg-transparent";
|
||||
const toolbarIconButtonClass = "netcatty-tab h-6 w-6 shrink-0 rounded-md p-0 hover:bg-transparent";
|
||||
const menuItemClass = "flex h-8 w-full items-center rounded-md px-3 text-left text-sm hover:bg-secondary";
|
||||
const NOTES_TREE_DEFAULT_WIDTH = 300;
|
||||
const NOTES_TREE_MIN_WIDTH = 220;
|
||||
@@ -1326,7 +1327,10 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
style={isSidebarMode ? undefined : { width: treeWidth }}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex h-9 shrink-0 items-center gap-1 border-b border-border/60 px-1.5 py-1">
|
||||
<div className={cn(
|
||||
TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS,
|
||||
"flex items-center gap-1 border-b border-border/60 px-1.5",
|
||||
)}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -1580,7 +1584,10 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
|
||||
{isSidebarMode && overlayNote && (
|
||||
<div className="absolute inset-0 z-30 flex min-h-0 flex-col bg-background text-foreground">
|
||||
<div className="flex h-9 shrink-0 items-center gap-1 border-b border-border/60 px-1.5 py-1">
|
||||
<div className={cn(
|
||||
TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS,
|
||||
"flex items-center gap-1 border-b border-border/60 px-1.5",
|
||||
)}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useImperativeHandle, useRef } from 'reac
|
||||
import { useClipboardBackend } from '@/application/state/useClipboardBackend';
|
||||
import {
|
||||
buildMonacoPasteEdits,
|
||||
pasteForMonacoEditorCommand,
|
||||
readClipboardTextWithFallbacks,
|
||||
} from '@/infrastructure/monaco/monacoClipboardPaste';
|
||||
import { useNetcattyMonacoTheme } from '@/infrastructure/monaco/useNetcattyMonacoTheme';
|
||||
@@ -64,6 +65,7 @@ export const ScriptCodeEditor = React.forwardRef<ScriptCodeEditorHandle, ScriptC
|
||||
const onSubmitShortcutRef = useRef(onSubmitShortcut);
|
||||
onSubmitShortcutRef.current = onSubmitShortcut;
|
||||
const handlePasteRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||
const readClipboardTextRef = useRef<() => Promise<string | null>>(() => Promise.resolve(null));
|
||||
|
||||
useImperativeHandle(forwardedRef, () => ({
|
||||
focus: () => editorRef.current?.focus(),
|
||||
@@ -82,16 +84,24 @@ export const ScriptCodeEditor = React.forwardRef<ScriptCodeEditorHandle, ScriptC
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [active, fill, height]);
|
||||
|
||||
const handlePaste = useCallback(async () => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
const text = await readClipboardTextWithFallbacks({
|
||||
const readClipboardText = useCallback(async (): Promise<string | null> => (
|
||||
readClipboardTextWithFallbacks({
|
||||
readNavigator: navigator.clipboard?.readText
|
||||
? () => navigator.clipboard.readText()
|
||||
: undefined,
|
||||
readBridge: readClipboardTextFromBridge,
|
||||
});
|
||||
})
|
||||
), [readClipboardTextFromBridge]);
|
||||
|
||||
useEffect(() => {
|
||||
readClipboardTextRef.current = readClipboardText;
|
||||
}, [readClipboardText]);
|
||||
|
||||
const handlePaste = useCallback(async () => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
const text = await readClipboardText();
|
||||
|
||||
if (text === null) {
|
||||
// Clipboard read unavailable; fall back to Monaco's native paste.
|
||||
@@ -105,7 +115,7 @@ export const ScriptCodeEditor = React.forwardRef<ScriptCodeEditorHandle, ScriptC
|
||||
|
||||
editor.executeEdits('netcatty-paste', buildMonacoPasteEdits(text, selections));
|
||||
editor.focus();
|
||||
}, [readClipboardTextFromBridge]);
|
||||
}, [readClipboardText]);
|
||||
|
||||
useEffect(() => {
|
||||
handlePasteRef.current = handlePaste;
|
||||
@@ -124,10 +134,15 @@ export const ScriptCodeEditor = React.forwardRef<ScriptCodeEditorHandle, ScriptC
|
||||
);
|
||||
}
|
||||
// Fallback paste path for Electron where Monaco clipboardPasteAction can fail.
|
||||
// When focus is in the find/replace widget, paste into that input instead of the body.
|
||||
editor.addCommand(
|
||||
monacoInstance.KeyMod.CtrlCmd | monacoInstance.KeyCode.KeyV,
|
||||
() => {
|
||||
void handlePasteRef.current();
|
||||
void pasteForMonacoEditorCommand({
|
||||
activeElement: document.activeElement,
|
||||
readClipboardText: () => readClipboardTextRef.current(),
|
||||
pasteIntoEditor: () => handlePasteRef.current(),
|
||||
});
|
||||
},
|
||||
);
|
||||
requestAnimationFrame(() => editor.layout());
|
||||
|
||||
@@ -37,6 +37,7 @@ import type {
|
||||
UserSkillsStatusResult,
|
||||
} from "./ai/types";
|
||||
import {
|
||||
AGENT_DEFAULTS,
|
||||
getBridge,
|
||||
isCursorAvailableForMode,
|
||||
normalizeCodexBridgeError,
|
||||
@@ -218,6 +219,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
cursor: string;
|
||||
codebuddy: string;
|
||||
opencode: string;
|
||||
grok: string;
|
||||
} | null>(null);
|
||||
if (!initialManagedPathsRef.current) {
|
||||
initialManagedPathsRef.current = getInitialManagedAgentPaths(externalAgents);
|
||||
@@ -283,6 +285,12 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
const [opencodeCustomPath, setOpencodeCustomPath] = useState(() => initialManagedPathsRef.current?.opencode ?? "");
|
||||
const [isResolvingOpencode, setIsResolvingOpencode] = useState(false);
|
||||
|
||||
const [grokPathInfo, setGrokPathInfo] = useState<AgentPathInfo | null>(
|
||||
() => getSavedManagedAgentPathInfo(externalAgents, "grok"),
|
||||
);
|
||||
const [grokCustomPath, setGrokCustomPath] = useState(() => initialManagedPathsRef.current?.grok ?? "");
|
||||
const [isResolvingGrok, setIsResolvingGrok] = useState(false);
|
||||
|
||||
const codebuddyManagedAgent = useMemo(
|
||||
() => externalAgents.find((a) => a.id === "discovered_codebuddy"),
|
||||
[externalAgents],
|
||||
@@ -330,7 +338,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
useEffect(() => () => {
|
||||
mountedRef.current = false;
|
||||
codexRequestIdRef.current += 1;
|
||||
for (const key of ["codex", "claude", "copilot", "cursor", "codebuddy", "opencode"] as ManagedAgentKey[]) {
|
||||
for (const key of ["codex", "claude", "copilot", "cursor", "codebuddy", "opencode", "grok"] as ManagedAgentKey[]) {
|
||||
agentPathRequestIdRef.current[key] = (agentPathRequestIdRef.current[key] ?? 0) + 1;
|
||||
}
|
||||
}, []);
|
||||
@@ -350,7 +358,9 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
? setCursorPathInfo
|
||||
: agentKey === "codebuddy"
|
||||
? setCodebuddyPathInfo
|
||||
: setOpencodePathInfo;
|
||||
: agentKey === "opencode"
|
||||
? setOpencodePathInfo
|
||||
: setGrokPathInfo;
|
||||
|
||||
setInfo(result);
|
||||
|
||||
@@ -391,7 +401,9 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
? setIsResolvingCursor
|
||||
: agentKey === "codebuddy"
|
||||
? setIsResolvingCodebuddy
|
||||
: setIsResolvingOpencode;
|
||||
: agentKey === "opencode"
|
||||
? setIsResolvingOpencode
|
||||
: setIsResolvingGrok;
|
||||
|
||||
setResolving(true);
|
||||
const requestId = (agentPathRequestIdRef.current[agentKey] ?? 0) + 1;
|
||||
@@ -430,7 +442,9 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
? setCursorPathInfo
|
||||
: agentKey === "codebuddy"
|
||||
? setCodebuddyPathInfo
|
||||
: setOpencodePathInfo;
|
||||
: agentKey === "opencode"
|
||||
? setOpencodePathInfo
|
||||
: setGrokPathInfo;
|
||||
setInfo(result);
|
||||
return result;
|
||||
}
|
||||
@@ -468,6 +482,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
},
|
||||
{ key: "codebuddy", delayMs: 1280, path: initialPaths?.codebuddy ?? "" },
|
||||
{ key: "opencode", delayMs: 1560, path: initialPaths?.opencode ?? "" },
|
||||
{ key: "grok", delayMs: 1840, path: initialPaths?.grok ?? "" },
|
||||
];
|
||||
const cancelTasks = tasks
|
||||
.filter((task) => !autoResolvedAgentStateRef.current[task.key])
|
||||
@@ -689,6 +704,30 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
)));
|
||||
}, [setExternalAgents]);
|
||||
|
||||
const handleGrokRuntimeChange = useCallback((runtime: 'acp' | 'streaming-json') => {
|
||||
setExternalAgents((agents) => {
|
||||
const managedId = "discovered_grok";
|
||||
const existing = agents.find((agent) => agent.id === managedId);
|
||||
if (existing) {
|
||||
return agents.map((agent) => (
|
||||
agent.id === managedId ? { ...agent, grokRuntime: runtime } : agent
|
||||
));
|
||||
}
|
||||
// Path not yet resolved: still persist preference on a disabled placeholder.
|
||||
return [
|
||||
...agents,
|
||||
{
|
||||
...AGENT_DEFAULTS.grok,
|
||||
id: managedId,
|
||||
command: "grok",
|
||||
enabled: false,
|
||||
available: false,
|
||||
grokRuntime: runtime,
|
||||
},
|
||||
];
|
||||
});
|
||||
}, [setExternalAgents]);
|
||||
|
||||
// Validate a custom path for an agent.
|
||||
const handleCheckCustomPath = useCallback(async (agentKey: ManagedAgentKey) => {
|
||||
const customPath = agentKey === "codex"
|
||||
@@ -701,7 +740,9 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
? codebuddyCustomPath
|
||||
: agentKey === "opencode"
|
||||
? opencodeCustomPath
|
||||
: "";
|
||||
: agentKey === "grok"
|
||||
? grokCustomPath
|
||||
: "";
|
||||
const result = await resolveAgentPath(agentKey, customPath, {
|
||||
refreshShellEnv: true,
|
||||
commandSource: customPath.trim() ? "manual" : "auto",
|
||||
@@ -713,7 +754,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
codexPath: result?.path || customPath.trim() || undefined,
|
||||
});
|
||||
}
|
||||
}, [claudeCustomPath, codexCustomPath, copilotCustomPath, codebuddyCustomPath, opencodeCustomPath, resolveAgentPath, refreshCodexIntegration]);
|
||||
}, [claudeCustomPath, codexCustomPath, copilotCustomPath, codebuddyCustomPath, opencodeCustomPath, grokCustomPath, resolveAgentPath, refreshCodexIntegration]);
|
||||
|
||||
const handleResetCustomPath = useCallback(async (agentKey: ManagedAgentKey) => {
|
||||
if (agentKey === "codex") {
|
||||
@@ -726,6 +767,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
setCodebuddyCustomPath("");
|
||||
} else if (agentKey === "opencode") {
|
||||
setOpencodeCustomPath("");
|
||||
} else if (agentKey === "grok") {
|
||||
setGrokCustomPath("");
|
||||
}
|
||||
|
||||
const result = await resolveAgentPath(agentKey, "", {
|
||||
@@ -1118,6 +1161,27 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t('ai.grok.title')}
|
||||
leading={<AgentIconBadge agent={{ id: "grok", icon: "grok", name: "Grok Build" }} variant="plain" className="h-5 w-5 text-muted-foreground/90" />}
|
||||
>
|
||||
<CopilotCliCard
|
||||
pathInfo={grokPathInfo}
|
||||
isResolvingPath={isResolvingGrok}
|
||||
customPath={grokCustomPath}
|
||||
onCustomPathChange={setGrokCustomPath}
|
||||
onRecheckPath={() => void handleCheckCustomPath("grok")}
|
||||
onResetPath={() => void handleResetCustomPath("grok")}
|
||||
i18nPrefix="ai.grok"
|
||||
grokRuntime={
|
||||
externalAgents.find((a) => a.id === "discovered_grok")?.grokRuntime === "streaming-json"
|
||||
? "streaming-json"
|
||||
: "acp"
|
||||
}
|
||||
onGrokRuntimeChange={handleGrokRuntimeChange}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
{agentOptions.length > 1 && (
|
||||
<SettingsSection title={t('ai.defaultAgent')}>
|
||||
<SettingCard>
|
||||
|
||||
@@ -91,6 +91,9 @@ interface SettingsSystemTabProps {
|
||||
setSshDeepLinkEnabled: (enabled: boolean) => void;
|
||||
jmsDeepLinkEnabled: boolean;
|
||||
setJmsDeepLinkEnabled: (enabled: boolean) => void;
|
||||
explorerContextMenuEnabled: boolean;
|
||||
setExplorerContextMenuEnabled: (enabled: boolean) => void;
|
||||
explorerContextMenuSupported: boolean;
|
||||
restorePreviousSession: boolean;
|
||||
setRestorePreviousSession: (enabled: boolean) => void;
|
||||
restoreTerminalCwd: boolean;
|
||||
@@ -129,6 +132,9 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
setSshDeepLinkEnabled,
|
||||
jmsDeepLinkEnabled,
|
||||
setJmsDeepLinkEnabled,
|
||||
explorerContextMenuEnabled,
|
||||
setExplorerContextMenuEnabled,
|
||||
explorerContextMenuSupported,
|
||||
restorePreviousSession,
|
||||
setRestorePreviousSession,
|
||||
restoreTerminalCwd,
|
||||
@@ -1012,6 +1018,24 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
|
||||
{explorerContextMenuSupported && (
|
||||
<>
|
||||
<SectionHeader title={t('settings.explorerContextMenu.title')} />
|
||||
<SettingCard>
|
||||
<SettingRow
|
||||
label={t('settings.explorerContextMenu.enable')}
|
||||
description={t('settings.explorerContextMenu.enableDesc')}
|
||||
>
|
||||
<Toggle
|
||||
checked={explorerContextMenuEnabled}
|
||||
onChange={setExplorerContextMenuEnabled}
|
||||
ariaLabel={t('settings.explorerContextMenu.enable')}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
</>
|
||||
)}
|
||||
|
||||
<SectionHeader title={t("settings.sshDebugLogs.title")} />
|
||||
<SettingCard className="min-w-0 max-w-full overflow-hidden space-y-4 py-4">
|
||||
<SettingRow
|
||||
|
||||
@@ -38,3 +38,38 @@ test("Copilot check button still requires a custom path", () => {
|
||||
|
||||
assert.equal(firstButton(markup).includes("disabled=\"\""), true);
|
||||
});
|
||||
|
||||
test("Grok card surfaces ACP runtime toggle when detected", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<CopilotCliCard
|
||||
pathInfo={{ path: "/usr/bin/grok", version: "0.2.118", available: true }}
|
||||
isResolvingPath={false}
|
||||
customPath=""
|
||||
onCustomPathChange={() => {}}
|
||||
onRecheckPath={() => {}}
|
||||
i18nPrefix="ai.grok"
|
||||
grokRuntime="acp"
|
||||
onGrokRuntimeChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /ai\.grok\.runtime\.acp\.title|Use Grok ACP/);
|
||||
assert.match(markup, /role="switch"/);
|
||||
});
|
||||
|
||||
test("Grok card hides ACP toggle without runtime change handler", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<CopilotCliCard
|
||||
pathInfo={{ path: "/usr/bin/grok", version: "0.2.118", available: true }}
|
||||
isResolvingPath={false}
|
||||
customPath=""
|
||||
onCustomPathChange={() => {}}
|
||||
onRecheckPath={() => {}}
|
||||
i18nPrefix="ai.grok"
|
||||
grokRuntime="acp"
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.doesNotMatch(markup, /role="switch"/);
|
||||
assert.doesNotMatch(markup, /ai\.grok\.runtime\.acp\.title|Use Grok ACP \(agent stdio\)/);
|
||||
});
|
||||
@@ -2,7 +2,9 @@ import React from "react";
|
||||
import { RefreshCw, RotateCcw } from "lucide-react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { Switch } from "../../../ui/switch";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import type { GrokRuntime } from "../../../../infrastructure/ai/types";
|
||||
import type { AgentPathInfo } from "./types";
|
||||
|
||||
export const CopilotCliCard: React.FC<{
|
||||
@@ -12,9 +14,12 @@ export const CopilotCliCard: React.FC<{
|
||||
onCustomPathChange: (path: string) => void;
|
||||
onRecheckPath: () => void;
|
||||
onResetPath?: () => void;
|
||||
i18nPrefix?: "ai.copilot" | "ai.cursor" | "ai.opencode";
|
||||
i18nPrefix?: "ai.copilot" | "ai.cursor" | "ai.opencode" | "ai.grok";
|
||||
allowEmptyCheck?: boolean;
|
||||
showCustomPathInput?: boolean;
|
||||
/** Grok only: ACP (default) vs headless streaming-json. */
|
||||
grokRuntime?: GrokRuntime;
|
||||
onGrokRuntimeChange?: (runtime: GrokRuntime) => void;
|
||||
}> = ({
|
||||
pathInfo,
|
||||
isResolvingPath,
|
||||
@@ -25,9 +30,12 @@ export const CopilotCliCard: React.FC<{
|
||||
i18nPrefix = "ai.copilot",
|
||||
allowEmptyCheck = false,
|
||||
showCustomPathInput = true,
|
||||
grokRuntime = "acp",
|
||||
onGrokRuntimeChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const found = pathInfo?.available;
|
||||
const showGrokRuntime = i18nPrefix === "ai.grok" && typeof onGrokRuntimeChange === "function";
|
||||
|
||||
const statusText = isResolvingPath
|
||||
? t(`${i18nPrefix}.detecting`)
|
||||
@@ -95,6 +103,34 @@ export const CopilotCliCard: React.FC<{
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showGrokRuntime && found && (
|
||||
<div className="border-t border-border/40 pt-3 flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{t("ai.grok.runtime.acp.title")}</span>
|
||||
<span className="rounded border border-primary/30 bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
|
||||
{t("ai.grok.runtime.acp.default")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-5">
|
||||
{t("ai.grok.runtime.acp.description")}
|
||||
</p>
|
||||
{grokRuntime === "streaming-json" && (
|
||||
<p className="text-xs text-muted-foreground leading-5">
|
||||
{t("ai.grok.runtime.streamingJson.hint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
checked={grokRuntime === "acp"}
|
||||
aria-label={t("ai.grok.runtime.acp.title")}
|
||||
onCheckedChange={(checked) =>
|
||||
onGrokRuntimeChange?.(checked ? "acp" : "streaming-json")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -234,5 +234,6 @@ export function getInitialManagedAgentPaths(agents: ExternalAgentConfig[]) {
|
||||
cursor: getAutoManagedAgentStoredPath(agents, "cursor") ?? "",
|
||||
codebuddy: getAutoManagedAgentStoredPath(agents, "codebuddy") ?? "",
|
||||
opencode: getAutoManagedAgentStoredPath(agents, "opencode") ?? "",
|
||||
grok: getAutoManagedAgentStoredPath(agents, "grok") ?? "",
|
||||
};
|
||||
}
|
||||
@@ -206,6 +206,12 @@ export const AGENT_DEFAULTS: Record<string, Omit<ExternalAgentConfig, "id" | "co
|
||||
icon: "opencode",
|
||||
sdkBackend: "opencode",
|
||||
},
|
||||
grok: {
|
||||
name: "Grok Build",
|
||||
args: [],
|
||||
icon: "grok",
|
||||
sdkBackend: "grok",
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -96,7 +96,7 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
|
||||
|
||||
return (
|
||||
<Dialog open={!!conflict} onOpenChange={() => handleAction('skip')}>
|
||||
<DialogContent className="gap-5 p-5 sm:max-w-[520px] sm:p-6">
|
||||
<DialogContent className="gap-5 p-5 sm:max-w-[640px] sm:p-6">
|
||||
<DialogHeader className="space-y-2 pr-8">
|
||||
<DialogTitle className="flex items-center gap-3 text-xl leading-tight">
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-border/70 text-muted-foreground">
|
||||
@@ -147,25 +147,25 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex flex-wrap gap-2 sm:items-center sm:justify-end sm:space-x-0">
|
||||
<DialogFooter className="flex flex-wrap gap-2 sm:flex-nowrap sm:items-center sm:justify-end sm:space-x-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleAction('stop')}
|
||||
className="min-w-24 border-border/70 text-muted-foreground hover:text-destructive sm:mr-auto"
|
||||
className="min-w-24 shrink-0 border-border/70 text-muted-foreground hover:text-destructive sm:mr-auto"
|
||||
>
|
||||
{t('sftp.conflict.action.stop')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleAction('skip')}
|
||||
className="min-w-24"
|
||||
className="min-w-24 shrink-0"
|
||||
>
|
||||
{t('sftp.conflict.action.skip')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleAction('duplicate')}
|
||||
className="min-w-24"
|
||||
className="min-w-24 shrink-0"
|
||||
>
|
||||
{t('sftp.conflict.action.duplicate')}
|
||||
</Button>
|
||||
@@ -174,7 +174,7 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
|
||||
variant="outline"
|
||||
onClick={() => handleAction('merge')}
|
||||
disabled={!canMerge}
|
||||
className="min-w-24"
|
||||
className="min-w-24 shrink-0"
|
||||
>
|
||||
{t('sftp.conflict.action.merge')}
|
||||
</Button>
|
||||
@@ -183,7 +183,7 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => handleAction('replace')}
|
||||
className="min-w-28"
|
||||
className="min-w-28 shrink-0"
|
||||
>
|
||||
{t('sftp.conflict.action.replace')}
|
||||
</Button>
|
||||
|
||||
@@ -50,6 +50,23 @@ test("sftp host picker uses single-line quick switcher row layout", () => {
|
||||
assert.doesNotMatch(source, /text-xs text-muted-foreground truncate/);
|
||||
});
|
||||
|
||||
test("sftp host picker omits sourceSessionId for sudo connected hosts", () => {
|
||||
assert.match(source, /sftpSourceSessionIdForHost/);
|
||||
assert.match(
|
||||
source,
|
||||
/const sourceSessionId = sftpSourceSessionIdForHost\(\s*item\.entry\.host,\s*item\.entry\.sessionId,\s*\);/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/onSelectHost\(\s*item\.entry\.host,\s*sourceSessionId \? \{ sourceSessionId \} : undefined,\s*\)/,
|
||||
);
|
||||
// Must not always pass sessionId as a reuse hint (sudo would hide the spinner).
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/onSelectHost\(item\.entry\.host, \{ sourceSessionId: item\.entry\.sessionId \}\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("sftp host picker virtualizes the host list", () => {
|
||||
assert.match(source, /VariableSizeVirtualList/);
|
||||
assert.match(source, /data-host-picker-virtual="sftp"/);
|
||||
|
||||
@@ -7,6 +7,7 @@ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from '
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import {
|
||||
sftpHostEndpointsEqual,
|
||||
sftpSourceSessionIdForHost,
|
||||
type SftpConnectedHostEntry,
|
||||
} from '../../domain/sftpConnectedHosts';
|
||||
import { isPluginHostProtocol } from '../../domain/pluginConnection';
|
||||
@@ -193,7 +194,16 @@ const SftpHostPickerInner: React.FC<SftpHostPickerProps> = ({
|
||||
if (item.type === 'local') {
|
||||
onSelectLocal();
|
||||
} else if (item.type === 'connected') {
|
||||
onSelectHost(item.entry.host, { sourceSessionId: item.entry.sessionId });
|
||||
// Sudo SFTP cannot reuse the terminal shell; omit the hint so connect
|
||||
// UI (reusedConnection / spinner) matches the dedicated open path.
|
||||
const sourceSessionId = sftpSourceSessionIdForHost(
|
||||
item.entry.host,
|
||||
item.entry.sessionId,
|
||||
);
|
||||
onSelectHost(
|
||||
item.entry.host,
|
||||
sourceSessionId ? { sourceSessionId } : undefined,
|
||||
);
|
||||
} else {
|
||||
onSelectHost(item.host);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ProcessManagerTab } from './ProcessManagerTab';
|
||||
import { SystemOverviewTab } from './SystemOverviewTab';
|
||||
import { TmuxManagerTab } from './TmuxManagerTab';
|
||||
import { WorkspaceSidebarHostHeader } from '../terminalLayer/WorkspaceSidebarHostHeader';
|
||||
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from '../terminalLayer/terminalSidePanelChrome';
|
||||
import { SystemPanelEmpty, SystemPanelShell } from './SystemPanelUi';
|
||||
import { useSessionCapabilities } from './hooks/useSystemManager';
|
||||
|
||||
@@ -182,13 +183,16 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
return (
|
||||
<SystemPanelShell section="system-manager-panel">
|
||||
{workspaceHostHeader}
|
||||
<div className="shrink-0 flex items-center gap-0.5 px-2 py-1 border-b border-border/50">
|
||||
<div className={cn(
|
||||
TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS,
|
||||
'flex items-center gap-0.5 px-2 border-b border-border/50',
|
||||
)}>
|
||||
{tabDefs.filter((tab) => availableTabs.includes(tab.id)).map(({ id, icon: Icon, label }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-2 py-1 rounded text-[11px] transition-colors',
|
||||
'h-6 flex items-center gap-1.5 px-2 rounded text-[11px] transition-colors',
|
||||
resolvedTab === id
|
||||
? 'bg-muted text-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
|
||||
@@ -21,6 +21,7 @@ import { cn } from '../../lib/utils';
|
||||
import { TerminalTheme } from '../../domain/models';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import { isFollowAppTerminalThemeId } from '../../domain/terminalAppearance';
|
||||
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from '../terminalLayer/terminalSidePanelChrome';
|
||||
|
||||
type TabType = 'theme' | 'font' | 'custom';
|
||||
|
||||
@@ -309,10 +310,13 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
|
||||
}}
|
||||
>
|
||||
{/* Tab Bar */}
|
||||
<div className="flex p-1.5 gap-0.5 shrink-0 border-b" style={{ borderColor: 'var(--terminal-panel-border)' }}>
|
||||
<div
|
||||
className={cn(TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS, 'flex px-1.5 gap-0.5 border-b')}
|
||||
style={{ borderColor: 'var(--terminal-panel-border)' }}
|
||||
>
|
||||
<button
|
||||
onClick={() => { setActiveTab('theme'); setEditingTheme(null); }}
|
||||
className="flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all"
|
||||
className="h-6 flex-1 flex items-center justify-center gap-1 px-1.5 rounded-md text-[11px] font-medium transition-all"
|
||||
style={{
|
||||
backgroundColor: activeTab === 'theme' ? 'var(--terminal-panel-active)' : 'transparent',
|
||||
color: activeTab === 'theme' ? 'var(--terminal-panel-fg)' : 'var(--terminal-panel-muted)',
|
||||
@@ -323,7 +327,7 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('font')}
|
||||
className="flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all"
|
||||
className="h-6 flex-1 flex items-center justify-center gap-1 px-1.5 rounded-md text-[11px] font-medium transition-all"
|
||||
style={{
|
||||
backgroundColor: activeTab === 'font' ? 'var(--terminal-panel-active)' : 'transparent',
|
||||
color: activeTab === 'font' ? 'var(--terminal-panel-fg)' : 'var(--terminal-panel-muted)',
|
||||
@@ -335,7 +339,7 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
|
||||
{!followAppTerminalTheme && (
|
||||
<button
|
||||
onClick={() => setActiveTab('custom')}
|
||||
className="flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all"
|
||||
className="h-6 flex-1 flex items-center justify-center gap-1 px-1.5 rounded-md text-[11px] font-medium transition-all"
|
||||
style={{
|
||||
backgroundColor: activeTab === 'custom' ? 'var(--terminal-panel-active)' : 'transparent',
|
||||
color: activeTab === 'custom' ? 'var(--terminal-panel-fg)' : 'var(--terminal-panel-muted)',
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -29,8 +29,6 @@ interface CachedDecorationRange {
|
||||
priority: number;
|
||||
}
|
||||
|
||||
type HighlightRange = Pick<CachedDecorationRange, 'x' | 'width'>;
|
||||
|
||||
interface LogicalDecorationRange {
|
||||
start: number;
|
||||
length: number;
|
||||
@@ -46,7 +44,6 @@ interface DirtyLineSegment {
|
||||
interface LineDecorationState {
|
||||
marker: IMarker;
|
||||
decorations: IDecoration[];
|
||||
ranges: readonly CachedDecorationRange[];
|
||||
signature: string;
|
||||
}
|
||||
|
||||
@@ -103,10 +100,11 @@ export class KeywordHighlighter implements IDisposable {
|
||||
private term: XTerm;
|
||||
private compiledRules: CompiledRule[] = [];
|
||||
private lineDecorations = new Map<number, LineDecorationState>();
|
||||
private decorationsVersion = 0;
|
||||
private debounceTimer: NodeJS.Timeout | null = null;
|
||||
/** Single quiet-window catch-up after bulk dumps (no per-write schedule). */
|
||||
private bulkPressureCatchUpTimer: NodeJS.Timeout | null = null;
|
||||
private enterInputIdleTimer: NodeJS.Timeout | null = null;
|
||||
private writePruneTimer: NodeJS.Timeout | null = null;
|
||||
private animationFrameId: number | null = null;
|
||||
private lastRefreshTime: number = 0;
|
||||
private matchCache = new Map<string, CachedDecorationRange[]>();
|
||||
@@ -126,6 +124,10 @@ export class KeywordHighlighter implements IDisposable {
|
||||
private lastWriteAt = 0;
|
||||
private lastBurstDecayAt = 0;
|
||||
private lastUserInputAt = 0;
|
||||
private enterInputPending = false;
|
||||
private enterQueuedWriteCancellationPending = false;
|
||||
private enterViewportScanInProgress = false;
|
||||
private enterViewportScanNeedsRepeat = false;
|
||||
private scrollRefreshJob: ScrollRefreshJob | null = null;
|
||||
private scrollRefreshGeneration = 0;
|
||||
private static readonly DIRTY_SCAN_PADDING = XTERM_PERFORMANCE_CONFIG.highlighting.dirtyScanPadding;
|
||||
@@ -140,6 +142,7 @@ export class KeywordHighlighter implements IDisposable {
|
||||
private static readonly WRITE_BURST_DEBOUNCE_MS = 180;
|
||||
private static readonly WRITE_BURST_IMMEDIATE_MIN_INTERVAL_MS = 48;
|
||||
private static readonly WRITE_BURST_HIGHLIGHT_PAUSE_MS = 260;
|
||||
private static readonly WRITE_PRUNE_IDLE_MS = 600;
|
||||
|
||||
constructor(term: XTerm) {
|
||||
this.term = term;
|
||||
@@ -152,13 +155,53 @@ export class KeywordHighlighter implements IDisposable {
|
||||
}),
|
||||
// User input should keep terminal echo responsive; highlight can catch up
|
||||
// once typing pauses.
|
||||
this.term.onData(() => {
|
||||
this.term.onData((data) => {
|
||||
this.lastUserInputAt = performance.now();
|
||||
if (data.includes("\r") || data.includes("\n")) {
|
||||
this.enterInputPending = true;
|
||||
this.enterQueuedWriteCancellationPending = true;
|
||||
if (this.enterInputIdleTimer) {
|
||||
clearTimeout(this.enterInputIdleTimer);
|
||||
this.enterInputIdleTimer = null;
|
||||
}
|
||||
}
|
||||
}),
|
||||
// When new data is written, refresh on the next frame so highlights land
|
||||
// with the freshly rendered content instead of trailing behind it.
|
||||
this.term.onWriteParsed(() => {
|
||||
if (this.enterInputPending) {
|
||||
this.scheduleEnterInputIdleClear();
|
||||
}
|
||||
const outputDrivenPendingScroll =
|
||||
this.pendingRefreshReason === "scroll"
|
||||
&& (
|
||||
this.hasOutputPositionChangedSinceLastSnapshot()
|
||||
|| this.hasDecorationMarkerShiftSinceLastRefresh()
|
||||
);
|
||||
const cancelQueuedWriteForEnter =
|
||||
this.enterQueuedWriteCancellationPending
|
||||
&& this.pendingRefreshReason === "write";
|
||||
if (this.enterInputPending || outputDrivenPendingScroll) {
|
||||
this.cancelScrollRefresh();
|
||||
if (
|
||||
this.pendingRefreshReason === "scroll"
|
||||
|| cancelQueuedWriteForEnter
|
||||
) {
|
||||
this.cancelQueuedRefreshSchedule();
|
||||
}
|
||||
if (this.pendingRefreshReason === "scroll") {
|
||||
this.pendingRefreshReason = "write";
|
||||
}
|
||||
}
|
||||
this.enterQueuedWriteCancellationPending = false;
|
||||
const pressure = getTerminalOutputPressure(this.term);
|
||||
if (pressure.background || pressure.longLine || pressure.largeOutput) {
|
||||
if (this.pendingRefreshReason === "write") {
|
||||
this.cancelQueuedRefreshSchedule();
|
||||
}
|
||||
this.enterViewportScanInProgress = false;
|
||||
this.enterViewportScanNeedsRepeat = false;
|
||||
}
|
||||
if (pressure.background) {
|
||||
// Hidden panes: avoid immediate scans that fight xterm, but still arm a
|
||||
// debounced refresh. Reveal only flushes writes/repaints — without a
|
||||
@@ -181,13 +224,29 @@ export class KeywordHighlighter implements IDisposable {
|
||||
this.scheduleBulkPressureCatchUp();
|
||||
return;
|
||||
}
|
||||
if (this.isInputProtectionActive(performance.now())) {
|
||||
this.updateWriteBurst();
|
||||
this.markVisibleRangeDirty();
|
||||
this.triggerRefresh("debounced", "write");
|
||||
const inputProtectionActive = this.isInputProtectionActive(performance.now());
|
||||
if (inputProtectionActive || this.enterInputPending) {
|
||||
if (this.enterInputPending) {
|
||||
if (this.enterViewportScanInProgress) {
|
||||
this.updateWriteBurst();
|
||||
this.enterViewportScanNeedsRepeat = true;
|
||||
} else {
|
||||
this.markDirtyFromWrite({ includeViewportProbe: false });
|
||||
const buffer = this.term.buffer.active;
|
||||
this.addDirtyRange(buffer.viewportY, buffer.viewportY + this.term.rows - 1);
|
||||
this.enterViewportScanInProgress = true;
|
||||
}
|
||||
} else {
|
||||
this.updateWriteBurst();
|
||||
this.markVisibleRangeDirty();
|
||||
}
|
||||
this.triggerRefresh(inputProtectionActive ? "debounced" : "immediate", "write");
|
||||
return;
|
||||
}
|
||||
this.markDirtyFromWrite();
|
||||
if (outputDrivenPendingScroll) {
|
||||
this.markVisibleRangeDirty();
|
||||
}
|
||||
this.triggerRefresh(
|
||||
"immediate",
|
||||
"write",
|
||||
@@ -279,14 +338,6 @@ export class KeywordHighlighter implements IDisposable {
|
||||
}
|
||||
}
|
||||
|
||||
public getForegroundRanges(lineY: number): readonly HighlightRange[] {
|
||||
return this.lineDecorations.get(lineY)?.ranges ?? EMPTY_RANGES;
|
||||
}
|
||||
|
||||
public getForegroundRangesVersion(): number {
|
||||
return this.decorationsVersion;
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
this.cancelScrollRefresh();
|
||||
this.clearDecorations();
|
||||
@@ -300,6 +351,14 @@ export class KeywordHighlighter implements IDisposable {
|
||||
clearTimeout(this.bulkPressureCatchUpTimer);
|
||||
this.bulkPressureCatchUpTimer = null;
|
||||
}
|
||||
if (this.enterInputIdleTimer) {
|
||||
clearTimeout(this.enterInputIdleTimer);
|
||||
this.enterInputIdleTimer = null;
|
||||
}
|
||||
if (this.writePruneTimer) {
|
||||
clearTimeout(this.writePruneTimer);
|
||||
this.writePruneTimer = null;
|
||||
}
|
||||
if (this.animationFrameId !== null) {
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
this.animationFrameId = null;
|
||||
@@ -364,11 +423,13 @@ export class KeywordHighlighter implements IDisposable {
|
||||
this.disposeLineDecorations(lineY, state);
|
||||
}
|
||||
this.lineDecorations.clear();
|
||||
if (hadDecorations) this.decorationsVersion += 1;
|
||||
this.lastViewportRange = null;
|
||||
this.lastRenderRange = null;
|
||||
this.clearDirtySegments();
|
||||
this.dirtyAllInRenderRange = false;
|
||||
this.enterQueuedWriteCancellationPending = false;
|
||||
this.enterViewportScanInProgress = false;
|
||||
this.enterViewportScanNeedsRepeat = false;
|
||||
if (hadDecorations) {
|
||||
this.term.refresh(0, this.term.rows - 1);
|
||||
}
|
||||
@@ -389,13 +450,13 @@ export class KeywordHighlighter implements IDisposable {
|
||||
if (lineHint != null) {
|
||||
const hinted = this.lineDecorations.get(lineHint);
|
||||
if (hinted === target) {
|
||||
if (this.lineDecorations.delete(lineHint)) this.decorationsVersion += 1;
|
||||
this.lineDecorations.delete(lineHint);
|
||||
return lineHint;
|
||||
}
|
||||
}
|
||||
for (const [mappedLineY, mappedState] of this.lineDecorations) {
|
||||
if (mappedState === target) {
|
||||
if (this.lineDecorations.delete(mappedLineY)) this.decorationsVersion += 1;
|
||||
this.lineDecorations.delete(mappedLineY);
|
||||
return mappedLineY;
|
||||
}
|
||||
}
|
||||
@@ -420,7 +481,7 @@ export class KeywordHighlighter implements IDisposable {
|
||||
const offset = lineY - cursorAbsoluteY;
|
||||
const marker = this.term.registerMarker(offset);
|
||||
if (!marker) {
|
||||
if (this.lineDecorations.delete(lineY)) this.decorationsVersion += 1;
|
||||
this.lineDecorations.delete(lineY);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -439,17 +500,15 @@ export class KeywordHighlighter implements IDisposable {
|
||||
|
||||
if (decorations.length === 0) {
|
||||
marker.dispose();
|
||||
if (this.lineDecorations.delete(lineY)) this.decorationsVersion += 1;
|
||||
this.lineDecorations.delete(lineY);
|
||||
return;
|
||||
}
|
||||
|
||||
this.lineDecorations.set(lineY, {
|
||||
marker,
|
||||
decorations,
|
||||
ranges,
|
||||
signature,
|
||||
});
|
||||
this.decorationsVersion += 1;
|
||||
this.markTerminalRefreshNeeded(lineY);
|
||||
}
|
||||
|
||||
@@ -657,11 +716,17 @@ export class KeywordHighlighter implements IDisposable {
|
||||
|
||||
const previousRange = this.lastRenderRange;
|
||||
this.beginTerminalRefreshTracking(viewportStart, viewportEnd);
|
||||
let writeContinuationPending = false;
|
||||
try {
|
||||
this.reindexLineDecorationsFromMarkers();
|
||||
|
||||
if (reason === "write") {
|
||||
this.processDirtyLinesInRange(rangeStart, rangeEnd, cursorAbsoluteY, "write");
|
||||
writeContinuationPending = this.processDirtyLinesInRange(
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
cursorAbsoluteY,
|
||||
"write",
|
||||
);
|
||||
} else if (reason === "scroll") {
|
||||
this.startScrollRefresh(viewportStart, viewportEnd, cursorAbsoluteY);
|
||||
return;
|
||||
@@ -676,9 +741,18 @@ export class KeywordHighlighter implements IDisposable {
|
||||
this.processLineRange(rangeStart, rangeEnd, cursorAbsoluteY);
|
||||
}
|
||||
|
||||
for (const [lineY, state] of this.lineDecorations) {
|
||||
if (lineY < rangeStart || lineY > rangeEnd || state.marker.isDisposed) {
|
||||
this.disposeLineDecorations(lineY, state);
|
||||
if (reason === "write") {
|
||||
this.pruneWriteDecorationsIfNeeded(rangeStart, rangeEnd, true);
|
||||
this.finishEnterViewportScan(
|
||||
writeContinuationPending,
|
||||
viewportStart,
|
||||
viewportEnd,
|
||||
);
|
||||
} else {
|
||||
for (const [lineY, state] of this.lineDecorations) {
|
||||
if (lineY < rangeStart || lineY > rangeEnd || state.marker.isDisposed) {
|
||||
this.disposeLineDecorations(lineY, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -691,12 +765,30 @@ export class KeywordHighlighter implements IDisposable {
|
||||
} else {
|
||||
this.lastViewportRange = { start: viewportStart, end: viewportEnd };
|
||||
this.lastRenderRange = { start: rangeStart, end: rangeEnd };
|
||||
if (reason === "full" && this.enterViewportScanInProgress) {
|
||||
this.triggerRefresh("debounced", "write");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.flushTerminalRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
private finishEnterViewportScan(
|
||||
continuationPending: boolean,
|
||||
viewportStart: number,
|
||||
viewportEnd: number,
|
||||
): void {
|
||||
if (!this.enterViewportScanInProgress || continuationPending) return;
|
||||
this.enterViewportScanInProgress = false;
|
||||
if (!this.enterViewportScanNeedsRepeat) return;
|
||||
|
||||
this.enterViewportScanNeedsRepeat = false;
|
||||
this.addDirtyRange(viewportStart, viewportEnd);
|
||||
this.enterViewportScanInProgress = true;
|
||||
this.triggerRefresh("continuation", "write");
|
||||
}
|
||||
|
||||
private beginTerminalRefreshTracking(viewportStart: number, viewportEnd: number) {
|
||||
this.activeRefreshViewport = { start: viewportStart, end: viewportEnd };
|
||||
this.pendingTerminalRefreshRange = null;
|
||||
@@ -731,7 +823,6 @@ export class KeywordHighlighter implements IDisposable {
|
||||
if (this.lineDecorations.size === 0) return;
|
||||
const nextLineDecorations = new Map<number, LineDecorationState>();
|
||||
const staleStates = new Set<LineDecorationState>();
|
||||
const changedLines = new Set<number>();
|
||||
|
||||
for (const state of this.lineDecorations.values()) {
|
||||
if (state.marker.isDisposed || state.marker.line < 0) {
|
||||
@@ -750,21 +841,7 @@ export class KeywordHighlighter implements IDisposable {
|
||||
staleStates.delete(state);
|
||||
}
|
||||
|
||||
let mappingChanged = this.lineDecorations.size !== nextLineDecorations.size;
|
||||
for (const [lineY, state] of this.lineDecorations) {
|
||||
if (nextLineDecorations.get(lineY) !== state) {
|
||||
mappingChanged = true;
|
||||
changedLines.add(lineY);
|
||||
}
|
||||
}
|
||||
for (const [lineY, state] of nextLineDecorations) {
|
||||
if (this.lineDecorations.get(lineY) !== state) changedLines.add(lineY);
|
||||
}
|
||||
if (staleStates.size > 0) mappingChanged = true;
|
||||
|
||||
this.lineDecorations = nextLineDecorations;
|
||||
if (mappingChanged) this.decorationsVersion += 1;
|
||||
for (const lineY of changedLines) this.markTerminalRefreshNeeded(lineY);
|
||||
|
||||
for (const state of staleStates) {
|
||||
const markerLineBeforeDispose = state.marker.isDisposed ? -1 : state.marker.line;
|
||||
@@ -781,7 +858,7 @@ export class KeywordHighlighter implements IDisposable {
|
||||
rangeEnd: number,
|
||||
cursorAbsoluteY: number,
|
||||
continuationReason: RefreshReason
|
||||
) {
|
||||
): boolean {
|
||||
if (this.dirtyAllInRenderRange) {
|
||||
this.dirtySegments = [{ start: rangeStart, end: rangeEnd }];
|
||||
this.rebuildDirtyLineCount();
|
||||
@@ -789,7 +866,7 @@ export class KeywordHighlighter implements IDisposable {
|
||||
}
|
||||
|
||||
if (this.dirtySegments.length === 0) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const dirtyInRange: DirtyLineSegment[] = [];
|
||||
@@ -803,7 +880,7 @@ export class KeywordHighlighter implements IDisposable {
|
||||
}
|
||||
|
||||
if (dirtyInRange.length === 0) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const { writeRefreshBudgetMs, dirtySegmentChunkSize } = this.getAdaptiveHighlightingProfile();
|
||||
@@ -820,14 +897,94 @@ export class KeywordHighlighter implements IDisposable {
|
||||
|
||||
if (chunkStart <= segment.end && performance.now() - startTime >= writeRefreshBudgetMs) {
|
||||
this.triggerRefresh("continuation", continuationReason);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (performance.now() - startTime >= writeRefreshBudgetMs) {
|
||||
if (
|
||||
this.dirtySegments.length > 0
|
||||
&& performance.now() - startTime >= writeRefreshBudgetMs
|
||||
) {
|
||||
this.triggerRefresh("continuation", continuationReason);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private pruneWriteDecorationsIfNeeded(
|
||||
rangeStart: number,
|
||||
rangeEnd: number,
|
||||
deferUntilIdle = false,
|
||||
): void {
|
||||
const renderLineCount = Math.max(1, rangeEnd - rangeStart + 1);
|
||||
const highWaterMark = Math.max(64, renderLineCount * 2);
|
||||
if (this.lineDecorations.size <= highWaterMark) return;
|
||||
if (deferUntilIdle) {
|
||||
const hardLimit = Math.max(256, highWaterMark * 4);
|
||||
if (this.lineDecorations.size > hardLimit) {
|
||||
const trimTarget = Math.max(highWaterMark, Math.floor(hardLimit * 0.75));
|
||||
this.pruneWriteDecorationsToLimit(rangeStart, rangeEnd, trimTarget);
|
||||
}
|
||||
this.scheduleDeferredWritePrune();
|
||||
return;
|
||||
}
|
||||
|
||||
// Decoration registration/removal makes xterm repaint the full viewport.
|
||||
// Prune in batches so ordinary one-line output keeps existing highlights
|
||||
// stable while long-running output remains bounded.
|
||||
for (const [lineY, state] of this.lineDecorations) {
|
||||
if (lineY < rangeStart || lineY > rangeEnd || state.marker.isDisposed) {
|
||||
this.disposeLineDecorations(lineY, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private pruneWriteDecorationsToLimit(
|
||||
rangeStart: number,
|
||||
rangeEnd: number,
|
||||
targetSize: number,
|
||||
): void {
|
||||
for (const [lineY, state] of this.lineDecorations) {
|
||||
if (this.lineDecorations.size <= targetSize) return;
|
||||
if (lineY < rangeStart || lineY > rangeEnd || state.marker.isDisposed) {
|
||||
this.disposeLineDecorations(lineY, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleDeferredWritePrune(): void {
|
||||
if (this.writePruneTimer) clearTimeout(this.writePruneTimer);
|
||||
this.writePruneTimer = setTimeout(() => {
|
||||
this.writePruneTimer = null;
|
||||
if (!this.enabled || this.compiledRules.length === 0) return;
|
||||
const now = performance.now();
|
||||
if (
|
||||
(
|
||||
this.lastUserInputAt > 0
|
||||
&& now - this.lastUserInputAt < KeywordHighlighter.WRITE_PRUNE_IDLE_MS
|
||||
)
|
||||
|| (
|
||||
this.lastWriteAt > 0
|
||||
&& now - this.lastWriteAt < KeywordHighlighter.WRITE_PRUNE_IDLE_MS
|
||||
)
|
||||
) {
|
||||
this.scheduleDeferredWritePrune();
|
||||
return;
|
||||
}
|
||||
const buffer = this.term.buffer.active;
|
||||
const overscan = this.getOverscanLines("write");
|
||||
const rangeStart = Math.max(0, buffer.viewportY - overscan);
|
||||
const rangeEnd = buffer.viewportY + this.term.rows - 1 + overscan;
|
||||
this.pruneWriteDecorationsIfNeeded(rangeStart, rangeEnd);
|
||||
}, KeywordHighlighter.WRITE_PRUNE_IDLE_MS);
|
||||
}
|
||||
|
||||
private scheduleEnterInputIdleClear(): void {
|
||||
if (this.enterInputIdleTimer) clearTimeout(this.enterInputIdleTimer);
|
||||
this.enterInputIdleTimer = setTimeout(() => {
|
||||
this.enterInputIdleTimer = null;
|
||||
this.enterInputPending = false;
|
||||
}, KeywordHighlighter.WRITE_PRUNE_IDLE_MS);
|
||||
}
|
||||
|
||||
private mergeRefreshReason(current: RefreshReason, next: RefreshReason): RefreshReason {
|
||||
@@ -850,6 +1007,24 @@ export class KeywordHighlighter implements IDisposable {
|
||||
};
|
||||
}
|
||||
|
||||
private hasOutputPositionChangedSinceLastSnapshot(): boolean {
|
||||
const previous = this.lastBufferSnapshot;
|
||||
const current = this.readBufferSnapshot({ includeViewportProbe: false });
|
||||
if (!previous || !current) return false;
|
||||
return (
|
||||
current.length !== previous.length
|
||||
|| current.baseY !== previous.baseY
|
||||
|| current.cursorAbsoluteY !== previous.cursorAbsoluteY
|
||||
);
|
||||
}
|
||||
|
||||
private hasDecorationMarkerShiftSinceLastRefresh(): boolean {
|
||||
for (const [lineY, state] of this.lineDecorations) {
|
||||
if (state.marker.isDisposed || state.marker.line !== lineY) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private buildViewportProbe(buffer: IBuffer, rows: number): readonly ViewportProbeSample[] {
|
||||
if (rows <= 0) return [];
|
||||
const viewportStart = buffer.viewportY;
|
||||
@@ -988,9 +1163,9 @@ export class KeywordHighlighter implements IDisposable {
|
||||
return Math.min(maxDirtyLines, Math.max(minDirtyLines, dynamicDirtyLines));
|
||||
}
|
||||
|
||||
private markDirtyFromWrite() {
|
||||
private markDirtyFromWrite({ includeViewportProbe = true }: { includeViewportProbe?: boolean } = {}) {
|
||||
this.updateWriteBurst();
|
||||
const snapshot = this.readBufferSnapshot();
|
||||
const snapshot = this.readBufferSnapshot({ includeViewportProbe });
|
||||
if (!snapshot) {
|
||||
this.markVisibleRangeDirty();
|
||||
return;
|
||||
@@ -1168,6 +1343,9 @@ export class KeywordHighlighter implements IDisposable {
|
||||
): boolean {
|
||||
if (mode !== "immediate") return false;
|
||||
if (!this.isWriteBurstActive(now)) return false;
|
||||
if (now - this.lastRefreshTime >= KeywordHighlighter.WRITE_BURST_HIGHLIGHT_PAUSE_MS) {
|
||||
return false;
|
||||
}
|
||||
return reason === "write";
|
||||
}
|
||||
|
||||
@@ -1310,6 +1488,17 @@ export class KeywordHighlighter implements IDisposable {
|
||||
this.scrollRefreshGeneration += 1;
|
||||
}
|
||||
|
||||
private cancelQueuedRefreshSchedule() {
|
||||
if (this.animationFrameId !== null) {
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
this.animationFrameId = null;
|
||||
}
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private clearLineDecorationsOutsideRange(start: number, end: number) {
|
||||
if (this.lineDecorations.size === 0) return;
|
||||
const entries = Array.from(this.lineDecorations.entries());
|
||||
|
||||
@@ -426,7 +426,7 @@ test("startSSH tells the bridge to skip shell discovery for network devices", as
|
||||
};
|
||||
const ctx = createStarterContext({
|
||||
isNetworkDevice: true,
|
||||
reuseConnectionFromSessionId: "source-session",
|
||||
reuseConnectionFromSessionIdRef: { current: "source-session" },
|
||||
terminalBackend,
|
||||
});
|
||||
|
||||
@@ -436,6 +436,206 @@ test("startSSH tells the bridge to skip shell discovery for network devices", as
|
||||
assert.equal(capturedOptions?.skipShellPidDiscovery, true);
|
||||
});
|
||||
|
||||
test("startSSH requests a fresh transport for ordinary opens with connection automation", async () => {
|
||||
const captured: Record<string, unknown>[] = [];
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
startSSHSession: async (options: Record<string, unknown>) => {
|
||||
captured.push(options);
|
||||
return `ssh-session-${captured.length}`;
|
||||
},
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: noop,
|
||||
resizeSession: noop,
|
||||
};
|
||||
|
||||
const reuseConnectionFromSessionIdRef = { current: "source-session" as string | undefined };
|
||||
const reuseAttempts: Array<string | undefined> = [];
|
||||
let requiresFreshConnection = true;
|
||||
let committedAutomationSnapshots = 0;
|
||||
const automatedStarters = createTerminalSessionStarters(createStarterContext({
|
||||
shouldUseFreshSshConnection: () => requiresFreshConnection,
|
||||
onConnectAutomationSnapshotCommitted: () => {
|
||||
committedAutomationSnapshots += 1;
|
||||
},
|
||||
reuseConnectionFromSessionIdRef,
|
||||
setConnectionReuseAttemptSourceId: (sourceSessionId: string | undefined) => {
|
||||
reuseAttempts.push(sourceSessionId);
|
||||
},
|
||||
terminalBackend,
|
||||
}) as never);
|
||||
await automatedStarters.startSSH(createTermStub() as never);
|
||||
await automatedStarters.startSSH(createTermStub() as never);
|
||||
assert.equal(committedAutomationSnapshots, 0);
|
||||
requiresFreshConnection = false;
|
||||
await automatedStarters.startSSH(createTermStub() as never);
|
||||
assert.equal(committedAutomationSnapshots, 1);
|
||||
await createTerminalSessionStarters(createStarterContext({
|
||||
shouldUseFreshSshConnection: () => false,
|
||||
terminalBackend,
|
||||
}) as never).startSSH(createTermStub() as never);
|
||||
|
||||
assert.equal(captured[0].reuseTransport, undefined);
|
||||
assert.equal(captured[0].sourceSessionId, "source-session");
|
||||
assert.equal(captured[1].reuseTransport, false);
|
||||
assert.equal(captured[1].sourceSessionId, undefined);
|
||||
assert.equal(captured[2].reuseTransport, undefined);
|
||||
assert.equal(captured[2].sourceSessionId, undefined);
|
||||
assert.equal(captured[3].reuseTransport, undefined);
|
||||
assert.deepEqual(reuseAttempts, ["source-session", undefined, undefined]);
|
||||
});
|
||||
|
||||
test("startSSH commits an empty automation snapshot only after the backend session succeeds", async () => {
|
||||
let resolveStart: ((sessionId: string) => void) | undefined;
|
||||
let commitCount = 0;
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
startSSHSession: () => new Promise<string>((resolve) => {
|
||||
resolveStart = resolve;
|
||||
}),
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: noop,
|
||||
resizeSession: noop,
|
||||
};
|
||||
const startPromise = createTerminalSessionStarters(createStarterContext({
|
||||
shouldUseFreshSshConnection: () => false,
|
||||
onConnectAutomationSnapshotCommitted: () => {
|
||||
commitCount += 1;
|
||||
},
|
||||
terminalBackend,
|
||||
}) as never).startSSH(createTermStub() as never);
|
||||
|
||||
await Promise.resolve();
|
||||
assert.equal(commitCount, 0);
|
||||
assert.ok(resolveStart);
|
||||
resolveStart("ssh-session");
|
||||
await startPromise;
|
||||
assert.equal(commitCount, 1);
|
||||
});
|
||||
|
||||
test("startSSH rechecks the live automation policy before password fallback", async () => {
|
||||
const captured: Record<string, unknown>[] = [];
|
||||
let requiresFreshConnection = false;
|
||||
let commitCount = 0;
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
startSSHSession: async (options: Record<string, unknown>) => {
|
||||
captured.push(options);
|
||||
if (captured.length === 1) {
|
||||
requiresFreshConnection = true;
|
||||
throw new Error("Authentication failed");
|
||||
}
|
||||
return "ssh-session";
|
||||
},
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: noop,
|
||||
resizeSession: noop,
|
||||
};
|
||||
const ctx = createStarterContext({
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Target",
|
||||
hostname: "target.example.test",
|
||||
username: "alice",
|
||||
authMethod: "key",
|
||||
identityFileId: "key-1",
|
||||
password: "login-secret",
|
||||
},
|
||||
keys: [{
|
||||
id: "key-1",
|
||||
name: "Key",
|
||||
privateKey: "plain-private-key",
|
||||
publicKey: "",
|
||||
source: "embedded",
|
||||
}],
|
||||
shouldUseFreshSshConnection: () => requiresFreshConnection,
|
||||
onConnectAutomationSnapshotCommitted: () => {
|
||||
commitCount += 1;
|
||||
},
|
||||
terminalBackend,
|
||||
});
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
||||
|
||||
assert.equal(captured.length, 2);
|
||||
assert.equal(captured[0].reuseTransport, undefined);
|
||||
assert.equal(captured[1].reuseTransport, false);
|
||||
assert.equal(commitCount, 0);
|
||||
});
|
||||
|
||||
test("startSSH keeps interactive source auth retries off unrelated pooled transports", async () => {
|
||||
const captured: Record<string, unknown>[] = [];
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
startSSHSession: async (options: Record<string, unknown>) => {
|
||||
captured.push(options);
|
||||
if (captured.length === 1) throw new Error("Authentication failed");
|
||||
return "ssh-session";
|
||||
},
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: noop,
|
||||
resizeSession: noop,
|
||||
};
|
||||
const reuseConnectionFromSessionIdRef = { current: "source-session" as string | undefined };
|
||||
const reuseConnectionSourceAttemptedRef = { current: false };
|
||||
const firstCtx = createStarterContext({
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Target",
|
||||
hostname: "target.example.test",
|
||||
username: "alice",
|
||||
authMethod: "key",
|
||||
identityFileId: "key-1",
|
||||
},
|
||||
keys: [{
|
||||
id: "key-1",
|
||||
name: "Key",
|
||||
privateKey: "plain-private-key",
|
||||
publicKey: "",
|
||||
source: "embedded",
|
||||
}],
|
||||
reuseConnectionFromSessionIdRef,
|
||||
reuseConnectionSourceAttemptedRef,
|
||||
shouldUseFreshSshConnection: () => false,
|
||||
terminalBackend,
|
||||
});
|
||||
|
||||
await createTerminalSessionStarters(firstCtx as never).startSSH(createTermStub() as never);
|
||||
|
||||
assert.equal(reuseConnectionSourceAttemptedRef.current, true);
|
||||
|
||||
const retryCtx = createStarterContext({
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Target",
|
||||
hostname: "target.example.test",
|
||||
username: "alice",
|
||||
authMethod: "password",
|
||||
password: "corrected-secret",
|
||||
},
|
||||
reuseConnectionFromSessionIdRef,
|
||||
reuseConnectionSourceAttemptedRef,
|
||||
shouldUseFreshSshConnection: () => false,
|
||||
terminalBackend,
|
||||
});
|
||||
await createTerminalSessionStarters(retryCtx as never).startSSH(createTermStub() as never);
|
||||
|
||||
assert.equal(captured.length, 2);
|
||||
assert.equal(captured[0].sourceSessionId, "source-session");
|
||||
assert.equal(captured[0].reuseTransport, undefined);
|
||||
assert.equal(captured[1].sourceSessionId, undefined);
|
||||
assert.equal(captured[1].reuseTransport, false);
|
||||
assert.equal(reuseConnectionSourceAttemptedRef.current, false);
|
||||
});
|
||||
|
||||
test("startSSH uses the system agent when a synced vault key cannot be decrypted", async () => {
|
||||
let capturedOptions: Record<string, unknown> | null = null;
|
||||
const terminalBackend = {
|
||||
|
||||
@@ -529,12 +529,27 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
? ctx.host.identityFilePaths
|
||||
: undefined;
|
||||
|
||||
let sourceReuseAttemptedWithinStart = false;
|
||||
const startAttempt = async (attempt: {
|
||||
password?: string;
|
||||
key?: SSHKey;
|
||||
useIdentityFiles?: boolean;
|
||||
useSshAgent?: boolean;
|
||||
}): Promise<string> => {
|
||||
const sourceSessionId = ctx.reuseConnectionFromSessionIdRef?.current;
|
||||
const sourceReuseAttempted = ctx.reuseConnectionSourceAttemptedRef?.current
|
||||
?? sourceReuseAttemptedWithinStart;
|
||||
const isFallbackAfterSourceReuse = sourceReuseAttempted && !sourceSessionId;
|
||||
if (ctx.reuseConnectionFromSessionIdRef) {
|
||||
ctx.reuseConnectionFromSessionIdRef.current = undefined;
|
||||
}
|
||||
if (sourceSessionId) {
|
||||
sourceReuseAttemptedWithinStart = true;
|
||||
if (ctx.reuseConnectionSourceAttemptedRef) {
|
||||
ctx.reuseConnectionSourceAttemptedRef.current = true;
|
||||
}
|
||||
}
|
||||
ctx.setConnectionReuseAttemptSourceId?.(sourceSessionId);
|
||||
ctx.setIsConnectionAwaitingUserInput?.(false);
|
||||
ctx.setIsConnectionPastTcpDial?.(false);
|
||||
// Resolve keepalive per-host: a host can opt into its own values
|
||||
@@ -546,7 +561,8 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
globalTerminalSettings,
|
||||
);
|
||||
const connectionTimeouts = resolveHostSshConnectionTimeouts(ctx.host);
|
||||
return ctx.terminalBackend.startSSHSession({
|
||||
const requiresFreshSshConnection = ctx.shouldUseFreshSshConnection?.() === true;
|
||||
const startedSessionId = await ctx.terminalBackend.startSSHSession({
|
||||
sessionId: ctx.sessionId,
|
||||
hostLabel: ctx.host.label,
|
||||
hostname: ctx.host.hostname,
|
||||
@@ -586,18 +602,36 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
|
||||
sshDebugLogEnabled: ctx.sshDebugLogEnabled,
|
||||
identityFilePaths: attempt.useIdentityFiles ? targetIdentityFilePaths : undefined,
|
||||
...(attempt.useSshAgent === false
|
||||
? { useSshAgent: false }
|
||||
: resolveBridgeSshAgentAuth(ctx.host, attempt.key, authMethod)),
|
||||
...resolveBridgeSshAgentAuth(
|
||||
attempt.useSshAgent === false
|
||||
? { ...ctx.host, useSshAgent: false }
|
||||
: ctx.host,
|
||||
attempt.key,
|
||||
authMethod,
|
||||
),
|
||||
knownHosts: ctx.knownHosts,
|
||||
sudoAutofillPassword: resolveSavedSudoAutofillPassword(),
|
||||
// Ask the bridge to reuse the source tab's authenticated connection
|
||||
// (issue #1204). Only honored on the very first connect attempt; the
|
||||
// bridge silently falls back to a fresh connection if the source is
|
||||
// gone, so reconnect/retry after the source closed still works.
|
||||
sourceSessionId: ctx.reuseConnectionFromSessionId,
|
||||
sourceSessionId,
|
||||
// Connect-time automation must see the complete login sequence. An
|
||||
// explicit Copy/Split keeps its source-session reuse contract, while
|
||||
// an ordinary open bypasses endpoint/idle transport reuse.
|
||||
reuseTransport: !sourceSessionId && (requiresFreshSshConnection || isFallbackAfterSourceReuse)
|
||||
? false
|
||||
: undefined,
|
||||
skipShellPidDiscovery: ctx.isNetworkDevice === true,
|
||||
});
|
||||
if (!requiresFreshSshConnection) {
|
||||
ctx.onConnectAutomationSnapshotCommitted?.();
|
||||
}
|
||||
sourceReuseAttemptedWithinStart = false;
|
||||
if (ctx.reuseConnectionSourceAttemptedRef) {
|
||||
ctx.reuseConnectionSourceAttemptedRef.current = false;
|
||||
}
|
||||
return startedSessionId;
|
||||
};
|
||||
|
||||
let id: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FitAddon } from "@xterm/addon-fit";
|
||||
import type { SerializeAddon } from "@xterm/addon-serialize";
|
||||
import type { Dispatch, RefObject, SetStateAction } from "react";
|
||||
import type { Dispatch, MutableRefObject, RefObject, SetStateAction } from "react";
|
||||
import type { Host, Identity, KnownHost, SerialConfig, SSHKey, TerminalSession, TerminalSettings } from "../../../types";
|
||||
import type { PromptLineBreakState } from "./promptLineBreak";
|
||||
import type {
|
||||
@@ -147,9 +147,21 @@ export type TerminalSessionStartersContext = {
|
||||
knownHosts?: KnownHost[];
|
||||
resolvedChainHosts: Host[];
|
||||
sessionId: string;
|
||||
// Source session id to reuse an authenticated SSH connection from when this
|
||||
// terminal was created from an existing SSH session.
|
||||
reuseConnectionFromSessionId?: string;
|
||||
// One-shot source session intent for Copy/Split. Consumed by the first SSH
|
||||
// attempt so later reconnects do not skip the initial login sequence.
|
||||
reuseConnectionFromSessionIdRef?: MutableRefObject<string | undefined>;
|
||||
// Persists across renderer auth retries after the one-shot source intent is
|
||||
// consumed. Cleared only after a backend session starts successfully.
|
||||
reuseConnectionSourceAttemptedRef?: MutableRefObject<boolean>;
|
||||
// Mirrors the source actually consumed by the current SSH attempt so the UI
|
||||
// only hides its connecting dialog while Copy/Split reuse is being tried.
|
||||
setConnectionReuseAttemptSourceId?: (sourceSessionId: string | undefined) => void;
|
||||
// Connect automation and still-unhandled pending scripts need the initial
|
||||
// login output. Evaluate per attempt because pending work is one-shot.
|
||||
shouldUseFreshSshConnection?: () => boolean;
|
||||
// Commit the no-automation snapshot only after the corresponding backend
|
||||
// session actually starts, so failed auth attempts do not consume scripts.
|
||||
onConnectAutomationSnapshotCommitted?: () => void;
|
||||
isNetworkDevice?: boolean;
|
||||
startupCommand?: string;
|
||||
noAutoRun?: boolean;
|
||||
|
||||
@@ -2044,11 +2044,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
const keywordHighlighter = new KeywordHighlighter(term);
|
||||
keywordHighlighter.setRules(keywordHighlightRules, keywordHighlightEnabled);
|
||||
|
||||
const cursorLineHighlighter = new CursorLineHighlighter(
|
||||
term,
|
||||
(absoluteLine) => keywordHighlighter.getForegroundRanges(absoluteLine),
|
||||
() => keywordHighlighter.getForegroundRangesVersion(),
|
||||
);
|
||||
const cursorLineHighlighter = new CursorLineHighlighter(term);
|
||||
cursorLineHighlighter.setBackgroundColor(
|
||||
resolveCursorLineHighlightBackground(ctx.terminalTheme.colors),
|
||||
);
|
||||
|
||||
@@ -246,71 +246,39 @@ test('CursorLineHighlighter gives selection and search matches priority', () =>
|
||||
|
||||
test('CursorLineHighlighter refreshes after hidden writes become visible', () => {
|
||||
const term = createFakeTerm(10);
|
||||
let protectedRanges: Array<{ x: number; width: number }> = [];
|
||||
const highlighter = new CursorLineHighlighter(
|
||||
term as never,
|
||||
() => protectedRanges,
|
||||
);
|
||||
const highlighter = new CursorLineHighlighter(term as never);
|
||||
highlighter.setEnabled(true);
|
||||
assert.equal(term.decorations.at(-1)?.options.width, 10);
|
||||
assert.equal(term.markers.at(-1)?.line, 0);
|
||||
|
||||
term.writeHiddenOutput(1);
|
||||
protectedRanges = [{ x: 0, width: 2 }];
|
||||
assert.equal(term.decorations.length, 1);
|
||||
term.render();
|
||||
|
||||
assert.deepEqual(
|
||||
term.decorations
|
||||
.filter(({ disposed }) => !disposed)
|
||||
.map(({ options }) => ({ x: options.x, width: options.width })),
|
||||
[
|
||||
{ x: 2, width: 8 },
|
||||
],
|
||||
);
|
||||
assert.equal(term.decorations.length, 2);
|
||||
assert.equal(term.decorations[0]?.disposed, true);
|
||||
assert.equal(term.markers.at(-1)?.line, 1);
|
||||
highlighter.dispose();
|
||||
});
|
||||
|
||||
test('CursorLineHighlighter refreshes after keyword ranges are removed', () => {
|
||||
test('CursorLineHighlighter keeps a continuous background under keyword decorations', () => {
|
||||
const term = createFakeTerm(10);
|
||||
let protectedRanges: Array<{ x: number; width: number }> = [{ x: 0, width: 2 }];
|
||||
let protectedRangesVersion = 1;
|
||||
const highlighter = new CursorLineHighlighter(
|
||||
term as never,
|
||||
() => protectedRanges,
|
||||
() => protectedRangesVersion,
|
||||
);
|
||||
highlighter.setEnabled(true);
|
||||
assert.deepEqual(
|
||||
term.decorations.filter(({ disposed }) => !disposed).map(({ options }) => options.width),
|
||||
[8],
|
||||
);
|
||||
|
||||
protectedRanges = [];
|
||||
protectedRangesVersion += 1;
|
||||
term.render();
|
||||
|
||||
assert.deepEqual(
|
||||
term.decorations.filter(({ disposed }) => !disposed).map(({ options }) => options.width),
|
||||
[10],
|
||||
);
|
||||
highlighter.dispose();
|
||||
});
|
||||
|
||||
test('CursorLineHighlighter leaves keyword decoration ranges untouched', () => {
|
||||
const term = createFakeTerm(10);
|
||||
const highlighter = new CursorLineHighlighter(
|
||||
term as never,
|
||||
() => [{ x: 2, width: 2 }],
|
||||
);
|
||||
const keywordMarker = term.registerMarker(0);
|
||||
term.registerDecoration({
|
||||
marker: keywordMarker,
|
||||
x: 2,
|
||||
width: 2,
|
||||
foregroundColor: '#F87171',
|
||||
});
|
||||
const highlighter = new CursorLineHighlighter(term as never);
|
||||
highlighter.setBackgroundColor('#263449');
|
||||
highlighter.setEnabled(true);
|
||||
|
||||
assert.deepEqual(
|
||||
term.decorations.map(({ options }) => ({ x: options.x, width: options.width })),
|
||||
[
|
||||
{ x: 0, width: 2 },
|
||||
{ x: 4, width: 6 },
|
||||
],
|
||||
);
|
||||
assert.equal(term.decorations[0]?.options.foregroundColor, '#F87171');
|
||||
assert.equal(term.decorations[0]?.options.backgroundColor, undefined);
|
||||
assert.equal(term.decorations[1]?.options.x, 0);
|
||||
assert.equal(term.decorations[1]?.options.width, 10);
|
||||
assert.equal(term.decorations[1]?.options.backgroundColor, '#263449');
|
||||
assert.equal(term.decorations[1]?.options.layer, 'bottom');
|
||||
highlighter.dispose();
|
||||
});
|
||||
|
||||
|
||||
@@ -37,24 +37,16 @@ export class CursorLineHighlighter implements IDisposable {
|
||||
private activeColor: string | null = null;
|
||||
private activeRanges: HighlightRange[] = [];
|
||||
private activeTailRanges: HighlightRange[] = [];
|
||||
private activeProtectedRangesVersion = 0;
|
||||
private readonly disposables: IDisposable[] = [];
|
||||
private pendingRefresh = false;
|
||||
private disposed = false;
|
||||
|
||||
constructor(
|
||||
private readonly term: CursorLineTerminal,
|
||||
private readonly getProtectedRanges: (absoluteLine: number) => readonly HighlightRange[] = () => [],
|
||||
private readonly getProtectedRangesVersion: () => number = () => 0,
|
||||
) {
|
||||
constructor(private readonly term: CursorLineTerminal) {
|
||||
this.disposables.push(
|
||||
this.term.onCursorMove(() => this.markPendingRefresh()),
|
||||
this.term.onWriteParsed(() => this.markPendingRefresh()),
|
||||
this.term.onRender(() => {
|
||||
if (
|
||||
this.pendingRefresh ||
|
||||
this.getProtectedRangesVersion() !== this.activeProtectedRangesVersion
|
||||
) {
|
||||
if (this.pendingRefresh) {
|
||||
this.pendingRefresh = false;
|
||||
this.refresh();
|
||||
}
|
||||
@@ -109,10 +101,8 @@ export class CursorLineHighlighter implements IDisposable {
|
||||
line,
|
||||
cols,
|
||||
buffer.getNullCell(),
|
||||
this.getProtectedRanges(absoluteLine),
|
||||
);
|
||||
const tailRanges = this.getTailRanges(contentEnd, cols);
|
||||
const protectedRangesVersion = this.getProtectedRangesVersion();
|
||||
|
||||
if (
|
||||
!options.force &&
|
||||
@@ -125,7 +115,6 @@ export class CursorLineHighlighter implements IDisposable {
|
||||
!this.marker.isDisposed &&
|
||||
this.marker.line === absoluteLine
|
||||
) {
|
||||
this.activeProtectedRangesVersion = protectedRangesVersion;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -175,7 +164,6 @@ export class CursorLineHighlighter implements IDisposable {
|
||||
this.activeColor = color;
|
||||
this.activeRanges = ranges;
|
||||
this.activeTailRanges = tailRanges;
|
||||
this.activeProtectedRangesVersion = protectedRangesVersion;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
@@ -192,7 +180,6 @@ export class CursorLineHighlighter implements IDisposable {
|
||||
line: ReturnType<CursorLineTerminal['buffer']['active']['getLine']>,
|
||||
cols: number,
|
||||
cell: ReturnType<CursorLineTerminal['buffer']['active']['getNullCell']>,
|
||||
protectedRanges: readonly HighlightRange[],
|
||||
): { ranges: HighlightRange[]; contentEnd: number } {
|
||||
const ranges: HighlightRange[] = [];
|
||||
let rangeStart: number | null = null;
|
||||
@@ -205,15 +192,11 @@ export class CursorLineHighlighter implements IDisposable {
|
||||
) {
|
||||
contentEnd = Math.min(cols, x + Math.max(1, currentCell.getWidth()));
|
||||
}
|
||||
const isProtected = protectedRanges.some(
|
||||
(range) => x >= range.x && x < range.x + range.width,
|
||||
);
|
||||
const isHighlightable =
|
||||
!isProtected &&
|
||||
(currentCell === undefined ||
|
||||
(currentCell.isBgDefault() &&
|
||||
currentCell.isFgDefault() &&
|
||||
!currentCell.isInverse()));
|
||||
currentCell === undefined ||
|
||||
(currentCell.isBgDefault() &&
|
||||
currentCell.isFgDefault() &&
|
||||
!currentCell.isInverse());
|
||||
if (isHighlightable && rangeStart === null) rangeStart = x;
|
||||
if ((!isHighlightable || x === cols - 1) && rangeStart !== null) {
|
||||
const end = isHighlightable && x === cols - 1 ? x + 1 : x;
|
||||
|
||||
@@ -421,6 +421,14 @@ test("connection dialog keeps existing local and disconnected behavior", () => {
|
||||
});
|
||||
|
||||
test("connection reuse hides connecting dialog only while reuse is still possible", () => {
|
||||
assert.equal(
|
||||
shouldHideConnectingDialogForConnectionReuse({
|
||||
reuseConnectionFromSessionId: undefined,
|
||||
host: host(),
|
||||
connectionReuseFellBack: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldHideConnectingDialogForConnectionReuse({
|
||||
reuseConnectionFromSessionId: "source-session",
|
||||
|
||||
@@ -5,9 +5,14 @@ import test from 'node:test';
|
||||
import {
|
||||
normalizeTerminalSidePanelTabOrder,
|
||||
reorderTerminalSidePanelTab,
|
||||
fitTerminalSidePanelTabs,
|
||||
TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER,
|
||||
} from '../../application/state/terminalSidePanelTabs.ts';
|
||||
import { getTerminalSidePanelShellWidth } from './TerminalLayerSidePanelSection.tsx';
|
||||
import {
|
||||
getTerminalSidePanelShellWidth,
|
||||
listenForSidePanelPaneFocus,
|
||||
} from './TerminalLayerSidePanelSection.tsx';
|
||||
import { resolveSidePanelPortalTarget } from './terminalLayerSidePanelSlots.tsx';
|
||||
|
||||
test('AI side panel shell can be force-hidden for layout isolation', () => {
|
||||
assert.equal(getTerminalSidePanelShellWidth({
|
||||
@@ -49,6 +54,24 @@ test('closed side panel shell has no width', () => {
|
||||
}), 0);
|
||||
});
|
||||
|
||||
test('pointer and keyboard focus from portaled tool content focus the owning pane', () => {
|
||||
const host = new EventTarget();
|
||||
let focusCount = 0;
|
||||
const stopListening = listenForSidePanelPaneFocus(host, () => {
|
||||
focusCount += 1;
|
||||
});
|
||||
|
||||
host.dispatchEvent(new Event('pointerdown'));
|
||||
assert.equal(focusCount, 1);
|
||||
host.dispatchEvent(new Event('focusin'));
|
||||
assert.equal(focusCount, 2);
|
||||
|
||||
stopListening();
|
||||
host.dispatchEvent(new Event('pointerdown'));
|
||||
host.dispatchEvent(new Event('focusin'));
|
||||
assert.equal(focusCount, 2);
|
||||
});
|
||||
|
||||
test('side panel tab order falls back to the default order', () => {
|
||||
assert.deepEqual(normalizeTerminalSidePanelTabOrder(null), TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER);
|
||||
// Partial / dirty lists keep known ids in order and append the rest of the defaults.
|
||||
@@ -92,6 +115,18 @@ test('side panel tab order can move the dragged tab after the target tab', () =>
|
||||
);
|
||||
});
|
||||
|
||||
test('narrow side panels keep the active tool visible and move extra tools into overflow', () => {
|
||||
const fitted = fitTerminalSidePanelTabs({
|
||||
shown: [...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER],
|
||||
collapsed: [],
|
||||
active: 'ai',
|
||||
maxShown: 2,
|
||||
});
|
||||
|
||||
assert.deepEqual(fitted.shown, ['sftp', 'ai']);
|
||||
assert.deepEqual(fitted.collapsed, ['scripts', 'history', 'theme', 'system', 'notes']);
|
||||
});
|
||||
|
||||
test('notes side panel forwards repeated open-note requests', () => {
|
||||
const layerSource = readFileSync(new URL('../TerminalLayer.tsx', import.meta.url), 'utf8');
|
||||
const slotsSource = readFileSync(new URL('./terminalLayerSidePanelSlots.tsx', import.meta.url), 'utf8');
|
||||
@@ -129,3 +164,123 @@ test('side panel content scopes app color utilities to the resolved terminal the
|
||||
assert.match(sectionSource, /buildTerminalSidePanelCssVars/);
|
||||
assert.match(sectionSource, /\.\.\.sidePanelCssVars/);
|
||||
});
|
||||
|
||||
test('a visible tool without a ready pane host stays in the hidden parking host', () => {
|
||||
const parkingHost = { id: 'parking' };
|
||||
const paneHost = { id: 'pane' };
|
||||
|
||||
assert.equal(resolveSidePanelPortalTarget(true, paneHost, parkingHost), paneHost);
|
||||
assert.equal(resolveSidePanelPortalTarget(true, null, parkingHost), parkingHost);
|
||||
assert.equal(resolveSidePanelPortalTarget(false, paneHost, parkingHost), parkingHost);
|
||||
assert.equal(resolveSidePanelPortalTarget(true, null, null), null);
|
||||
});
|
||||
|
||||
test('split pane hosts strictly clip each tool and do not use the side panel root as a portal fallback', () => {
|
||||
const sectionSource = readFileSync(new URL('./TerminalLayerSidePanelSection.tsx', import.meta.url), 'utf8');
|
||||
const slotsSource = readFileSync(new URL('./terminalLayerSidePanelSlots.tsx', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(sectionSource, /terminal-side-panel-pane-content/);
|
||||
assert.match(sectionSource, /overflow-hidden \[contain:strict\]/);
|
||||
assert.match(sectionSource, /terminal-side-panel-parking/);
|
||||
assert.match(slotsSource, /paneHosts\.get\(tool\)/);
|
||||
assert.match(slotsSource, /document\.createElement\('div'\)/);
|
||||
assert.match(slotsSource, /target\.appendChild\(mountNode\)/);
|
||||
assert.match(slotsSource, /createPortal\(children, mountNode, portalKey\)/);
|
||||
assert.doesNotMatch(slotsSource, /document\.querySelector\([^)]*terminal-side-panel/);
|
||||
});
|
||||
|
||||
test('the shared toolbar owns split controls while panes only render minimal chrome', () => {
|
||||
const sectionSource = readFileSync(new URL('./TerminalLayerSidePanelSection.tsx', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(sectionSource, /<SidePanelSplitMenu[\s\S]*direction="horizontal"/);
|
||||
assert.match(sectionSource, /<SidePanelSplitMenu[\s\S]*direction="vertical"/);
|
||||
assert.match(sectionSource, /data-section="terminal-side-panel-pane"/);
|
||||
assert.match(sectionSource, /paneCount > 1/);
|
||||
});
|
||||
|
||||
test('split icons depict the same pane arrangement as their actions', () => {
|
||||
const sectionSource = readFileSync(new URL('./TerminalLayerSidePanelSection.tsx', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(
|
||||
sectionSource,
|
||||
/direction === 'horizontal'\s*\? <SplitSquareVertical size=\{15\} \/>\s*: <SplitSquareHorizontal size=\{15\} \/>/,
|
||||
);
|
||||
});
|
||||
|
||||
test('side panel layout state is initialized before callbacks read it', () => {
|
||||
const layerSource = readFileSync(new URL('../TerminalLayer.tsx', import.meta.url), 'utf8');
|
||||
const layoutStateIndex = layerSource.indexOf('} = useTerminalSidePanelLayoutState();');
|
||||
const statusCallbackIndex = layerSource.indexOf('const handleStatusChange = useCallback');
|
||||
|
||||
assert.notEqual(layoutStateIndex, -1);
|
||||
assert.notEqual(statusCallbackIndex, -1);
|
||||
assert.ok(layoutStateIndex < statusCallbackIndex);
|
||||
});
|
||||
|
||||
test('split dragging previews locally, commits once, and cleans up on every exit path', () => {
|
||||
const sectionSource = readFileSync(new URL('./TerminalLayerSidePanelSection.tsx', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(sectionSource, /const updatePreview = \(\) => \{[\s\S]*setPreviewSizes\(next\)/);
|
||||
assert.match(sectionSource, /const finish = \(\) => \{[\s\S]*onResize\(node\.id, latestSizes\)/);
|
||||
const previewBody = sectionSource.slice(
|
||||
sectionSource.indexOf('const updatePreview = () => {'),
|
||||
sectionSource.indexOf('const onMouseMove = (moveEvent: MouseEvent) => {'),
|
||||
);
|
||||
assert.doesNotMatch(previewBody, /onResize/);
|
||||
assert.match(sectionSource, /window\.addEventListener\('blur', finish\)/);
|
||||
assert.match(sectionSource, /resizeCleanupRef\.current\?\.\(\)/);
|
||||
assert.match(sectionSource, /terminalLayoutSuppressStore\.end\(\)/);
|
||||
});
|
||||
|
||||
test('split resizers do not consume visible gutter space', () => {
|
||||
const sectionSource = readFileSync(new URL('./TerminalLayerSidePanelSection.tsx', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(sectionSource, /group relative w-px shrink-0 cursor-ew-resize/);
|
||||
assert.match(sectionSource, /group relative h-px shrink-0 cursor-ns-resize/);
|
||||
assert.match(sectionSource, /after:w-2/);
|
||||
assert.match(sectionSource, /after:h-2/);
|
||||
assert.doesNotMatch(sectionSource, /group relative w-1 shrink-0 cursor-ew-resize/);
|
||||
assert.doesNotMatch(sectionSource, /group relative h-1 shrink-0 cursor-ns-resize/);
|
||||
});
|
||||
|
||||
test('side panel resize uses the expanded width limit and protects terminal space', () => {
|
||||
const layerSource = readFileSync(new URL('../TerminalLayer.tsx', import.meta.url), 'utf8');
|
||||
const sectionSource = readFileSync(new URL('./TerminalLayerSidePanelSection.tsx', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(layerSource, /TERMINAL_SIDE_PANEL_MAX_WIDTH/);
|
||||
assert.match(sectionSource, /clampTerminalSidePanelWidth/);
|
||||
assert.match(sectionSource, /terminalLayer\.getBoundingClientRect\(\)\.width/);
|
||||
assert.match(sectionSource, /terminal-workspace-sidebar/);
|
||||
assert.match(sectionSource, /new ResizeObserver/);
|
||||
assert.match(sectionSource, /shellRef\.current\?\.getBoundingClientRect\(\)\.width \?\? shellWidth/);
|
||||
assert.doesNotMatch(sectionSource, /window\.innerWidth[,)\n]/);
|
||||
assert.doesNotMatch(sectionSource, /100vw/);
|
||||
assert.doesNotMatch(sectionSource, /const startWidth = sidePanelWidth/);
|
||||
assert.doesNotMatch(sectionSource, /Math\.min\(800,/);
|
||||
});
|
||||
|
||||
test('side panel width dragging cleans up on mouseup, blur, and unmount', () => {
|
||||
const sectionSource = readFileSync(new URL('./TerminalLayerSidePanelSection.tsx', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(sectionSource, /shellResizeCleanupRef\.current\?\.\(\)/);
|
||||
assert.match(sectionSource, /window\.addEventListener\('mouseup', finish\)/);
|
||||
assert.match(sectionSource, /window\.addEventListener\('blur', finish\)/);
|
||||
assert.match(sectionSource, /window\.removeEventListener\('blur', finish\)/);
|
||||
});
|
||||
|
||||
test('split resizing preserves nested minimums without rerendering on every pixel', () => {
|
||||
const sectionSource = readFileSync(new URL('./TerminalLayerSidePanelSection.tsx', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(sectionSource, /getSidePanelSplitResizeBounds/);
|
||||
assert.match(sectionSource, /getSidePanelNodeMinimumPixels\(activeSidePanelLayout\.root, 'vertical'\)/);
|
||||
assert.match(sectionSource, /focusedPaneSplitAvailability/);
|
||||
assert.doesNotMatch(sectionSource, /focusedPaneSize/);
|
||||
});
|
||||
|
||||
test('available-width observer releases a removed focus sidebar immediately', () => {
|
||||
const sectionSource = readFileSync(new URL('./TerminalLayerSidePanelSection.tsx', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(sectionSource, /focusSidebar !== observedFocusSidebar/);
|
||||
assert.match(sectionSource, /if \(observedFocusSidebar\) resizeObserver\.unobserve\(observedFocusSidebar\)/);
|
||||
assert.match(sectionSource, /observedFocusSidebar = null/);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { Activity, FolderTree, History, MessageSquare, NotebookText, Palette, PanelLeft, PanelRight, Play, X } from 'lucide-react';
|
||||
import { Activity, FolderTree, History, MessageSquare, NotebookText, Palette, PanelLeft, PanelRight, Play, SplitSquareHorizontal, SplitSquareVertical, X } from 'lucide-react';
|
||||
import {
|
||||
buildSidePanelChromeThemeFromTerminalTheme,
|
||||
buildTerminalSidePanelCssVars,
|
||||
@@ -14,11 +14,18 @@ import {
|
||||
} from '../../application/state/sidePanelLiveStore';
|
||||
import {
|
||||
reorderTerminalSidePanelTab,
|
||||
fitTerminalSidePanelTabs,
|
||||
TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER,
|
||||
TERMINAL_SIDE_PANEL_TAB_IDS,
|
||||
type TerminalSidePanelTabId,
|
||||
useTerminalSidePanelTabOrder,
|
||||
} from '../../application/state/terminalSidePanelTabs';
|
||||
import {
|
||||
clampTerminalSidePanelWidth,
|
||||
getTerminalSidePanelAvailableWidth,
|
||||
getTerminalSidePanelMaxShownTools,
|
||||
getTerminalSidePanelMaxWidth,
|
||||
} from '../../application/state/terminalSidePanelWidth';
|
||||
import { terminalLayoutSuppressStore } from '../../application/state/terminalLayoutSuppressStore';
|
||||
import { AI_PANEL_FORCE_HIDE_SHELL } from '../ai/aiPanelDiagnostics';
|
||||
|
||||
@@ -27,19 +34,445 @@ import {
|
||||
ToolbarOverflowMenu,
|
||||
} from '../ui/toolbar-item-layout';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
import type { SidePanelTab } from './TerminalLayerSupport';
|
||||
import {
|
||||
MAX_SIDE_PANEL_PANES,
|
||||
canSplitSidePanelPaneAtSize,
|
||||
collectSidePanelPanes,
|
||||
getFocusedSidePanelPane,
|
||||
getSidePanelNodeMinimumPixels,
|
||||
getSidePanelSplitResizeBounds,
|
||||
SIDE_PANEL_SPLIT_DIVIDER_PIXELS,
|
||||
type SidePanelLayout,
|
||||
type SidePanelLayoutNode,
|
||||
type SidePanelSplitDirection,
|
||||
type SidePanelSplitNode,
|
||||
} from '../../domain/sidePanelLayout';
|
||||
import { terminalLayerSidePanelStableCtxEqual } from './terminalLayerViewMemo';
|
||||
import { SidePanelMountedContent } from './terminalLayerSidePanelSlots';
|
||||
|
||||
const MemoizedSidePanelMountedContent = memo(
|
||||
SidePanelMountedContent,
|
||||
(prev, next) => terminalLayerSidePanelStableCtxEqual(prev.ctx, next.ctx),
|
||||
(prev, next) => (
|
||||
prev.paneHosts === next.paneHosts
|
||||
&& prev.parkingHost === next.parkingHost
|
||||
&& terminalLayerSidePanelStableCtxEqual(prev.ctx, next.ctx)
|
||||
),
|
||||
);
|
||||
MemoizedSidePanelMountedContent.displayName = 'MemoizedSidePanelMountedContent';
|
||||
|
||||
type SidePanelContext = Record<string, any>;
|
||||
const SIDE_PANEL_TAB_DRAG_MIME = 'application/x-netcatty-sidepanel-tab';
|
||||
|
||||
type SidePanelTabItem = {
|
||||
id: SidePanelTab;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export function listenForSidePanelPaneFocus(
|
||||
target: EventTarget,
|
||||
onFocus: () => void,
|
||||
): () => void {
|
||||
target.addEventListener('pointerdown', onFocus);
|
||||
target.addEventListener('focusin', onFocus);
|
||||
return () => {
|
||||
target.removeEventListener('pointerdown', onFocus);
|
||||
target.removeEventListener('focusin', onFocus);
|
||||
};
|
||||
}
|
||||
|
||||
function SidePanelPaneHost({
|
||||
node,
|
||||
focused,
|
||||
paneCount,
|
||||
label,
|
||||
onClose,
|
||||
onFocus,
|
||||
onHostChange,
|
||||
closePaneLabel,
|
||||
separator,
|
||||
mutedColor,
|
||||
}: {
|
||||
node: Extract<SidePanelLayoutNode, { type: 'pane' }>;
|
||||
focused: boolean;
|
||||
paneCount: number;
|
||||
label: string;
|
||||
onClose: (paneId: string) => void;
|
||||
onFocus: (paneId: string) => void;
|
||||
onHostChange: (tool: SidePanelTab, host: HTMLElement | null) => void;
|
||||
closePaneLabel: string;
|
||||
separator: string;
|
||||
mutedColor: string;
|
||||
}) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Registration happens after commit and only when the actual host changes.
|
||||
// This avoids state writes from ref callbacks and provides a parking window
|
||||
// for portals while a pane tree is being replaced.
|
||||
useLayoutEffect(() => {
|
||||
const host = hostRef.current;
|
||||
onHostChange(node.tool, host);
|
||||
const stopListening = host
|
||||
? listenForSidePanelPaneFocus(host, () => onFocus(node.id))
|
||||
: null;
|
||||
return () => {
|
||||
stopListening?.();
|
||||
onHostChange(node.tool, null);
|
||||
};
|
||||
}, [node.id, node.tool, onFocus, onHostChange]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full w-full min-h-0 min-w-0 overflow-hidden flex flex-col relative"
|
||||
data-section="terminal-side-panel-pane"
|
||||
data-pane-id={node.id}
|
||||
data-pane-tool={node.tool}
|
||||
data-focused={focused ? 'true' : 'false'}
|
||||
onMouseDown={() => onFocus(node.id)}
|
||||
onFocusCapture={() => onFocus(node.id)}
|
||||
>
|
||||
{paneCount > 1 && (
|
||||
<div
|
||||
className="h-7 px-2 flex items-center gap-2 shrink-0 select-none"
|
||||
style={{
|
||||
borderBottom: `1px solid ${separator}`,
|
||||
boxShadow: focused ? `inset 2px 0 0 ${mutedColor}` : undefined,
|
||||
}}
|
||||
>
|
||||
<span className="text-[11px] font-medium truncate flex-1">{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="h-5 w-5 grid place-items-center rounded-sm opacity-70 hover:opacity-100 hover:bg-white/10"
|
||||
aria-label={`${closePaneLabel}: ${label}`}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onClose(node.id);
|
||||
}}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={hostRef}
|
||||
className="relative flex-1 min-h-0 min-w-0 overflow-hidden [contain:strict]"
|
||||
data-section="terminal-side-panel-pane-content"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidePanelSplitView({
|
||||
node,
|
||||
children,
|
||||
onResize,
|
||||
separator,
|
||||
resizeLabel,
|
||||
}: {
|
||||
node: SidePanelSplitNode;
|
||||
children: React.ReactNode[];
|
||||
onResize: (splitId: string, sizes: number[]) => void;
|
||||
separator: string;
|
||||
resizeLabel: string;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const resizeCleanupRef = useRef<(() => void) | null>(null);
|
||||
const normalizedSizes = useMemo(() => {
|
||||
const total = node.sizes.reduce((sum, size) => sum + size, 0) || 1;
|
||||
return node.children.map((_, index) => (node.sizes[index] ?? 1) / total);
|
||||
}, [node.children, node.sizes]);
|
||||
const [previewSizes, setPreviewSizes] = useState<number[] | null>(null);
|
||||
const renderedSizes = previewSizes ?? normalizedSizes;
|
||||
|
||||
useLayoutEffect(() => () => {
|
||||
resizeCleanupRef.current?.();
|
||||
}, []);
|
||||
|
||||
const startResize = useCallback((event: React.MouseEvent, index: number) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const axisLength = (node.direction === 'vertical' ? rect.width : rect.height)
|
||||
- Math.max(0, node.children.length - 1) * SIDE_PANEL_SPLIT_DIVIDER_PIXELS;
|
||||
if (axisLength <= 0) return;
|
||||
|
||||
resizeCleanupRef.current?.();
|
||||
terminalLayoutSuppressStore.begin();
|
||||
const startClient = node.direction === 'vertical' ? event.clientX : event.clientY;
|
||||
const startSizes = [...normalizedSizes];
|
||||
const pairSize = startSizes[index] + startSizes[index + 1];
|
||||
const { firstMin, firstMax } = getSidePanelSplitResizeBounds(
|
||||
node,
|
||||
index,
|
||||
pairSize,
|
||||
axisLength,
|
||||
);
|
||||
let frame: number | null = null;
|
||||
let pendingClient = startClient;
|
||||
|
||||
let latestSizes = startSizes;
|
||||
const updatePreview = () => {
|
||||
frame = null;
|
||||
const delta = (pendingClient - startClient) / axisLength;
|
||||
const first = Math.max(firstMin, Math.min(firstMax, startSizes[index] + delta));
|
||||
const next = [...startSizes];
|
||||
next[index] = first;
|
||||
next[index + 1] = pairSize - first;
|
||||
latestSizes = next;
|
||||
setPreviewSizes(next);
|
||||
};
|
||||
const onMouseMove = (moveEvent: MouseEvent) => {
|
||||
pendingClient = node.direction === 'vertical' ? moveEvent.clientX : moveEvent.clientY;
|
||||
if (frame === null) frame = requestAnimationFrame(updatePreview);
|
||||
};
|
||||
let cleanedUp = false;
|
||||
const cleanup = () => {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
if (frame !== null) cancelAnimationFrame(frame);
|
||||
frame = null;
|
||||
terminalLayoutSuppressStore.end();
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
window.removeEventListener('blur', finish);
|
||||
if (resizeCleanupRef.current === cleanup) resizeCleanupRef.current = null;
|
||||
};
|
||||
const finish = () => {
|
||||
if (frame !== null) {
|
||||
cancelAnimationFrame(frame);
|
||||
updatePreview();
|
||||
}
|
||||
onResize(node.id, latestSizes);
|
||||
setPreviewSizes(null);
|
||||
cleanup();
|
||||
};
|
||||
const onMouseUp = () => finish();
|
||||
resizeCleanupRef.current = cleanup;
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
window.addEventListener('blur', finish);
|
||||
}, [node, normalizedSizes, onResize]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={node.direction === 'vertical'
|
||||
? 'h-full w-full min-h-0 min-w-0 flex flex-row overflow-hidden'
|
||||
: 'h-full w-full min-h-0 min-w-0 flex flex-col overflow-hidden'}
|
||||
data-section="terminal-side-panel-split"
|
||||
data-split-id={node.id}
|
||||
data-split-direction={node.direction}
|
||||
>
|
||||
{children.map((child, index) => (
|
||||
<React.Fragment key={node.children[index].id}>
|
||||
<div
|
||||
className="min-h-0 min-w-0 overflow-hidden relative"
|
||||
style={{ flexBasis: 0, flexGrow: renderedSizes[index] }}
|
||||
>
|
||||
{child}
|
||||
</div>
|
||||
{index < children.length - 1 && (
|
||||
<div
|
||||
className={node.direction === 'vertical'
|
||||
? "group relative w-px shrink-0 cursor-ew-resize z-20 after:content-[''] after:absolute after:inset-y-0 after:left-1/2 after:w-2 after:-translate-x-1/2"
|
||||
: "group relative h-px shrink-0 cursor-ns-resize z-20 after:content-[''] after:absolute after:inset-x-0 after:top-1/2 after:h-2 after:-translate-y-1/2"}
|
||||
data-section="terminal-side-panel-split-resizer"
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
aria-label={resizeLabel}
|
||||
aria-orientation={node.direction === 'vertical' ? 'vertical' : 'horizontal'}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(
|
||||
(renderedSizes[index] / (renderedSizes[index] + renderedSizes[index + 1])) * 100,
|
||||
)}
|
||||
onMouseDown={(event) => startResize(event, index)}
|
||||
onKeyDown={(event) => {
|
||||
const decrease = node.direction === 'vertical'
|
||||
? event.key === 'ArrowLeft'
|
||||
: event.key === 'ArrowUp';
|
||||
const increase = node.direction === 'vertical'
|
||||
? event.key === 'ArrowRight'
|
||||
: event.key === 'ArrowDown';
|
||||
if (!decrease && !increase) return;
|
||||
event.preventDefault();
|
||||
const next = [...renderedSizes];
|
||||
const pairSize = next[index] + next[index + 1];
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
const axisLength = (node.direction === 'vertical'
|
||||
? (rect?.width ?? 0)
|
||||
: (rect?.height ?? 0))
|
||||
- Math.max(0, node.children.length - 1) * SIDE_PANEL_SPLIT_DIVIDER_PIXELS;
|
||||
const { firstMin, firstMax } = getSidePanelSplitResizeBounds(
|
||||
node,
|
||||
index,
|
||||
pairSize,
|
||||
axisLength,
|
||||
);
|
||||
const first = Math.max(
|
||||
firstMin,
|
||||
Math.min(firstMax, next[index] + (decrease ? -0.04 : 0.04)),
|
||||
);
|
||||
next[index] = first;
|
||||
next[index + 1] = pairSize - first;
|
||||
onResize(node.id, next);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={node.direction === 'vertical'
|
||||
? 'absolute inset-y-0 left-1/2 w-px -translate-x-1/2 group-hover:w-0.5'
|
||||
: 'absolute inset-x-0 top-1/2 h-px -translate-y-1/2 group-hover:h-0.5'}
|
||||
style={{ backgroundColor: separator }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidePanelLayoutTree({
|
||||
node,
|
||||
layout,
|
||||
paneCount,
|
||||
labels,
|
||||
onClose,
|
||||
onFocus,
|
||||
onHostChange,
|
||||
onResize,
|
||||
separator,
|
||||
accent,
|
||||
closePaneLabel,
|
||||
resizeLabel,
|
||||
}: {
|
||||
node: SidePanelLayoutNode;
|
||||
layout: SidePanelLayout;
|
||||
paneCount: number;
|
||||
labels: ReadonlyMap<SidePanelTab, string>;
|
||||
onClose: (paneId: string) => void;
|
||||
onFocus: (paneId: string) => void;
|
||||
onHostChange: (tool: SidePanelTab, host: HTMLElement | null) => void;
|
||||
onResize: (splitId: string, sizes: number[]) => void;
|
||||
separator: string;
|
||||
accent: string;
|
||||
closePaneLabel: string;
|
||||
resizeLabel: string;
|
||||
}): React.ReactNode {
|
||||
if (node.type === 'pane') {
|
||||
return (
|
||||
<SidePanelPaneHost
|
||||
node={node}
|
||||
focused={layout.focusedPaneId === node.id}
|
||||
paneCount={paneCount}
|
||||
label={labels.get(node.tool) ?? node.tool}
|
||||
onClose={onClose}
|
||||
onFocus={onFocus}
|
||||
onHostChange={onHostChange}
|
||||
closePaneLabel={closePaneLabel}
|
||||
separator={separator}
|
||||
mutedColor={accent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidePanelSplitView
|
||||
node={node}
|
||||
onResize={onResize}
|
||||
separator={separator}
|
||||
resizeLabel={resizeLabel}
|
||||
>
|
||||
{node.children.map((child) => (
|
||||
<SidePanelLayoutTree
|
||||
key={child.id}
|
||||
node={child}
|
||||
layout={layout}
|
||||
paneCount={paneCount}
|
||||
labels={labels}
|
||||
onClose={onClose}
|
||||
onFocus={onFocus}
|
||||
onHostChange={onHostChange}
|
||||
onResize={onResize}
|
||||
separator={separator}
|
||||
accent={accent}
|
||||
closePaneLabel={closePaneLabel}
|
||||
resizeLabel={resizeLabel}
|
||||
/>
|
||||
))}
|
||||
</SidePanelSplitView>
|
||||
);
|
||||
}
|
||||
|
||||
function SidePanelSplitMenu({
|
||||
direction,
|
||||
items,
|
||||
occupiedTools,
|
||||
disabled,
|
||||
onSelect,
|
||||
t,
|
||||
buttonColor,
|
||||
}: {
|
||||
direction: SidePanelSplitDirection;
|
||||
items: SidePanelTabItem[];
|
||||
occupiedTools: ReadonlySet<SidePanelTab>;
|
||||
disabled: boolean;
|
||||
onSelect: (tool: SidePanelTab, direction: SidePanelSplitDirection) => void;
|
||||
t: (key: string) => string;
|
||||
buttonColor: string;
|
||||
}) {
|
||||
const available = items.filter((item) => !occupiedTools.has(item.id));
|
||||
const label = direction === 'horizontal'
|
||||
? t('terminal.layer.splitHorizontal')
|
||||
: t('terminal.layer.splitVertical');
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || available.length === 0}
|
||||
className="h-7 w-7 rounded-md p-0 grid place-items-center disabled:opacity-35"
|
||||
style={{ color: buttonColor }}
|
||||
aria-label={label}
|
||||
>
|
||||
{direction === 'horizontal'
|
||||
? <SplitSquareVertical size={15} />
|
||||
: <SplitSquareHorizontal size={15} />}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent align="end" side="bottom" className="w-52 p-1">
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{t('terminal.layer.openInNewSplit')}
|
||||
</div>
|
||||
{available.map((item) => (
|
||||
<PopoverClose asChild key={item.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary text-left"
|
||||
onClick={() => onSelect(item.id, direction)}
|
||||
>
|
||||
<span className="shrink-0">{item.icon}</span>
|
||||
<span className="truncate">{item.label}</span>
|
||||
</button>
|
||||
</PopoverClose>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export function getTerminalSidePanelShellWidth({
|
||||
activeSidePanelTab,
|
||||
forceHideAiShell,
|
||||
@@ -102,8 +535,10 @@ TerminalLayerSidePanelSection.displayName = 'TerminalLayerSidePanelSection';
|
||||
function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
const activeTabId = useActiveTabId();
|
||||
const sidePanelOpenTabs = ctx.sidePanelOpenTabs as Map<string, SidePanelTab>;
|
||||
const sidePanelLayouts = ctx.sidePanelLayouts as Map<string, SidePanelLayout>;
|
||||
const isSidePanelOpenForCurrentTab = activeTabId ? sidePanelOpenTabs.has(activeTabId) : false;
|
||||
const activeSidePanelTab = activeTabId ? sidePanelOpenTabs.get(activeTabId) ?? null : null;
|
||||
const activeSidePanelLayout = activeTabId ? sidePanelLayouts.get(activeTabId) ?? null : null;
|
||||
|
||||
const {
|
||||
Button: Btn,
|
||||
@@ -116,6 +551,10 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
handleOpenScripts,
|
||||
handleOpenSystem,
|
||||
handleOpenTheme,
|
||||
handleFocusSidePanelPane,
|
||||
handleSplitSidePanelPane,
|
||||
handleCloseSidePanelPane,
|
||||
handleResizeSidePanelSplit,
|
||||
handleToggleSftpFromBar,
|
||||
resolvedPreviewTheme: ctxResolvedPreviewTheme,
|
||||
setSidePanelPosition,
|
||||
@@ -142,6 +581,66 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
: ctxResolvedPreviewTheme);
|
||||
|
||||
const [resizePreviewWidth, setResizePreviewWidth] = useState<number | null>(null);
|
||||
const shellRef = useRef<HTMLDivElement>(null);
|
||||
const shellResizeCleanupRef = useRef<(() => void) | null>(null);
|
||||
const [availableSurfaceWidth, setAvailableSurfaceWidth] = useState(0);
|
||||
const availableSurfaceWidthRef = useRef(availableSurfaceWidth);
|
||||
const [paneHosts, setPaneHosts] = useState<Map<SidePanelTab, HTMLElement>>(new Map());
|
||||
const [parkingHost, setParkingHost] = useState<HTMLElement | null>(null);
|
||||
const parkingHostRef = useRef<HTMLDivElement>(null);
|
||||
useLayoutEffect(() => {
|
||||
setParkingHost(parkingHostRef.current);
|
||||
return () => setParkingHost(null);
|
||||
}, []);
|
||||
useLayoutEffect(() => () => {
|
||||
shellResizeCleanupRef.current?.();
|
||||
}, []);
|
||||
useLayoutEffect(() => {
|
||||
const shell = shellRef.current;
|
||||
const terminalLayer = shell?.parentElement;
|
||||
if (!terminalLayer) return undefined;
|
||||
|
||||
let observedFocusSidebar: Element | null = null;
|
||||
const resizeObserver = typeof ResizeObserver === 'undefined'
|
||||
? null
|
||||
: new ResizeObserver(() => updateAvailableWidth());
|
||||
const updateAvailableWidth = () => {
|
||||
const focusSidebar = terminalLayer.querySelector('[data-section="terminal-workspace-sidebar"]');
|
||||
if (resizeObserver && focusSidebar !== observedFocusSidebar) {
|
||||
if (observedFocusSidebar) resizeObserver.unobserve(observedFocusSidebar);
|
||||
observedFocusSidebar = focusSidebar;
|
||||
if (focusSidebar) resizeObserver.observe(focusSidebar);
|
||||
}
|
||||
const nextWidth = getTerminalSidePanelAvailableWidth(
|
||||
terminalLayer.getBoundingClientRect().width,
|
||||
focusSidebar?.getBoundingClientRect().width ?? 0,
|
||||
);
|
||||
availableSurfaceWidthRef.current = nextWidth;
|
||||
setAvailableSurfaceWidth((current) => current === nextWidth ? current : nextWidth);
|
||||
};
|
||||
|
||||
updateAvailableWidth();
|
||||
resizeObserver?.observe(terminalLayer);
|
||||
const mutationObserver = typeof MutationObserver === 'undefined'
|
||||
? null
|
||||
: new MutationObserver(updateAvailableWidth);
|
||||
mutationObserver?.observe(terminalLayer, { childList: true });
|
||||
return () => {
|
||||
resizeObserver?.disconnect();
|
||||
mutationObserver?.disconnect();
|
||||
observedFocusSidebar = null;
|
||||
};
|
||||
}, []);
|
||||
const handlePaneHostChange = useCallback((tool: SidePanelTab, host: HTMLElement | null) => {
|
||||
setPaneHosts((current) => {
|
||||
if (host && current.get(tool) === host) return current;
|
||||
if (!host && !current.has(tool)) return current;
|
||||
const next = new Map(current);
|
||||
if (host) next.set(tool, host);
|
||||
else next.delete(tool);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const {
|
||||
sidePanelTabOrder,
|
||||
setSidePanelTabOrder,
|
||||
@@ -183,29 +682,46 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
placement: 'before' | 'after';
|
||||
} | null>(null);
|
||||
const draggedSidePanelTabRef = useRef<TerminalSidePanelTabId | null>(null);
|
||||
const isAiShellForceHidden = AI_PANEL_FORCE_HIDE_SHELL && activeSidePanelTab === 'ai';
|
||||
const shellWidth = getTerminalSidePanelShellWidth({
|
||||
const activePaneCount = activeSidePanelLayout
|
||||
? collectSidePanelPanes(activeSidePanelLayout.root).length
|
||||
: 0;
|
||||
const isAiShellForceHidden = AI_PANEL_FORCE_HIDE_SHELL
|
||||
&& activeSidePanelTab === 'ai'
|
||||
&& activePaneCount <= 1;
|
||||
const requestedShellWidth = getTerminalSidePanelShellWidth({
|
||||
activeSidePanelTab,
|
||||
forceHideAiShell: AI_PANEL_FORCE_HIDE_SHELL,
|
||||
forceHideAiShell: AI_PANEL_FORCE_HIDE_SHELL && activePaneCount <= 1,
|
||||
isSidePanelOpenForCurrentTab,
|
||||
resizePreviewWidth,
|
||||
sidePanelWidth,
|
||||
});
|
||||
const sidePanelContentMinimumWidth = activeSidePanelLayout
|
||||
? getSidePanelNodeMinimumPixels(activeSidePanelLayout.root, 'vertical')
|
||||
: 0;
|
||||
const shellWidth = requestedShellWidth > 0
|
||||
? clampTerminalSidePanelWidth(
|
||||
requestedShellWidth,
|
||||
availableSurfaceWidth,
|
||||
sidePanelContentMinimumWidth,
|
||||
)
|
||||
: 0;
|
||||
|
||||
const handleSidePanelResizeStart = useCallback((event: React.MouseEvent) => {
|
||||
if (!isSidePanelOpenForCurrentTab) return;
|
||||
event.preventDefault();
|
||||
shellResizeCleanupRef.current?.();
|
||||
terminalLayoutSuppressStore.begin();
|
||||
const startX = event.clientX;
|
||||
const startWidth = sidePanelWidth;
|
||||
const startWidth = shellRef.current?.getBoundingClientRect().width ?? shellWidth;
|
||||
let lastWidth = startWidth;
|
||||
let rafId: number | null = null;
|
||||
|
||||
const onMouseMove = (moveEvent: MouseEvent) => {
|
||||
const delta = moveEvent.clientX - startX;
|
||||
lastWidth = Math.max(
|
||||
280,
|
||||
Math.min(800, startWidth + (sidePanelPosition === 'left' ? delta : -delta)),
|
||||
lastWidth = clampTerminalSidePanelWidth(
|
||||
startWidth + (sidePanelPosition === 'left' ? delta : -delta),
|
||||
availableSurfaceWidthRef.current,
|
||||
sidePanelContentMinimumWidth,
|
||||
);
|
||||
if (rafId !== null) return;
|
||||
rafId = requestAnimationFrame(() => {
|
||||
@@ -213,23 +729,35 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
setResizePreviewWidth(lastWidth);
|
||||
});
|
||||
};
|
||||
const onMouseUp = () => {
|
||||
let cleanedUp = false;
|
||||
const cleanup = () => {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
terminalLayoutSuppressStore.end();
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', finish);
|
||||
window.removeEventListener('blur', finish);
|
||||
if (shellResizeCleanupRef.current === cleanup) shellResizeCleanupRef.current = null;
|
||||
};
|
||||
const finish = () => {
|
||||
setSidePanelWidth(lastWidth);
|
||||
persistSidePanelWidth(lastWidth);
|
||||
setResizePreviewWidth(null);
|
||||
terminalLayoutSuppressStore.end();
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
cleanup();
|
||||
};
|
||||
shellResizeCleanupRef.current = cleanup;
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
window.addEventListener('mouseup', finish);
|
||||
window.addEventListener('blur', finish);
|
||||
}, [
|
||||
isSidePanelOpenForCurrentTab,
|
||||
persistSidePanelWidth,
|
||||
setSidePanelWidth,
|
||||
sidePanelContentMinimumWidth,
|
||||
sidePanelPosition,
|
||||
sidePanelWidth,
|
||||
shellWidth,
|
||||
]);
|
||||
|
||||
const handleSidePanelTabDragStart = useCallback((event: React.DragEvent, tab: TerminalSidePanelTabId) => {
|
||||
@@ -279,7 +807,7 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
setDragOverSidePanelTab(null);
|
||||
}, [dragOverSidePanelTab]);
|
||||
|
||||
const sidePanelTabItems = useMemo(() => [
|
||||
const sidePanelTabItems = useMemo<SidePanelTabItem[]>(() => [
|
||||
{ id: 'sftp' as const, label: t('terminal.layer.sftp'), icon: <FolderTree size={15} />, onClick: handleToggleSftpFromBar },
|
||||
{ id: 'scripts' as const, label: t('terminal.layer.scripts'), icon: <Play size={15} />, onClick: handleOpenScripts },
|
||||
{ id: 'history' as const, label: t('terminal.layer.history'), icon: <History size={15} />, onClick: handleOpenHistory },
|
||||
@@ -301,8 +829,65 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
() => new Map(sidePanelTabItems.map((item) => [item.id, item])),
|
||||
[sidePanelTabItems],
|
||||
);
|
||||
const sidePanelToolLabels = useMemo(
|
||||
() => new Map(sidePanelTabItems.map((item) => [item.id, item.label])),
|
||||
[sidePanelTabItems],
|
||||
);
|
||||
const occupiedSidePanelTools = useMemo(
|
||||
() => new Set(activeSidePanelLayout
|
||||
? collectSidePanelPanes(activeSidePanelLayout.root).map((pane) => pane.tool)
|
||||
: []),
|
||||
[activeSidePanelLayout],
|
||||
);
|
||||
const focusedPane = activeSidePanelLayout
|
||||
? getFocusedSidePanelPane(activeSidePanelLayout)
|
||||
: null;
|
||||
const focusedPaneHost = focusedPane ? paneHosts.get(focusedPane.tool) ?? null : null;
|
||||
const [focusedPaneSplitAvailability, setFocusedPaneSplitAvailability] = useState({
|
||||
horizontal: false,
|
||||
vertical: false,
|
||||
});
|
||||
useLayoutEffect(() => {
|
||||
const pane = focusedPaneHost?.closest<HTMLElement>('[data-section="terminal-side-panel-pane"]');
|
||||
if (!pane) {
|
||||
setFocusedPaneSplitAvailability((current) => (
|
||||
!current.horizontal && !current.vertical
|
||||
? current
|
||||
: { horizontal: false, vertical: false }
|
||||
));
|
||||
return undefined;
|
||||
}
|
||||
const update = () => {
|
||||
const rect = pane.getBoundingClientRect();
|
||||
const next = {
|
||||
horizontal: canSplitSidePanelPaneAtSize(rect.height),
|
||||
vertical: canSplitSidePanelPaneAtSize(rect.width),
|
||||
};
|
||||
setFocusedPaneSplitAvailability((current) => (
|
||||
current.horizontal === next.horizontal && current.vertical === next.vertical
|
||||
? current
|
||||
: next
|
||||
));
|
||||
};
|
||||
update();
|
||||
if (typeof ResizeObserver === 'undefined') return undefined;
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(pane);
|
||||
return () => observer.disconnect();
|
||||
}, [focusedPaneHost]);
|
||||
|
||||
const { shown: shownSidePanelTabs, collapsed: collapsedSidePanelTabs } = useMemo(() => {
|
||||
const handleSplitSidePanelSelect = useCallback((
|
||||
tool: SidePanelTab,
|
||||
direction: SidePanelSplitDirection,
|
||||
) => {
|
||||
const pane = focusedPaneHost?.closest<HTMLElement>('[data-section="terminal-side-panel-pane"]');
|
||||
if (!pane) return;
|
||||
const rect = pane.getBoundingClientRect();
|
||||
const availableAxisLength = direction === 'vertical' ? rect.width : rect.height;
|
||||
handleSplitSidePanelPane(tool, direction, availableAxisLength);
|
||||
}, [focusedPaneHost, handleSplitSidePanelPane]);
|
||||
|
||||
const { shown: configuredShownSidePanelTabs, collapsed: configuredCollapsedSidePanelTabs } = useMemo(() => {
|
||||
const parts = partitionSidePanelTabs(TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER);
|
||||
// If an external path opens a hidden tab, still show its chip while active.
|
||||
if (
|
||||
@@ -318,6 +903,20 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
}
|
||||
return parts;
|
||||
}, [activeSidePanelTab, partitionSidePanelTabs]);
|
||||
const { shown: shownSidePanelTabs, collapsed: collapsedSidePanelTabs } = useMemo(
|
||||
() => fitTerminalSidePanelTabs({
|
||||
shown: configuredShownSidePanelTabs,
|
||||
collapsed: configuredCollapsedSidePanelTabs,
|
||||
active: activeSidePanelTab,
|
||||
maxShown: getTerminalSidePanelMaxShownTools(shellWidth),
|
||||
}),
|
||||
[
|
||||
activeSidePanelTab,
|
||||
configuredCollapsedSidePanelTabs,
|
||||
configuredShownSidePanelTabs,
|
||||
shellWidth,
|
||||
],
|
||||
);
|
||||
|
||||
const sidePanelCustomizeItems = useMemo(
|
||||
() =>
|
||||
@@ -334,7 +933,12 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ width: shellWidth, contain: 'layout paint style' }}
|
||||
ref={shellRef}
|
||||
style={{
|
||||
width: shellWidth,
|
||||
maxWidth: getTerminalSidePanelMaxWidth(availableSurfaceWidth),
|
||||
contain: 'layout paint style',
|
||||
}}
|
||||
className={cn(
|
||||
'flex-shrink-0 h-full relative z-20',
|
||||
shellWidth === 0 && 'overflow-hidden',
|
||||
@@ -485,6 +1089,32 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
</div>
|
||||
</ToolbarOverflowMenu>
|
||||
<div className="flex-1" />
|
||||
<SidePanelSplitMenu
|
||||
direction="horizontal"
|
||||
items={sidePanelTabItems}
|
||||
occupiedTools={occupiedSidePanelTools}
|
||||
disabled={
|
||||
!activeSidePanelLayout
|
||||
|| activePaneCount >= MAX_SIDE_PANEL_PANES
|
||||
|| !focusedPaneSplitAvailability.horizontal
|
||||
}
|
||||
onSelect={handleSplitSidePanelSelect}
|
||||
t={t}
|
||||
buttonColor={sidePanelTheme.mutedFg}
|
||||
/>
|
||||
<SidePanelSplitMenu
|
||||
direction="vertical"
|
||||
items={sidePanelTabItems}
|
||||
occupiedTools={occupiedSidePanelTools}
|
||||
disabled={
|
||||
!activeSidePanelLayout
|
||||
|| activePaneCount >= MAX_SIDE_PANEL_PANES
|
||||
|| !focusedPaneSplitAvailability.vertical
|
||||
}
|
||||
onSelect={handleSplitSidePanelSelect}
|
||||
t={t}
|
||||
buttonColor={sidePanelTheme.mutedFg}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Btn
|
||||
@@ -517,8 +1147,34 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
</Tooltip>
|
||||
</ToolbarCustomizeContextMenu>
|
||||
)}
|
||||
<div className="flex-1 min-h-0 relative" data-section="terminal-side-panel-content">
|
||||
<MemoizedSidePanelMountedContent ctx={ctx} />
|
||||
<div className="flex-1 min-h-0 min-w-0 relative overflow-hidden" data-section="terminal-side-panel-content">
|
||||
{isSidePanelOpenForCurrentTab && activeSidePanelLayout && (
|
||||
<SidePanelLayoutTree
|
||||
node={activeSidePanelLayout.root}
|
||||
layout={activeSidePanelLayout}
|
||||
paneCount={activePaneCount}
|
||||
labels={sidePanelToolLabels}
|
||||
onClose={handleCloseSidePanelPane}
|
||||
onFocus={handleFocusSidePanelPane}
|
||||
onHostChange={handlePaneHostChange}
|
||||
onResize={handleResizeSidePanelSplit}
|
||||
separator={sidePanelTheme.separator}
|
||||
accent={sidePanelTheme.accent}
|
||||
closePaneLabel={t('terminal.layer.closePane')}
|
||||
resizeLabel={t('terminal.layer.resizeSplit')}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={parkingHostRef}
|
||||
className="hidden absolute inset-0 overflow-hidden [content-visibility:hidden] [contain:strict]"
|
||||
aria-hidden="true"
|
||||
data-section="terminal-side-panel-parking"
|
||||
/>
|
||||
<MemoizedSidePanelMountedContent
|
||||
ctx={ctx}
|
||||
paneHosts={paneHosts}
|
||||
parkingHost={parkingHost}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -42,8 +42,9 @@ import {
|
||||
} from '../terminalPaneVisibility';
|
||||
import type { ResolvedAppearance, TerminalAppearanceHostScope } from '../../domain/terminalAppearanceRuntime';
|
||||
import type { TerminalSidePanelAutoOpenTab } from '../../domain/terminalSidePanelAutoOpen';
|
||||
import type { SidePanelTool } from '../../domain/sidePanelLayout';
|
||||
|
||||
export type SidePanelTab = 'sftp' | 'scripts' | 'history' | 'theme' | 'ai' | 'system' | 'notes';
|
||||
export type SidePanelTab = SidePanelTool;
|
||||
|
||||
const LazyAIChatSidePanel = lazy(() =>
|
||||
import('../AIChatSidePanel').then((module) => ({ default: module.AIChatSidePanel })),
|
||||
|
||||
@@ -21,6 +21,10 @@ import { sidePanelLiveStore, type SidePanelLiveSnapshot } from '../../applicatio
|
||||
import { terminalCwdStore } from '../../application/state/terminalCwdStore';
|
||||
import { useTerminalWorkspaceLayout } from './useTerminalWorkspaceLayout';
|
||||
import type { SidePanelTab } from './TerminalLayerSupport';
|
||||
import {
|
||||
collectSidePanelPanes,
|
||||
type SidePanelLayout,
|
||||
} from '../../domain/sidePanelLayout';
|
||||
|
||||
type StableRef = React.MutableRefObject<Record<string, any>>;
|
||||
|
||||
@@ -44,6 +48,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
const sessionHostsMap = s.sessionHostsMap as Map<string, Host>;
|
||||
const sftpHostForTab = s.sftpHostForTab as Map<string, Host>;
|
||||
const sidePanelOpenTabs = s.sidePanelOpenTabs as Map<string, SidePanelTab>;
|
||||
const sidePanelLayouts = s.sidePanelLayouts as Map<string, SidePanelLayout>;
|
||||
const showHostTreeSidebar = s.showHostTreeSidebar as boolean | undefined;
|
||||
|
||||
const activeWorkspace = useMemo(
|
||||
@@ -112,7 +117,12 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
|
||||
const isSidePanelOpenForCurrentTab = activeTabId ? sidePanelOpenTabs.has(activeTabId) : false;
|
||||
const activeSidePanelTab = activeTabId ? sidePanelOpenTabs.get(activeTabId) ?? null : null;
|
||||
const isSftpOpenForCurrentTab = activeSidePanelTab === 'sftp';
|
||||
const activeSidePanelLayout = activeTabId ? sidePanelLayouts.get(activeTabId) ?? null : null;
|
||||
const activeSidePanelTools = useMemo(
|
||||
() => new Set(activeSidePanelLayout ? collectSidePanelPanes(activeSidePanelLayout.root).map((pane) => pane.tool) : []),
|
||||
[activeSidePanelLayout],
|
||||
);
|
||||
const isSftpOpenForCurrentTab = activeSidePanelTools.has('sftp');
|
||||
|
||||
const activeHostIdForSidebar = useMemo(() => {
|
||||
const sessionId = activeWorkspace ? focusedSessionId : activeSession?.id;
|
||||
@@ -182,11 +192,11 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
}, [activeTerminalSessionForSystem?.id, sessionHostsMap]);
|
||||
|
||||
const systemWarmupSessionIds = useMemo(() => {
|
||||
if (!activeTabId || activeSidePanelTab !== 'system') return [];
|
||||
if (!activeTabId || !activeSidePanelTools.has('system')) return [];
|
||||
const session = activeTerminalSessionForSystem;
|
||||
if (!session || session.status !== 'connected') return [];
|
||||
return [session.id];
|
||||
}, [activeSidePanelTab, activeTabId, activeTerminalSessionForSystem]);
|
||||
}, [activeSidePanelTools, activeTabId, activeTerminalSessionForSystem]);
|
||||
|
||||
useSystemCapabilitiesWarmup(
|
||||
systemWarmupSessionIds,
|
||||
@@ -207,7 +217,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
const themeState = useTerminalThemePanelState({
|
||||
accentMode: s.accentMode,
|
||||
activeSession,
|
||||
activeSidePanelTab,
|
||||
activeSidePanelTab: activeSidePanelTools.has('theme') ? 'theme' : activeSidePanelTab,
|
||||
activeWorkspace,
|
||||
customAccent: s.customAccent,
|
||||
followAppTerminalTheme: s.followAppTerminalTheme,
|
||||
@@ -310,6 +320,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
|
||||
useTerminalLayerEffects({
|
||||
activeSidePanelTab,
|
||||
activeSidePanelLayout,
|
||||
activeTabId,
|
||||
activeTabIdRef: s.activeTabIdRef,
|
||||
activeWorkspace,
|
||||
@@ -388,6 +399,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
activeHostIdForSidebar,
|
||||
activeResizers,
|
||||
activeSidePanelTab,
|
||||
activeSidePanelLayout,
|
||||
activeTabId,
|
||||
activeTerminalCwd,
|
||||
activeTerminalSessionIdForSftp,
|
||||
@@ -420,6 +432,10 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
followAppTerminalTheme: s.followAppTerminalTheme,
|
||||
handleHistoryPaste: s.handleHistoryPaste,
|
||||
handleHistoryRun: s.handleHistoryRun,
|
||||
handleFocusSidePanelPane: s.handleFocusSidePanelPane,
|
||||
handleSplitSidePanelPane: s.handleSplitSidePanelPane,
|
||||
handleCloseSidePanelPane: s.handleCloseSidePanelPane,
|
||||
handleResizeSidePanelSplit: s.handleResizeSidePanelSplit,
|
||||
fontSize: s.fontSize,
|
||||
getTerminalCwd: s.getTerminalCwd,
|
||||
handleAddKnownHost: s.handleAddKnownHost,
|
||||
@@ -572,6 +588,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
sidePanelPosition: s.sidePanelPosition,
|
||||
sidePanelWidth: s.sidePanelWidth,
|
||||
sidePanelOpenTabs,
|
||||
sidePanelLayouts,
|
||||
snippetPackages: s.snippetPackages,
|
||||
snippets: s.snippets,
|
||||
updateSnippetPackages: s.updateSnippetPackages,
|
||||
@@ -610,6 +627,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
}), [
|
||||
activeHostIdForSidebar,
|
||||
activeResizers,
|
||||
activeSidePanelLayout,
|
||||
activeSidePanelTab,
|
||||
activeTabId,
|
||||
activeTerminalCwd,
|
||||
@@ -633,6 +651,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
resizing,
|
||||
resolveAIExecutorContext,
|
||||
sessionHostsMap,
|
||||
sidePanelLayouts,
|
||||
s.resolvedSessionHostIds,
|
||||
sessions,
|
||||
s.terminalSettings,
|
||||
|
||||
@@ -10,12 +10,39 @@ import {
|
||||
listInvalidSftpPanelTabIds,
|
||||
listTerminalTabIdsWithRetainingTransfers,
|
||||
resolveSftpActiveTransfersCount,
|
||||
shouldCloseSftpSidePanel,
|
||||
shouldClearSftpPanelAfterTransferChange,
|
||||
shouldKeepSftpMountedAfterClose,
|
||||
shouldScheduleSftpRetainedPanelCleanup,
|
||||
terminalSftpTransferOwnerId,
|
||||
} from "./sftpPanelLifecycle.ts";
|
||||
|
||||
test("reopening focused SFTP does not close the other split panes", () => {
|
||||
assert.equal(shouldCloseSftpSidePanel({
|
||||
shouldKeepOpen: false,
|
||||
isOpen: true,
|
||||
isSameEndpoint: true,
|
||||
paneCount: 2,
|
||||
}), false);
|
||||
assert.equal(shouldCloseSftpSidePanel({
|
||||
shouldKeepOpen: false,
|
||||
isOpen: true,
|
||||
isSameEndpoint: true,
|
||||
paneCount: 1,
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("single-pane SFTP close uses the shared full-panel cleanup and stops opening work", () => {
|
||||
const layerSource = readFileSync(new URL("../TerminalLayer.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(
|
||||
layerSource,
|
||||
/if \(isClosing\) \{\s*closeTerminalSidePanelTab\(tabId\);\s*return;\s*\}/,
|
||||
);
|
||||
assert.match(layerSource, /const handleCloseSidePanel = useCallback[\s\S]*closeTerminalSidePanelTab\(activeTabId\)/);
|
||||
assert.match(layerSource, /closeTerminalSidePanelTab[\s\S]*setAiMountedTabIds[\s\S]*setNotesOpenNoteByTab/);
|
||||
});
|
||||
|
||||
function task(
|
||||
partial: Partial<TransferTask> & Pick<TransferTask, "id" | "status">,
|
||||
): Pick<TransferTask, "id" | "status" | "parentTaskId" | "ownerId"> {
|
||||
|
||||
@@ -60,6 +60,18 @@ export function shouldKeepSftpMountedAfterClose(activeTransfersCount: number): b
|
||||
return activeTransfersCount > 0;
|
||||
}
|
||||
|
||||
export function shouldCloseSftpSidePanel(params: {
|
||||
shouldKeepOpen: boolean;
|
||||
isOpen: boolean;
|
||||
isSameEndpoint: boolean;
|
||||
paneCount: number;
|
||||
}): boolean {
|
||||
return !params.shouldKeepOpen
|
||||
&& params.isOpen
|
||||
&& params.isSameEndpoint
|
||||
&& params.paneCount <= 1;
|
||||
}
|
||||
|
||||
export function shouldClearSftpPanelAfterTransferChange(params: {
|
||||
activeTransfersCount: number;
|
||||
panelOpen: boolean;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React, { memo, useCallback, useSyncExternalStore } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { activeTabStore, useIsTabActive } from '../../application/state/activeTabStore';
|
||||
import { activeTabStore } from '../../application/state/activeTabStore';
|
||||
import { getSftpCurrentPathMemoryKey } from '../../application/state/sftp/sftpReopenLocation';
|
||||
import {
|
||||
getSidePanelLiveSnapshot,
|
||||
@@ -21,6 +22,11 @@ import { SystemManagerSidePanel } from '../systemManager/SystemManagerSidePanel'
|
||||
import { resolveSftpFollowTerminalCwdTargetHost } from '../sftp/sftpFollowTerminalCwd';
|
||||
import { AI_PANEL_FORCE_HIDE_SHELL } from '../ai/aiPanelDiagnostics';
|
||||
import type { SidePanelTab } from './TerminalLayerSupport';
|
||||
import {
|
||||
collectSidePanelPanes,
|
||||
sidePanelLayoutHasTool,
|
||||
type SidePanelLayout,
|
||||
} from '../../domain/sidePanelLayout';
|
||||
import { sidePanelHiddenNotesPanelClassName, sidePanelHiddenPanelClassName } from './terminalLayerSidePanelHiddenWrapper';
|
||||
|
||||
type SidePanelStableContext = Record<string, any>;
|
||||
@@ -28,14 +34,10 @@ const navigatorPlatform = typeof navigator !== 'undefined' ? navigator.platform
|
||||
const EMPTY_VAULT_NOTES: never[] = [];
|
||||
const EMPTY_VAULT_HOSTS: never[] = [];
|
||||
const EMPTY_VAULT_SNIPPETS: never[] = [];
|
||||
const EMPTY_SHELL_HISTORY: readonly never[] = [];
|
||||
const EMPTY_SHELL_HISTORY = Object.freeze([]) as readonly never[];
|
||||
const subscribeShellHistoryNoop = () => () => {};
|
||||
const getEmptyShellHistorySnapshot = () => EMPTY_SHELL_HISTORY;
|
||||
|
||||
function useSidePanelTabType(tabId: string, sidePanelOpenTabs: Map<string, SidePanelTab>): SidePanelTab | null {
|
||||
return sidePanelOpenTabs.get(tabId) ?? null;
|
||||
}
|
||||
|
||||
function useSidePanelLiveSnapshotForTab(tabId: string, subscribe: boolean) {
|
||||
const getSnapshot = useCallback(
|
||||
() => getSidePanelLiveSnapshot(subscribe),
|
||||
@@ -51,14 +53,12 @@ function useSidePanelLiveSnapshotForTab(tabId: string, subscribe: boolean) {
|
||||
function SidePanelSftpSlotInner({
|
||||
tabId,
|
||||
ctx,
|
||||
isVisible,
|
||||
}: {
|
||||
tabId: string;
|
||||
ctx: SidePanelStableContext;
|
||||
isVisible: boolean;
|
||||
}) {
|
||||
const isTabActive = useIsTabActive(tabId);
|
||||
const sidePanelOpenTabs = ctx.sidePanelOpenTabs as Map<string, SidePanelTab>;
|
||||
const sidePanelTab = useSidePanelTabType(tabId, sidePanelOpenTabs);
|
||||
const isVisible = isTabActive && sidePanelTab === 'sftp';
|
||||
const live = useSidePanelLiveSnapshotForTab(tabId, isVisible);
|
||||
|
||||
const {
|
||||
@@ -206,18 +206,19 @@ SidePanelSftpSlot.displayName = 'SidePanelSftpSlot';
|
||||
function SidePanelSystemSlotInner({
|
||||
tabId,
|
||||
ctx,
|
||||
isTabActive,
|
||||
isVisible,
|
||||
isSelected,
|
||||
}: {
|
||||
tabId: string;
|
||||
ctx: SidePanelStableContext;
|
||||
isTabActive: boolean;
|
||||
isVisible: boolean;
|
||||
isSelected: boolean;
|
||||
}) {
|
||||
const isTabActive = useIsTabActive(tabId);
|
||||
const sidePanelOpenTabs = ctx.sidePanelOpenTabs as Map<string, SidePanelTab>;
|
||||
const sidePanelTab = useSidePanelTabType(tabId, sidePanelOpenTabs);
|
||||
const panelSelected = sidePanelTab === 'system';
|
||||
const isVisible = isTabActive && panelSelected;
|
||||
// When this tab is active, prefer live store so focus changes do not require
|
||||
// workspaceById identity churn through the stable side-panel ctx.
|
||||
const live = useSidePanelLiveSnapshotForTab(tabId, isTabActive && panelSelected);
|
||||
const live = useSidePanelLiveSnapshotForTab(tabId, isVisible);
|
||||
const sessions = ctx.sessions as TerminalSession[];
|
||||
const sessionHostsMap = ctx.sessionHostsMap as Map<string, Host>;
|
||||
const workspace = (ctx.workspaceById as Map<string, Workspace>).get(tabId);
|
||||
@@ -228,13 +229,13 @@ function SidePanelSystemSlotInner({
|
||||
workspace?.focusedSessionId,
|
||||
standaloneSession,
|
||||
);
|
||||
const systemSession = (isTabActive && panelSelected
|
||||
const systemSession = (isVisible
|
||||
? (live.activeTerminalSessionForSystem ?? resolvedSession)
|
||||
: resolvedSession) ?? null;
|
||||
const systemHost = (isTabActive && panelSelected && live.activeSystemSessionHost)
|
||||
const systemHost = (isVisible && live.activeSystemSessionHost)
|
||||
? live.activeSystemSessionHost
|
||||
: (systemSession ? sessionHostsMap.get(systemSession.id) ?? null : null);
|
||||
const keepSystemWorkActive = panelSelected
|
||||
const keepSystemWorkActive = isSelected
|
||||
&& shouldKeepTerminalBackgroundWorkActive(
|
||||
ctx.terminalSettings,
|
||||
systemHost?.protocol,
|
||||
@@ -269,14 +270,12 @@ SidePanelSystemSlot.displayName = 'SidePanelSystemSlot';
|
||||
function SidePanelScriptsSlotInner({
|
||||
tabId,
|
||||
ctx,
|
||||
isVisible,
|
||||
}: {
|
||||
tabId: string;
|
||||
ctx: SidePanelStableContext;
|
||||
isVisible: boolean;
|
||||
}) {
|
||||
const isTabActive = useIsTabActive(tabId);
|
||||
const sidePanelOpenTabs = ctx.sidePanelOpenTabs as Map<string, SidePanelTab>;
|
||||
const sidePanelTab = useSidePanelTabType(tabId, sidePanelOpenTabs);
|
||||
const isVisible = isTabActive && sidePanelTab === 'scripts';
|
||||
const live = useSidePanelLiveSnapshotForTab(tabId, isVisible);
|
||||
// Subscribe only while visible so retained scripts slots skip log thrash.
|
||||
const { runs: scriptRuns } = useScriptExecution({ enabled: isVisible });
|
||||
@@ -324,14 +323,12 @@ SidePanelScriptsSlot.displayName = 'SidePanelScriptsSlot';
|
||||
function SidePanelThemeSlotInner({
|
||||
tabId,
|
||||
ctx,
|
||||
isVisible,
|
||||
}: {
|
||||
tabId: string;
|
||||
ctx: SidePanelStableContext;
|
||||
isVisible: boolean;
|
||||
}) {
|
||||
const isTabActive = useIsTabActive(tabId);
|
||||
const sidePanelOpenTabs = ctx.sidePanelOpenTabs as Map<string, SidePanelTab>;
|
||||
const sidePanelTab = useSidePanelTabType(tabId, sidePanelOpenTabs);
|
||||
const isVisible = isTabActive && sidePanelTab === 'theme';
|
||||
// Only subscribe while the theme panel is visible — not merely tab-active —
|
||||
// so cwd/focus live ticks do not thrash a retained ThemeSidePanel.
|
||||
const live = useSidePanelLiveSnapshotForTab(tabId, isVisible);
|
||||
@@ -386,14 +383,12 @@ SidePanelThemeSlot.displayName = 'SidePanelThemeSlot';
|
||||
function SidePanelNotesSlotInner({
|
||||
tabId,
|
||||
ctx,
|
||||
isVisible,
|
||||
}: {
|
||||
tabId: string;
|
||||
ctx: SidePanelStableContext;
|
||||
isVisible: boolean;
|
||||
}) {
|
||||
const isTabActive = useIsTabActive(tabId);
|
||||
const sidePanelOpenTabs = ctx.sidePanelOpenTabs as Map<string, SidePanelTab>;
|
||||
const sidePanelTab = useSidePanelTabType(tabId, sidePanelOpenTabs);
|
||||
const isVisible = isTabActive && sidePanelTab === 'notes';
|
||||
const openNoteRequest = (ctx.notesOpenNoteByTab as Map<string, { noteId: string; requestId: number }>).get(tabId) ?? null;
|
||||
|
||||
const {
|
||||
@@ -429,15 +424,15 @@ function SidePanelNotesSlotInner({
|
||||
export const SidePanelNotesSlot = memo(SidePanelNotesSlotInner);
|
||||
SidePanelNotesSlot.displayName = 'SidePanelNotesSlot';
|
||||
|
||||
function SidePanelHistorySlotInner({ ctx }: { ctx: SidePanelStableContext }) {
|
||||
const activeTabId = useSyncExternalStore(
|
||||
activeTabStore.subscribe,
|
||||
activeTabStore.getActiveTabId,
|
||||
activeTabStore.getActiveTabId,
|
||||
);
|
||||
const sidePanelOpenTabs = ctx.sidePanelOpenTabs as Map<string, SidePanelTab>;
|
||||
const sidePanelTab = activeTabId ? sidePanelOpenTabs.get(activeTabId) ?? null : null;
|
||||
const isVisible = sidePanelTab === 'history';
|
||||
function SidePanelHistorySlotInner({
|
||||
activeTabId,
|
||||
ctx,
|
||||
isVisible,
|
||||
}: {
|
||||
activeTabId: string | null;
|
||||
ctx: SidePanelStableContext;
|
||||
isVisible: boolean;
|
||||
}) {
|
||||
const live = useSidePanelLiveSnapshotForTab(activeTabId ?? '', isVisible);
|
||||
// Own remote-history state here so fetch/loading does not re-render TerminalLayer.
|
||||
const remoteHistory = useRemoteHistoryState();
|
||||
@@ -476,15 +471,15 @@ function SidePanelHistorySlotInner({ ctx }: { ctx: SidePanelStableContext }) {
|
||||
export const SidePanelHistorySlot = memo(SidePanelHistorySlotInner);
|
||||
SidePanelHistorySlot.displayName = 'SidePanelHistorySlot';
|
||||
|
||||
function SidePanelAiSlotInner({ ctx }: { ctx: SidePanelStableContext }) {
|
||||
const activeTabId = useSyncExternalStore(
|
||||
activeTabStore.subscribe,
|
||||
activeTabStore.getActiveTabId,
|
||||
activeTabStore.getActiveTabId,
|
||||
);
|
||||
const sidePanelOpenTabs = ctx.sidePanelOpenTabs as Map<string, SidePanelTab>;
|
||||
const activeSidePanelTab = activeTabId ? sidePanelOpenTabs.get(activeTabId) ?? null : null;
|
||||
|
||||
function SidePanelAiSlotInner({
|
||||
activeTabId,
|
||||
ctx,
|
||||
isVisible,
|
||||
}: {
|
||||
activeTabId: string | null;
|
||||
ctx: SidePanelStableContext;
|
||||
isVisible: boolean;
|
||||
}) {
|
||||
const {
|
||||
AIChatPanelsHost,
|
||||
AISidePanelStateRoot,
|
||||
@@ -504,18 +499,25 @@ function SidePanelAiSlotInner({ ctx }: { ctx: SidePanelStableContext }) {
|
||||
} = ctx;
|
||||
|
||||
if (mountedAiTabIds.length === 0) return null;
|
||||
if (AI_PANEL_FORCE_HIDE_SHELL && activeSidePanelTab === 'ai') return null;
|
||||
const activeLayout = activeTabId
|
||||
? (ctx.sidePanelLayouts as Map<string, SidePanelLayout>).get(activeTabId)
|
||||
: null;
|
||||
if (
|
||||
AI_PANEL_FORCE_HIDE_SHELL
|
||||
&& isVisible
|
||||
&& (!activeLayout || collectSidePanelPanes(activeLayout.root).length <= 1)
|
||||
) return null;
|
||||
|
||||
// Only the visible AI panel needs vault catalogs for artifact navigation.
|
||||
// Hidden retained panels keep session state without re-binding huge hosts/notes.
|
||||
const injectVaultCatalog = activeSidePanelTab === 'ai';
|
||||
const injectVaultCatalog = isVisible;
|
||||
|
||||
return (
|
||||
<AISidePanelStateRoot validAIScopeTargetIds={validAIScopeTargetIds}>
|
||||
<AIChatPanelsHost
|
||||
mountedTabIds={mountedAiTabIds}
|
||||
activeTabId={activeTabId}
|
||||
activeSidePanelTab={activeSidePanelTab}
|
||||
activeSidePanelTab={isVisible ? 'ai' : null}
|
||||
contextsByTabId={aiContextsByTabId}
|
||||
resolveExecutorContext={resolveAIExecutorContext}
|
||||
pendingTerminalSelection={pendingTerminalSelectionForAI}
|
||||
@@ -535,7 +537,54 @@ function SidePanelAiSlotInner({ ctx }: { ctx: SidePanelStableContext }) {
|
||||
export const SidePanelAiSlot = memo(SidePanelAiSlotInner);
|
||||
SidePanelAiSlot.displayName = 'SidePanelAiSlot';
|
||||
|
||||
export function SidePanelMountedContent({ ctx }: { ctx: SidePanelStableContext }) {
|
||||
function PersistentSidePanelPortal({
|
||||
portalKey,
|
||||
target,
|
||||
children,
|
||||
}: {
|
||||
portalKey: string;
|
||||
target: HTMLElement | null;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [mountNode] = React.useState(() => {
|
||||
const node = document.createElement('div');
|
||||
node.className = 'absolute inset-0 overflow-hidden';
|
||||
node.dataset.sidePanelPortal = portalKey;
|
||||
return node;
|
||||
});
|
||||
|
||||
// The React portal always targets the same detached node. Moving that node
|
||||
// between a pane host and the hidden parking host preserves the mounted
|
||||
// subtree (including active SFTP/AI state) instead of remounting it whenever
|
||||
// the focused pane changes.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!target) return;
|
||||
target.appendChild(mountNode);
|
||||
return () => {
|
||||
if (mountNode.parentNode === target) mountNode.remove();
|
||||
};
|
||||
}, [mountNode, target]);
|
||||
|
||||
return createPortal(children, mountNode, portalKey);
|
||||
}
|
||||
|
||||
export function resolveSidePanelPortalTarget<T>(
|
||||
isVisible: boolean,
|
||||
paneHost: T | null | undefined,
|
||||
parkingHost: T | null,
|
||||
): T | null {
|
||||
return isVisible ? (paneHost ?? parkingHost) : parkingHost;
|
||||
}
|
||||
|
||||
export function SidePanelMountedContent({
|
||||
ctx,
|
||||
paneHosts,
|
||||
parkingHost,
|
||||
}: {
|
||||
ctx: SidePanelStableContext;
|
||||
paneHosts: ReadonlyMap<SidePanelTab, HTMLElement>;
|
||||
parkingHost: HTMLElement | null;
|
||||
}) {
|
||||
const {
|
||||
mountedSftpTabIds,
|
||||
systemMountedTabIds,
|
||||
@@ -543,26 +592,91 @@ export function SidePanelMountedContent({ ctx }: { ctx: SidePanelStableContext }
|
||||
themeMountedTabIds,
|
||||
notesMountedTabIds,
|
||||
} = ctx;
|
||||
const activeTabId = useSyncExternalStore(
|
||||
activeTabStore.subscribe,
|
||||
activeTabStore.getActiveTabId,
|
||||
activeTabStore.getActiveTabId,
|
||||
);
|
||||
const layouts = ctx.sidePanelLayouts as Map<string, SidePanelLayout>;
|
||||
const isToolVisible = (tabId: string, tool: SidePanelTab) => (
|
||||
activeTabId === tabId && sidePanelLayoutHasTool(layouts.get(tabId), tool)
|
||||
);
|
||||
const portalTarget = (tabId: string, tool: SidePanelTab) => (
|
||||
resolveSidePanelPortalTarget(isToolVisible(tabId, tool), paneHosts.get(tool), parkingHost)
|
||||
);
|
||||
const activeLayout = activeTabId ? layouts.get(activeTabId) : undefined;
|
||||
const historyVisible = !!activeTabId && sidePanelLayoutHasTool(activeLayout, 'history');
|
||||
const aiVisible = !!activeTabId && sidePanelLayoutHasTool(activeLayout, 'ai');
|
||||
|
||||
return (
|
||||
<>
|
||||
{mountedSftpTabIds.map((tabId: string) => (
|
||||
<SidePanelSftpSlot key={tabId} tabId={tabId} ctx={ctx} />
|
||||
))}
|
||||
{systemMountedTabIds.map((tabId: string) => (
|
||||
<SidePanelSystemSlot key={`system-${tabId}`} tabId={tabId} ctx={ctx} />
|
||||
<PersistentSidePanelPortal
|
||||
key={`sftp-${tabId}`}
|
||||
portalKey={`sftp-${tabId}`}
|
||||
target={portalTarget(tabId, 'sftp')}
|
||||
>
|
||||
<SidePanelSftpSlot tabId={tabId} ctx={ctx} isVisible={isToolVisible(tabId, 'sftp')} />
|
||||
</PersistentSidePanelPortal>
|
||||
))}
|
||||
{systemMountedTabIds.map((tabId: string) => {
|
||||
const isSelected = sidePanelLayoutHasTool(layouts.get(tabId), 'system');
|
||||
const isVisible = activeTabId === tabId && isSelected;
|
||||
return (
|
||||
<PersistentSidePanelPortal
|
||||
key={`system-${tabId}`}
|
||||
portalKey={`system-${tabId}`}
|
||||
target={resolveSidePanelPortalTarget(isVisible, paneHosts.get('system'), parkingHost)}
|
||||
>
|
||||
<SidePanelSystemSlot
|
||||
tabId={tabId}
|
||||
ctx={ctx}
|
||||
isTabActive={activeTabId === tabId}
|
||||
isVisible={isVisible}
|
||||
isSelected={isSelected}
|
||||
/>
|
||||
</PersistentSidePanelPortal>
|
||||
);
|
||||
})}
|
||||
{scriptsMountedTabIds.map((tabId: string) => (
|
||||
<SidePanelScriptsSlot key={`scripts-${tabId}`} tabId={tabId} ctx={ctx} />
|
||||
<PersistentSidePanelPortal
|
||||
key={`scripts-${tabId}`}
|
||||
portalKey={`scripts-${tabId}`}
|
||||
target={portalTarget(tabId, 'scripts')}
|
||||
>
|
||||
<SidePanelScriptsSlot tabId={tabId} ctx={ctx} isVisible={isToolVisible(tabId, 'scripts')} />
|
||||
</PersistentSidePanelPortal>
|
||||
))}
|
||||
<SidePanelHistorySlot ctx={ctx} />
|
||||
<PersistentSidePanelPortal
|
||||
portalKey="history-active"
|
||||
target={resolveSidePanelPortalTarget(historyVisible, paneHosts.get('history'), parkingHost)}
|
||||
>
|
||||
<SidePanelHistorySlot activeTabId={activeTabId} ctx={ctx} isVisible={historyVisible} />
|
||||
</PersistentSidePanelPortal>
|
||||
{themeMountedTabIds.map((tabId: string) => (
|
||||
<SidePanelThemeSlot key={`theme-${tabId}`} tabId={tabId} ctx={ctx} />
|
||||
<PersistentSidePanelPortal
|
||||
key={`theme-${tabId}`}
|
||||
portalKey={`theme-${tabId}`}
|
||||
target={portalTarget(tabId, 'theme')}
|
||||
>
|
||||
<SidePanelThemeSlot tabId={tabId} ctx={ctx} isVisible={isToolVisible(tabId, 'theme')} />
|
||||
</PersistentSidePanelPortal>
|
||||
))}
|
||||
{notesMountedTabIds.map((tabId: string) => (
|
||||
<SidePanelNotesSlot key={`notes-${tabId}`} tabId={tabId} ctx={ctx} />
|
||||
<PersistentSidePanelPortal
|
||||
key={`notes-${tabId}`}
|
||||
portalKey={`notes-${tabId}`}
|
||||
target={portalTarget(tabId, 'notes')}
|
||||
>
|
||||
<SidePanelNotesSlot tabId={tabId} ctx={ctx} isVisible={isToolVisible(tabId, 'notes')} />
|
||||
</PersistentSidePanelPortal>
|
||||
))}
|
||||
<SidePanelAiSlot ctx={ctx} />
|
||||
<PersistentSidePanelPortal
|
||||
portalKey="ai-host"
|
||||
target={resolveSidePanelPortalTarget(aiVisible, paneHosts.get('ai'), parkingHost)}
|
||||
>
|
||||
<SidePanelAiSlot activeTabId={activeTabId} ctx={ctx} isVisible={aiVisible} />
|
||||
</PersistentSidePanelPortal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -239,6 +239,7 @@ const SIDE_PANEL_STABLE_CTX_KEYS = [
|
||||
'sidePanelWidth',
|
||||
'sidePanelPosition',
|
||||
'sidePanelOpenTabs',
|
||||
'sidePanelLayouts',
|
||||
'sftpHostForTab',
|
||||
'effectiveHosts',
|
||||
'hosts',
|
||||
@@ -307,6 +308,10 @@ const SIDE_PANEL_STABLE_CTX_KEYS = [
|
||||
'handleOpenHostFromNotes',
|
||||
'handleOpenSystem',
|
||||
'handleCloseSidePanel',
|
||||
'handleFocusSidePanelPane',
|
||||
'handleSplitSidePanelPane',
|
||||
'handleCloseSidePanelPane',
|
||||
'handleResizeSidePanelSplit',
|
||||
'setSidePanelPosition',
|
||||
'handleSftpInitialLocationApplied',
|
||||
'handlePendingUploadHandled',
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS = 'h-8 shrink-0';
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
resolveSftpTransferNavigationPath,
|
||||
resolveSftpTransferNavigationTarget,
|
||||
} from '../../domain/sftpTransferNavigation';
|
||||
import { collectSidePanelPanes, sidePanelLayoutHasTool } from '../../domain/sidePanelLayout';
|
||||
import { AI_PANEL_FORCE_HIDE_SHELL } from '../ai/aiPanelDiagnostics';
|
||||
import { toast } from '../ui/toast';
|
||||
import { getTerminalSidePanelShellWidth } from './TerminalLayerSidePanelSection';
|
||||
@@ -92,7 +93,7 @@ export function pruneTerminalTabMemoryState(
|
||||
|
||||
export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
|
||||
const { openPath } = useSftpBackend();
|
||||
const { activeSidePanelTab, activeTabId, activeTabIdRef, activeWorkspace, activityTrackedSessions, cancelAnimationFrame, ChunkedEscapeFilter, clearTopTabsPreviewVars, document, dropHint, effectiveHosts, filterTabsMap, focusedSessionId, getSessionActivityIdsToClear, handleToggleAiFromTopBar, handleToggleScriptsSidePanel, handleToggleSidePanel, hasNotifiableTerminalOutput, isComposeBarOpen, isFocusMode, isTerminalLayerVisible, lastSidePanelTabRef, Map, onConnectToHost, onSessionData, onSplitSessionRef, onToggleBroadcastRef, onToggleWorkspaceViewModeRef, prevFocusedSessionIdRef, refocusActiveTerminalSession, requestAnimationFrame, ResizeObserver, sessionActivityStore, sessions, Set, setAiMountedTabIds, setDropHint, setNotesMountedTabIds, setScriptsMountedTabIds, setSystemMountedTabIds, setSftpHostForTab, setSftpInitialLocationForTab, setSftpPendingUploadsForTab, setSidePanelOpenTabs, setThemeMountedTabIds, setWorkspaceArea, shouldMeasureTerminalLayerLayout, sidePanelPosition, sidePanelWidth, sftpActiveHost, sftpHostForTab, shouldMarkSessionActivity, sidePanelOpenTabs, splitHorizontalHandlersRef, splitVerticalHandlersRef, toggleScriptsSidePanelRef, toggleSidePanelRef, validAIScopeTargetIds, validSessionActivityIds, window, workspaceBroadcastHandlersRef, workspaceFocusHandlersRef, workspaceInnerRef, workspaces } = ctx;
|
||||
const { activeSidePanelTab, activeSidePanelLayout, activeTabId, activeTabIdRef, activeWorkspace, activityTrackedSessions, cancelAnimationFrame, ChunkedEscapeFilter, clearTopTabsPreviewVars, document, dropHint, effectiveHosts, filterTabsMap, focusedSessionId, getSessionActivityIdsToClear, handleToggleAiFromTopBar, handleToggleScriptsSidePanel, handleToggleSidePanel, hasNotifiableTerminalOutput, isComposeBarOpen, isFocusMode, isTerminalLayerVisible, lastSidePanelTabRef, Map, onConnectToHost, onSessionData, onSplitSessionRef, onToggleBroadcastRef, onToggleWorkspaceViewModeRef, prevFocusedSessionIdRef, refocusActiveTerminalSession, requestAnimationFrame, ResizeObserver, sessionActivityStore, sessions, Set, setAiMountedTabIds, setDropHint, setNotesMountedTabIds, setScriptsMountedTabIds, setSystemMountedTabIds, setSftpHostForTab, setSftpInitialLocationForTab, setSftpPendingUploadsForTab, setSidePanelOpenTabs, setThemeMountedTabIds, setWorkspaceArea, shouldMeasureTerminalLayerLayout, sidePanelPosition, sidePanelWidth, sftpActiveHost, sftpHostForTab, shouldMarkSessionActivity, sidePanelOpenTabs, splitHorizontalHandlersRef, splitVerticalHandlersRef, toggleScriptsSidePanelRef, toggleSidePanelRef, validAIScopeTargetIds, validSessionActivityIds, window, workspaceBroadcastHandlersRef, workspaceFocusHandlersRef, workspaceInnerRef, workspaces } = ctx;
|
||||
|
||||
const activeWorkspaceId = activeWorkspace?.id;
|
||||
const activeWorkspaceViewMode = activeWorkspace?.viewMode;
|
||||
@@ -100,7 +101,8 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
|
||||
const isSidePanelOpenForCurrentTab = activeTabId ? sidePanelOpenTabs.has(activeTabId) : false;
|
||||
const sidePanelShellWidth = getTerminalSidePanelShellWidth({
|
||||
activeSidePanelTab,
|
||||
forceHideAiShell: AI_PANEL_FORCE_HIDE_SHELL,
|
||||
forceHideAiShell: AI_PANEL_FORCE_HIDE_SHELL
|
||||
&& (!activeSidePanelLayout || collectSidePanelPanes(activeSidePanelLayout.root).length <= 1),
|
||||
isSidePanelOpenForCurrentTab,
|
||||
resizePreviewWidth: null,
|
||||
sidePanelWidth,
|
||||
@@ -296,7 +298,7 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
|
||||
// so that the toggle check uses the currently displayed host.
|
||||
useEffect(() => {
|
||||
if (!activeTabId || !sftpActiveHost) return;
|
||||
if (sidePanelOpenTabs.get(activeTabId) !== 'sftp') return;
|
||||
if (!sidePanelLayoutHasTool(activeSidePanelLayout, 'sftp')) return;
|
||||
const stored = sftpHostForTab.get(activeTabId);
|
||||
if (stored?.id === sftpActiveHost.id
|
||||
&& stored?.hostname === sftpActiveHost.hostname
|
||||
@@ -307,7 +309,7 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
|
||||
next.set(activeTabId, sftpActiveHost);
|
||||
return next;
|
||||
});
|
||||
}, [activeTabId, sftpActiveHost, sidePanelOpenTabs, sftpHostForTab]);
|
||||
}, [activeSidePanelLayout, activeTabId, sftpActiveHost, sftpHostForTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toggleScriptsSidePanelRef) return;
|
||||
|
||||
@@ -9,7 +9,7 @@ export const InputGroup = forwardRef<HTMLDivElement, InputGroupProps>(
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex flex-col rounded-[22px] border border-border/65 bg-background transition-[border-color,background-color]',
|
||||
'flex flex-col rounded-xl border border-border/65 bg-background transition-[border-color,background-color]',
|
||||
'focus-within:border-primary/45 focus-within:ring-1 focus-within:ring-primary/20',
|
||||
'overflow-hidden',
|
||||
className,
|
||||
|
||||
@@ -27,18 +27,19 @@
|
||||
<text x="85" y="85.0" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">5K</text>
|
||||
<text x="43" y="271.5" fill="#e6edf3" font-size="17" font-family="Handlee, cursive" font-weight="bold" transform="rotate(-90, 43, 271.5)">GitHub Stars</text>
|
||||
<text x="90.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jan 2026</text>
|
||||
<text x="185.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
|
||||
<text x="277.7" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
|
||||
<text x="379.8" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
|
||||
<text x="478.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
|
||||
<text x="580.6" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
|
||||
<text x="679.4" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
|
||||
<text x="182.3" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
|
||||
<text x="271.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
|
||||
<text x="370.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
|
||||
<text x="465.7" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
|
||||
<text x="564.4" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
|
||||
<text x="659.9" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
|
||||
<text x="758.6" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
|
||||
<text x="50%" y="525" fill="#e6edf3" font-size="17" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Date</text>
|
||||
<line x1="90" y1="80" x2="90" y2="463" stroke="#30363d" stroke-width="2.5" filter="url(#xkcdify)"/>
|
||||
<line x1="90" y1="463" x2="765" y2="463" stroke="#30363d" stroke-width="2.5" filter="url(#xkcdify)"/>
|
||||
<path d="M 90.0,461.9 C 90.5,461.7 92.7,461.3 93.3,461.2 C 94.4,461.0 95.5,460.8 96.6,460.5 C 97.7,460.3 98.8,460.0 99.9,459.9 C 101.0,459.7 102.1,459.8 103.2,459.8 C 104.3,459.7 105.4,459.7 106.5,459.6 C 107.6,459.6 108.7,459.5 109.8,459.4 C 110.9,459.3 111.4,459.2 113.0,459.2 C 114.7,459.1 118.0,459.0 119.6,458.9 C 121.3,458.9 121.8,458.8 122.9,458.8 C 124.0,458.7 125.1,458.7 126.2,458.7 C 127.3,458.7 128.4,458.7 129.5,458.6 C 130.6,458.6 131.7,458.6 132.8,458.6 C 133.9,458.5 135.0,458.4 136.1,458.3 C 137.2,458.2 138.3,458.3 139.4,458.0 C 140.5,457.8 141.6,457.4 142.7,456.9 C 143.8,456.5 144.9,455.4 146.0,455.0 C 147.1,454.7 148.2,454.9 149.3,454.8 C 150.4,454.7 151.5,454.7 152.6,454.7 C 153.7,454.6 154.2,454.5 155.9,454.5 C 157.5,454.5 160.2,454.5 162.4,454.4 C 164.6,454.4 167.4,454.3 169.0,454.3 C 170.7,454.2 171.2,454.2 172.3,454.1 C 173.4,454.1 174.5,454.0 175.6,454.0 C 176.7,453.9 177.8,453.8 178.9,453.8 C 180.0,453.8 181.1,453.8 182.2,453.7 C 183.3,453.7 183.8,453.7 185.5,453.7 C 187.1,453.6 189.3,453.5 192.1,453.5 C 194.8,453.5 199.2,453.5 202.0,453.4 C 204.7,453.4 205.2,453.4 208.5,453.3 C 211.8,453.3 219.0,453.1 221.7,453.0 C 224.5,453.0 223.4,453.0 225.0,453.0 C 226.6,452.9 228.8,452.9 231.6,452.9 C 234.3,452.9 236.0,452.8 241.5,452.7 C 247.0,452.7 260.1,452.6 264.5,452.5 C 268.9,452.5 266.7,452.5 267.8,452.4 C 268.9,452.4 269.5,453.3 271.1,452.4 C 272.7,451.4 276.0,450.6 277.7,446.5 C 279.3,442.5 279.9,431.6 281.0,428.2 C 282.1,424.8 283.2,426.8 284.3,426.2 C 285.4,425.7 286.5,425.4 287.6,425.0 C 288.7,424.7 289.8,424.4 290.9,424.2 C 292.0,423.9 293.0,423.9 294.1,423.7 C 295.2,423.6 296.3,423.3 297.4,423.2 C 298.5,423.2 299.6,423.3 300.7,423.2 C 301.8,423.1 302.9,422.8 304.0,422.6 C 305.1,422.5 306.2,422.6 307.3,422.4 C 308.4,422.2 309.5,421.7 310.6,421.6 C 311.7,421.4 312.8,421.5 313.9,421.3 C 315.0,421.2 316.1,420.9 317.2,420.8 C 318.3,420.7 319.4,420.8 320.5,420.7 C 321.6,420.7 322.7,420.6 323.8,420.5 C 324.9,420.4 326.0,420.3 327.1,420.3 C 328.2,420.2 329.3,420.2 330.4,420.0 C 331.5,419.9 332.6,419.5 333.7,419.3 C 334.8,419.1 335.9,420.1 337.0,418.8 C 338.0,417.5 339.1,413.1 340.2,411.6 C 341.3,410.1 342.4,410.3 343.5,410.1 C 344.6,409.8 345.7,410.2 346.8,410.0 C 347.9,409.8 349.0,409.3 350.1,408.9 C 351.2,408.5 352.3,408.2 353.4,407.5 C 354.5,406.7 355.6,405.4 356.7,404.6 C 357.8,403.7 358.9,402.9 360.0,402.3 C 361.1,401.6 362.2,401.2 363.3,400.8 C 364.4,400.4 365.5,400.3 366.6,400.1 C 367.7,399.9 368.8,400.0 369.9,399.8 C 371.0,399.7 372.1,399.3 373.2,399.2 C 374.3,399.1 375.4,399.1 376.5,399.0 C 377.6,399.0 378.7,398.9 379.8,398.7 C 380.9,398.6 382.0,398.6 383.0,398.0 C 384.1,397.5 385.2,396.0 386.3,395.3 C 387.4,394.6 388.5,394.4 389.6,393.8 C 390.7,393.3 391.8,394.4 392.9,392.1 C 394.0,389.9 395.1,383.2 396.2,380.6 C 397.3,378.0 398.4,377.6 399.5,376.4 C 400.6,375.3 401.7,374.7 402.8,373.9 C 403.9,373.1 405.0,372.4 406.1,371.8 C 407.2,371.2 408.3,370.7 409.4,370.3 C 410.5,369.9 411.6,369.9 412.7,369.5 C 413.8,369.2 414.9,368.9 416.0,368.5 C 417.1,368.1 418.2,367.4 419.3,367.0 C 420.4,366.7 421.5,366.7 422.6,366.3 C 423.7,366.0 424.8,365.3 425.9,364.9 C 427.0,364.4 428.0,364.1 429.1,363.7 C 430.2,363.4 431.3,363.1 432.4,362.9 C 433.5,362.7 434.6,362.6 435.7,362.4 C 436.8,362.2 437.9,361.9 439.0,361.7 C 440.1,361.4 441.2,361.2 442.3,360.9 C 443.4,360.6 444.5,360.3 445.6,359.9 C 446.7,359.4 447.8,358.6 448.9,358.2 C 450.0,357.8 451.1,357.7 452.2,357.4 C 453.3,357.2 454.4,357.1 455.5,356.9 C 456.6,356.7 457.7,356.6 458.8,356.4 C 459.9,356.3 461.0,356.1 462.1,356.0 C 463.2,355.9 464.3,356.0 465.4,355.8 C 466.5,355.6 467.6,355.2 468.7,354.8 C 469.8,354.4 470.9,353.8 472.0,353.3 C 473.0,352.8 474.1,352.3 475.2,352.0 C 476.3,351.7 477.4,351.7 478.5,351.5 C 479.6,351.3 480.7,351.2 481.8,350.9 C 482.9,350.5 484.0,349.8 485.1,349.5 C 486.2,349.2 487.3,349.3 488.4,349.1 C 489.5,348.9 490.6,348.5 491.7,348.3 C 492.8,348.0 493.9,347.8 495.0,347.5 C 496.1,347.2 497.2,346.9 498.3,346.6 C 499.4,346.4 500.5,346.3 501.6,346.0 C 502.7,345.6 503.8,345.1 504.9,344.8 C 506.0,344.5 507.1,344.3 508.2,344.0 C 509.3,343.6 510.4,343.1 511.5,342.7 C 512.6,342.3 513.7,341.9 514.8,341.6 C 515.9,341.2 517.0,341.2 518.0,340.6 C 519.1,340.0 520.2,338.7 521.3,338.2 C 522.4,337.7 523.5,337.6 524.6,337.5 C 525.7,337.3 526.8,337.3 527.9,337.1 C 529.0,337.0 530.1,336.8 531.2,336.6 C 532.3,336.5 533.4,336.7 534.5,336.2 C 535.6,335.7 536.7,334.1 537.8,333.5 C 538.9,332.9 540.0,333.0 541.1,332.6 C 542.2,332.3 543.3,331.6 544.4,331.3 C 545.5,331.0 546.6,331.2 547.7,330.9 C 548.8,330.6 549.9,329.9 551.0,329.6 C 552.1,329.3 553.2,329.3 554.3,329.2 C 555.4,329.0 556.5,329.0 557.6,328.7 C 558.7,328.4 559.8,328.0 560.9,327.3 C 562.0,326.5 563.0,325.1 564.1,324.4 C 565.2,323.6 566.3,323.3 567.4,322.7 C 568.5,322.2 569.6,322.1 570.7,321.1 C 571.8,320.0 572.9,317.7 574.0,316.5 C 575.1,315.4 576.2,314.9 577.3,314.2 C 578.4,313.5 579.5,313.4 580.6,312.3 C 581.7,311.3 582.8,309.0 583.9,308.0 C 585.0,307.0 586.1,306.9 587.2,306.3 C 588.Line truncated
|
||||
<path d="M 90.0,461.9 C 90.5,461.7 92.7,461.3 93.3,461.2 C 94.4,461.0 95.5,460.8 96.6,460.5 C 97.7,460.3 98.8,460.0 99.9,459.9 C 101.0,459.7 102.1,459.8 103.2,459.8 C 104.3,459.7 105.4,459.7 106.5,459.6 C 107.6,459.6 108.7,459.5 109.8,459.4 C 110.9,459.3 111.4,459.2 113.0,459.2 C 114.7,459.1 118.0,459.0 119.6,458.9 C 121.3,458.9 121.8,458.8 122.9,458.8 C 124.0,458.7 125.1,458.7 126.2,458.7 C 127.3,458.7 128.4,458.7 129.5,458.6 C 130.6,458.6 131.7,458.6 132.8,458.6 C 133.9,458.5 135.0,458.4 136.1,458.3 C 137.2,458.2 138.3,458.3 139.4,458.0 C 140.5,457.8 141.6,457.4 142.7,456.9 C 143.8,456.5 144.9,455.4 146.0,455.0 C 147.1,454.7 148.2,454.9 149.3,454.8 C 150.4,454.7 151.5,454.7 152.6,454.7 C 153.7,454.6 154.2,454.5 155.9,454.5 C 157.5,454.5 160.2,454.5 162.4,454.4 C 164.6,454.4 167.4,454.3 169.0,454.3 C 170.7,454.2 171.2,454.2 172.3,454.1 C 173.4,454.1 174.5,454.0 175.6,454.0 C 176.7,453.9 177.8,453.8 178.9,453.8 C 180.0,453.8 181.1,453.8 182.2,453.7 C 183.3,453.7 183.8,453.7 185.5,453.7 C 187.1,453.6 189.3,453.5 192.1,453.5 C 194.8,453.5 199.2,453.5 202.0,453.4 C 204.7,453.4 205.2,453.4 208.5,453.3 C 211.8,453.3 219.0,453.1 221.7,453.0 C 224.5,453.0 223.4,453.0 225.0,453.0 C 226.6,452.9 228.8,452.9 231.6,452.9 C 234.3,452.9 236.0,452.8 241.5,452.7 C 247.0,452.7 260.1,452.6 264.5,452.5 C 268.9,452.5 266.7,452.5 267.8,452.4 C 268.9,452.4 269.5,453.3 271.1,452.4 C 272.7,451.4 276.0,450.6 277.7,446.5 C 279.3,442.5 279.9,431.6 281.0,428.2 C 282.1,424.8 283.2,426.8 284.3,426.2 C 285.4,425.7 286.5,425.4 287.6,425.0 C 288.7,424.7 289.8,424.4 290.9,424.2 C 292.0,423.9 293.0,423.9 294.1,423.7 C 295.2,423.6 296.3,423.3 297.4,423.2 C 298.5,423.2 299.6,423.3 300.7,423.2 C 301.8,423.1 302.9,422.8 304.0,422.6 C 305.1,422.5 306.2,422.6 307.3,422.4 C 308.4,422.2 309.5,421.7 310.6,421.6 C 311.7,421.4 312.8,421.5 313.9,421.3 C 315.0,421.2 316.1,420.9 317.2,420.8 C 318.3,420.7 319.4,420.8 320.5,420.7 C 321.6,420.7 322.7,420.6 323.8,420.5 C 324.9,420.4 326.0,420.3 327.1,420.3 C 328.2,420.2 329.3,420.2 330.4,420.0 C 331.5,419.9 332.6,419.5 333.7,419.3 C 334.8,419.1 335.9,420.1 337.0,418.8 C 338.0,417.5 339.1,413.1 340.2,411.6 C 341.3,410.1 342.4,410.3 343.5,410.1 C 344.6,409.8 345.7,410.2 346.8,410.0 C 347.9,409.8 349.0,409.3 350.1,408.9 C 351.2,408.5 352.3,408.2 353.4,407.5 C 354.5,406.7 355.6,405.4 356.7,404.6 C 357.8,403.7 358.9,402.9 360.0,402.3 C 361.1,401.6 362.2,401.2 363.3,400.8 C 364.4,400.4 365.5,400.3 366.6,400.1 C 367.7,399.9 368.8,400.0 369.9,399.8 C 371.0,399.7 372.1,399.3 373.2,399.2 C 374.3,399.1 375.4,399.1 376.5,399.0 C 377.6,399.0 378.7,398.9 379.8,398.7 C 380.9,398.6 382.0,398.6 383.0,398.0 C 384.1,397.5 385.2,396.0 386.3,395.3 C 387.4,394.6 388.5,394.4 389.6,393.8 C 390.7,393.3 391.8,394.4 392.9,392.1 C 394.0,389.9 395.1,383.2 396.2,380.6 C 397.3,378.0 398.4,377.6 399.5,376.4 C 400.6,375.3 401.7,374.7 402.8,373.9 C 403.9,373.1 405.0,372.4 406.1,371.8 C 407.2,371.2 408.3,370.7 409.4,370.3 C 410.5,369.9 411.6,369.9 412.7,369.5 C 413.8,369.2 414.9,368.9 416.0,368.5 C 417.1,368.1 418.2,367.4 419.3,367.0 C 420.4,366.7 421.5,366.7 422.6,366.3 C 423.7,366.0 424.8,365.3 425.9,364.9 C 427.0,364.4 428.0,364.1 429.1,363.7 C 430.2,363.4 431.3,363.1 432.4,362.9 C 433.5,362.7 434.6,362.6 435.7,362.4 C 436.8,362.2 437.9,361.9 439.0,361.7 C 440.1,361.4 441.2,361.2 442.3,360.9 C 443.4,360.6 444.5,360.3 445.6,359.9 C 446.7,359.4 447.8,358.6 448.9,358.2 C 450.0,357.8 451.1,357.7 452.2,357.4 C 453.3,357.2 454.4,357.1 455.5,356.9 C 456.6,356.7 457.7,356.6 458.8,356.4 C 459.9,356.3 461.0,356.1 462.1,356.0 C 463.2,355.9 464.3,356.0 465.4,355.8 C 466.5,355.6 467.6,355.2 468.7,354.8 C 469.8,354.4 470.9,353.8 472.0,353.3 C 473.0,352.8 474.1,352.3 475.2,352.0 C 476.3,351.7 477.4,351.7 478.5,351.5 C 479.6,351.3 480.7,351.2 481.8,350.9 C 482.9,350.5 484.0,349.8 485.1,349.5 C 486.2,349.2 487.3,349.3 488.4,349.1 C 489.5,348.9 490.6,348.5 491.7,348.3 C 492.8,348.0 493.9,347.8 495.0,347.5 C 496.1,347.2 497.2,346.9 498.3,346.6 C 499.4,346.4 500.5,346.3 501.6,346.0 C 502.7,345.6 503.8,345.1 504.9,344.8 C 506.0,344.5 507.1,344.3 508.2,344.0 C 509.3,343.6 510.4,343.1 511.5,342.7 C 512.6,342.3 513.7,341.9 514.8,341.6 C 515.9,341.2 517.0,341.2 518.0,340.6 C 519.1,340.0 520.2,338.7 521.3,338.2 C 522.4,337.7 523.5,337.6 524.6,337.5 C 525.7,337.3 526.8,337.3 527.9,337.1 C 529.0,337.0 530.1,336.8 531.2,336.6 C 532.3,336.5 533.4,336.7 534.5,336.2 C 535.6,335.7 536.7,334.1 537.8,333.5 C 538.9,332.9 540.0,333.0 541.1,332.6 C 542.2,332.3 543.3,331.6 544.4,331.3 C 545.5,331.0 546.6,331.2 547.7,330.9 C 548.8,330.6 549.9,329.9 551.0,329.6 C 552.1,329.3 553.2,329.3 554.3,329.2 C 555.4,329.0 556.5,329.0 557.6,328.7 C 558.7,328.4 559.8,328.0 560.9,327.3 C 562.0,326.5 563.0,325.1 564.1,324.4 C 565.2,323.6 566.3,323.3 567.4,322.7 C 568.5,322.2 569.6,322.1 570.7,321.1 C 571.8,320.0 572.9,317.7 574.0,316.5 C 575.1,315.4 576.2,314.9 577.3,314.2 C 578.4,313.5 579.5,313.4 580.6,312.3 C 581.7,311.3 582.8,309.0 583.9,308.0 C 585.0,307.0 586.1,306.9 587.2,306.3 C 588.Line truncated
|
||||
<circle cx="765.0" cy="139.0" r="5" fill="#dd4528" stroke="#0d1117" stroke-width="2" filter="url(#xkcdify)"/>
|
||||
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">4230</text>
|
||||
<path d="M 90.0,461.9 C 90.5,461.7 92.7,461.3 93.2,461.2 C 94.2,461.0 95.3,460.8 96.4,460.5 C 97.4,460.3 98.5,460.0 99.6,459.9 C 100.6,459.7 101.7,459.8 102.7,459.8 C 103.8,459.7 104.9,459.7 105.9,459.6 C 107.0,459.6 108.0,459.5 109.1,459.4 C 110.2,459.3 110.7,459.2 112.3,459.2 C 113.9,459.1 117.1,459.0 118.7,458.9 C 120.2,458.9 120.8,458.8 121.8,458.8 C 122.9,458.7 124.0,458.7 125.0,458.7 C 126.1,458.7 127.1,458.7 128.2,458.6 C 129.3,458.6 130.3,458.6 131.4,458.6 C 132.5,458.5 133.5,458.4 134.6,458.3 C 135.6,458.2 136.7,458.3 137.8,458.0 C 138.8,457.8 139.9,457.4 140.9,456.9 C 142.0,456.5 143.1,455.4 144.1,455.0 C 145.2,454.7 146.2,454.9 147.3,454.8 C 148.4,454.7 149.4,454.7 150.5,454.7 C 151.6,454.6 152.1,454.5 153.7,454.5 C 155.3,454.5 157.9,454.5 160.0,454.4 C 162.2,454.4 164.8,454.3 166.4,454.3 C 168.0,454.2 168.5,454.2 169.6,454.1 C 170.7,454.1 171.7,454.0 172.8,454.0 C 173.8,453.9 174.9,453.8 176.0,453.8 C 177.0,453.8 178.1,453.8 179.2,453.7 C 180.2,453.7 180.7,453.7 182.3,453.7 C 183.9,453.6 186.0,453.5 188.7,453.5 C 191.4,453.5 195.6,453.5 198.3,453.4 C 200.9,453.4 201.4,453.4 204.6,453.3 C 207.8,453.3 214.7,453.1 217.4,453.0 C 220.0,453.0 219.0,453.0 220.5,453.0 C 222.1,452.9 224.3,452.9 226.9,452.9 C 229.6,452.9 231.2,452.8 236.5,452.7 C 241.8,452.7 254.5,452.6 258.8,452.5 C 263.0,452.5 260.9,452.5 261.9,452.4 C 263.0,452.4 263.5,453.3 265.1,452.4 C 266.7,451.4 269.9,450.6 271.5,446.5 C 273.1,442.5 273.6,431.6 274.7,428.2 C 275.7,424.8 276.8,426.8 277.9,426.2 C 278.9,425.7 280.0,425.4 281.0,425.0 C 282.1,424.7 283.2,424.4 284.2,424.2 C 285.3,423.9 286.3,423.9 287.4,423.7 C 288.5,423.6 289.5,423.3 290.6,423.2 C 291.7,423.2 292.7,423.3 293.8,423.2 C 294.8,423.1 295.9,422.8 297.0,422.6 C 298.0,422.5 299.1,422.6 300.1,422.4 C 301.2,422.2 302.3,421.7 303.3,421.6 C 304.4,421.4 305.4,421.5 306.5,421.3 C 307.6,421.2 308.6,420.9 309.7,420.8 C 310.8,420.7 311.8,420.8 312.9,420.7 C 313.9,420.7 315.0,420.6 316.1,420.5 C 317.1,420.4 318.2,420.3 319.2,420.3 C 320.3,420.2 321.4,420.2 322.4,420.0 C 323.5,419.9 324.6,419.5 325.6,419.3 C 326.7,419.1 327.7,420.1 328.8,418.8 C 329.9,417.5 330.9,413.1 332.0,411.6 C 333.0,410.1 334.1,410.3 335.2,410.1 C 336.2,409.8 337.3,410.2 338.3,410.0 C 339.4,409.8 340.5,409.3 341.5,408.9 C 342.6,408.5 343.7,408.2 344.7,407.5 C 345.8,406.7 346.8,405.4 347.9,404.6 C 349.0,403.7 350.0,402.9 351.1,402.3 C 352.1,401.6 353.2,401.2 354.3,400.8 C 355.3,400.4 356.4,400.3 357.5,400.1 C 358.5,399.9 359.6,400.0 360.6,399.8 C 361.7,399.7 362.8,399.3 363.8,399.2 C 364.9,399.1 365.9,399.1 367.0,399.0 C 368.1,399.0 369.1,398.9 370.2,398.7 C 371.2,398.6 372.3,398.6 373.4,398.0 C 374.4,397.5 375.5,396.0 376.6,395.3 C 377.6,394.6 378.7,394.4 379.7,393.8 C 380.8,393.3 381.9,394.4 382.9,392.1 C 384.0,389.9 385.0,383.2 386.1,380.6 C 387.2,378.0 388.2,377.6 389.3,376.4 C 390.4,375.3 391.4,374.7 392.5,373.9 C 393.5,373.1 394.6,372.4 395.7,371.8 C 396.7,371.2 397.8,370.7 398.8,370.3 C 399.9,369.9 401.0,369.9 402.0,369.5 C 403.1,369.2 404.2,368.9 405.2,368.5 C 406.3,368.1 407.3,367.4 408.4,367.0 C 409.5,366.7 410.5,366.7 411.6,366.3 C 412.6,366.0 413.7,365.3 414.8,364.9 C 415.8,364.4 416.9,364.1 417.9,363.7 C 419.0,363.4 420.1,363.1 421.1,362.9 C 422.2,362.7 423.3,362.6 424.3,362.4 C 425.4,362.2 426.4,361.9 427.5,361.7 C 428.6,361.4 429.6,361.2 430.7,360.9 C 431.7,360.6 432.8,360.3 433.9,359.9 C 434.9,359.4 436.0,358.6 437.1,358.2 C 438.1,357.8 439.2,357.7 440.2,357.4 C 441.3,357.2 442.4,357.1 443.4,356.9 C 444.5,356.7 445.5,356.6 446.6,356.4 C 447.7,356.3 448.7,356.1 449.8,356.0 C 450.8,355.9 451.9,356.0 453.0,355.8 C 454.0,355.6 455.1,355.2 456.2,354.8 C 457.2,354.4 458.3,353.8 459.3,353.3 C 460.4,352.8 461.5,352.3 462.5,352.0 C 463.6,351.7 464.6,351.7 465.7,351.5 C 466.8,351.3 467.8,351.2 468.9,350.9 C 470.0,350.5 471.0,349.8 472.1,349.5 C 473.1,349.2 474.2,349.3 475.3,349.1 C 476.3,348.9 477.4,348.5 478.4,348.3 C 479.5,348.0 480.6,347.8 481.6,347.5 C 482.7,347.2 483.8,346.9 484.8,346.6 C 485.9,346.4 486.9,346.3 488.0,346.0 C 489.1,345.6 490.1,345.1 491.2,344.8 C 492.2,344.5 493.3,344.3 494.4,344.0 C 495.4,343.6 496.5,343.1 497.5,342.7 C 498.6,342.3 499.7,341.9 500.7,341.6 C 501.8,341.2 502.9,341.2 503.9,340.6 C 505.0,340.0 506.0,338.7 507.1,338.2 C 508.2,337.7 509.2,337.6 510.3,337.5 C 511.3,337.3 512.4,337.3 513.5,337.1 C 514.5,337.0 515.6,336.8 516.7,336.6 C 517.7,336.5 518.8,336.7 519.8,336.2 C 520.9,335.7 522.0,334.1 523.0,333.5 C 524.1,332.9 525.1,333.0 526.2,332.6 C 527.3,332.3 528.3,331.6 529.4,331.3 C 530.4,331.0 531.5,331.2 532.6,330.9 C 533.6,330.6 534.7,329.9 535.8,329.6 C 536.8,329.3 537.9,329.3 538.9,329.2 C 540.0,329.0 541.1,329.0 542.1,328.7 C 543.2,328.4 544.2,328.0 545.3,327.3 C 546.4,326.5 547.4,325.1 548.5,324.4 C 549.6,323.6 550.6,323.3 551.7,322.7 C 552.7,322.2 553.8,322.1 554.9,321.1 C 555.9,320.0 557.0,317.7 558.0,316.5 C 559.1,315.4 560.2,314.9 561.2,314.2 C 562.3,313.5 563.3,313.4 564.4,312.3 C 565.5,311.3 566.5,309.0 567.6,308.0 C 568.7,307.0 569.7,306.9 570.8,306.3 C 571.Line truncated
|
||||
<path d="M 90.0,461.9 C 90.5,461.7 92.7,461.3 93.2,461.2 C 94.2,461.0 95.3,460.8 96.4,460.5 C 97.4,460.3 98.5,460.0 99.6,459.9 C 100.6,459.7 101.7,459.8 102.7,459.8 C 103.8,459.7 104.9,459.7 105.9,459.6 C 107.0,459.6 108.0,459.5 109.1,459.4 C 110.2,459.3 110.7,459.2 112.3,459.2 C 113.9,459.1 117.1,459.0 118.7,458.9 C 120.2,458.9 120.8,458.8 121.8,458.8 C 122.9,458.7 124.0,458.7 125.0,458.7 C 126.1,458.7 127.1,458.7 128.2,458.6 C 129.3,458.6 130.3,458.6 131.4,458.6 C 132.5,458.5 133.5,458.4 134.6,458.3 C 135.6,458.2 136.7,458.3 137.8,458.0 C 138.8,457.8 139.9,457.4 140.9,456.9 C 142.0,456.5 143.1,455.4 144.1,455.0 C 145.2,454.7 146.2,454.9 147.3,454.8 C 148.4,454.7 149.4,454.7 150.5,454.7 C 151.6,454.6 152.1,454.5 153.7,454.5 C 155.3,454.5 157.9,454.5 160.0,454.4 C 162.2,454.4 164.8,454.3 166.4,454.3 C 168.0,454.2 168.5,454.2 169.6,454.1 C 170.7,454.1 171.7,454.0 172.8,454.0 C 173.8,453.9 174.9,453.8 176.0,453.8 C 177.0,453.8 178.1,453.8 179.2,453.7 C 180.2,453.7 180.7,453.7 182.3,453.7 C 183.9,453.6 186.0,453.5 188.7,453.5 C 191.4,453.5 195.6,453.5 198.3,453.4 C 200.9,453.4 201.4,453.4 204.6,453.3 C 207.8,453.3 214.7,453.1 217.4,453.0 C 220.0,453.0 219.0,453.0 220.5,453.0 C 222.1,452.9 224.3,452.9 226.9,452.9 C 229.6,452.9 231.2,452.8 236.5,452.7 C 241.8,452.7 254.5,452.6 258.8,452.5 C 263.0,452.5 260.9,452.5 261.9,452.4 C 263.0,452.4 263.5,453.3 265.1,452.4 C 266.7,451.4 269.9,450.6 271.5,446.5 C 273.1,442.5 273.6,431.6 274.7,428.2 C 275.7,424.8 276.8,426.8 277.9,426.2 C 278.9,425.7 280.0,425.4 281.0,425.0 C 282.1,424.7 283.2,424.4 284.2,424.2 C 285.3,423.9 286.3,423.9 287.4,423.7 C 288.5,423.6 289.5,423.3 290.6,423.2 C 291.7,423.2 292.7,423.3 293.8,423.2 C 294.8,423.1 295.9,422.8 297.0,422.6 C 298.0,422.5 299.1,422.6 300.1,422.4 C 301.2,422.2 302.3,421.7 303.3,421.6 C 304.4,421.4 305.4,421.5 306.5,421.3 C 307.6,421.2 308.6,420.9 309.7,420.8 C 310.8,420.7 311.8,420.8 312.9,420.7 C 313.9,420.7 315.0,420.6 316.1,420.5 C 317.1,420.4 318.2,420.3 319.2,420.3 C 320.3,420.2 321.4,420.2 322.4,420.0 C 323.5,419.9 324.6,419.5 325.6,419.3 C 326.7,419.1 327.7,420.1 328.8,418.8 C 329.9,417.5 330.9,413.1 332.0,411.6 C 333.0,410.1 334.1,410.3 335.2,410.1 C 336.2,409.8 337.3,410.2 338.3,410.0 C 339.4,409.8 340.5,409.3 341.5,408.9 C 342.6,408.5 343.7,408.2 344.7,407.5 C 345.8,406.7 346.8,405.4 347.9,404.6 C 349.0,403.7 350.0,402.9 351.1,402.3 C 352.1,401.6 353.2,401.2 354.3,400.8 C 355.3,400.4 356.4,400.3 357.5,400.1 C 358.5,399.9 359.6,400.0 360.6,399.8 C 361.7,399.7 362.8,399.3 363.8,399.2 C 364.9,399.1 365.9,399.1 367.0,399.0 C 368.1,399.0 369.1,398.9 370.2,398.7 C 371.2,398.6 372.3,398.6 373.4,398.0 C 374.4,397.5 375.5,396.0 376.6,395.3 C 377.6,394.6 378.7,394.4 379.7,393.8 C 380.8,393.3 381.9,394.4 382.9,392.1 C 384.0,389.9 385.0,383.2 386.1,380.6 C 387.2,378.0 388.2,377.6 389.3,376.4 C 390.4,375.3 391.4,374.7 392.5,373.9 C 393.5,373.1 394.6,372.4 395.7,371.8 C 396.7,371.2 397.8,370.7 398.8,370.3 C 399.9,369.9 401.0,369.9 402.0,369.5 C 403.1,369.2 404.2,368.9 405.2,368.5 C 406.3,368.1 407.3,367.4 408.4,367.0 C 409.5,366.7 410.5,366.7 411.6,366.3 C 412.6,366.0 413.7,365.3 414.8,364.9 C 415.8,364.4 416.9,364.1 417.9,363.7 C 419.0,363.4 420.1,363.1 421.1,362.9 C 422.2,362.7 423.3,362.6 424.3,362.4 C 425.4,362.2 426.4,361.9 427.5,361.7 C 428.6,361.4 429.6,361.2 430.7,360.9 C 431.7,360.6 432.8,360.3 433.9,359.9 C 434.9,359.4 436.0,358.6 437.1,358.2 C 438.1,357.8 439.2,357.7 440.2,357.4 C 441.3,357.2 442.4,357.1 443.4,356.9 C 444.5,356.7 445.5,356.6 446.6,356.4 C 447.7,356.3 448.7,356.1 449.8,356.0 C 450.8,355.9 451.9,356.0 453.0,355.8 C 454.0,355.6 455.1,355.2 456.2,354.8 C 457.2,354.4 458.3,353.8 459.3,353.3 C 460.4,352.8 461.5,352.3 462.5,352.0 C 463.6,351.7 464.6,351.7 465.7,351.5 C 466.8,351.3 467.8,351.2 468.9,350.9 C 470.0,350.5 471.0,349.8 472.1,349.5 C 473.1,349.2 474.2,349.3 475.3,349.1 C 476.3,348.9 477.4,348.5 478.4,348.3 C 479.5,348.0 480.6,347.8 481.6,347.5 C 482.7,347.2 483.8,346.9 484.8,346.6 C 485.9,346.4 486.9,346.3 488.0,346.0 C 489.1,345.6 490.1,345.1 491.2,344.8 C 492.2,344.5 493.3,344.3 494.4,344.0 C 495.4,343.6 496.5,343.1 497.5,342.7 C 498.6,342.3 499.7,341.9 500.7,341.6 C 501.8,341.2 502.9,341.2 503.9,340.6 C 505.0,340.0 506.0,338.7 507.1,338.2 C 508.2,337.7 509.2,337.6 510.3,337.5 C 511.3,337.3 512.4,337.3 513.5,337.1 C 514.5,337.0 515.6,336.8 516.7,336.6 C 517.7,336.5 518.8,336.7 519.8,336.2 C 520.9,335.7 522.0,334.1 523.0,333.5 C 524.1,332.9 525.1,333.0 526.2,332.6 C 527.3,332.3 528.3,331.6 529.4,331.3 C 530.4,331.0 531.5,331.2 532.6,330.9 C 533.6,330.6 534.7,329.9 535.8,329.6 C 536.8,329.3 537.9,329.3 538.9,329.2 C 540.0,329.0 541.1,329.0 542.1,328.7 C 543.2,328.4 544.2,328.0 545.3,327.3 C 546.4,326.5 547.4,325.1 548.5,324.4 C 549.6,323.6 550.6,323.3 551.7,322.7 C 552.7,322.2 553.8,322.1 554.9,321.1 C 555.9,320.0 557.0,317.7 558.0,316.5 C 559.1,315.4 560.2,314.9 561.2,314.2 C 562.3,313.5 563.3,313.4 564.4,312.3 C 565.5,311.3 566.5,309.0 567.6,308.0 C 568.7,307.0 569.7,306.9 570.8,306.3 C 571.Line truncated
|
||||
<circle cx="765.0" cy="116.9" r="5" fill="#dd4528" stroke="#0d1117" stroke-width="2" filter="url(#xkcdify)"/>
|
||||
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">4518</text>
|
||||
<a href="https://github.com/carsteneu/mystarhistory" target="_blank" rel="noopener"><text x="763" y="527" font-size="13" font-family="Handlee, cursive" fill="#fff" text-anchor="end">Made with carsteneu/mystarhistory</text></a>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 30 KiB |
@@ -27,18 +27,19 @@
|
||||
<text x="85" y="85.0" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">5K</text>
|
||||
<text x="43" y="271.5" fill="#000" font-size="17" font-family="Handlee, cursive" font-weight="bold" transform="rotate(-90, 43, 271.5)">GitHub Stars</text>
|
||||
<text x="90.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jan 2026</text>
|
||||
<text x="185.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
|
||||
<text x="277.7" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
|
||||
<text x="379.8" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
|
||||
<text x="478.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
|
||||
<text x="580.6" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
|
||||
<text x="679.4" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
|
||||
<text x="182.3" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
|
||||
<text x="271.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
|
||||
<text x="370.2" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
|
||||
<text x="465.7" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
|
||||
<text x="564.4" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
|
||||
<text x="659.9" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
|
||||
<text x="758.6" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
|
||||
<text x="50%" y="525" fill="#000" font-size="17" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Date</text>
|
||||
<line x1="90" y1="80" x2="90" y2="463" stroke="#222" stroke-width="2.5" filter="url(#xkcdify)"/>
|
||||
<line x1="90" y1="463" x2="765" y2="463" stroke="#222" stroke-width="2.5" filter="url(#xkcdify)"/>
|
||||
<path d="M 90.0,461.9 C 90.5,461.7 92.7,461.3 93.3,461.2 C 94.4,461.0 95.5,460.8 96.6,460.5 C 97.7,460.3 98.8,460.0 99.9,459.9 C 101.0,459.7 102.1,459.8 103.2,459.8 C 104.3,459.7 105.4,459.7 106.5,459.6 C 107.6,459.6 108.7,459.5 109.8,459.4 C 110.9,459.3 111.4,459.2 113.0,459.2 C 114.7,459.1 118.0,459.0 119.6,458.9 C 121.3,458.9 121.8,458.8 122.9,458.8 C 124.0,458.7 125.1,458.7 126.2,458.7 C 127.3,458.7 128.4,458.7 129.5,458.6 C 130.6,458.6 131.7,458.6 132.8,458.6 C 133.9,458.5 135.0,458.4 136.1,458.3 C 137.2,458.2 138.3,458.3 139.4,458.0 C 140.5,457.8 141.6,457.4 142.7,456.9 C 143.8,456.5 144.9,455.4 146.0,455.0 C 147.1,454.7 148.2,454.9 149.3,454.8 C 150.4,454.7 151.5,454.7 152.6,454.7 C 153.7,454.6 154.2,454.5 155.9,454.5 C 157.5,454.5 160.2,454.5 162.4,454.4 C 164.6,454.4 167.4,454.3 169.0,454.3 C 170.7,454.2 171.2,454.2 172.3,454.1 C 173.4,454.1 174.5,454.0 175.6,454.0 C 176.7,453.9 177.8,453.8 178.9,453.8 C 180.0,453.8 181.1,453.8 182.2,453.7 C 183.3,453.7 183.8,453.7 185.5,453.7 C 187.1,453.6 189.3,453.5 192.1,453.5 C 194.8,453.5 199.2,453.5 202.0,453.4 C 204.7,453.4 205.2,453.4 208.5,453.3 C 211.8,453.3 219.0,453.1 221.7,453.0 C 224.5,453.0 223.4,453.0 225.0,453.0 C 226.6,452.9 228.8,452.9 231.6,452.9 C 234.3,452.9 236.0,452.8 241.5,452.7 C 247.0,452.7 260.1,452.6 264.5,452.5 C 268.9,452.5 266.7,452.5 267.8,452.4 C 268.9,452.4 269.5,453.3 271.1,452.4 C 272.7,451.4 276.0,450.6 277.7,446.5 C 279.3,442.5 279.9,431.6 281.0,428.2 C 282.1,424.8 283.2,426.8 284.3,426.2 C 285.4,425.7 286.5,425.4 287.6,425.0 C 288.7,424.7 289.8,424.4 290.9,424.2 C 292.0,423.9 293.0,423.9 294.1,423.7 C 295.2,423.6 296.3,423.3 297.4,423.2 C 298.5,423.2 299.6,423.3 300.7,423.2 C 301.8,423.1 302.9,422.8 304.0,422.6 C 305.1,422.5 306.2,422.6 307.3,422.4 C 308.4,422.2 309.5,421.7 310.6,421.6 C 311.7,421.4 312.8,421.5 313.9,421.3 C 315.0,421.2 316.1,420.9 317.2,420.8 C 318.3,420.7 319.4,420.8 320.5,420.7 C 321.6,420.7 322.7,420.6 323.8,420.5 C 324.9,420.4 326.0,420.3 327.1,420.3 C 328.2,420.2 329.3,420.2 330.4,420.0 C 331.5,419.9 332.6,419.5 333.7,419.3 C 334.8,419.1 335.9,420.1 337.0,418.8 C 338.0,417.5 339.1,413.1 340.2,411.6 C 341.3,410.1 342.4,410.3 343.5,410.1 C 344.6,409.8 345.7,410.2 346.8,410.0 C 347.9,409.8 349.0,409.3 350.1,408.9 C 351.2,408.5 352.3,408.2 353.4,407.5 C 354.5,406.7 355.6,405.4 356.7,404.6 C 357.8,403.7 358.9,402.9 360.0,402.3 C 361.1,401.6 362.2,401.2 363.3,400.8 C 364.4,400.4 365.5,400.3 366.6,400.1 C 367.7,399.9 368.8,400.0 369.9,399.8 C 371.0,399.7 372.1,399.3 373.2,399.2 C 374.3,399.1 375.4,399.1 376.5,399.0 C 377.6,399.0 378.7,398.9 379.8,398.7 C 380.9,398.6 382.0,398.6 383.0,398.0 C 384.1,397.5 385.2,396.0 386.3,395.3 C 387.4,394.6 388.5,394.4 389.6,393.8 C 390.7,393.3 391.8,394.4 392.9,392.1 C 394.0,389.9 395.1,383.2 396.2,380.6 C 397.3,378.0 398.4,377.6 399.5,376.4 C 400.6,375.3 401.7,374.7 402.8,373.9 C 403.9,373.1 405.0,372.4 406.1,371.8 C 407.2,371.2 408.3,370.7 409.4,370.3 C 410.5,369.9 411.6,369.9 412.7,369.5 C 413.8,369.2 414.9,368.9 416.0,368.5 C 417.1,368.1 418.2,367.4 419.3,367.0 C 420.4,366.7 421.5,366.7 422.6,366.3 C 423.7,366.0 424.8,365.3 425.9,364.9 C 427.0,364.4 428.0,364.1 429.1,363.7 C 430.2,363.4 431.3,363.1 432.4,362.9 C 433.5,362.7 434.6,362.6 435.7,362.4 C 436.8,362.2 437.9,361.9 439.0,361.7 C 440.1,361.4 441.2,361.2 442.3,360.9 C 443.4,360.6 444.5,360.3 445.6,359.9 C 446.7,359.4 447.8,358.6 448.9,358.2 C 450.0,357.8 451.1,357.7 452.2,357.4 C 453.3,357.2 454.4,357.1 455.5,356.9 C 456.6,356.7 457.7,356.6 458.8,356.4 C 459.9,356.3 461.0,356.1 462.1,356.0 C 463.2,355.9 464.3,356.0 465.4,355.8 C 466.5,355.6 467.6,355.2 468.7,354.8 C 469.8,354.4 470.9,353.8 472.0,353.3 C 473.0,352.8 474.1,352.3 475.2,352.0 C 476.3,351.7 477.4,351.7 478.5,351.5 C 479.6,351.3 480.7,351.2 481.8,350.9 C 482.9,350.5 484.0,349.8 485.1,349.5 C 486.2,349.2 487.3,349.3 488.4,349.1 C 489.5,348.9 490.6,348.5 491.7,348.3 C 492.8,348.0 493.9,347.8 495.0,347.5 C 496.1,347.2 497.2,346.9 498.3,346.6 C 499.4,346.4 500.5,346.3 501.6,346.0 C 502.7,345.6 503.8,345.1 504.9,344.8 C 506.0,344.5 507.1,344.3 508.2,344.0 C 509.3,343.6 510.4,343.1 511.5,342.7 C 512.6,342.3 513.7,341.9 514.8,341.6 C 515.9,341.2 517.0,341.2 518.0,340.6 C 519.1,340.0 520.2,338.7 521.3,338.2 C 522.4,337.7 523.5,337.6 524.6,337.5 C 525.7,337.3 526.8,337.3 527.9,337.1 C 529.0,337.0 530.1,336.8 531.2,336.6 C 532.3,336.5 533.4,336.7 534.5,336.2 C 535.6,335.7 536.7,334.1 537.8,333.5 C 538.9,332.9 540.0,333.0 541.1,332.6 C 542.2,332.3 543.3,331.6 544.4,331.3 C 545.5,331.0 546.6,331.2 547.7,330.9 C 548.8,330.6 549.9,329.9 551.0,329.6 C 552.1,329.3 553.2,329.3 554.3,329.2 C 555.4,329.0 556.5,329.0 557.6,328.7 C 558.7,328.4 559.8,328.0 560.9,327.3 C 562.0,326.5 563.0,325.1 564.1,324.4 C 565.2,323.6 566.3,323.3 567.4,322.7 C 568.5,322.2 569.6,322.1 570.7,321.1 C 571.8,320.0 572.9,317.7 574.0,316.5 C 575.1,315.4 576.2,314.9 577.3,314.2 C 578.4,313.5 579.5,313.4 580.6,312.3 C 581.7,311.3 582.8,309.0 583.9,308.0 C 585.0,307.0 586.1,306.9 587.2,306.3 C 588.Line truncated
|
||||
<path d="M 90.0,461.9 C 90.5,461.7 92.7,461.3 93.3,461.2 C 94.4,461.0 95.5,460.8 96.6,460.5 C 97.7,460.3 98.8,460.0 99.9,459.9 C 101.0,459.7 102.1,459.8 103.2,459.8 C 104.3,459.7 105.4,459.7 106.5,459.6 C 107.6,459.6 108.7,459.5 109.8,459.4 C 110.9,459.3 111.4,459.2 113.0,459.2 C 114.7,459.1 118.0,459.0 119.6,458.9 C 121.3,458.9 121.8,458.8 122.9,458.8 C 124.0,458.7 125.1,458.7 126.2,458.7 C 127.3,458.7 128.4,458.7 129.5,458.6 C 130.6,458.6 131.7,458.6 132.8,458.6 C 133.9,458.5 135.0,458.4 136.1,458.3 C 137.2,458.2 138.3,458.3 139.4,458.0 C 140.5,457.8 141.6,457.4 142.7,456.9 C 143.8,456.5 144.9,455.4 146.0,455.0 C 147.1,454.7 148.2,454.9 149.3,454.8 C 150.4,454.7 151.5,454.7 152.6,454.7 C 153.7,454.6 154.2,454.5 155.9,454.5 C 157.5,454.5 160.2,454.5 162.4,454.4 C 164.6,454.4 167.4,454.3 169.0,454.3 C 170.7,454.2 171.2,454.2 172.3,454.1 C 173.4,454.1 174.5,454.0 175.6,454.0 C 176.7,453.9 177.8,453.8 178.9,453.8 C 180.0,453.8 181.1,453.8 182.2,453.7 C 183.3,453.7 183.8,453.7 185.5,453.7 C 187.1,453.6 189.3,453.5 192.1,453.5 C 194.8,453.5 199.2,453.5 202.0,453.4 C 204.7,453.4 205.2,453.4 208.5,453.3 C 211.8,453.3 219.0,453.1 221.7,453.0 C 224.5,453.0 223.4,453.0 225.0,453.0 C 226.6,452.9 228.8,452.9 231.6,452.9 C 234.3,452.9 236.0,452.8 241.5,452.7 C 247.0,452.7 260.1,452.6 264.5,452.5 C 268.9,452.5 266.7,452.5 267.8,452.4 C 268.9,452.4 269.5,453.3 271.1,452.4 C 272.7,451.4 276.0,450.6 277.7,446.5 C 279.3,442.5 279.9,431.6 281.0,428.2 C 282.1,424.8 283.2,426.8 284.3,426.2 C 285.4,425.7 286.5,425.4 287.6,425.0 C 288.7,424.7 289.8,424.4 290.9,424.2 C 292.0,423.9 293.0,423.9 294.1,423.7 C 295.2,423.6 296.3,423.3 297.4,423.2 C 298.5,423.2 299.6,423.3 300.7,423.2 C 301.8,423.1 302.9,422.8 304.0,422.6 C 305.1,422.5 306.2,422.6 307.3,422.4 C 308.4,422.2 309.5,421.7 310.6,421.6 C 311.7,421.4 312.8,421.5 313.9,421.3 C 315.0,421.2 316.1,420.9 317.2,420.8 C 318.3,420.7 319.4,420.8 320.5,420.7 C 321.6,420.7 322.7,420.6 323.8,420.5 C 324.9,420.4 326.0,420.3 327.1,420.3 C 328.2,420.2 329.3,420.2 330.4,420.0 C 331.5,419.9 332.6,419.5 333.7,419.3 C 334.8,419.1 335.9,420.1 337.0,418.8 C 338.0,417.5 339.1,413.1 340.2,411.6 C 341.3,410.1 342.4,410.3 343.5,410.1 C 344.6,409.8 345.7,410.2 346.8,410.0 C 347.9,409.8 349.0,409.3 350.1,408.9 C 351.2,408.5 352.3,408.2 353.4,407.5 C 354.5,406.7 355.6,405.4 356.7,404.6 C 357.8,403.7 358.9,402.9 360.0,402.3 C 361.1,401.6 362.2,401.2 363.3,400.8 C 364.4,400.4 365.5,400.3 366.6,400.1 C 367.7,399.9 368.8,400.0 369.9,399.8 C 371.0,399.7 372.1,399.3 373.2,399.2 C 374.3,399.1 375.4,399.1 376.5,399.0 C 377.6,399.0 378.7,398.9 379.8,398.7 C 380.9,398.6 382.0,398.6 383.0,398.0 C 384.1,397.5 385.2,396.0 386.3,395.3 C 387.4,394.6 388.5,394.4 389.6,393.8 C 390.7,393.3 391.8,394.4 392.9,392.1 C 394.0,389.9 395.1,383.2 396.2,380.6 C 397.3,378.0 398.4,377.6 399.5,376.4 C 400.6,375.3 401.7,374.7 402.8,373.9 C 403.9,373.1 405.0,372.4 406.1,371.8 C 407.2,371.2 408.3,370.7 409.4,370.3 C 410.5,369.9 411.6,369.9 412.7,369.5 C 413.8,369.2 414.9,368.9 416.0,368.5 C 417.1,368.1 418.2,367.4 419.3,367.0 C 420.4,366.7 421.5,366.7 422.6,366.3 C 423.7,366.0 424.8,365.3 425.9,364.9 C 427.0,364.4 428.0,364.1 429.1,363.7 C 430.2,363.4 431.3,363.1 432.4,362.9 C 433.5,362.7 434.6,362.6 435.7,362.4 C 436.8,362.2 437.9,361.9 439.0,361.7 C 440.1,361.4 441.2,361.2 442.3,360.9 C 443.4,360.6 444.5,360.3 445.6,359.9 C 446.7,359.4 447.8,358.6 448.9,358.2 C 450.0,357.8 451.1,357.7 452.2,357.4 C 453.3,357.2 454.4,357.1 455.5,356.9 C 456.6,356.7 457.7,356.6 458.8,356.4 C 459.9,356.3 461.0,356.1 462.1,356.0 C 463.2,355.9 464.3,356.0 465.4,355.8 C 466.5,355.6 467.6,355.2 468.7,354.8 C 469.8,354.4 470.9,353.8 472.0,353.3 C 473.0,352.8 474.1,352.3 475.2,352.0 C 476.3,351.7 477.4,351.7 478.5,351.5 C 479.6,351.3 480.7,351.2 481.8,350.9 C 482.9,350.5 484.0,349.8 485.1,349.5 C 486.2,349.2 487.3,349.3 488.4,349.1 C 489.5,348.9 490.6,348.5 491.7,348.3 C 492.8,348.0 493.9,347.8 495.0,347.5 C 496.1,347.2 497.2,346.9 498.3,346.6 C 499.4,346.4 500.5,346.3 501.6,346.0 C 502.7,345.6 503.8,345.1 504.9,344.8 C 506.0,344.5 507.1,344.3 508.2,344.0 C 509.3,343.6 510.4,343.1 511.5,342.7 C 512.6,342.3 513.7,341.9 514.8,341.6 C 515.9,341.2 517.0,341.2 518.0,340.6 C 519.1,340.0 520.2,338.7 521.3,338.2 C 522.4,337.7 523.5,337.6 524.6,337.5 C 525.7,337.3 526.8,337.3 527.9,337.1 C 529.0,337.0 530.1,336.8 531.2,336.6 C 532.3,336.5 533.4,336.7 534.5,336.2 C 535.6,335.7 536.7,334.1 537.8,333.5 C 538.9,332.9 540.0,333.0 541.1,332.6 C 542.2,332.3 543.3,331.6 544.4,331.3 C 545.5,331.0 546.6,331.2 547.7,330.9 C 548.8,330.6 549.9,329.9 551.0,329.6 C 552.1,329.3 553.2,329.3 554.3,329.2 C 555.4,329.0 556.5,329.0 557.6,328.7 C 558.7,328.4 559.8,328.0 560.9,327.3 C 562.0,326.5 563.0,325.1 564.1,324.4 C 565.2,323.6 566.3,323.3 567.4,322.7 C 568.5,322.2 569.6,322.1 570.7,321.1 C 571.8,320.0 572.9,317.7 574.0,316.5 C 575.1,315.4 576.2,314.9 577.3,314.2 C 578.4,313.5 579.5,313.4 580.6,312.3 C 581.7,311.3 582.8,309.0 583.9,308.0 C 585.0,307.0 586.1,306.9 587.2,306.3 C 588.Line truncated
|
||||
<circle cx="765.0" cy="139.0" r="5" fill="#dd4528" stroke="#fff" stroke-width="2" filter="url(#xkcdify)"/>
|
||||
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">4230</text>
|
||||
<path d="M 90.0,461.9 C 90.5,461.7 92.7,461.3 93.2,461.2 C 94.2,461.0 95.3,460.8 96.4,460.5 C 97.4,460.3 98.5,460.0 99.6,459.9 C 100.6,459.7 101.7,459.8 102.7,459.8 C 103.8,459.7 104.9,459.7 105.9,459.6 C 107.0,459.6 108.0,459.5 109.1,459.4 C 110.2,459.3 110.7,459.2 112.3,459.2 C 113.9,459.1 117.1,459.0 118.7,458.9 C 120.2,458.9 120.8,458.8 121.8,458.8 C 122.9,458.7 124.0,458.7 125.0,458.7 C 126.1,458.7 127.1,458.7 128.2,458.6 C 129.3,458.6 130.3,458.6 131.4,458.6 C 132.5,458.5 133.5,458.4 134.6,458.3 C 135.6,458.2 136.7,458.3 137.8,458.0 C 138.8,457.8 139.9,457.4 140.9,456.9 C 142.0,456.5 143.1,455.4 144.1,455.0 C 145.2,454.7 146.2,454.9 147.3,454.8 C 148.4,454.7 149.4,454.7 150.5,454.7 C 151.6,454.6 152.1,454.5 153.7,454.5 C 155.3,454.5 157.9,454.5 160.0,454.4 C 162.2,454.4 164.8,454.3 166.4,454.3 C 168.0,454.2 168.5,454.2 169.6,454.1 C 170.7,454.1 171.7,454.0 172.8,454.0 C 173.8,453.9 174.9,453.8 176.0,453.8 C 177.0,453.8 178.1,453.8 179.2,453.7 C 180.2,453.7 180.7,453.7 182.3,453.7 C 183.9,453.6 186.0,453.5 188.7,453.5 C 191.4,453.5 195.6,453.5 198.3,453.4 C 200.9,453.4 201.4,453.4 204.6,453.3 C 207.8,453.3 214.7,453.1 217.4,453.0 C 220.0,453.0 219.0,453.0 220.5,453.0 C 222.1,452.9 224.3,452.9 226.9,452.9 C 229.6,452.9 231.2,452.8 236.5,452.7 C 241.8,452.7 254.5,452.6 258.8,452.5 C 263.0,452.5 260.9,452.5 261.9,452.4 C 263.0,452.4 263.5,453.3 265.1,452.4 C 266.7,451.4 269.9,450.6 271.5,446.5 C 273.1,442.5 273.6,431.6 274.7,428.2 C 275.7,424.8 276.8,426.8 277.9,426.2 C 278.9,425.7 280.0,425.4 281.0,425.0 C 282.1,424.7 283.2,424.4 284.2,424.2 C 285.3,423.9 286.3,423.9 287.4,423.7 C 288.5,423.6 289.5,423.3 290.6,423.2 C 291.7,423.2 292.7,423.3 293.8,423.2 C 294.8,423.1 295.9,422.8 297.0,422.6 C 298.0,422.5 299.1,422.6 300.1,422.4 C 301.2,422.2 302.3,421.7 303.3,421.6 C 304.4,421.4 305.4,421.5 306.5,421.3 C 307.6,421.2 308.6,420.9 309.7,420.8 C 310.8,420.7 311.8,420.8 312.9,420.7 C 313.9,420.7 315.0,420.6 316.1,420.5 C 317.1,420.4 318.2,420.3 319.2,420.3 C 320.3,420.2 321.4,420.2 322.4,420.0 C 323.5,419.9 324.6,419.5 325.6,419.3 C 326.7,419.1 327.7,420.1 328.8,418.8 C 329.9,417.5 330.9,413.1 332.0,411.6 C 333.0,410.1 334.1,410.3 335.2,410.1 C 336.2,409.8 337.3,410.2 338.3,410.0 C 339.4,409.8 340.5,409.3 341.5,408.9 C 342.6,408.5 343.7,408.2 344.7,407.5 C 345.8,406.7 346.8,405.4 347.9,404.6 C 349.0,403.7 350.0,402.9 351.1,402.3 C 352.1,401.6 353.2,401.2 354.3,400.8 C 355.3,400.4 356.4,400.3 357.5,400.1 C 358.5,399.9 359.6,400.0 360.6,399.8 C 361.7,399.7 362.8,399.3 363.8,399.2 C 364.9,399.1 365.9,399.1 367.0,399.0 C 368.1,399.0 369.1,398.9 370.2,398.7 C 371.2,398.6 372.3,398.6 373.4,398.0 C 374.4,397.5 375.5,396.0 376.6,395.3 C 377.6,394.6 378.7,394.4 379.7,393.8 C 380.8,393.3 381.9,394.4 382.9,392.1 C 384.0,389.9 385.0,383.2 386.1,380.6 C 387.2,378.0 388.2,377.6 389.3,376.4 C 390.4,375.3 391.4,374.7 392.5,373.9 C 393.5,373.1 394.6,372.4 395.7,371.8 C 396.7,371.2 397.8,370.7 398.8,370.3 C 399.9,369.9 401.0,369.9 402.0,369.5 C 403.1,369.2 404.2,368.9 405.2,368.5 C 406.3,368.1 407.3,367.4 408.4,367.0 C 409.5,366.7 410.5,366.7 411.6,366.3 C 412.6,366.0 413.7,365.3 414.8,364.9 C 415.8,364.4 416.9,364.1 417.9,363.7 C 419.0,363.4 420.1,363.1 421.1,362.9 C 422.2,362.7 423.3,362.6 424.3,362.4 C 425.4,362.2 426.4,361.9 427.5,361.7 C 428.6,361.4 429.6,361.2 430.7,360.9 C 431.7,360.6 432.8,360.3 433.9,359.9 C 434.9,359.4 436.0,358.6 437.1,358.2 C 438.1,357.8 439.2,357.7 440.2,357.4 C 441.3,357.2 442.4,357.1 443.4,356.9 C 444.5,356.7 445.5,356.6 446.6,356.4 C 447.7,356.3 448.7,356.1 449.8,356.0 C 450.8,355.9 451.9,356.0 453.0,355.8 C 454.0,355.6 455.1,355.2 456.2,354.8 C 457.2,354.4 458.3,353.8 459.3,353.3 C 460.4,352.8 461.5,352.3 462.5,352.0 C 463.6,351.7 464.6,351.7 465.7,351.5 C 466.8,351.3 467.8,351.2 468.9,350.9 C 470.0,350.5 471.0,349.8 472.1,349.5 C 473.1,349.2 474.2,349.3 475.3,349.1 C 476.3,348.9 477.4,348.5 478.4,348.3 C 479.5,348.0 480.6,347.8 481.6,347.5 C 482.7,347.2 483.8,346.9 484.8,346.6 C 485.9,346.4 486.9,346.3 488.0,346.0 C 489.1,345.6 490.1,345.1 491.2,344.8 C 492.2,344.5 493.3,344.3 494.4,344.0 C 495.4,343.6 496.5,343.1 497.5,342.7 C 498.6,342.3 499.7,341.9 500.7,341.6 C 501.8,341.2 502.9,341.2 503.9,340.6 C 505.0,340.0 506.0,338.7 507.1,338.2 C 508.2,337.7 509.2,337.6 510.3,337.5 C 511.3,337.3 512.4,337.3 513.5,337.1 C 514.5,337.0 515.6,336.8 516.7,336.6 C 517.7,336.5 518.8,336.7 519.8,336.2 C 520.9,335.7 522.0,334.1 523.0,333.5 C 524.1,332.9 525.1,333.0 526.2,332.6 C 527.3,332.3 528.3,331.6 529.4,331.3 C 530.4,331.0 531.5,331.2 532.6,330.9 C 533.6,330.6 534.7,329.9 535.8,329.6 C 536.8,329.3 537.9,329.3 538.9,329.2 C 540.0,329.0 541.1,329.0 542.1,328.7 C 543.2,328.4 544.2,328.0 545.3,327.3 C 546.4,326.5 547.4,325.1 548.5,324.4 C 549.6,323.6 550.6,323.3 551.7,322.7 C 552.7,322.2 553.8,322.1 554.9,321.1 C 555.9,320.0 557.0,317.7 558.0,316.5 C 559.1,315.4 560.2,314.9 561.2,314.2 C 562.3,313.5 563.3,313.4 564.4,312.3 C 565.5,311.3 566.5,309.0 567.6,308.0 C 568.7,307.0 569.7,306.9 570.8,306.3 C 571.Line truncated
|
||||
<path d="M 90.0,461.9 C 90.5,461.7 92.7,461.3 93.2,461.2 C 94.2,461.0 95.3,460.8 96.4,460.5 C 97.4,460.3 98.5,460.0 99.6,459.9 C 100.6,459.7 101.7,459.8 102.7,459.8 C 103.8,459.7 104.9,459.7 105.9,459.6 C 107.0,459.6 108.0,459.5 109.1,459.4 C 110.2,459.3 110.7,459.2 112.3,459.2 C 113.9,459.1 117.1,459.0 118.7,458.9 C 120.2,458.9 120.8,458.8 121.8,458.8 C 122.9,458.7 124.0,458.7 125.0,458.7 C 126.1,458.7 127.1,458.7 128.2,458.6 C 129.3,458.6 130.3,458.6 131.4,458.6 C 132.5,458.5 133.5,458.4 134.6,458.3 C 135.6,458.2 136.7,458.3 137.8,458.0 C 138.8,457.8 139.9,457.4 140.9,456.9 C 142.0,456.5 143.1,455.4 144.1,455.0 C 145.2,454.7 146.2,454.9 147.3,454.8 C 148.4,454.7 149.4,454.7 150.5,454.7 C 151.6,454.6 152.1,454.5 153.7,454.5 C 155.3,454.5 157.9,454.5 160.0,454.4 C 162.2,454.4 164.8,454.3 166.4,454.3 C 168.0,454.2 168.5,454.2 169.6,454.1 C 170.7,454.1 171.7,454.0 172.8,454.0 C 173.8,453.9 174.9,453.8 176.0,453.8 C 177.0,453.8 178.1,453.8 179.2,453.7 C 180.2,453.7 180.7,453.7 182.3,453.7 C 183.9,453.6 186.0,453.5 188.7,453.5 C 191.4,453.5 195.6,453.5 198.3,453.4 C 200.9,453.4 201.4,453.4 204.6,453.3 C 207.8,453.3 214.7,453.1 217.4,453.0 C 220.0,453.0 219.0,453.0 220.5,453.0 C 222.1,452.9 224.3,452.9 226.9,452.9 C 229.6,452.9 231.2,452.8 236.5,452.7 C 241.8,452.7 254.5,452.6 258.8,452.5 C 263.0,452.5 260.9,452.5 261.9,452.4 C 263.0,452.4 263.5,453.3 265.1,452.4 C 266.7,451.4 269.9,450.6 271.5,446.5 C 273.1,442.5 273.6,431.6 274.7,428.2 C 275.7,424.8 276.8,426.8 277.9,426.2 C 278.9,425.7 280.0,425.4 281.0,425.0 C 282.1,424.7 283.2,424.4 284.2,424.2 C 285.3,423.9 286.3,423.9 287.4,423.7 C 288.5,423.6 289.5,423.3 290.6,423.2 C 291.7,423.2 292.7,423.3 293.8,423.2 C 294.8,423.1 295.9,422.8 297.0,422.6 C 298.0,422.5 299.1,422.6 300.1,422.4 C 301.2,422.2 302.3,421.7 303.3,421.6 C 304.4,421.4 305.4,421.5 306.5,421.3 C 307.6,421.2 308.6,420.9 309.7,420.8 C 310.8,420.7 311.8,420.8 312.9,420.7 C 313.9,420.7 315.0,420.6 316.1,420.5 C 317.1,420.4 318.2,420.3 319.2,420.3 C 320.3,420.2 321.4,420.2 322.4,420.0 C 323.5,419.9 324.6,419.5 325.6,419.3 C 326.7,419.1 327.7,420.1 328.8,418.8 C 329.9,417.5 330.9,413.1 332.0,411.6 C 333.0,410.1 334.1,410.3 335.2,410.1 C 336.2,409.8 337.3,410.2 338.3,410.0 C 339.4,409.8 340.5,409.3 341.5,408.9 C 342.6,408.5 343.7,408.2 344.7,407.5 C 345.8,406.7 346.8,405.4 347.9,404.6 C 349.0,403.7 350.0,402.9 351.1,402.3 C 352.1,401.6 353.2,401.2 354.3,400.8 C 355.3,400.4 356.4,400.3 357.5,400.1 C 358.5,399.9 359.6,400.0 360.6,399.8 C 361.7,399.7 362.8,399.3 363.8,399.2 C 364.9,399.1 365.9,399.1 367.0,399.0 C 368.1,399.0 369.1,398.9 370.2,398.7 C 371.2,398.6 372.3,398.6 373.4,398.0 C 374.4,397.5 375.5,396.0 376.6,395.3 C 377.6,394.6 378.7,394.4 379.7,393.8 C 380.8,393.3 381.9,394.4 382.9,392.1 C 384.0,389.9 385.0,383.2 386.1,380.6 C 387.2,378.0 388.2,377.6 389.3,376.4 C 390.4,375.3 391.4,374.7 392.5,373.9 C 393.5,373.1 394.6,372.4 395.7,371.8 C 396.7,371.2 397.8,370.7 398.8,370.3 C 399.9,369.9 401.0,369.9 402.0,369.5 C 403.1,369.2 404.2,368.9 405.2,368.5 C 406.3,368.1 407.3,367.4 408.4,367.0 C 409.5,366.7 410.5,366.7 411.6,366.3 C 412.6,366.0 413.7,365.3 414.8,364.9 C 415.8,364.4 416.9,364.1 417.9,363.7 C 419.0,363.4 420.1,363.1 421.1,362.9 C 422.2,362.7 423.3,362.6 424.3,362.4 C 425.4,362.2 426.4,361.9 427.5,361.7 C 428.6,361.4 429.6,361.2 430.7,360.9 C 431.7,360.6 432.8,360.3 433.9,359.9 C 434.9,359.4 436.0,358.6 437.1,358.2 C 438.1,357.8 439.2,357.7 440.2,357.4 C 441.3,357.2 442.4,357.1 443.4,356.9 C 444.5,356.7 445.5,356.6 446.6,356.4 C 447.7,356.3 448.7,356.1 449.8,356.0 C 450.8,355.9 451.9,356.0 453.0,355.8 C 454.0,355.6 455.1,355.2 456.2,354.8 C 457.2,354.4 458.3,353.8 459.3,353.3 C 460.4,352.8 461.5,352.3 462.5,352.0 C 463.6,351.7 464.6,351.7 465.7,351.5 C 466.8,351.3 467.8,351.2 468.9,350.9 C 470.0,350.5 471.0,349.8 472.1,349.5 C 473.1,349.2 474.2,349.3 475.3,349.1 C 476.3,348.9 477.4,348.5 478.4,348.3 C 479.5,348.0 480.6,347.8 481.6,347.5 C 482.7,347.2 483.8,346.9 484.8,346.6 C 485.9,346.4 486.9,346.3 488.0,346.0 C 489.1,345.6 490.1,345.1 491.2,344.8 C 492.2,344.5 493.3,344.3 494.4,344.0 C 495.4,343.6 496.5,343.1 497.5,342.7 C 498.6,342.3 499.7,341.9 500.7,341.6 C 501.8,341.2 502.9,341.2 503.9,340.6 C 505.0,340.0 506.0,338.7 507.1,338.2 C 508.2,337.7 509.2,337.6 510.3,337.5 C 511.3,337.3 512.4,337.3 513.5,337.1 C 514.5,337.0 515.6,336.8 516.7,336.6 C 517.7,336.5 518.8,336.7 519.8,336.2 C 520.9,335.7 522.0,334.1 523.0,333.5 C 524.1,332.9 525.1,333.0 526.2,332.6 C 527.3,332.3 528.3,331.6 529.4,331.3 C 530.4,331.0 531.5,331.2 532.6,330.9 C 533.6,330.6 534.7,329.9 535.8,329.6 C 536.8,329.3 537.9,329.3 538.9,329.2 C 540.0,329.0 541.1,329.0 542.1,328.7 C 543.2,328.4 544.2,328.0 545.3,327.3 C 546.4,326.5 547.4,325.1 548.5,324.4 C 549.6,323.6 550.6,323.3 551.7,322.7 C 552.7,322.2 553.8,322.1 554.9,321.1 C 555.9,320.0 557.0,317.7 558.0,316.5 C 559.1,315.4 560.2,314.9 561.2,314.2 C 562.3,313.5 563.3,313.4 564.4,312.3 C 565.5,311.3 566.5,309.0 567.6,308.0 C 568.7,307.0 569.7,306.9 570.8,306.3 C 571.Line truncated
|
||||
<circle cx="765.0" cy="116.9" r="5" fill="#dd4528" stroke="#fff" stroke-width="2" filter="url(#xkcdify)"/>
|
||||
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">4518</text>
|
||||
<a href="https://github.com/carsteneu/mystarhistory" target="_blank" rel="noopener"><text x="763" y="527" font-size="13" font-family="Handlee, cursive" fill="#000" text-anchor="end">Made with carsteneu/mystarhistory</text></a>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 30 KiB |
@@ -21,6 +21,7 @@ export type AgentIconKey =
|
||||
| 'opencode'
|
||||
| 'kimi'
|
||||
| 'codebuddy'
|
||||
| 'grok'
|
||||
| 'terminal'
|
||||
| 'plus';
|
||||
|
||||
@@ -116,6 +117,11 @@ export const AGENT_ICON_VISUALS: Record<AgentIconKey, AgentIconVisual> = {
|
||||
badgeClassName: 'border-indigo-500/22 bg-indigo-500/12',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-90',
|
||||
},
|
||||
grok: {
|
||||
src: '/ai/providers/grok.svg',
|
||||
badgeClassName: 'border-zinc-500/22 bg-zinc-500/12',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-90',
|
||||
},
|
||||
terminal: {
|
||||
src: '/ai/agents/terminal.svg',
|
||||
badgeClassName: 'border-white/8 bg-white/[0.04]',
|
||||
@@ -205,6 +211,9 @@ export function resolveAgentIconKey(source: AgentIconSource | 'add-more'): Agent
|
||||
if (tokens.some((token) => token.includes('factory'))) {
|
||||
return 'atom';
|
||||
}
|
||||
if (tokens.some((token) => token.includes('grok') || token.includes('xai'))) {
|
||||
return 'grok';
|
||||
}
|
||||
|
||||
return 'terminal';
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ test('matchCodingCliProviderFromCommand resolves known CLIs', () => {
|
||||
assert.equal(matchCodingCliProviderFromCommand('opencode')?.id, 'opencode');
|
||||
assert.equal(matchCodingCliProviderFromCommand('droid')?.id, 'droid');
|
||||
assert.equal(matchCodingCliProviderFromCommand('factory')?.id, 'droid');
|
||||
assert.equal(matchCodingCliProviderFromCommand('grok')?.id, 'grok');
|
||||
assert.equal(matchCodingCliProviderFromCommand('C:\\\\Tools\\\\grok.exe')?.id, 'grok');
|
||||
});
|
||||
|
||||
test('matchCodingCliProviderFromTitle detects Claude Code and Codex titles', () => {
|
||||
|
||||
@@ -9,7 +9,8 @@ export type CodingCliProviderId =
|
||||
| 'droid'
|
||||
| 'copilot'
|
||||
| 'cursor'
|
||||
| 'codebuddy';
|
||||
| 'codebuddy'
|
||||
| 'grok';
|
||||
|
||||
export type CodingCliProvider = {
|
||||
id: CodingCliProviderId;
|
||||
@@ -93,6 +94,13 @@ export const CODING_CLI_PROVIDERS: readonly CodingCliProvider[] = [
|
||||
titleHints: ['codebuddy'],
|
||||
iconKey: 'codebuddy',
|
||||
},
|
||||
{
|
||||
id: 'grok',
|
||||
label: 'Grok Build',
|
||||
command: 'grok',
|
||||
titleHints: ['grok build', 'grok'],
|
||||
iconKey: 'grok',
|
||||
},
|
||||
] as const;
|
||||
|
||||
const PROVIDER_BY_ID = new Map(
|
||||
|
||||
@@ -5,10 +5,13 @@ import {
|
||||
appendHostConnectScript,
|
||||
getGlobalConnectScripts,
|
||||
getHostConnectScriptIds,
|
||||
hasHostConnectAutomation,
|
||||
migrateHostConnectScriptIds,
|
||||
reorderHostConnectScript,
|
||||
removeHostConnectScript,
|
||||
resolveConnectScriptsForHost,
|
||||
shouldMarkConnectAutomationConsumed,
|
||||
shouldUseFreshSshConnectionForAutomation,
|
||||
syncHostsForSnippetTargetChange,
|
||||
} from './hostConnectScripts.ts';
|
||||
|
||||
@@ -54,6 +57,70 @@ test('resolveConnectScriptsForHost runs globals before host queue and dedupes',
|
||||
assert.deepEqual(resolved.map((item) => item.id), ['global', 'both', 'host-only']);
|
||||
});
|
||||
|
||||
test('hasHostConnectAutomation covers host, global, and unresolved connect scripts', () => {
|
||||
assert.equal(
|
||||
hasHostConnectAutomation(
|
||||
{ ...host, connectScriptIds: ['host-script'] },
|
||||
[script({ id: 'host-script', targets: ['host-a'] })],
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
hasHostConnectAutomation(host, [script({ id: 'global', targetsAllHosts: true })]),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
hasHostConnectAutomation({ ...host, loginScriptId: 'not-loaded-yet' }, []),
|
||||
true,
|
||||
);
|
||||
assert.equal(hasHostConnectAutomation(host, []), false);
|
||||
});
|
||||
|
||||
test('fresh SSH automation policy is conservative before vault hydration and for pending scripts', () => {
|
||||
assert.equal(shouldUseFreshSshConnectionForAutomation({
|
||||
host,
|
||||
snippets: [],
|
||||
vaultInitialized: false,
|
||||
}), true);
|
||||
assert.equal(shouldUseFreshSshConnectionForAutomation({
|
||||
host,
|
||||
snippets: [],
|
||||
vaultInitialized: true,
|
||||
hasPendingScript: true,
|
||||
}), true);
|
||||
assert.equal(shouldUseFreshSshConnectionForAutomation({
|
||||
host,
|
||||
snippets: [],
|
||||
vaultInitialized: true,
|
||||
}), false);
|
||||
assert.equal(shouldUseFreshSshConnectionForAutomation({
|
||||
host,
|
||||
snippets: [script({ id: 'global', targetsAllHosts: true })],
|
||||
vaultInitialized: true,
|
||||
connectAutomationConsumed: true,
|
||||
}), false);
|
||||
assert.equal(shouldUseFreshSshConnectionForAutomation({
|
||||
host,
|
||||
snippets: [script({ id: 'global', targetsAllHosts: true })],
|
||||
vaultInitialized: true,
|
||||
hasPendingScript: true,
|
||||
connectAutomationConsumed: true,
|
||||
}), true);
|
||||
});
|
||||
|
||||
test('empty hydrated vault finalizes the current connection automation decision', () => {
|
||||
assert.equal(shouldMarkConnectAutomationConsumed({
|
||||
allConnectScriptsDone: true,
|
||||
vaultInitialized: true,
|
||||
hasUnresolvedBindings: false,
|
||||
}), true);
|
||||
assert.equal(shouldMarkConnectAutomationConsumed({
|
||||
allConnectScriptsDone: true,
|
||||
vaultInitialized: false,
|
||||
hasUnresolvedBindings: false,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test('append updates host connectScriptIds order', () => {
|
||||
const snippets = [
|
||||
script({ id: 'a', targets: ['host-a'] }),
|
||||
|
||||
@@ -118,6 +118,49 @@ export function resolveConnectScriptsForHost(host: Host, snippets: Snippet[]): S
|
||||
return [...globals, ...hostScripts];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether connecting this host can run automation that depends on the initial
|
||||
* login output. Missing referenced scripts still count: vault hydration or a
|
||||
* later sync may restore them after the terminal has already started.
|
||||
*/
|
||||
export function hasHostConnectAutomation(host: Host, snippets: Snippet[]): boolean {
|
||||
return hasUnresolvedConnectScriptBindings(host, snippets)
|
||||
|| resolveConnectScriptsForHost(host, snippets).length > 0;
|
||||
}
|
||||
|
||||
export function shouldUseFreshSshConnectionForAutomation(options: {
|
||||
host: Host;
|
||||
snippets: Snippet[];
|
||||
vaultInitialized: boolean;
|
||||
hasPendingScript?: boolean;
|
||||
connectAutomationConsumed?: boolean;
|
||||
}): boolean {
|
||||
return options.hasPendingScript === true
|
||||
|| (
|
||||
options.connectAutomationConsumed !== true
|
||||
&& (
|
||||
!options.vaultInitialized
|
||||
|| hasHostConnectAutomation(options.host, options.snippets)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the current connection's automation decision as final once the vault
|
||||
* has hydrated, even when it hydrated to an empty script list. This prevents a
|
||||
* later sync from starting a newly arrived global script against a connection
|
||||
* whose initial login output may already have been skipped.
|
||||
*/
|
||||
export function shouldMarkConnectAutomationConsumed(options: {
|
||||
allConnectScriptsDone: boolean;
|
||||
vaultInitialized: boolean;
|
||||
hasUnresolvedBindings: boolean;
|
||||
}): boolean {
|
||||
return options.allConnectScriptsDone
|
||||
&& options.vaultInitialized
|
||||
&& !options.hasUnresolvedBindings;
|
||||
}
|
||||
|
||||
export function appendHostConnectScript(host: Host, scriptId: string, snippets: Snippet[]): Host {
|
||||
const snippet = scriptById(snippets, scriptId);
|
||||
if (!snippet) return host;
|
||||
|
||||
Loaded 100 of 168 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user