mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 14:30:13 +00:00
Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5463915f5 | ||
|
|
69ef663844 | ||
|
|
a3083a4ac2 | ||
|
|
b2503ff6ba | ||
|
|
b27f0ad7d3 | ||
|
|
0c36ef7275 | ||
|
|
110c79c8dc | ||
|
|
e8c31a7800 | ||
|
|
2527f140c0 | ||
|
|
aaee43417f | ||
|
|
a622d953ad | ||
|
|
16c9af6e32 | ||
|
|
64717fffd5 | ||
|
|
2ba045c8e7 | ||
|
|
8b0b7706a3 | ||
|
|
fa34a5602e | ||
|
|
d14591b36a | ||
|
|
2bb7baebde | ||
|
|
aa597135b1 | ||
|
|
42b8e2f347 | ||
|
|
9c312b7b98 | ||
|
|
05e4bf69f7 | ||
|
|
eb57b73442 | ||
|
|
b366967bcc | ||
|
|
8771ec9e3d | ||
|
|
55002aefa0 | ||
|
|
1a7490497b | ||
|
|
786a317b26 | ||
|
|
73d15da765 |
No files matched your search
@@ -0,0 +1,72 @@
|
||||
name: Forward issue events to Grok Bot
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, reopened, edited]
|
||||
issue_comment:
|
||||
types: [created, edited]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
forward:
|
||||
# PR comments are handled elsewhere; only forward real issues.
|
||||
if: github.event_name == 'issues' || github.event.issue.pull_request == null
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
steps:
|
||||
- name: Forward minimal event to Grok Bot webhook
|
||||
env:
|
||||
WEBHOOK_URL: ${{ secrets.GROKBOT_WEBHOOK_URL }}
|
||||
WEBHOOK_KEY: ${{ secrets.GROKBOT_WEBHOOK_KEY }}
|
||||
run: |
|
||||
if [ -z "$WEBHOOK_URL" ] || [ -z "$WEBHOOK_KEY" ]; then
|
||||
echo "Grok Bot webhook secrets not set; skipping."
|
||||
exit 0
|
||||
fi
|
||||
jq --arg event "$GITHUB_EVENT_NAME" '
|
||||
def clip:
|
||||
if type == "string" then .[0:4000] else . end;
|
||||
{
|
||||
event: $event,
|
||||
action: .action,
|
||||
repository: .repository.full_name,
|
||||
sender: .sender.login,
|
||||
issue: (
|
||||
{
|
||||
number: .issue.number,
|
||||
title: .issue.title,
|
||||
author: .issue.user.login,
|
||||
url: .issue.html_url
|
||||
} + (
|
||||
if $event == "issues" then {
|
||||
author_association: .issue.author_association,
|
||||
body: (.issue.body | clip)
|
||||
} else {} end
|
||||
)
|
||||
),
|
||||
comment: (
|
||||
if .comment then
|
||||
{
|
||||
id: .comment.id,
|
||||
author: .comment.user.login,
|
||||
url: .comment.html_url,
|
||||
node_id: .comment.node_id,
|
||||
author_association: .comment.author_association,
|
||||
user_type: .comment.user.type,
|
||||
created_at: .comment.created_at,
|
||||
updated_at: .comment.updated_at,
|
||||
body: (.comment.body | clip)
|
||||
} + (
|
||||
if .action == "edited" then {
|
||||
previous_body: (.changes.body.from | clip)
|
||||
} else {} end
|
||||
)
|
||||
else null end
|
||||
)
|
||||
}
|
||||
' "$GITHUB_EVENT_PATH" \
|
||||
| curl -fsS --max-time 20 -X POST \
|
||||
-H "Authorization: Bearer $WEBHOOK_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data @- "$WEBHOOK_URL"
|
||||
@@ -2,7 +2,7 @@ name: spam-comments
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
types: [created, edited]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -10,13 +10,13 @@ permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
delete-spam:
|
||||
hide-spam:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout filter rules
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Delete suspected malware spam
|
||||
- name: Hide suspected malware spam
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
@@ -25,26 +25,59 @@ jobs:
|
||||
path.join(process.env.GITHUB_WORKSPACE, 'scripts/spam-comment-filter.cjs')
|
||||
);
|
||||
|
||||
const comment = context.payload.comment;
|
||||
const comment = context.payload.comment || {};
|
||||
const issue = context.payload.issue || {};
|
||||
const login = (comment.user && comment.user.login) || 'unknown';
|
||||
const result = detectSpamComment({
|
||||
body: comment.body || '',
|
||||
authorAssociation: comment.author_association,
|
||||
userType: comment.user && comment.user.type,
|
||||
commentAuthor: comment.user && comment.user.login,
|
||||
issueAuthor: issue.user && issue.user.login,
|
||||
});
|
||||
|
||||
core.info(JSON.stringify(result, null, 2));
|
||||
|
||||
if (!result.spam) {
|
||||
core.info('Comment did not match the zip/dangerous-file spam filter.');
|
||||
core.info('Comment did not match the risky-download spam filter.');
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: comment.id,
|
||||
});
|
||||
const matchedUrl = result.matchedUrls.join(', ');
|
||||
if (!comment.node_id) {
|
||||
core.setFailed(
|
||||
`Comment ${comment.id} by @${login} on #${issue.number} matched ${matchedUrl} but has no node_id.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await github.graphql(
|
||||
`mutation($input: MinimizeCommentInput!) {
|
||||
minimizeComment(input: $input) {
|
||||
minimizedComment {
|
||||
isMinimized
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{
|
||||
input: {
|
||||
subjectId: comment.node_id,
|
||||
classifier: 'SPAM',
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
const message = String((error && error.message) || error);
|
||||
if (/already minimized/i.test(message)) {
|
||||
core.info(
|
||||
`Comment ${comment.id} by @${login} on #${issue.number} is already minimized. Matched URL: ${matchedUrl}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
core.notice(
|
||||
`Deleted comment with zip/dangerous attachment by @${comment.user.login} on #${context.payload.issue.number}: ${result.reasons.join('; ')}`
|
||||
`Hid comment ${comment.id} by @${login} on #${issue.number} as spam. Matched URL: ${matchedUrl}`
|
||||
);
|
||||
@@ -87,6 +87,7 @@ Watch Catty Agent orchestrate a Docker Swarm cluster across two servers in one c
|
||||
- [Split Terminals](#split-terminals)
|
||||
- [Supported Distros](#supported-distros)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Open a local terminal from the command line](#open-a-local-terminal-from-the-command-line)
|
||||
- [Build & Package](#build--package)
|
||||
- [Tech Stack](#tech-stack)
|
||||
- [Contributing](#contributing)
|
||||
@@ -241,6 +242,24 @@ application and integration are complete.
|
||||
|
||||
> **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.
|
||||
|
||||
<a name="open-a-local-terminal-from-the-command-line"></a>
|
||||
### Open a local terminal from the command line
|
||||
|
||||
`--open-terminal-path <dir>` opens a local terminal in that folder. `--open-terminal-shell <id-or-path>` selects the shell: a discovered id such as `pwsh`, `cmd`, or `wsl-ubuntu`, or a path to a shell executable. Either flag is enough. A shell alone opens a local tab in the default directory. A path alone uses the configured local shell. If Netcatty is already running, the tab opens in that window.
|
||||
|
||||
Windows needs an extra `--` so Electron forwards the flags:
|
||||
|
||||
```bat
|
||||
Netcatty.exe -- --open-terminal-shell=pwsh --open-terminal-path="D:\\proj"
|
||||
```
|
||||
|
||||
On macOS, start a new instance with `open`, or run the binary directly:
|
||||
|
||||
```bash
|
||||
open -n -a Netcatty --args --open-terminal-shell=zsh --open-terminal-path="$HOME/proj"
|
||||
/Applications/Netcatty.app/Contents/MacOS/Netcatty --open-terminal-shell=zsh --open-terminal-path="$HOME/proj"
|
||||
```
|
||||
|
||||
### Scoop (Windows x64, community-maintained)
|
||||
|
||||
With [Scoop](https://scoop.sh/) installed, you can install Netcatty from the community-maintained [lemon bucket](https://github.com/hoilc/scoop-lemon/blob/master/bucket/netcatty.json), which downloads the installer from Netcatty's official GitHub Releases:
|
||||
|
||||
@@ -15,4 +15,14 @@ test("open-terminal requests wait behind App Lock and resume after unlock", () =
|
||||
assert.match(handlerSource, /shouldDeferExternalActionWhileAppLocked/);
|
||||
assert.match(handlerSource, /kind: 'open-terminal-path'/);
|
||||
assert.match(drainSource, /_processOpenTerminalPath\(item\.payload\)/);
|
||||
|
||||
const processStart = source.indexOf("const _processOpenTerminalPath");
|
||||
const processSource = source.slice(processStart, handlerStart);
|
||||
assert.match(processSource, /handleCreateLocalTerminal\(undefined, \{ localStartDir \}\)/);
|
||||
assert.match(processSource, /resolveShellSetting\(shellSetting, shells\)/);
|
||||
assert.match(source, /const openTerminalQueueRef = useRef\(createOpenTerminalQueue\(\)\)/);
|
||||
const enqueueAt = processSource.indexOf("openTerminalQueueRef.current.enqueue(() => {");
|
||||
const createAt = processSource.indexOf("handleCreateLocalTerminal");
|
||||
assert.ok(enqueueAt >= 0 && createAt > enqueueAt);
|
||||
assert.match(processSource, /await ensureDiscoveredShells\(\)/);
|
||||
});
|
||||
@@ -94,6 +94,7 @@ import { resolveSnippetCommand } from '../../components/SnippetExecutionProvider
|
||||
import { isScriptSnippet } from '../../domain/snippetScript.ts';
|
||||
import { collectSnippetDeleteIds } from '../../domain/snippetSelection.ts';
|
||||
import { shouldOpenLocalTerminalOnStartup, resolveStartupLandingSetting } from '../../domain/startupLanding';
|
||||
import { createOpenTerminalQueue } from './openTerminalQueue';
|
||||
import { useAppStartupEffects } from './useAppStartupEffects';
|
||||
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, handleFidoPromptSubmitImpl, handleFidoPromptCancelImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, duplicateSessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl, markForwardedNativeShortcutEvent } from './AppHandlers';
|
||||
|
||||
@@ -1557,7 +1558,7 @@ export function AppSideEffects() {
|
||||
| { kind: 'ssh'; payload: DeepLinkPayload }
|
||||
| { kind: 'telnet'; payload: DeepLinkPayload }
|
||||
| { kind: 'jms'; payload: { url?: string } }
|
||||
| { kind: 'open-terminal-path'; payload: { path?: string } }
|
||||
| { kind: 'open-terminal-path'; payload: { path?: string; shell?: string } }
|
||||
>>([]);
|
||||
|
||||
const _processSshDeepLink = useEffectEvent((payload: DeepLinkPayload) => {
|
||||
@@ -1721,14 +1722,44 @@ export function AppSideEffects() {
|
||||
});
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
const _processOpenTerminalPath = useEffectEvent((payload: { path?: string }) => {
|
||||
// Shell discovery is part of this queue. A later path-only request, including
|
||||
// one drained after App Lock, cannot create its tab ahead of an in-flight shell.
|
||||
const openTerminalQueueRef = useRef(createOpenTerminalQueue());
|
||||
|
||||
const _processOpenTerminalPath = useEffectEvent((payload: { path?: string; shell?: string }) => {
|
||||
startupLaunchIntentReceivedRef.current = true;
|
||||
const localStartDir = typeof payload?.path === 'string' ? payload.path : '';
|
||||
if (!localStartDir.trim()) return;
|
||||
handleCreateLocalTerminal(undefined, { localStartDir });
|
||||
const shellSetting = typeof payload?.shell === 'string' ? payload.shell.trim() : '';
|
||||
void openTerminalQueueRef.current.enqueue(() => {
|
||||
if (!shellSetting) {
|
||||
if (!localStartDir.trim()) return;
|
||||
handleCreateLocalTerminal(undefined, { localStartDir });
|
||||
return;
|
||||
}
|
||||
|
||||
return (async () => {
|
||||
const shells = await ensureDiscoveredShells();
|
||||
const resolved = resolveShellSetting(shellSetting, shells);
|
||||
if (!resolved?.command) {
|
||||
if (!localStartDir.trim()) return;
|
||||
handleCreateLocalTerminal(undefined, { localStartDir });
|
||||
return;
|
||||
}
|
||||
const matchedShell = shells.find((shell) => shell.id === shellSetting);
|
||||
handleCreateLocalTerminal(
|
||||
{
|
||||
command: resolved.command,
|
||||
args: resolved.args,
|
||||
name: matchedShell?.name,
|
||||
icon: matchedShell?.icon,
|
||||
},
|
||||
localStartDir.trim() ? { localStartDir } : undefined,
|
||||
);
|
||||
})();
|
||||
});
|
||||
});
|
||||
|
||||
const _handleOpenTerminalPath = useEffectEvent((payload: { path?: string }) => {
|
||||
const _handleOpenTerminalPath = useEffectEvent((payload: { path?: string; shell?: string }) => {
|
||||
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
|
||||
pendingDeepLinksWhileLockedRef.current.push({
|
||||
kind: 'open-terminal-path',
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createOpenTerminalQueue } from './openTerminalQueue';
|
||||
|
||||
test('idle open-terminal work stays in call order on the caller turn', () => {
|
||||
const queue = createOpenTerminalQueue();
|
||||
const order: string[] = [];
|
||||
|
||||
queue.enqueue(() => { order.push('path-a'); });
|
||||
queue.enqueue(() => { order.push('path-b'); });
|
||||
|
||||
assert.deepEqual(order, ['path-a', 'path-b']);
|
||||
});
|
||||
|
||||
test('a path-only open waits for an in-flight shell discovery', async () => {
|
||||
const queue = createOpenTerminalQueue();
|
||||
const order: string[] = [];
|
||||
let releaseDiscovery: () => void = () => {};
|
||||
const discovery = new Promise<void>((resolve) => {
|
||||
releaseDiscovery = resolve;
|
||||
});
|
||||
|
||||
const shellRequest = queue.enqueue(async () => {
|
||||
order.push('shell');
|
||||
await discovery;
|
||||
order.push('shell-tab');
|
||||
});
|
||||
const pathRequest = queue.enqueue(() => {
|
||||
order.push('path-tab');
|
||||
});
|
||||
|
||||
assert.deepEqual(order, ['shell']);
|
||||
releaseDiscovery();
|
||||
await shellRequest;
|
||||
await pathRequest;
|
||||
assert.deepEqual(order, ['shell', 'shell-tab', 'path-tab']);
|
||||
});
|
||||
|
||||
test('a failed open-terminal request does not block the next one', async () => {
|
||||
const queue = createOpenTerminalQueue();
|
||||
const order: string[] = [];
|
||||
|
||||
const failed = queue.enqueue(async () => {
|
||||
order.push('shell');
|
||||
throw new Error('discovery failed');
|
||||
});
|
||||
const next = queue.enqueue(() => {
|
||||
order.push('path-tab');
|
||||
});
|
||||
|
||||
await assert.rejects(failed, /discovery failed/);
|
||||
await next;
|
||||
assert.deepEqual(order, ['shell', 'path-tab']);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
type OpenTerminalTask = () => Promise<void> | void;
|
||||
|
||||
function isPromise(value: Promise<void> | void): value is Promise<void> {
|
||||
return Boolean(value) && typeof (value as Promise<void>).then === 'function';
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered launcher queue. An idle queue runs the task in the caller turn so a
|
||||
* path-only open still creates its tab synchronously. A task that awaits (shell
|
||||
* discovery) holds the tail, and later requests cannot pass it.
|
||||
*/
|
||||
export function createOpenTerminalQueue() {
|
||||
let tail: Promise<void> = Promise.resolve();
|
||||
let depth = 0;
|
||||
|
||||
return {
|
||||
enqueue(task: OpenTerminalTask): Promise<void> {
|
||||
depth += 1;
|
||||
let release: () => void;
|
||||
const finished = new Promise<void>((resolve) => {
|
||||
release = () => {
|
||||
depth -= 1;
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
const previous = tail;
|
||||
tail = finished;
|
||||
|
||||
const run = (): Promise<void> => {
|
||||
try {
|
||||
const result = task();
|
||||
if (isPromise(result)) {
|
||||
return result.then(release, (error: unknown) => {
|
||||
release();
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
release();
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
release();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
if (depth === 1) return run();
|
||||
return previous.then(run, run);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -356,6 +356,14 @@ export const enAiMessages: Messages = {
|
||||
'ai.externalMcp.idleTimeout.minutes': 'min',
|
||||
'ai.externalMcp.focusOnHostOpen': 'Focus window on host_open',
|
||||
'ai.externalMcp.focusOnHostOpen.description': 'When an MCP client opens a host, bring the main window to the foreground. Turn off to keep working without interruption.',
|
||||
'ai.externalMcp.approvalAlert': 'Approval alerts',
|
||||
'ai.externalMcp.approvalAlert.description': 'When an external MCP client asks for confirmation, show a system notification. While Netcatty is unfocused, the taskbar or Dock asks for attention. The notification uses the system sound.',
|
||||
'ai.externalMcp.approvalAlert.off': 'Off',
|
||||
'ai.externalMcp.approvalAlert.unfocused': 'When unfocused',
|
||||
'ai.externalMcp.approvalAlert.always': 'Always',
|
||||
'ai.externalMcp.approvalAlert.notificationTitle': 'External MCP approval',
|
||||
'ai.externalMcp.approvalAlert.notificationBody': '{tool} is waiting for approval',
|
||||
'ai.externalMcp.approvalAlert.notificationBodyOnHost': '{tool} on {host} is waiting for approval',
|
||||
'ai.externalMcp.silentSessions': 'Silent MCP sessions',
|
||||
'ai.externalMcp.silentSessions.description': 'Sessions opened by AI stay out of your tab bar and are not restored after restart. View them anytime from the tray panel.',
|
||||
'ai.externalMcp.sessionIdleTimeout': 'Opened session idle timeout',
|
||||
|
||||
@@ -413,7 +413,7 @@ export const enCoreMessages: Messages = {
|
||||
'settings.appearance.themeColor.dark': 'Dark palette',
|
||||
'settings.appearance.customCss': 'Custom CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'Add custom CSS to personalize the app appearance. Changes apply immediately.',
|
||||
'Add custom CSS to personalize the app appearance. Changes apply immediately. Selected top tabs (`.top-tab[data-active="true"]`) read `--top-tab-active-bg`, `--top-tab-active-fg`, and `--top-tab-active-border`. Leave them unset to keep the current look.',
|
||||
'settings.appearance.customCss.help.ariaLabel': 'Custom CSS help',
|
||||
'settings.appearance.customCss.help.title': 'Custom CSS guide',
|
||||
'settings.appearance.customCss.help.body': `Major UI regions expose a \`data-section\` attribute you can target. Changes apply as soon as you edit the CSS.
|
||||
@@ -440,6 +440,22 @@ Because many styles come from Tailwind utilities, you often need \`!important\`
|
||||
- \`top-tabs-host-tree-toggle\`
|
||||
- \`top-tabs-quick-switcher-toggle\`
|
||||
|
||||
Selected top tabs use the class \`top-tab\` and \`data-active="true"\`. Set these variables to recolor the selected tab. Leave them unset to keep the current look. They apply without \`!important\`.
|
||||
|
||||
- \`--top-tab-active-bg\` — selected fill (defaults to the current tab background)
|
||||
- \`--top-tab-active-fg\` — selected label color (defaults to the current tab text)
|
||||
- \`--top-tab-active-border\` — 1px inset outline (defaults to transparent, so there is no border)
|
||||
|
||||
The Vaults tab stays flat and does not use the fill.
|
||||
|
||||
\`\`\`css
|
||||
.top-tab[data-active="true"] {
|
||||
--top-tab-active-bg: #2563eb;
|
||||
--top-tab-active-fg: #ffffff;
|
||||
--top-tab-active-border: #1d4ed8;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### Vault
|
||||
|
||||
- \`vault-view\`
|
||||
@@ -533,7 +549,7 @@ Highlight the focused split pane:
|
||||
\`\`\`
|
||||
`,
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Hide the host-list toggle in the top tab bar */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Hide the plus button that opens the quick switcher */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Border around the SFTP / side panel (does not linger after closing) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Change the whole side panel background, not only the top tabs */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Style selected SFTP file rows */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Thicker split dividers */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Highlight the focused split pane */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Or use Settings → Terminal → Workspace Focus Indicator → Border on focused pane */',
|
||||
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Selected top tab. Leave these unset to keep the current look. */\n.top-tab[data-active="true"] {\n --top-tab-active-bg: #2563eb;\n --top-tab-active-fg: #ffffff;\n --top-tab-active-border: #1d4ed8;\n}\n\n/* Hide the host-list toggle in the top tab bar */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Hide the plus button that opens the quick switcher */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Border around the SFTP / side panel (does not linger after closing) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Change the whole side panel background, not only the top tabs */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Style selected SFTP file rows */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Thicker split dividers */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Highlight the focused split pane */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Or use Settings → Terminal → Workspace Focus Indicator → Border on focused pane */',
|
||||
'settings.appearance.language': 'Language',
|
||||
'settings.appearance.language.desc': 'Choose the UI language',
|
||||
'settings.appearance.uiFont': 'Interface Font',
|
||||
@@ -712,7 +728,7 @@ Highlight the focused split pane:
|
||||
'Send configured text instead of normal Enter when pressing Shift+Enter in the terminal.',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': 'Text to send',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'Use \\n for newline, \\t for tab, \\e for Escape, and \\\\ for backslash.',
|
||||
'Use \\n for newline, \\t for tab, \\e for Escape, and \\\\ for backslash. Defaults to \\e\\r (Escape + Enter, the legacy Alt+Enter encoding), which most TUI CLIs treat as a newline.',
|
||||
'settings.terminal.behavior.shiftEnterForceText': 'Send text in Win32 input mode',
|
||||
'settings.terminal.behavior.shiftEnterForceText.desc':
|
||||
'Also send the text above when a local Windows ConPTY session negotiates Win32 input mode. Needed for Node/Bun CLIs (Claude Code, CodeBuddy) that cannot read modifier keys from console input records.',
|
||||
|
||||
@@ -347,6 +347,14 @@ export const esAiMessages: Messages = {
|
||||
'ai.externalMcp.idleTimeout.minutes': 'min',
|
||||
'ai.externalMcp.focusOnHostOpen': 'Enfocar ventana en host_open',
|
||||
'ai.externalMcp.focusOnHostOpen.description': 'Cuando un cliente de MCP abre un host, trae la ventana principal al frente. Apágalo para seguir trabajando sin interrupciones.',
|
||||
'ai.externalMcp.approvalAlert': 'Alertas de aprobación',
|
||||
'ai.externalMcp.approvalAlert.description': 'Cuando un cliente MCP externo pide confirmación, muestra una notificación del sistema. Si Netcatty no tiene el foco, la barra de tareas o el Dock pide atención. La notificación usa el sonido del sistema.',
|
||||
'ai.externalMcp.approvalAlert.off': 'Desactivado',
|
||||
'ai.externalMcp.approvalAlert.unfocused': 'Si no tiene el foco',
|
||||
'ai.externalMcp.approvalAlert.always': 'Siempre',
|
||||
'ai.externalMcp.approvalAlert.notificationTitle': 'Aprobación de MCP externo',
|
||||
'ai.externalMcp.approvalAlert.notificationBody': '{tool} espera aprobación',
|
||||
'ai.externalMcp.approvalAlert.notificationBodyOnHost': '{tool} en {host} espera aprobación',
|
||||
'ai.externalMcp.silentSessions': 'Sesiones MCP silenciosas',
|
||||
'ai.externalMcp.silentSessions.description': 'Las sesiones abiertas por la IA se mantienen fuera de tu barra de pestañas y no se restauran tras reiniciar. Puedes verlas en cualquier momento desde el panel de la bandeja.',
|
||||
'ai.externalMcp.sessionIdleTimeout': 'Tiempo de inactividad de la sesión abierta',
|
||||
|
||||
@@ -328,7 +328,7 @@ export const esCoreMessages: Messages = {
|
||||
'settings.appearance.themeColor.dark': 'Paleta oscura',
|
||||
'settings.appearance.customCss': 'CSS personalizado',
|
||||
'settings.appearance.customCss.desc':
|
||||
'Agrega CSS personalizado para personalizar la apariencia de la aplicación. Los cambios se aplican de inmediato.',
|
||||
'Agrega CSS personalizado para personalizar la apariencia de la aplicación. Los cambios se aplican de inmediato. Las pestañas superiores seleccionadas (`.top-tab[data-active="true"]`) leen `--top-tab-active-bg`, `--top-tab-active-fg` y `--top-tab-active-border`. Si no las defines, se conserva el aspecto actual.',
|
||||
'settings.appearance.customCss.help.ariaLabel': 'Ayuda de CSS personalizado',
|
||||
'settings.appearance.customCss.help.title': 'Guía de CSS personalizado',
|
||||
'settings.appearance.customCss.help.body': `Las principales regiones de la interfaz exponen un atributo \`data-section\` que puedes usar como destino. Los cambios se aplican en cuanto editas el CSS.
|
||||
@@ -355,6 +355,22 @@ Como muchos estilos provienen de utilidades de Tailwind, a menudo necesitas \`!i
|
||||
- \`top-tabs-host-tree-toggle\`
|
||||
- \`top-tabs-quick-switcher-toggle\`
|
||||
|
||||
Las pestañas superiores seleccionadas usan la clase \`top-tab\` y \`data-active="true"\`. Define estas variables para cambiar el color de la pestaña seleccionada. Si no las defines, se conserva el aspecto actual. Se aplican sin \`!important\`.
|
||||
|
||||
- \`--top-tab-active-bg\` — relleno seleccionado (por defecto, el fondo actual de la pestaña)
|
||||
- \`--top-tab-active-fg\` — color del texto seleccionado (por defecto, el texto actual)
|
||||
- \`--top-tab-active-border\` — contorno interior de 1px (transparente por defecto, así que no hay borde)
|
||||
|
||||
La pestaña Vaults permanece plana y no usa el relleno.
|
||||
|
||||
\`\`\`css
|
||||
.top-tab[data-active="true"] {
|
||||
--top-tab-active-bg: #2563eb;
|
||||
--top-tab-active-fg: #ffffff;
|
||||
--top-tab-active-border: #1d4ed8;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### Vault
|
||||
|
||||
- \`vault-view\`
|
||||
@@ -448,7 +464,7 @@ Resalta el panel dividido enfocado:
|
||||
\`\`\`
|
||||
`,
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* Ejemplos: usa !important para superar la especificidad de las utilidades de Tailwind */\n\n/* Oculta el botón de lista de hosts en la barra de pestañas superior */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Oculta el botón + que abre el selector rápido */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Borde alrededor del panel SFTP / lateral (no persiste tras cerrarlo) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Cambia el fondo de todo el panel lateral, no solo las pestañas superiores */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Estiliza las filas de archivos SFTP seleccionadas */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Divisores de división más gruesos */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Resalta el panel dividido enfocado */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* O usa Configuración → Terminal → Indicador de foco del espacio de trabajo → Borde en el panel enfocado */',
|
||||
'/* Ejemplos: usa !important para superar la especificidad de las utilidades de Tailwind */\n\n/* Pestaña superior seleccionada. Si no defines estas variables, se conserva el aspecto actual. */\n.top-tab[data-active="true"] {\n --top-tab-active-bg: #2563eb;\n --top-tab-active-fg: #ffffff;\n --top-tab-active-border: #1d4ed8;\n}\n\n/* Oculta el botón de lista de hosts en la barra de pestañas superior */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Oculta el botón + que abre el selector rápido */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Borde alrededor del panel SFTP / lateral (no persiste tras cerrarlo) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Cambia el fondo de todo el panel lateral, no solo las pestañas superiores */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Estiliza las filas de archivos SFTP seleccionadas */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Divisores de división más gruesos */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Resalta el panel dividido enfocado */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* O usa Configuración → Terminal → Indicador de foco del espacio de trabajo → Borde en el panel enfocado */',
|
||||
'settings.appearance.language': 'Idioma',
|
||||
'settings.appearance.language.desc': 'Elige el idioma de la interfaz',
|
||||
'settings.appearance.uiFont': 'Fuente de la interfaz',
|
||||
@@ -627,7 +643,7 @@ Resalta el panel dividido enfocado:
|
||||
'Envía el texto configurado en lugar del Enter normal al presionar Shift+Enter en la terminal.',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': 'Texto a enviar',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'Usa \\n para nueva línea, \\t para tabulación, \\e para Escape y \\\\ para barra invertida.',
|
||||
'Usa \\n para nueva línea, \\t para tabulación, \\e para Escape y \\\\ para barra invertida. Por defecto \\e\\r (Escape + Enter, la codificación clásica de Alt+Enter), que la mayoría de las CLI TUI interpretan como nueva línea.',
|
||||
'settings.terminal.behavior.shiftEnterForceText': 'Enviar texto también en modo de entrada Win32',
|
||||
'settings.terminal.behavior.shiftEnterForceText.desc':
|
||||
'Envía también el texto anterior cuando una sesión ConPTY local de Windows negocia el modo de entrada Win32. Necesario para CLI escritas en Node/Bun (Claude Code, CodeBuddy) que no pueden leer modificadores desde los registros de entrada de la consola.',
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import en from './en.ts';
|
||||
import ru from './ru.ts';
|
||||
import es from './es.ts';
|
||||
import zhCN from './zh-CN.ts';
|
||||
import zhTW from './zh-TW.ts';
|
||||
|
||||
const KEYS = [
|
||||
'ai.externalMcp.approvalAlert',
|
||||
'ai.externalMcp.approvalAlert.description',
|
||||
'ai.externalMcp.approvalAlert.off',
|
||||
'ai.externalMcp.approvalAlert.unfocused',
|
||||
'ai.externalMcp.approvalAlert.always',
|
||||
'ai.externalMcp.approvalAlert.notificationTitle',
|
||||
'ai.externalMcp.approvalAlert.notificationBody',
|
||||
'ai.externalMcp.approvalAlert.notificationBodyOnHost',
|
||||
] as const;
|
||||
|
||||
test('External MCP approval alerts are localized in every supported locale', () => {
|
||||
for (const [name, messages] of Object.entries({ en, es, 'zh-CN': zhCN, 'zh-TW': zhTW, ru })) {
|
||||
const missing = KEYS.filter((key) => !messages[key]);
|
||||
assert.deepEqual(missing, [], `${name} is missing approval alert labels`);
|
||||
assert.match(messages['ai.externalMcp.approvalAlert.notificationBody'], /\{tool\}/);
|
||||
assert.match(messages['ai.externalMcp.approvalAlert.notificationBodyOnHost'], /\{tool\}/);
|
||||
assert.match(messages['ai.externalMcp.approvalAlert.notificationBodyOnHost'], /\{host\}/);
|
||||
}
|
||||
});
|
||||
@@ -303,6 +303,14 @@ export const ruAiMessages: Messages = {
|
||||
'ai.externalMcp.idleTimeout.minutes': 'мин',
|
||||
'ai.externalMcp.focusOnHostOpen': 'Активировать окно при host_open',
|
||||
'ai.externalMcp.focusOnHostOpen.description': 'Когда MCP-клиент открывает хост, выводить главное окно на передний план. Отключите, чтобы работать без прерываний.',
|
||||
'ai.externalMcp.approvalAlert': 'Оповещения об одобрении',
|
||||
'ai.externalMcp.approvalAlert.description': 'Когда внешний MCP-клиент запрашивает подтверждение, показать системное уведомление. Пока Netcatty не в фокусе, панель задач или Dock привлекают внимание. Уведомление использует системный звук.',
|
||||
'ai.externalMcp.approvalAlert.off': 'Выкл.',
|
||||
'ai.externalMcp.approvalAlert.unfocused': 'Без фокуса',
|
||||
'ai.externalMcp.approvalAlert.always': 'Всегда',
|
||||
'ai.externalMcp.approvalAlert.notificationTitle': 'Одобрение External MCP',
|
||||
'ai.externalMcp.approvalAlert.notificationBody': '{tool} ожидает одобрения',
|
||||
'ai.externalMcp.approvalAlert.notificationBodyOnHost': '{tool} на {host} ожидает одобрения',
|
||||
'ai.externalMcp.silentSessions': 'Бесшумные сеансы MCP',
|
||||
'ai.externalMcp.silentSessions.description': 'Сеансы, открытые ИИ, не отображаются на панели вкладок и не восстанавливаются после перезапуска. Их можно посмотреть в любой момент через панель в трее.',
|
||||
'ai.externalMcp.sessionIdleTimeout': 'Тайм-аут открытого сеанса',
|
||||
|
||||
@@ -397,7 +397,7 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.appearance.themeColor.dark': 'Палитра тёмной темы',
|
||||
'settings.appearance.customCss': 'Пользовательский CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'Добавьте пользовательский CSS, чтобы настроить внешний вид. Изменения применяются сразу.',
|
||||
'Добавьте пользовательский CSS, чтобы настроить внешний вид. Изменения применяются сразу. Выбранные верхние вкладки (`.top-tab[data-active="true"]`) читают `--top-tab-active-bg`, `--top-tab-active-fg` и `--top-tab-active-border`. Не задавайте их, чтобы сохранить текущий вид.',
|
||||
'settings.appearance.customCss.help.ariaLabel': 'Справка по пользовательскому CSS',
|
||||
'settings.appearance.customCss.help.title': 'Руководство по пользовательскому CSS',
|
||||
'settings.appearance.customCss.help.body': `Основные области интерфейса помечены атрибутом \`data-section\` для CSS-селекторов. Изменения применяются сразу после правки.
|
||||
@@ -423,6 +423,22 @@ export const ruCoreMessages: Messages = {
|
||||
- \`top-tabs-host-tree-toggle\`
|
||||
- \`top-tabs-quick-switcher-toggle\`
|
||||
|
||||
У выбранных верхних вкладок есть класс \`top-tab\` и атрибут \`data-active="true"\`. Задайте эти переменные, чтобы перекрасить выбранную вкладку. Не задавайте их, чтобы сохранить текущий вид. \`!important\` не нужен.
|
||||
|
||||
- \`--top-tab-active-bg\` — заливка выбранной вкладки (по умолчанию текущий фон вкладки)
|
||||
- \`--top-tab-active-fg\` — цвет подписи (по умолчанию текущий цвет текста)
|
||||
- \`--top-tab-active-border\` — внутренняя обводка 1px (по умолчанию прозрачная, то есть без рамки)
|
||||
|
||||
Вкладка Vaults остаётся плоской и не использует заливку.
|
||||
|
||||
\`\`\`css
|
||||
.top-tab[data-active="true"] {
|
||||
--top-tab-active-bg: #2563eb;
|
||||
--top-tab-active-fg: #ffffff;
|
||||
--top-tab-active-border: #1d4ed8;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### Хранилище
|
||||
|
||||
- \`vault-view\`
|
||||
@@ -502,7 +518,7 @@ export const ruCoreMessages: Messages = {
|
||||
\`\`\`
|
||||
`,
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\n\n/* Скрыть переключатель списка хостов в верхней панели вкладок */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Скрыть кнопку плюса, открывающую быстрый переключатель */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Рамка вокруг боковой панели SFTP (не остаётся после закрытия) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Изменить фон всей боковой панели, а не только верхних вкладок */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Настроить выбранные строки SFTP */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Более заметные разделители сплита */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Подсветка активной панели сплита */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Или: Настройки → Терминал → Индикатор фокуса → Рамка вокруг активной панели */',
|
||||
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\n\n/* Выбранная верхняя вкладка. Не задавайте переменные, чтобы сохранить текущий вид. */\n.top-tab[data-active="true"] {\n --top-tab-active-bg: #2563eb;\n --top-tab-active-fg: #ffffff;\n --top-tab-active-border: #1d4ed8;\n}\n\n/* Скрыть переключатель списка хостов в верхней панели вкладок */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Скрыть кнопку плюса, открывающую быстрый переключатель */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Рамка вокруг боковой панели SFTP (не остаётся после закрытия) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Изменить фон всей боковой панели, а не только верхних вкладок */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Настроить выбранные строки SFTP */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Более заметные разделители сплита */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Подсветка активной панели сплита */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Или: Настройки → Терминал → Индикатор фокуса → Рамка вокруг активной панели */',
|
||||
'settings.appearance.language': 'Язык',
|
||||
'settings.appearance.language.desc': 'Выберите язык интерфейса',
|
||||
'settings.appearance.uiFont': 'Шрифт интерфейса',
|
||||
@@ -663,7 +679,7 @@ export const ruCoreMessages: Messages = {
|
||||
'Отправлять настроенный текст вместо обычного Enter при нажатии Shift+Enter в терминале.',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': 'Отправляемый текст',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'Используйте \\n для перевода строки, \\t для табуляции, \\e для Escape и \\\\ для обратной косой черты.',
|
||||
'Используйте \\n для перевода строки, \\t для табуляции, \\e для Escape и \\\\ для обратной косой черты. По умолчанию \\e\\r (Escape + Enter, классическая кодировка Alt+Enter), которую большинство TUI-CLI распознают как перевод строки.',
|
||||
'settings.terminal.behavior.shiftEnterForceText': 'Отправлять текст и в режиме ввода Win32',
|
||||
'settings.terminal.behavior.shiftEnterForceText.desc':
|
||||
'Отправлять указанный выше текст даже когда локальная сессия Windows ConPTY согласует режим ввода Win32. Нужно для CLI на Node/Bun (Claude Code, CodeBuddy), которые не читают модификаторы из записей ввода консоли.',
|
||||
|
||||
@@ -356,6 +356,14 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.externalMcp.idleTimeout.minutes': '分钟',
|
||||
'ai.externalMcp.focusOnHostOpen': 'host_open 时激活窗口',
|
||||
'ai.externalMcp.focusOnHostOpen.description': '当 MCP 客户端打开主机连接时,将主窗口切换到前台。关闭后可不受打扰地继续当前工作。',
|
||||
'ai.externalMcp.approvalAlert': '审批提醒',
|
||||
'ai.externalMcp.approvalAlert.description': '外部 MCP 客户端请求确认时显示系统通知。Netcatty 未聚焦时,任务栏或 Dock 会请求注意。通知使用系统提示音。',
|
||||
'ai.externalMcp.approvalAlert.off': '关闭',
|
||||
'ai.externalMcp.approvalAlert.unfocused': '未聚焦时',
|
||||
'ai.externalMcp.approvalAlert.always': '始终',
|
||||
'ai.externalMcp.approvalAlert.notificationTitle': '对外 MCP 审批',
|
||||
'ai.externalMcp.approvalAlert.notificationBody': '{tool} 正在等待审批',
|
||||
'ai.externalMcp.approvalAlert.notificationBodyOnHost': '{host} 上的 {tool} 正在等待审批',
|
||||
'ai.externalMcp.silentSessions': 'AI 会话静默运行',
|
||||
'ai.externalMcp.silentSessions.description': 'AI 打开的会话不会出现在标签栏,重启后也不会恢复。可随时在托盘面板中查看。',
|
||||
'ai.externalMcp.sessionIdleTimeout': '已打开会话空闲超时',
|
||||
|
||||
@@ -417,7 +417,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.appearance.themeColor.dark': '深色主题',
|
||||
'settings.appearance.customCss': '自定义 CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'使用自定义 CSS 个性化界面,修改会立即生效。',
|
||||
'使用自定义 CSS 个性化界面,修改会立即生效。选中的顶部标签(`.top-tab[data-active="true"]`)会读取 `--top-tab-active-bg`、`--top-tab-active-fg` 和 `--top-tab-active-border`。不设置则保持当前外观。',
|
||||
'settings.appearance.customCss.help.ariaLabel': '自定义 CSS 说明',
|
||||
'settings.appearance.customCss.help.title': '自定义 CSS 说明',
|
||||
'settings.appearance.customCss.help.body': `主要 UI 区块都暴露了 \`data-section\` 属性,供你用 CSS 定位。编辑后会立即生效。
|
||||
@@ -444,6 +444,22 @@ export const zhCNCoreMessages: Messages = {
|
||||
- \`top-tabs-host-tree-toggle\`
|
||||
- \`top-tabs-quick-switcher-toggle\`
|
||||
|
||||
选中的顶部标签带有 \`top-tab\` 类和 \`data-active="true"\`。设置下面的变量即可改变选中标签的颜色;不设置则保持当前外观。这些变量不需要 \`!important\`。
|
||||
|
||||
- \`--top-tab-active-bg\` — 选中标签的背景(默认是现在的标签背景)
|
||||
- \`--top-tab-active-fg\` — 选中标签的文字颜色(默认是现在的文字颜色)
|
||||
- \`--top-tab-active-border\` — 1px 内描边(默认透明,也就是没有边框)
|
||||
|
||||
Vaults 标签保持扁平,不使用这块背景。
|
||||
|
||||
\`\`\`css
|
||||
.top-tab[data-active="true"] {
|
||||
--top-tab-active-bg: #2563eb;
|
||||
--top-tab-active-fg: #ffffff;
|
||||
--top-tab-active-border: #1d4ed8;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### 保险库
|
||||
|
||||
- \`vault-view\`
|
||||
@@ -537,7 +553,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
\`\`\`
|
||||
`,
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* 隐藏顶部标签栏里的主机列表开关 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隐藏打开快速切换器的加号按钮 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* SFTP / 操作侧栏边框(关闭侧栏后不会残留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整个操作侧栏背景,而不只是顶部标签 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改选中的 SFTP 文件行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分屏分割线 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 高亮当前聚焦的分屏 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 设置 → 终端 → 工作区聚焦指示 → 聚焦窗格显示边框 */',
|
||||
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* 选中的顶部标签。不设置这些变量则保持当前外观。 */\n.top-tab[data-active="true"] {\n --top-tab-active-bg: #2563eb;\n --top-tab-active-fg: #ffffff;\n --top-tab-active-border: #1d4ed8;\n}\n\n/* 隐藏顶部标签栏里的主机列表开关 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隐藏打开快速切换器的加号按钮 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* SFTP / 操作侧栏边框(关闭侧栏后不会残留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整个操作侧栏背景,而不只是顶部标签 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改选中的 SFTP 文件行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分屏分割线 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 高亮当前聚焦的分屏 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 设置 → 终端 → 工作区聚焦指示 → 聚焦窗格显示边框 */',
|
||||
'settings.appearance.language': '语言',
|
||||
'settings.appearance.language.desc': '选择界面语言',
|
||||
'settings.appearance.uiFont': '界面字体',
|
||||
|
||||
@@ -352,7 +352,7 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'在终端中按 Shift+Enter 时发送配置的文本,而不是普通回车。',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': '发送内容',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'使用 \\n 表示换行,\\t 表示 Tab,\\e 表示 ESC,\\\\ 表示反斜杠。',
|
||||
'使用 \\n 表示换行,\\t 表示 Tab,\\e 表示 ESC,\\\\ 表示反斜杠。默认 \\e\\r(ESC+回车,传统 Alt+Enter 编码),大多数 TUI CLI 会将其识别为换行。',
|
||||
'settings.terminal.behavior.shiftEnterForceText': '在 Win32 输入模式下也发送文本',
|
||||
'settings.terminal.behavior.shiftEnterForceText.desc':
|
||||
'本机 Windows ConPTY 会话协商 Win32 输入模式时,仍然发送上面的文本。Node/Bun 写的 CLI(Claude Code、CodeBuddy)读不到控制台输入记录里的修饰键,需要这个开关才能区分 Shift+Enter。',
|
||||
|
||||
@@ -346,6 +346,14 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.externalMcp.idleTimeout.minutes': '分鐘',
|
||||
'ai.externalMcp.focusOnHostOpen': 'host_open 時啟用視窗',
|
||||
'ai.externalMcp.focusOnHostOpen.description': '當 MCP 用戶端開啟主機連線時,將主視窗切換到前景。關閉後可不受打擾地繼續目前工作。',
|
||||
'ai.externalMcp.approvalAlert': '核准提醒',
|
||||
'ai.externalMcp.approvalAlert.description': '外部 MCP 用戶端請求確認時顯示系統通知。Netcatty 未聚焦時,工作列或 Dock 會要求注意。通知使用系統提示音。',
|
||||
'ai.externalMcp.approvalAlert.off': '關閉',
|
||||
'ai.externalMcp.approvalAlert.unfocused': '未聚焦時',
|
||||
'ai.externalMcp.approvalAlert.always': '一律',
|
||||
'ai.externalMcp.approvalAlert.notificationTitle': '對外 MCP 核准',
|
||||
'ai.externalMcp.approvalAlert.notificationBody': '{tool} 正在等待核准',
|
||||
'ai.externalMcp.approvalAlert.notificationBodyOnHost': '{host} 上的 {tool} 正在等待核准',
|
||||
'ai.externalMcp.silentSessions': 'AI 工作階段靜默執行',
|
||||
'ai.externalMcp.silentSessions.description': 'AI 開啟的工作階段不會出現在分頁列,重新啟動後也不會還原。可隨時在系統匣面板中查看。',
|
||||
'ai.externalMcp.sessionIdleTimeout': '已開啟工作階段閒置逾時',
|
||||
|
||||
@@ -419,7 +419,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.appearance.themeColor.dark': '深色主題',
|
||||
'settings.appearance.customCss': '自訂 CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'使用自訂 CSS 個人化介面,修改會立即生效。',
|
||||
'使用自訂 CSS 個人化介面,修改會立即生效。選取的頂部分頁(`.top-tab[data-active="true"]`)會讀取 `--top-tab-active-bg`、`--top-tab-active-fg` 和 `--top-tab-active-border`。不設定則維持目前外觀。',
|
||||
'settings.appearance.customCss.help.ariaLabel': '自訂 CSS 說明',
|
||||
'settings.appearance.customCss.help.title': '自訂 CSS 說明',
|
||||
'settings.appearance.customCss.help.body': `主要 UI 區塊都公開了 \`data-section\` 屬性,供你用 CSS 定位。編輯後會立即生效。
|
||||
@@ -446,6 +446,22 @@ export const zhTWCoreMessages: Messages = {
|
||||
- \`top-tabs-host-tree-toggle\`
|
||||
- \`top-tabs-quick-switcher-toggle\`
|
||||
|
||||
選取的頂部分頁帶有 \`top-tab\` 類別和 \`data-active="true"\`。設定下列變數即可改變選取分頁的顏色;不設定則維持目前外觀。這些變數不需要 \`!important\`。
|
||||
|
||||
- \`--top-tab-active-bg\` — 選取分頁的背景(預設為目前的分頁背景)
|
||||
- \`--top-tab-active-fg\` — 選取分頁的文字顏色(預設為目前的文字顏色)
|
||||
- \`--top-tab-active-border\` — 1px 內描邊(預設透明,也就是沒有邊框)
|
||||
|
||||
Vaults 分頁維持扁平,不使用這塊背景。
|
||||
|
||||
\`\`\`css
|
||||
.top-tab[data-active="true"] {
|
||||
--top-tab-active-bg: #2563eb;
|
||||
--top-tab-active-fg: #ffffff;
|
||||
--top-tab-active-border: #1d4ed8;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### 保險庫
|
||||
|
||||
- \`vault-view\`
|
||||
@@ -539,7 +555,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
\`\`\`
|
||||
`,
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* 範例 — 由於 Tailwind 優先順序較高,需要使用 !important */\n\n/* 隱藏頂部分頁列裡的主機列表開關 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隱藏開啟快速切換器的加號按鈕 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* SFTP / 操作側欄邊框(關閉側欄後不會殘留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整個操作側欄背景,而不只是頂部分頁 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改選取的 SFTP 檔案行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分割分割線 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 標示目前聚焦的分割 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 設定 → 終端 → 工作區聚焦指示 → 聚焦窗格顯示邊框 */',
|
||||
'/* 範例 — 由於 Tailwind 優先順序較高,需要使用 !important */\n\n/* 選取的頂部分頁。不設定這些變數則維持目前外觀。 */\n.top-tab[data-active="true"] {\n --top-tab-active-bg: #2563eb;\n --top-tab-active-fg: #ffffff;\n --top-tab-active-border: #1d4ed8;\n}\n\n/* 隱藏頂部分頁列裡的主機列表開關 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隱藏開啟快速切換器的加號按鈕 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* SFTP / 操作側欄邊框(關閉側欄後不會殘留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整個操作側欄背景,而不只是頂部分頁 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改選取的 SFTP 檔案行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分割分割線 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 標示目前聚焦的分割 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 設定 → 終端 → 工作區聚焦指示 → 聚焦窗格顯示邊框 */',
|
||||
'settings.appearance.language': '語言',
|
||||
'settings.appearance.language.desc': '選擇介面語言',
|
||||
'settings.appearance.uiFont': '介面字型',
|
||||
|
||||
@@ -352,7 +352,7 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'在終端中按 Shift+Enter 時傳送設定的文字,而不是一般 Enter。',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': '傳送內容',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'使用 \\n 表示換行,\\t 表示 Tab,\\e 表示 ESC,\\\\ 表示反斜線。',
|
||||
'使用 \\n 表示換行,\\t 表示 Tab,\\e 表示 ESC,\\\\ 表示反斜線。預設 \\e\\r(ESC+Enter,傳統 Alt+Enter 編碼),大多數 TUI CLI 會將其識別為換行。',
|
||||
'settings.terminal.behavior.shiftEnterForceText': '在 Win32 輸入模式下也傳送文字',
|
||||
'settings.terminal.behavior.shiftEnterForceText.desc':
|
||||
'本機 Windows ConPTY 工作階段協商 Win32 輸入模式時,仍然傳送上面的文字。Node/Bun 寫的 CLI(Claude Code、CodeBuddy)讀不到主控台輸入記錄中的修飾鍵,需要這個開關才能區分 Shift+Enter。',
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { presentExternalMcpApprovalNotification } from './externalMcpApprovalAlert.ts';
|
||||
|
||||
const t = (key: string, values?: Record<string, string | number | boolean | null | undefined>) => {
|
||||
if (key === 'ai.externalMcp.approvalAlert.notificationTitle') return 'External MCP approval';
|
||||
if (key === 'ai.externalMcp.approvalAlert.notificationBody') return `${values?.tool} is waiting for approval`;
|
||||
if (key === 'ai.externalMcp.approvalAlert.notificationBodyOnHost') {
|
||||
return `${values?.tool} on ${values?.host} is waiting for approval`;
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
test('approval alerts stay quiet when the preference is off', () => {
|
||||
const calls: unknown[] = [];
|
||||
const shown = presentExternalMcpApprovalNotification({
|
||||
toolName: 'sftp.write',
|
||||
dedupeKey: 'mcp_approval_1',
|
||||
mode: 'off',
|
||||
t,
|
||||
notify: (payload) => calls.push(payload),
|
||||
});
|
||||
assert.equal(shown, false);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('unfocused approval alerts name the tool and host and defer focus to the main process', () => {
|
||||
const calls: Array<{ title: string; body: string; whenUnfocused: boolean; dedupeKey: string }> = [];
|
||||
const shown = presentExternalMcpApprovalNotification({
|
||||
toolName: 'sftp.write',
|
||||
hostLabel: 'bastion',
|
||||
dedupeKey: 'mcp_approval_2',
|
||||
mode: 'unfocused',
|
||||
t,
|
||||
notify: (payload) => calls.push(payload),
|
||||
});
|
||||
assert.equal(shown, true);
|
||||
assert.deepEqual(calls, [{
|
||||
title: 'External MCP approval',
|
||||
body: 'sftp.write on bastion is waiting for approval',
|
||||
dedupeKey: 'mcp_approval_2',
|
||||
whenUnfocused: true,
|
||||
}]);
|
||||
});
|
||||
|
||||
test('always approval alerts notify even while a window may be focused', () => {
|
||||
const calls: Array<{ title: string; body: string; whenUnfocused: boolean; dedupeKey: string }> = [];
|
||||
presentExternalMcpApprovalNotification({
|
||||
toolName: 'terminal.execute',
|
||||
dedupeKey: 'mcp_approval_3',
|
||||
mode: 'always',
|
||||
t,
|
||||
notify: (payload) => calls.push(payload),
|
||||
});
|
||||
assert.deepEqual(calls, [{
|
||||
title: 'External MCP approval',
|
||||
body: 'terminal.execute is waiting for approval',
|
||||
dedupeKey: 'mcp_approval_3',
|
||||
whenUnfocused: false,
|
||||
}]);
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ApprovalRequest } from '../../infrastructure/ai/shared/approvalGate';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import {
|
||||
readExternalMcpApprovalAlertMode,
|
||||
type ExternalMcpApprovalAlertMode,
|
||||
} from './useExternalMcpToggleState';
|
||||
|
||||
export type ExternalMcpApprovalNotification = {
|
||||
title: string;
|
||||
body: string;
|
||||
dedupeKey: string;
|
||||
whenUnfocused: boolean;
|
||||
};
|
||||
|
||||
type Translate = (
|
||||
key: string,
|
||||
values?: Record<string, string | number | boolean | null | undefined>,
|
||||
) => string;
|
||||
|
||||
/**
|
||||
* Build the system notification for one External MCP approval.
|
||||
* Returns null when the preference is off. Focus gating stays in the main
|
||||
* process so every Netcatty window is considered, not only the sender.
|
||||
*/
|
||||
export function presentExternalMcpApprovalNotification(options: {
|
||||
toolName: string;
|
||||
hostLabel?: string;
|
||||
dedupeKey: string;
|
||||
mode: ExternalMcpApprovalAlertMode;
|
||||
t: Translate;
|
||||
notify: (payload: ExternalMcpApprovalNotification) => void;
|
||||
}): boolean {
|
||||
if (options.mode === 'off') return false;
|
||||
const tool = options.toolName || '';
|
||||
const host = options.hostLabel?.trim() || '';
|
||||
const body = host
|
||||
? options.t('ai.externalMcp.approvalAlert.notificationBodyOnHost', { tool, host })
|
||||
: options.t('ai.externalMcp.approvalAlert.notificationBody', { tool });
|
||||
options.notify({
|
||||
title: options.t('ai.externalMcp.approvalAlert.notificationTitle'),
|
||||
body,
|
||||
dedupeKey: options.dedupeKey,
|
||||
whenUnfocused: options.mode === 'unfocused',
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export function publishExternalMcpApprovalAlertMode(): void {
|
||||
void netcattyBridge.get()?.setExternalMcpApprovalAlertMode?.({
|
||||
mode: readExternalMcpApprovalAlertMode(),
|
||||
});
|
||||
}
|
||||
|
||||
export function notifyExternalMcpApproval(
|
||||
request: Pick<ApprovalRequest, 'toolName' | 'toolCallId' | 'target'>,
|
||||
t: Translate,
|
||||
): boolean {
|
||||
return presentExternalMcpApprovalNotification({
|
||||
toolName: request.toolName,
|
||||
hostLabel: request.target?.label || request.target?.hostname || '',
|
||||
dedupeKey: request.toolCallId,
|
||||
mode: readExternalMcpApprovalAlertMode(),
|
||||
t,
|
||||
notify: (payload) => {
|
||||
void netcattyBridge.get()?.showSystemNotification?.(payload);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -530,8 +530,12 @@ test("only trusted live terminal cwd recovery resumes follow after initial probe
|
||||
await act(async () => renderer?.unmount());
|
||||
});
|
||||
|
||||
test("an in-flight follow probe cannot navigate after the focused session changes", async () => {
|
||||
test("regular follow waits for trusted live cwd instead of polling after command clear", async () => {
|
||||
// Regression for #3588: command submit clears cwd to null and schedules a
|
||||
// backend-strict probe. Auto-follow must not preferFreshBackend-poll in that
|
||||
// gap — that race aborted the navigate and left the panel stuck.
|
||||
const navigatedPaths: string[] = [];
|
||||
let probeCalls = 0;
|
||||
const connection = {
|
||||
id: "conn-1",
|
||||
hostId: "host-1",
|
||||
@@ -550,20 +554,15 @@ test("an in-flight follow probe cannot navigate after the focused session change
|
||||
},
|
||||
},
|
||||
};
|
||||
let activeSessionId = "session-a";
|
||||
let activeTerminalCwd: string | null = "/home/alice";
|
||||
let resolveCwd: ((cwd: string) => void) | null = null;
|
||||
let deferProbe = false;
|
||||
let activeTerminalCwdTrusted = false;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const onGetTerminalCwd = () => {
|
||||
if (!deferProbe) return Promise.resolve("/root/session-a");
|
||||
return new Promise<string>((resolve) => { resolveCwd = resolve; });
|
||||
};
|
||||
|
||||
function Probe() {
|
||||
useSftpFollowTerminalCwd({
|
||||
activeSessionId,
|
||||
activeSessionId: "session-1",
|
||||
activeTerminalCwd,
|
||||
activeTerminalCwdTrusted,
|
||||
canFollowTerminalCwd: true,
|
||||
connectionId: connection.id,
|
||||
connectionIsLocal: connection.isLocal,
|
||||
@@ -575,7 +574,11 @@ test("an in-flight follow probe cannot navigate after the focused session change
|
||||
hasActiveWork: false,
|
||||
isVisible: true,
|
||||
ownerPanelOpen: true,
|
||||
onGetTerminalCwd,
|
||||
onGetTerminalCwd: async () => {
|
||||
probeCalls += 1;
|
||||
// First-open may probe; keep the pane where it already is.
|
||||
return connection.currentPath;
|
||||
},
|
||||
onPendingFollowOverride: () => {},
|
||||
sftpRef,
|
||||
});
|
||||
@@ -586,23 +589,28 @@ test("an in-flight follow probe cannot navigate after the focused session change
|
||||
renderer = create(React.createElement(Probe));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
assert.deepEqual(navigatedPaths, ["/root/session-a"]);
|
||||
const probesAfterOpen = probeCalls;
|
||||
assert.ok(probesAfterOpen >= 1, "first-open sync still probes");
|
||||
assert.deepEqual(navigatedPaths, []);
|
||||
|
||||
deferProbe = true;
|
||||
// Command-submit style clear: no trusted cwd yet.
|
||||
activeTerminalCwd = null;
|
||||
activeTerminalCwdTrusted = false;
|
||||
await act(async () => {
|
||||
renderer?.update(React.createElement(Probe));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
assert.ok(resolveCwd);
|
||||
assert.equal(probeCalls, probesAfterOpen, "regular follow must not poll while cwd is cleared");
|
||||
assert.deepEqual(navigatedPaths, []);
|
||||
|
||||
activeSessionId = "session-b";
|
||||
await act(async () => renderer?.update(React.createElement(Probe)));
|
||||
// Post-command backend-strict (or OSC 7) publishes a trusted live cwd.
|
||||
activeTerminalCwd = "/etc";
|
||||
activeTerminalCwdTrusted = true;
|
||||
await act(async () => {
|
||||
resolveCwd?.("/root/from-session-a");
|
||||
renderer?.update(React.createElement(Probe));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
|
||||
assert.deepEqual(navigatedPaths, ["/root/session-a"]);
|
||||
assert.deepEqual(navigatedPaths, ["/etc"]);
|
||||
assert.equal(probeCalls, probesAfterOpen, "trusted live cwd must navigate without another poll");
|
||||
await act(async () => renderer?.unmount());
|
||||
});
|
||||
@@ -302,6 +302,12 @@ export function useSftpFollowTerminalCwd({
|
||||
&& previousOriginId !== expectedSessionId,
|
||||
);
|
||||
if (expectedSessionId) lastFollowOriginIdRef.current = expectedSessionId;
|
||||
// Auto-follow must wait for a trusted live cwd (OSC 7, post-command
|
||||
// backend-strict probe, or inferred). Polling preferFreshBackend here while
|
||||
// cwd is null/untrusted races that probe: navigate starts, the probe then
|
||||
// invalidates the sync generation, abort restores the previous path, and
|
||||
// without a connectionPath effect dependency the panel stays put (#3588).
|
||||
// Manual "Go to terminal directory" and first-open sync still probe.
|
||||
const usesLiveTerminalCwd = Boolean(activeTerminalCwd && activeTerminalCwdTrusted);
|
||||
let terminalCwd = usesLiveTerminalCwd ? activeTerminalCwd : null;
|
||||
if (!terminalCwd && originChanged) {
|
||||
@@ -311,12 +317,6 @@ export function useSftpFollowTerminalCwd({
|
||||
homeDir: sftpRef.current.leftPane.connection?.homeDir,
|
||||
currentPath: sftpRef.current.leftPane.connection?.currentPath,
|
||||
});
|
||||
} else if (!terminalCwd) {
|
||||
terminalCwd = await onGetTerminalCwd({
|
||||
preferFreshBackend: true,
|
||||
allowRendererFallback: false,
|
||||
requireActiveShellCwd: true,
|
||||
});
|
||||
}
|
||||
if (!terminalCwd) return;
|
||||
if (!shouldApplyFollowTerminalCwdSyncResult({
|
||||
@@ -430,6 +430,7 @@ export function useSftpFollowTerminalCwd({
|
||||
canFollowTerminalCwd,
|
||||
connectionId,
|
||||
connectionIsLocal,
|
||||
connectionPath,
|
||||
connectionStatus,
|
||||
effectiveFollowTerminalCwd,
|
||||
focusedSessionId,
|
||||
|
||||
@@ -56,6 +56,28 @@ test('active reconnect state blocks duplicate requests and notifies subscribers'
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test('enter reconnect stays off until the session allows it and does not queue', () => {
|
||||
const registry = createTerminalReconnectRegistry();
|
||||
const requests: string[] = [];
|
||||
|
||||
assert.equal(registry.requestEnterReconnect('session-1'), false);
|
||||
registry.register('session-1', () => requests.push('session-1'));
|
||||
assert.deepEqual(requests, []);
|
||||
|
||||
registry.setEnterReconnectEnabled('session-1', true);
|
||||
assert.equal(registry.requestEnterReconnect('session-1'), true);
|
||||
assert.deepEqual(requests, ['session-1']);
|
||||
|
||||
registry.setActive('session-1', true);
|
||||
assert.equal(registry.requestEnterReconnect('session-1'), false);
|
||||
assert.deepEqual(requests, ['session-1']);
|
||||
|
||||
registry.setActive('session-1', false);
|
||||
registry.setEnterReconnectEnabled('session-1', false);
|
||||
assert.equal(registry.requestEnterReconnect('session-1'), false);
|
||||
assert.deepEqual(requests, ['session-1']);
|
||||
});
|
||||
|
||||
test('unregistering the current handler clears active reconnect state', () => {
|
||||
const registry = createTerminalReconnectRegistry();
|
||||
const unregister = registry.register('session-1', () => {});
|
||||
|
||||
@@ -4,6 +4,7 @@ export const createTerminalReconnectRegistry = () => {
|
||||
const handlers = new Map<string, TerminalReconnectHandler>();
|
||||
const pendingRequests = new Set<string>();
|
||||
const activeSessionIds = new Set<string>();
|
||||
const enterReconnectEnabledIds = new Set<string>();
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const emit = () => {
|
||||
@@ -51,7 +52,27 @@ export const createTerminalReconnectRegistry = () => {
|
||||
return true;
|
||||
};
|
||||
|
||||
return { register, request, setActive, isActive, subscribe };
|
||||
// Enter-reconnect while a compose bar is open. Explicit reconnect clicks use
|
||||
// request(); this path stays off during auth, host-key, and blocking overlays.
|
||||
const setEnterReconnectEnabled = (sessionId: string, enabled: boolean): void => {
|
||||
if (enabled) enterReconnectEnabledIds.add(sessionId);
|
||||
else enterReconnectEnabledIds.delete(sessionId);
|
||||
};
|
||||
|
||||
const requestEnterReconnect = (sessionId: string): boolean => {
|
||||
if (!enterReconnectEnabledIds.has(sessionId)) return false;
|
||||
return request(sessionId);
|
||||
};
|
||||
|
||||
return {
|
||||
register,
|
||||
request,
|
||||
requestEnterReconnect,
|
||||
setEnterReconnectEnabled,
|
||||
setActive,
|
||||
isActive,
|
||||
subscribe,
|
||||
};
|
||||
};
|
||||
|
||||
export const terminalReconnectRegistry = createTerminalReconnectRegistry();
|
||||
@@ -11,9 +11,11 @@ import {
|
||||
getExternalMcpStartupReadyWaiterCountForTests,
|
||||
isExternalMcpStartupReady,
|
||||
markExternalMcpStartupReady,
|
||||
normalizeExternalMcpApprovalAlertMode,
|
||||
normalizeExternalMcpIdleTimeoutMinutes,
|
||||
normalizeExternalMcpMode,
|
||||
normalizeSessionIdleTimeoutMinutes,
|
||||
readExternalMcpApprovalAlertMode,
|
||||
readExternalMcpFocusOnHostOpen,
|
||||
readExternalMcpSilentSessions,
|
||||
readExternalMcpStoredEnabled,
|
||||
@@ -25,6 +27,7 @@ import {
|
||||
syncExternalMcpStartupState,
|
||||
syncExternalMcpStartupStateOnce,
|
||||
waitForExternalMcpStartupReady,
|
||||
writeExternalMcpApprovalAlertMode,
|
||||
writeExternalMcpFocusOnHostOpen,
|
||||
writeExternalMcpSilentSessions,
|
||||
} from './useExternalMcpToggleState.ts';
|
||||
@@ -69,6 +72,22 @@ describe('useExternalMcpToggleState helpers', () => {
|
||||
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(99999), 24 * 60);
|
||||
assert.equal(normalizeSessionIdleTimeoutMinutes(null), 8 * 60);
|
||||
assert.equal(normalizeSessionIdleTimeoutMinutes(0), 1);
|
||||
assert.equal(normalizeExternalMcpApprovalAlertMode(null), 'unfocused');
|
||||
assert.equal(normalizeExternalMcpApprovalAlertMode('always'), 'always');
|
||||
assert.equal(normalizeExternalMcpApprovalAlertMode('nope'), 'unfocused');
|
||||
});
|
||||
|
||||
it('stores the approval alert preference and defaults to unfocused', () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
assert.equal(readExternalMcpApprovalAlertMode(), 'unfocused');
|
||||
assert.equal(writeExternalMcpApprovalAlertMode('off'), 'off');
|
||||
assert.equal(readExternalMcpApprovalAlertMode(), 'off');
|
||||
assert.equal(writeExternalMcpApprovalAlertMode('always'), 'always');
|
||||
assert.equal(readExternalMcpApprovalAlertMode(), 'always');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
for (const savedMinutes of [null, 30]) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES,
|
||||
@@ -14,6 +15,9 @@ import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
||||
|
||||
export type ExternalMcpMode = 'temporary' | 'persistent';
|
||||
export type ExternalMcpApprovalAlertMode = 'off' | 'unfocused' | 'always';
|
||||
|
||||
export const DEFAULT_EXTERNAL_MCP_APPROVAL_ALERT_MODE: ExternalMcpApprovalAlertMode = 'unfocused';
|
||||
|
||||
const DEFAULT_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 10;
|
||||
const MIN_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 1;
|
||||
@@ -183,6 +187,26 @@ export function writeExternalMcpFocusOnHostOpen(focusOnHostOpen: boolean): void
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN);
|
||||
}
|
||||
|
||||
export function normalizeExternalMcpApprovalAlertMode(value: string | null): ExternalMcpApprovalAlertMode {
|
||||
return value === 'off' || value === 'always' || value === 'unfocused'
|
||||
? value
|
||||
: DEFAULT_EXTERNAL_MCP_APPROVAL_ALERT_MODE;
|
||||
}
|
||||
|
||||
/** System notification preference for confirm-mode External MCP approvals. */
|
||||
export function readExternalMcpApprovalAlertMode(): ExternalMcpApprovalAlertMode {
|
||||
return normalizeExternalMcpApprovalAlertMode(
|
||||
localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT),
|
||||
);
|
||||
}
|
||||
|
||||
export function writeExternalMcpApprovalAlertMode(mode: ExternalMcpApprovalAlertMode): ExternalMcpApprovalAlertMode {
|
||||
const normalized = normalizeExternalMcpApprovalAlertMode(mode);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT, normalized);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** Whether host_open sessions stay hidden from the tab bar. Defaults to false (existing behavior). */
|
||||
export function readExternalMcpSilentSessions(): boolean {
|
||||
return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS) ?? false;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React, { act } from 'react';
|
||||
import { create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { useServerStats } from './useServerStats';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
for (const scenario of ['retry', 'pause', 'manual'] as const) {
|
||||
test(`server stats ${scenario} after three failures`, async t => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval', 'Date'], now: 10_000 });
|
||||
const originalActEnvironment = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
||||
let calls = 0;
|
||||
t.mock.method(netcattyBridge, 'get', () => ({
|
||||
getServerStats: async () => {
|
||||
calls++;
|
||||
return calls <= 3 ? { success: false, error: 'temporary failure' }
|
||||
: { success: true, stats: { cpu: 42 } };
|
||||
},
|
||||
}));
|
||||
let current: ReturnType<typeof useServerStats>;
|
||||
function Probe({ enabled = true }: { enabled?: boolean }) {
|
||||
current = useServerStats({ sessionId: `recovery-${scenario}`, enabled,
|
||||
refreshInterval: 5, isSupportedOs: true, isConnected: true });
|
||||
return null;
|
||||
}
|
||||
let root: ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => { root = create(React.createElement(Probe)); });
|
||||
await act(async () => t.mock.timers.tick(2000));
|
||||
await act(async () => t.mock.timers.tick(3000));
|
||||
await act(async () => t.mock.timers.tick(5000));
|
||||
assert.equal(calls, 3);
|
||||
assert.equal(current!.error, 'temporary failure');
|
||||
if (scenario === 'pause') {
|
||||
await act(async () => root!.update(React.createElement(Probe, { enabled: false })));
|
||||
await act(async () => t.mock.timers.tick(60_000));
|
||||
assert.equal(calls, 3, 'disabled clients must not retry');
|
||||
await act(async () => root!.update(React.createElement(Probe)));
|
||||
await act(async () => t.mock.timers.tick(1));
|
||||
} else if (scenario === 'manual') {
|
||||
await act(async () => current!.refresh());
|
||||
} else {
|
||||
await act(async () => t.mock.timers.tick(29_999));
|
||||
assert.equal(calls, 3);
|
||||
await act(async () => t.mock.timers.tick(1));
|
||||
await act(async () => t.mock.timers.tick(1));
|
||||
}
|
||||
assert.equal(calls, 4);
|
||||
assert.equal(current!.stats.cpu, 42);
|
||||
assert.equal(current!.error, null);
|
||||
await act(async () => t.mock.timers.tick(5000));
|
||||
assert.equal(calls, 5, 'successful recovery must resume normal polling');
|
||||
} finally {
|
||||
await act(async () => root?.unmount());
|
||||
if (originalActEnvironment) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', originalActEnvironment);
|
||||
else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
t.mock.timers.reset();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('stats reply after disabling cannot publish or schedule recovery', async t => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval', 'Date'], now: 10_000 });
|
||||
const originalActEnvironment = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
||||
let finish: (result: unknown) => void;
|
||||
let calls = 0;
|
||||
t.mock.method(netcattyBridge, 'get', () => ({ getServerStats: () => {
|
||||
calls++;
|
||||
return new Promise(resolve => { finish = resolve; });
|
||||
} }));
|
||||
let current: ReturnType<typeof useServerStats>;
|
||||
function Probe({ enabled }: { enabled: boolean }) {
|
||||
current = useServerStats({ sessionId: 'late-disabled', enabled,
|
||||
refreshInterval: 5, isSupportedOs: true, isConnected: true });
|
||||
return null;
|
||||
}
|
||||
let root: ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => { root = create(React.createElement(Probe, { enabled: true })); });
|
||||
await act(async () => t.mock.timers.tick(2000));
|
||||
assert.equal(calls, 1);
|
||||
await act(async () => root!.update(React.createElement(Probe, { enabled: false })));
|
||||
await act(async () => finish!({ success: true, stats: { cpu: 99 } }));
|
||||
assert.equal(current!.stats.lastUpdated, null);
|
||||
assert.equal(current!.isLoading, false);
|
||||
await act(async () => t.mock.timers.tick(60_000));
|
||||
assert.equal(calls, 1);
|
||||
} finally {
|
||||
await act(async () => root?.unmount());
|
||||
if (originalActEnvironment) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', originalActEnvironment);
|
||||
else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
t.mock.timers.reset();
|
||||
}
|
||||
});
|
||||
@@ -86,6 +86,7 @@ interface SharedServerStatsSession {
|
||||
clients: Map<number, ServerStatsClient>;
|
||||
intervalRef: ReturnType<typeof setInterval> | null;
|
||||
initialTimerRef: ReturnType<typeof setTimeout> | null;
|
||||
retryTimerRef: ReturnType<typeof setTimeout> | null;
|
||||
connectedAt: number;
|
||||
hasFetched: boolean;
|
||||
fetchGeneration: number;
|
||||
@@ -154,6 +155,7 @@ function getSharedServerStatsSession(sessionId: string): SharedServerStatsSessio
|
||||
clients: new Map(),
|
||||
intervalRef: null,
|
||||
initialTimerRef: null,
|
||||
retryTimerRef: null,
|
||||
connectedAt: 0,
|
||||
hasFetched: false,
|
||||
fetchGeneration: 0,
|
||||
@@ -180,6 +182,10 @@ function updateServerStatsState(session: SharedServerStatsSession, patch: Partia
|
||||
}
|
||||
|
||||
function clearServerStatsTimers(session: SharedServerStatsSession): void {
|
||||
if (session.retryTimerRef) {
|
||||
clearTimeout(session.retryTimerRef);
|
||||
session.retryTimerRef = null;
|
||||
}
|
||||
if (session.initialTimerRef) {
|
||||
clearTimeout(session.initialTimerRef);
|
||||
session.initialTimerRef = null;
|
||||
@@ -236,10 +242,17 @@ function markServerStatsFailure(session: SharedServerStatsSession, message: stri
|
||||
session.consecutiveFailures += 1;
|
||||
updateServerStatsState(session, { error: message });
|
||||
if (session.consecutiveFailures >= CONSECUTIVE_FAILURE_LIMIT) {
|
||||
// Stop polling this session. This covers network devices that advertise
|
||||
// OpenSSH but do not support the POSIX stats shell command.
|
||||
// Back off unsupported or temporarily unavailable probes, then recover
|
||||
// automatically. Three transient failures must not disable the panel for
|
||||
// the entire lifetime of an otherwise healthy terminal session.
|
||||
session.givenUp = true;
|
||||
clearServerStatsTimers(session);
|
||||
session.retryTimerRef = setTimeout(() => {
|
||||
session.retryTimerRef = null;
|
||||
session.givenUp = false;
|
||||
session.consecutiveFailures = 0;
|
||||
reconcileSharedServerStatsSession(session);
|
||||
}, 30_000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,6 +269,10 @@ async function fetchSharedServerStats(session: SharedServerStatsSession, force =
|
||||
if (!bridge?.getServerStats) return;
|
||||
|
||||
if (force) {
|
||||
if (session.retryTimerRef) {
|
||||
clearTimeout(session.retryTimerRef);
|
||||
session.retryTimerRef = null;
|
||||
}
|
||||
session.givenUp = false;
|
||||
session.consecutiveFailures = 0;
|
||||
}
|
||||
@@ -319,6 +336,11 @@ function reconcileSharedServerStatsSession(session: SharedServerStatsSession): v
|
||||
// (or re-enabling a terminal stats strip) can paint immediately instead of
|
||||
// flashing "No system overview data yet." Still clear give-up / failure so a
|
||||
// later resume can auto-retry (old reset wiped stats + flags together).
|
||||
// Invalidate a request before pausing: a late reply must not publish data
|
||||
// or re-arm failure/retry timers after disconnect/disable.
|
||||
if (session.pollingActive || session.inflight || session.givenUp) {
|
||||
session.fetchGeneration += 1;
|
||||
}
|
||||
clearServerStatsTimers(session);
|
||||
session.givenUp = false;
|
||||
session.consecutiveFailures = 0;
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider";
|
||||
import type { Host, PortForwardingRule, PortForwardingType } from "../domain/models";
|
||||
import { EditPanel } from "./port-forwarding/EditPanel";
|
||||
import { NewFormPanel } from "./port-forwarding/NewFormPanel";
|
||||
import { WizardContent } from "./port-forwarding/WizardContent";
|
||||
import { TooltipProvider } from "./ui/tooltip";
|
||||
|
||||
const baseHost: Omit<Host, "os"> = {
|
||||
id: "host-1",
|
||||
label: "Forwarding host",
|
||||
hostname: "example.com",
|
||||
username: "user",
|
||||
tags: [],
|
||||
};
|
||||
|
||||
const baseRule: PortForwardingRule = {
|
||||
id: "rule-1",
|
||||
label: "Test forwarding rule",
|
||||
type: "local",
|
||||
hostId: baseHost.id,
|
||||
localPort: 8080,
|
||||
bindAddress: "127.0.0.1",
|
||||
remoteHost: "localhost",
|
||||
remotePort: 80,
|
||||
status: "inactive",
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
const noop = () => undefined;
|
||||
const panelRenderers = {
|
||||
edit: (hosts: Host[], draft: PortForwardingRule) => (
|
||||
<EditPanel
|
||||
rule={draft}
|
||||
draft={draft}
|
||||
hosts={hosts}
|
||||
onDraftChange={noop}
|
||||
onSave={noop}
|
||||
onClose={noop}
|
||||
onDuplicate={noop}
|
||||
onDelete={noop}
|
||||
onOpenHostSelector={noop}
|
||||
/>
|
||||
),
|
||||
new: (hosts: Host[], draft: PortForwardingRule) => (
|
||||
<NewFormPanel
|
||||
draft={draft}
|
||||
hosts={hosts}
|
||||
onDraftChange={noop}
|
||||
onSave={noop}
|
||||
onClose={noop}
|
||||
onOpenHostSelector={noop}
|
||||
onOpenWizard={noop}
|
||||
isValid={false}
|
||||
/>
|
||||
),
|
||||
"remote-host-selection": (hosts: Host[], draft: PortForwardingRule) => (
|
||||
<WizardContent
|
||||
step="remote-host-selection"
|
||||
type={draft.type}
|
||||
draft={draft}
|
||||
hosts={hosts}
|
||||
onTypeChange={noop}
|
||||
onDraftChange={noop}
|
||||
onOpenHostSelector={noop}
|
||||
/>
|
||||
),
|
||||
"host-selection": (hosts: Host[], draft: PortForwardingRule) => (
|
||||
<WizardContent
|
||||
step="host-selection"
|
||||
type={draft.type}
|
||||
draft={draft}
|
||||
hosts={hosts}
|
||||
onTypeChange={noop}
|
||||
onDraftChange={noop}
|
||||
onOpenHostSelector={noop}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
const hostCases: { name: string; fields: { os?: Host["os"] | "" | null } }[] = [
|
||||
{ name: "missing OS", fields: {} },
|
||||
{ name: "undefined OS", fields: { os: undefined } },
|
||||
{ name: "null OS", fields: { os: null } },
|
||||
{ name: "empty OS", fields: { os: "" } },
|
||||
{ name: "Linux", fields: { os: "linux" } },
|
||||
{ name: "Windows", fields: { os: "windows" } },
|
||||
{ name: "macOS", fields: { os: "macos" } },
|
||||
];
|
||||
|
||||
function render(panel: React.ReactNode): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<TooltipProvider>{panel}</TooltipProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
for (const [panelName, renderPanel] of Object.entries(panelRenderers)) {
|
||||
for (const type of ["local", "remote", "dynamic"] as PortForwardingType[]) {
|
||||
for (const { name, fields } of hostCases) {
|
||||
test(`${panelName} renders ${type} forwarding with ${name}`, () => {
|
||||
// Persisted legacy records can violate the required Host.os type at runtime.
|
||||
const host = { ...baseHost, ...fields } as unknown as Host;
|
||||
const markup = render(renderPanel([host], { ...baseRule, type }));
|
||||
|
||||
assert.match(markup, /Forwarding host/);
|
||||
assert.match(markup, /lucide-server/);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test(`${panelName} preserves the selected host's distro icon`, () => {
|
||||
const host: Host = { ...baseHost, os: "linux", distro: "ubuntu" };
|
||||
const markup = render(renderPanel([host], baseRule));
|
||||
|
||||
assert.match(markup, /Forwarding host/);
|
||||
assert.match(markup, /src="\/distro\/ubuntu\.svg"/);
|
||||
});
|
||||
|
||||
test(`${panelName} renders the host picker when no host is selected`, () => {
|
||||
const markup = render(renderPanel([], { ...baseRule, hostId: undefined }));
|
||||
|
||||
assert.match(markup, /Select a host/);
|
||||
assert.doesNotMatch(markup, /Forwarding host/);
|
||||
});
|
||||
|
||||
test(`${panelName} renders the host picker when the selected host is unavailable`, () => {
|
||||
const markup = render(renderPanel([], baseRule));
|
||||
|
||||
assert.match(markup, /Select a host/);
|
||||
assert.doesNotMatch(markup, /Forwarding host/);
|
||||
});
|
||||
}
|
||||
@@ -76,8 +76,8 @@ import {
|
||||
resolveLocateSftpPathInTerminalAction,
|
||||
resolveLocateSftpPathSessionId,
|
||||
} from "../domain/sftpLocatePathInTerminal";
|
||||
import { sftpRequiresDedicatedLogin } from "../domain/sftpDedicatedLogin";
|
||||
import { classifyDistroId } from "../domain/host";
|
||||
import { remoteSoftwareRequiresSingleChannel } from "../domain/singleChannelSshBanner.shared.mjs";
|
||||
import { useTerminalBackend } from "../application/state/useTerminalBackend";
|
||||
import { isTerminalSensitiveInputActive } from "./terminal/runtime/terminalSensitiveInputRegistry";
|
||||
import { isTerminalReadyForCommandInjection } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
|
||||
@@ -577,7 +577,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
const terminalBackend = useTerminalBackend();
|
||||
const [sftpBannerDecision, setSftpBannerDecision] = useState<{
|
||||
sessionId: string;
|
||||
singleChannel: boolean;
|
||||
remoteSshVersion?: string;
|
||||
} | null>(null);
|
||||
const activeSessionStatusForBanner = activeSessionId
|
||||
? sessions.find((session) => session.id === activeSessionId)?.status ?? null
|
||||
@@ -590,7 +590,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
}
|
||||
const readRemoteInfo = terminalBackend.getSessionRemoteInfo;
|
||||
if (!readRemoteInfo) {
|
||||
setSftpBannerDecision({ sessionId: activeSessionId, singleChannel: false });
|
||||
setSftpBannerDecision({ sessionId: activeSessionId });
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
@@ -598,11 +598,11 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
if (cancelled) return;
|
||||
setSftpBannerDecision({
|
||||
sessionId: activeSessionId,
|
||||
singleChannel: remoteSoftwareRequiresSingleChannel(info?.remoteSshVersion),
|
||||
remoteSshVersion: info?.remoteSshVersion,
|
||||
});
|
||||
}).catch(() => {
|
||||
if (!cancelled) {
|
||||
setSftpBannerDecision({ sessionId: activeSessionId, singleChannel: false });
|
||||
setSftpBannerDecision({ sessionId: activeSessionId });
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
@@ -733,8 +733,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
nextStatus: activeSessionStatus ?? (activeSessionId ? "connected" : null),
|
||||
});
|
||||
// The peer banner is known only after the terminal handshake. Wait for it
|
||||
// before choosing a shared terminal channel, so a one-channel bastion
|
||||
// dials its file login directly.
|
||||
// before choosing a shared terminal channel, so a one-channel bastion or
|
||||
// network-vendor switch dials its file login directly.
|
||||
if (
|
||||
activeSessionId
|
||||
&& activeSessionStatus === "connected"
|
||||
@@ -935,8 +935,14 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
|
||||
connectedKeyRef.current = connectionKey;
|
||||
connectedHostObjRef.current = activeHost;
|
||||
const dedicatedSftpLogin = sftpBannerDecision?.sessionId === activeSessionId
|
||||
&& sftpBannerDecision.singleChannel;
|
||||
// Independent login: network-device mode, a network-vendor banner, or a
|
||||
// one-channel bastion. Do not open SFTP on the terminal connection.
|
||||
const dedicatedSftpLogin = sftpRequiresDedicatedLogin({
|
||||
host: activeHost,
|
||||
remoteSshVersion: sftpBannerDecision?.sessionId === activeSessionId
|
||||
? sftpBannerDecision.remoteSshVersion
|
||||
: undefined,
|
||||
});
|
||||
const reuseTerminalTransport = !dedicatedSftpLogin && (
|
||||
Boolean(pendingStrictSourceSessionId) || activeSessionStatus === "connected"
|
||||
);
|
||||
@@ -1382,10 +1388,12 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
activeHost={activeHost}
|
||||
activeSessionId={activeSessionId}
|
||||
bannerRequiresSingleChannel={
|
||||
sftpBannerDecision?.sessionId === activeSessionId
|
||||
&& sftpBannerDecision.singleChannel
|
||||
}
|
||||
bannerRequiresSingleChannel={sftpRequiresDedicatedLogin({
|
||||
host: activeHost,
|
||||
remoteSshVersion: sftpBannerDecision?.sessionId === activeSessionId
|
||||
? sftpBannerDecision.remoteSshVersion
|
||||
: undefined,
|
||||
})}
|
||||
focusedSessionId={focusedSessionId}
|
||||
showWorkspaceHostHeader={showWorkspaceHostHeader}
|
||||
renderOverlays={renderOverlays}
|
||||
|
||||
+12
-2
@@ -1162,7 +1162,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
allowRendererFallback?: boolean;
|
||||
requireActiveShellCwd?: boolean;
|
||||
}): Promise<string | undefined> => {
|
||||
const skipBackendPwd = hostRestrictsExtraSshChannels(host);
|
||||
// The backend pwd probe opens an exec channel on the terminal connection;
|
||||
// network devices (mode, detected distro, or vendor banner such as Huawei
|
||||
// `-`) can drop the whole session for it (#1043, #3631).
|
||||
let skipBackendPwd = hostRestrictsExtraSshChannels(host);
|
||||
if (!skipBackendPwd && sessionRef.current) {
|
||||
const info = await terminalBackend.getSessionRemoteInfo?.(sessionRef.current).catch(() => null);
|
||||
skipBackendPwd = !shouldProbeSessionCwd({
|
||||
isNetworkDevice: classifyDistroId(host.distro) === 'network-device',
|
||||
remoteSshVersion: info?.remoteSshVersion,
|
||||
});
|
||||
}
|
||||
const cwd = await resolvePreferredTerminalCwd({
|
||||
rendererCwd: terminalCwdTracker.getRendererCwd(),
|
||||
rendererCwdSource: terminalCwdTracker.getRendererCwdSource(),
|
||||
@@ -1175,7 +1185,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
requireActiveShellCwd: options?.requireActiveShellCwd,
|
||||
});
|
||||
return cwd ?? undefined;
|
||||
}, [terminalBackend, terminalCwdTracker]);
|
||||
}, [host, terminalBackend, terminalCwdTracker]);
|
||||
|
||||
const clearTerminalCwd = useCallback((options?: { persistRestoreMetadata?: boolean }) => {
|
||||
terminalCwdTracker.clearRendererCwd();
|
||||
|
||||
@@ -1594,7 +1594,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}): Promise<string | null> => {
|
||||
const sessionId = getActiveTerminalSessionId();
|
||||
const host = sessionId ? sessionHostsMapRef.current.get(sessionId) : undefined;
|
||||
const skipBackendPwd = hostRestrictsExtraSshChannels(host);
|
||||
// Same rule as the terminal's own SFTP path lookup (#3631).
|
||||
let skipBackendPwd = hostRestrictsExtraSshChannels(host);
|
||||
if (!skipBackendPwd && sessionId && host) {
|
||||
const info = await terminalBackend.getSessionRemoteInfo?.(sessionId).catch(() => null);
|
||||
skipBackendPwd = !shouldProbeSessionCwd({
|
||||
isNetworkDevice: classifyDistroId(host.distro) === 'network-device',
|
||||
remoteSshVersion: info?.remoteSshVersion,
|
||||
});
|
||||
}
|
||||
return resolvePreferredTerminalCwd({
|
||||
rendererCwd: sessionId ? terminalRendererCwdBySessionRef.current.get(sessionId) : undefined,
|
||||
rendererCwdSource: sessionId
|
||||
|
||||
@@ -294,6 +294,26 @@ test("session close button double click stays on the close button", () => {
|
||||
assert.match(topTabItemsSource, /onDoubleClick=\{stopCloseButtonDoubleClickPropagation\}/);
|
||||
});
|
||||
|
||||
test("main window controls keep mouse presses from taking focus", () => {
|
||||
const windowControlsSource = topTabItemsSource.slice(
|
||||
topTabItemsSource.indexOf("export const WindowControls"),
|
||||
topTabItemsSource.indexOf("WindowControls.displayName"),
|
||||
);
|
||||
|
||||
assert.match(
|
||||
windowControlsSource,
|
||||
/const preventWindowControlFocus: React\.MouseEventHandler<HTMLButtonElement> = \(event\) => \{\s*event\.preventDefault\(\);\s*\};/,
|
||||
);
|
||||
assert.equal(windowControlsSource.match(/onMouseDown=\{preventWindowControlFocus\}/g)?.length, 2);
|
||||
const closeButton = windowControlsSource.match(
|
||||
/<button type="button" className=\{closeControlClassName\}[^>]*>/,
|
||||
)?.[0] ?? "";
|
||||
assert.equal(closeButton.includes("onMouseDown"), false);
|
||||
assert.match(windowControlsSource, /onClick=\{handleMinimize\}/);
|
||||
assert.match(windowControlsSource, /onClick=\{handleMaximize\}/);
|
||||
assert.match(windowControlsSource, /onClick=\{handleClose\}/);
|
||||
});
|
||||
|
||||
test("session top tab label only shows the host label for ssh sessions", () => {
|
||||
assert.equal(
|
||||
formatSessionTopTabLabel({
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* even when the Catty AI side panel has never been opened.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ToolCall } from '../ai-elements/tool-call';
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
@@ -20,6 +20,12 @@ import {
|
||||
resolveCapabilityId,
|
||||
} from '../../infrastructure/ai/harness/permissionGrants';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { AI_STATE_CHANGED_EVENT } from '../../application/state/aiStateEvents';
|
||||
import {
|
||||
notifyExternalMcpApproval,
|
||||
publishExternalMcpApprovalAlertMode,
|
||||
} from '../../application/state/externalMcpApprovalAlert';
|
||||
import { STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT } from '../../infrastructure/config/storageKeys';
|
||||
|
||||
const EXTERNAL_MCP_CHAT_SESSION_ID = '__external_mcp__';
|
||||
|
||||
@@ -31,19 +37,37 @@ function isExternalMcpApproval(request: ApprovalRequest): boolean {
|
||||
export const ExternalMcpApprovalsHost: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const [pendingApprovals, setPendingApprovals] = useState<Map<string, ApprovalRequest>>(new Map());
|
||||
const alertedIdsRef = useRef(new Set<string>());
|
||||
|
||||
useEffect(() => {
|
||||
const pushMode = () => {
|
||||
publishExternalMcpApprovalAlertMode();
|
||||
};
|
||||
pushMode();
|
||||
const onChange = (event: Event) => {
|
||||
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
|
||||
if (key === STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT) pushMode();
|
||||
};
|
||||
window.addEventListener(AI_STATE_CHANGED_EVENT, onChange);
|
||||
return () => window.removeEventListener(AI_STATE_CHANGED_EVENT, onChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (request: ApprovalRequest) => {
|
||||
if (!isExternalMcpApproval(request)) return;
|
||||
setPendingApprovals((prev) => new Map(prev).set(request.toolCallId, request));
|
||||
if (alertedIdsRef.current.has(request.toolCallId)) return;
|
||||
alertedIdsRef.current.add(request.toolCallId);
|
||||
notifyExternalMcpApproval(request, t);
|
||||
};
|
||||
const unsub = onApprovalRequest(handler);
|
||||
replayPendingApprovals(handler);
|
||||
return unsub;
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
return onApprovalCleared((clearedIds) => {
|
||||
for (const id of clearedIds) alertedIdsRef.current.delete(id);
|
||||
setPendingApprovals((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const id of clearedIds) next.delete(id);
|
||||
|
||||
@@ -134,7 +134,7 @@ export const EditPanel: React.FC<EditPanelProps> = ({
|
||||
<div className="flex items-center gap-2">
|
||||
<DistroAvatar
|
||||
host={selectedHost}
|
||||
fallback={selectedHost.os[0].toUpperCase()}
|
||||
fallback={(selectedHost.os || 'unknown').charAt(0).toUpperCase()}
|
||||
size="tree"
|
||||
/>
|
||||
<span>{selectedHost.label}</span>
|
||||
|
||||
@@ -134,7 +134,7 @@ export const NewFormPanel: React.FC<NewFormPanelProps> = ({
|
||||
<div className="flex items-center gap-2">
|
||||
<DistroAvatar
|
||||
host={selectedHost}
|
||||
fallback={selectedHost.os[0].toUpperCase()}
|
||||
fallback={(selectedHost.os || 'unknown').charAt(0).toUpperCase()}
|
||||
size="tree"
|
||||
/>
|
||||
<span>{selectedHost.label}</span>
|
||||
|
||||
@@ -123,7 +123,11 @@ export const WizardContent: React.FC<WizardContentProps> = ({
|
||||
>
|
||||
{selectedHost ? (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} size="sm" />
|
||||
<DistroAvatar
|
||||
host={selectedHost}
|
||||
fallback={(selectedHost.os || 'unknown').charAt(0).toUpperCase()}
|
||||
size="sm"
|
||||
/>
|
||||
<span>{selectedHost.label}</span>
|
||||
<Check size={14} className="ml-auto" />
|
||||
</div>
|
||||
@@ -228,7 +232,11 @@ export const WizardContent: React.FC<WizardContentProps> = ({
|
||||
>
|
||||
{selectedHost ? (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} size="sm" />
|
||||
<DistroAvatar
|
||||
host={selectedHost}
|
||||
fallback={(selectedHost.os || 'unknown').charAt(0).toUpperCase()}
|
||||
size="sm"
|
||||
/>
|
||||
<span>{selectedHost.label}</span>
|
||||
<Check size={14} className="ml-auto" />
|
||||
</div>
|
||||
|
||||
@@ -224,9 +224,9 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
|
||||
description={t("settings.terminal.behavior.shiftEnterNewlineText.desc")}
|
||||
>
|
||||
<Input
|
||||
value={terminalSettings.shiftEnterNewlineText ?? "\\n"}
|
||||
value={terminalSettings.shiftEnterNewlineText ?? "\\e\\r"}
|
||||
onChange={(e) => updateTerminalSetting("shiftEnterNewlineText", e.target.value)}
|
||||
placeholder="\\n"
|
||||
placeholder="\\e\\r"
|
||||
className="w-56 font-mono"
|
||||
spellCheck={false}
|
||||
/>
|
||||
|
||||
@@ -2,16 +2,19 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Check, Copy, HelpCircle, RefreshCw } from "lucide-react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import {
|
||||
readExternalMcpApprovalAlertMode,
|
||||
readExternalMcpFocusOnHostOpen,
|
||||
readExternalMcpIdleTimeoutMinutes,
|
||||
readExternalMcpMode,
|
||||
readExternalMcpSilentSessions,
|
||||
readSessionIdleTimeoutMinutes,
|
||||
writeExternalMcpApprovalAlertMode,
|
||||
writeExternalMcpFocusOnHostOpen,
|
||||
writeExternalMcpIdleTimeoutMinutes,
|
||||
writeExternalMcpMode,
|
||||
writeExternalMcpSilentSessions,
|
||||
writeSessionIdleTimeoutMinutes,
|
||||
type ExternalMcpApprovalAlertMode,
|
||||
type ExternalMcpMode,
|
||||
useExternalMcpToggleState,
|
||||
} from "../../../../application/state/useExternalMcpToggleState";
|
||||
@@ -297,6 +300,9 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
const [mode, setModeRaw] = useState<ExternalMcpMode>(() => readExternalMcpMode());
|
||||
const [idleTimeoutMinutes, setIdleTimeoutRaw] = useState<number>(() => readExternalMcpIdleTimeoutMinutes());
|
||||
const [focusOnHostOpen, setFocusOnHostOpenRaw] = useState<boolean>(() => readExternalMcpFocusOnHostOpen());
|
||||
const [approvalAlert, setApprovalAlertRaw] = useState<ExternalMcpApprovalAlertMode>(
|
||||
() => readExternalMcpApprovalAlertMode(),
|
||||
);
|
||||
const [sessionIdleTimeoutMinutes, setSessionIdleTimeoutRaw] = useState<number>(() => readSessionIdleTimeoutMinutes());
|
||||
const [silentSessions, setSilentSessionsRaw] = useState<boolean>(() => readExternalMcpSilentSessions());
|
||||
const [status, setStatus] = useState<ExternalMcpStatus | null>(null);
|
||||
@@ -344,6 +350,10 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
writeExternalMcpFocusOnHostOpen(nextFocusOnHostOpen);
|
||||
}, []);
|
||||
|
||||
const setApprovalAlert = useCallback((next: ExternalMcpApprovalAlertMode) => {
|
||||
setApprovalAlertRaw(writeExternalMcpApprovalAlertMode(next));
|
||||
}, []);
|
||||
|
||||
const setSilentSessions = useCallback((nextSilentSessions: boolean) => {
|
||||
setSilentSessionsRaw(nextSilentSessions);
|
||||
writeExternalMcpSilentSessions(nextSilentSessions);
|
||||
@@ -698,6 +708,24 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<SettingCard divided className="rounded-md border-border/60 bg-background/70">
|
||||
<SettingRow
|
||||
anchorId="ai-external-mcp-approval-alert"
|
||||
label={t("ai.externalMcp.approvalAlert")}
|
||||
description={t("ai.externalMcp.approvalAlert.description")}
|
||||
>
|
||||
<Select
|
||||
value={approvalAlert}
|
||||
options={[
|
||||
{ value: "always", label: t("ai.externalMcp.approvalAlert.always") },
|
||||
{ value: "unfocused", label: t("ai.externalMcp.approvalAlert.unfocused") },
|
||||
{ value: "off", label: t("ai.externalMcp.approvalAlert.off") },
|
||||
]}
|
||||
onChange={(value) => setApprovalAlert(
|
||||
value === "always" || value === "off" ? value : "unfocused",
|
||||
)}
|
||||
className="w-40"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("ai.externalMcp.mode")}
|
||||
description={t("ai.externalMcp.mode.description")}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { remoteSoftwareRequiresSingleChannel } from "../../domain/singleChannelSshBanner.shared.mjs";
|
||||
import { sftpRequiresDedicatedLogin } from "../../domain/sftpDedicatedLogin";
|
||||
|
||||
const sidePanelSource = readFileSync(new URL("../SftpSidePanel.tsx", import.meta.url), "utf8");
|
||||
const slotSource = readFileSync(
|
||||
@@ -49,12 +51,99 @@ test("locate-path uses focused session fallback when SFTP cannot reuse the termi
|
||||
test("single-channel SFTP opens a dedicated connection instead of the terminal channel", () => {
|
||||
assert.match(
|
||||
sidePanelSource,
|
||||
/const dedicatedSftpLogin = sftpBannerDecision\?\.sessionId === activeSessionId\s*&& sftpBannerDecision\.singleChannel;/,
|
||||
/const dedicatedSftpLogin = sftpRequiresDedicatedLogin\(\{\s*host: activeHost,\s*remoteSshVersion: sftpBannerDecision\?\.sessionId === activeSessionId\s*\? sftpBannerDecision\.remoteSshVersion\s*: undefined,\s*\}\);/,
|
||||
);
|
||||
assert.doesNotMatch(sidePanelSource, /activeHost\.singleChannelSsh/);
|
||||
assert.doesNotMatch(sidePanelSource, /sftpBannerDecision\.singleChannel/);
|
||||
assert.match(sidePanelSource, /reuseTransport: false/);
|
||||
assert.match(
|
||||
sidePanelSource,
|
||||
/const reuseTerminalTransport = !dedicatedSftpLogin &&/,
|
||||
);
|
||||
assert.match(
|
||||
sidePanelSource,
|
||||
/sourceSessionId: reuseTerminalTransport/,
|
||||
);
|
||||
});
|
||||
|
||||
test("network-device mode and network-vendor banners force dedicated SFTP", () => {
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "network" }, remoteSshVersion: "OpenSSH_9.6" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "network" } }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion: "-" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion: "SSH-2.0--" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion: "SSH-2.0-RGOS_SSH" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion: "OpenSSH_9.6" }),
|
||||
false,
|
||||
);
|
||||
assert.equal(remoteSoftwareRequiresSingleChannel("-"), false);
|
||||
assert.equal(remoteSoftwareRequiresSingleChannel("SSH-2.0--"), false);
|
||||
});
|
||||
|
||||
test("vendor banners block follow-terminal-cwd the same way bastions do", () => {
|
||||
assert.match(
|
||||
sidePanelSource,
|
||||
/bannerRequiresSingleChannel=\{sftpRequiresDedicatedLogin\(\{\s*host: activeHost,\s*remoteSshVersion: sftpBannerDecision\?\.sessionId === activeSessionId\s*\? sftpBannerDecision\.remoteSshVersion\s*: undefined,\s*\}\)\}/,
|
||||
);
|
||||
assert.doesNotMatch(sidePanelSource, /remoteSoftwareRequiresSingleChannel/);
|
||||
for (const remoteSshVersion of ["-", "SSH-2.0--", "BHostSSH_7.0"]) {
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({
|
||||
host: { deviceType: "general" },
|
||||
remoteSshVersion,
|
||||
}),
|
||||
true,
|
||||
remoteSshVersion,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({
|
||||
host: { deviceType: "network" },
|
||||
remoteSshVersion: "OpenSSH_9.6",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("opening SFTP skips the terminal pwd exec for network-vendor banners", () => {
|
||||
const terminalSource = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
|
||||
const layerSource = readFileSync(new URL("../TerminalLayer.tsx", import.meta.url), "utf8");
|
||||
|
||||
const assertBannerPwdGuard = (source: string, fnName: string) => {
|
||||
const start = source.indexOf(`const ${fnName} = useCallback`);
|
||||
assert.ok(start >= 0, fnName);
|
||||
const body = source.slice(start, start + 1600);
|
||||
assert.match(body, /let skipBackendPwd = hostRestrictsExtraSshChannels\(host\);/);
|
||||
assert.match(body, /getSessionRemoteInfo\?\./);
|
||||
assert.match(body, /skipBackendPwd = !shouldProbeSessionCwd\(\{/);
|
||||
assert.match(body, /classifyDistroId\(host\.distro\) === 'network-device'/);
|
||||
assert.match(body, /remoteSshVersion: info\?\.remoteSshVersion/);
|
||||
assert.match(body, /getSessionPwd: skipBackendPwd/);
|
||||
};
|
||||
|
||||
assertBannerPwdGuard(terminalSource, "resolveSftpInitialPath");
|
||||
assertBannerPwdGuard(layerSource, "getTerminalCwd");
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/import \{ classifyDistroId, hostRestrictsExtraSshChannels, shouldProbeSessionCwd \} from "\.\.\/domain\/host";/,
|
||||
);
|
||||
assert.match(
|
||||
layerSource,
|
||||
/import \{ classifyDistroId, hostRestrictsExtraSshChannels, shouldProbeSessionCwd \} from '\.\.\/domain\/host';/,
|
||||
);
|
||||
});
|
||||
@@ -17,8 +17,13 @@ test("SFTP side panel rebinds after same-tab SSH start-over", () => {
|
||||
/previousStatus:\s*lastSourceSessionStatusRef\.current/,
|
||||
);
|
||||
// Reuse the terminal transport only when SSH is connected, or when a strict
|
||||
// pending source id is already pinned. A dedicated single-channel login never
|
||||
// reuses that transport. A linked id may arrive while reconnecting.
|
||||
// pending source id is already pinned. A dedicated login (bastion banner,
|
||||
// network-vendor banner, or network-device mode) never reuses that
|
||||
// transport. A linked id may arrive while reconnecting.
|
||||
assert.match(
|
||||
sidePanelSource,
|
||||
/const dedicatedSftpLogin = sftpRequiresDedicatedLogin\(\{/,
|
||||
);
|
||||
assert.match(
|
||||
sidePanelSource,
|
||||
/const reuseTerminalTransport = !dedicatedSftpLogin && \(\s*Boolean\(pendingStrictSourceSessionId\) \|\| activeSessionStatus === "connected"\s*\);/,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type AutocompleteSettings,
|
||||
} from "./autocomplete";
|
||||
import type { Snippet } from "../../domain/models";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { usePaneVisible } from "./paneVisibilityStore";
|
||||
import { getWindowPluginTerminalProviderRegistry } from "../../application/state/pluginTerminalProviderRegistry";
|
||||
import { provideTerminalCompletions } from "./autocomplete/terminalCompletionProviders";
|
||||
@@ -95,6 +96,7 @@ export function TerminalAutocomplete({
|
||||
// flow through (and re-render) the TerminalView ctx. Popup / standalone
|
||||
// Terminal mounts never publish the store — fall back to the isVisible prop
|
||||
// (same contract as hibernate).
|
||||
const { resolvedLocale } = useI18n();
|
||||
const visible = usePaneVisible(sessionId, isVisible);
|
||||
const provideCompletions = useCallback(async (
|
||||
input: string,
|
||||
@@ -131,8 +133,9 @@ export function TerminalAutocomplete({
|
||||
historyScope: options.historyScope ?? settings?.historyScope,
|
||||
signal: options.signal,
|
||||
onLatePathSuggestions: options.onLatePathSuggestions,
|
||||
locale: resolvedLocale,
|
||||
});
|
||||
}, [allowHostStyleGreaterThanPrompt, hostGroup, hostId, hostOs, isPluginCompletionProviderAvailable, protocol, sensitiveInputActiveRef, sessionId, settings?.historyScope, status, workspaceId]);
|
||||
}, [allowHostStyleGreaterThanPrompt, hostGroup, hostId, hostOs, isPluginCompletionProviderAvailable, protocol, resolvedLocale, sensitiveInputActiveRef, sessionId, settings?.historyScope, status, workspaceId]);
|
||||
const autocomplete = useTerminalAutocomplete({
|
||||
termRef,
|
||||
containerRef,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { TerminalComposeBar } from './TerminalComposeBar';
|
||||
import { TooltipProvider } from '../ui/tooltip';
|
||||
import { getComposeBarHistory, pruneComposeBarHistory, recordComposeBarHistory } from '../../application/state/composeBarHistoryStore';
|
||||
import { terminalReconnectRegistry } from '../../application/state/terminalReconnectRegistry';
|
||||
|
||||
test('compose bar preserves a draft on pane focus and records only successful async sends', async (t) => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
|
||||
@@ -102,3 +103,95 @@ test('compose bar preserves a draft on pane focus and records only successful as
|
||||
await act(async () => { root.unmount(); });
|
||||
pruneComposeBarHistory([]);
|
||||
});
|
||||
|
||||
test('empty compose draft Enter reconnects; a typed draft still sends', async (t) => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
|
||||
const originals = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const name of ['window', 'document', 'localStorage']) {
|
||||
originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { configurable: true, value: dom.window[name as keyof Window] });
|
||||
}
|
||||
const sessionId = 'compose-enter-reconnect';
|
||||
const reconnects: string[] = [];
|
||||
const unregister = terminalReconnectRegistry.register(sessionId, () => {
|
||||
reconnects.push(sessionId);
|
||||
});
|
||||
t.after(() => {
|
||||
unregister();
|
||||
terminalReconnectRegistry.setEnterReconnectEnabled(sessionId, false);
|
||||
dom.window.close();
|
||||
for (const [name, descriptor] of originals) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
});
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
const createTextarea = () => ({
|
||||
value: '', selectionStart: 0, selectionEnd: 0,
|
||||
focus() {},
|
||||
setSelectionRange() {},
|
||||
});
|
||||
let textarea = createTextarea();
|
||||
const sent: string[] = [];
|
||||
let root!: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<TooltipProvider>
|
||||
<TerminalComposeBar sessionId={sessionId} onSend={(text) => { sent.push(text); return true; }} onClose={() => {}} />
|
||||
</TooltipProvider>,
|
||||
{ createNodeMock: (el) => {
|
||||
if (el.type !== 'textarea') return null;
|
||||
textarea = createTextarea();
|
||||
return textarea;
|
||||
} },
|
||||
);
|
||||
});
|
||||
const key = (name: string, extra: Record<string, unknown> = {}) => {
|
||||
let prevented = false;
|
||||
root.root.findByType('textarea').props.onKeyDown({
|
||||
key: name,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
nativeEvent: {},
|
||||
currentTarget: textarea,
|
||||
preventDefault() { prevented = true; },
|
||||
...extra,
|
||||
});
|
||||
return prevented;
|
||||
};
|
||||
|
||||
terminalReconnectRegistry.setEnterReconnectEnabled(sessionId, true);
|
||||
assert.equal(key('Enter'), true);
|
||||
assert.deepEqual(reconnects, [sessionId]);
|
||||
assert.deepEqual(sent, []);
|
||||
assert.equal(textarea.value, '');
|
||||
|
||||
textarea.value = 'echo hi';
|
||||
assert.equal(key('Enter'), true);
|
||||
assert.deepEqual(reconnects, [sessionId]);
|
||||
assert.deepEqual(sent, ['echo hi']);
|
||||
assert.equal(textarea.value, '');
|
||||
|
||||
textarea.value = 'keep me';
|
||||
assert.equal(key('Enter', { shiftKey: true }), false);
|
||||
assert.equal(textarea.value, 'keep me');
|
||||
assert.deepEqual(sent, ['echo hi']);
|
||||
assert.deepEqual(reconnects, [sessionId]);
|
||||
|
||||
textarea.value = '';
|
||||
assert.equal(key('Enter', { ctrlKey: true }), true);
|
||||
assert.deepEqual(sent, ['echo hi']);
|
||||
assert.deepEqual(reconnects, [sessionId]);
|
||||
|
||||
assert.equal(key('Enter', { nativeEvent: { isComposing: true, keyCode: 229 } }), false);
|
||||
assert.deepEqual(reconnects, [sessionId]);
|
||||
|
||||
terminalReconnectRegistry.setEnterReconnectEnabled(sessionId, false);
|
||||
assert.equal(key('Enter'), true);
|
||||
assert.deepEqual(reconnects, [sessionId]);
|
||||
assert.deepEqual(sent, ['echo hi']);
|
||||
|
||||
await act(async () => { root.unmount(); });
|
||||
});
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
import { GripHorizontal, Pin, Plus, Radio, Search, X } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { terminalReconnectRegistry } from '../../application/state/terminalReconnectRegistry';
|
||||
import { useComposeBarHistory } from '../../application/state/useComposeBarHistory';
|
||||
import { canNavigateComposeBarHistory } from '../../domain/composeBarHistory';
|
||||
import { STORAGE_KEY_TERMINAL_BROADCAST_PASSWORD_BYPASS } from '../../infrastructure/config/storageKeys';
|
||||
@@ -496,12 +497,22 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey && !isComposingRef.current) {
|
||||
e.preventDefault();
|
||||
// Plain Enter on an empty draft reconnects a disconnected session.
|
||||
// Modified Enter and a non-empty draft keep the existing send path (#3767).
|
||||
const plainEnter = !e.altKey && !e.ctrlKey && !e.metaKey;
|
||||
if (
|
||||
plainEnter
|
||||
&& !e.currentTarget.value
|
||||
&& terminalReconnectRegistry.requestEnterReconnect(sessionId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
handleSend();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}, [handleSend, navigate, onClose]);
|
||||
}, [handleSend, navigate, onClose, sessionId]);
|
||||
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -220,7 +220,6 @@ test("disconnected terminal reconnects on plain Enter when input is not claimed
|
||||
key: "Enter",
|
||||
status: "disconnected",
|
||||
hasRetryHandler: true,
|
||||
isComposeBarOpen: false,
|
||||
needsAuth: false,
|
||||
needsHostKeyVerification: false,
|
||||
hasBlockingOverlay: false,
|
||||
@@ -234,7 +233,6 @@ test("terminal enter reconnect ignores active controls and non-disconnected stat
|
||||
key: "Enter",
|
||||
status: "disconnected" as const,
|
||||
hasRetryHandler: true,
|
||||
isComposeBarOpen: false,
|
||||
needsAuth: false,
|
||||
needsHostKeyVerification: false,
|
||||
hasBlockingOverlay: false,
|
||||
@@ -243,14 +241,16 @@ test("terminal enter reconnect ignores active controls and non-disconnected stat
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, status: "connected" }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, key: "a" }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, hasRetryHandler: false }), false);
|
||||
// Open search must not globally suppress Enter reconnect / the hint (#2546).
|
||||
// Open search and an open compose bar must not globally suppress Enter
|
||||
// reconnect / the hint (#2546 / #3767).
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base }), true);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, isComposeBarOpen: true }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, needsAuth: true }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, needsHostKeyVerification: true }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, hasBlockingOverlay: true }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, isReconnectActive: true }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, altKey: true }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, shiftKey: true }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, isComposing: true }), false);
|
||||
});
|
||||
|
||||
test("terminal enter reconnect ignores interactive controls outside xterm only", () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { isSessionReconnectDisabled } from '../top-tabs/SessionTabContextMenuCon
|
||||
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
|
||||
import { classifyDistroId } from '../../domain/host';
|
||||
import type { HostInfoBarTitleMode } from '../../domain/models';
|
||||
import { terminalReconnectRegistry } from '../../application/state/terminalReconnectRegistry';
|
||||
import { useNetworkDeviceModeSuggestion } from '../../application/state/useNetworkDeviceModeSuggestion';
|
||||
import { isPluginHostProtocol } from '../../domain/pluginConnection';
|
||||
import { OSC7_SETUP_TARGETS } from './osc7Setup';
|
||||
@@ -184,7 +185,6 @@ export function shouldReconnectTerminalOnEnterKey({
|
||||
key,
|
||||
status,
|
||||
hasRetryHandler,
|
||||
isComposeBarOpen,
|
||||
needsAuth,
|
||||
needsHostKeyVerification,
|
||||
hasBlockingOverlay,
|
||||
@@ -198,7 +198,6 @@ export function shouldReconnectTerminalOnEnterKey({
|
||||
key: string;
|
||||
status?: string;
|
||||
hasRetryHandler: boolean;
|
||||
isComposeBarOpen: boolean;
|
||||
needsAuth: boolean;
|
||||
needsHostKeyVerification: boolean;
|
||||
hasBlockingOverlay: boolean;
|
||||
@@ -209,15 +208,15 @@ export function shouldReconnectTerminalOnEnterKey({
|
||||
shiftKey?: boolean;
|
||||
isComposing?: boolean;
|
||||
}): boolean {
|
||||
// Search-bar open state is intentionally not a global gate. While disconnected,
|
||||
// find-next is less useful than reconnect; the capture handler still refuses
|
||||
// Enter only when a real interactive control outside xterm owns the event
|
||||
// (compose/auth/buttons). The terminal search input is allow-listed so an open
|
||||
// search bar cannot hide the hint or swallow reconnect (#2544 / #2546).
|
||||
// Search and compose bars are not global gates. While disconnected, plain
|
||||
// Enter still reconnects from the terminal and the disconnected dialog.
|
||||
// Capture still skips real controls outside xterm (compose textarea, auth,
|
||||
// buttons). Search input is allow-listed so an open search bar cannot hide
|
||||
// the hint or swallow reconnect (#2544 / #2546). An empty compose draft
|
||||
// reconnects from TerminalComposeBar; a non-empty draft keeps send (#3767).
|
||||
return key === "Enter"
|
||||
&& status === "disconnected"
|
||||
&& hasRetryHandler
|
||||
&& !isComposeBarOpen
|
||||
&& !needsAuth
|
||||
&& !needsHostKeyVerification
|
||||
&& !hasBlockingOverlay
|
||||
@@ -529,18 +528,24 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
|
||||
key: "Enter",
|
||||
status,
|
||||
hasRetryHandler: Boolean(handleRetry) && terminalReconnectAvailable !== false,
|
||||
isComposeBarOpen,
|
||||
needsAuth: Boolean(auth.needsAuth),
|
||||
needsHostKeyVerification: Boolean(needsHostKeyVerification),
|
||||
hasBlockingOverlay: hasBlockingReconnectOverlay,
|
||||
isReconnectActive,
|
||||
});
|
||||
// Publish Enter-reconnect so an empty compose draft can reconnect this
|
||||
// session, including the workspace compose bar (#3767).
|
||||
useEffect(() => {
|
||||
terminalReconnectRegistry.setEnterReconnectEnabled(sessionId, showEnterReconnectHint);
|
||||
return () => {
|
||||
terminalReconnectRegistry.setEnterReconnectEnabled(sessionId, false);
|
||||
};
|
||||
}, [sessionId, showEnterReconnectHint]);
|
||||
const handleTerminalKeyDownCapture = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!shouldReconnectTerminalOnEnterKey({
|
||||
key: event.key,
|
||||
status,
|
||||
hasRetryHandler: Boolean(handleRetry) && terminalReconnectAvailable !== false,
|
||||
isComposeBarOpen,
|
||||
needsAuth: Boolean(auth.needsAuth),
|
||||
needsHostKeyVerification: Boolean(needsHostKeyVerification),
|
||||
hasBlockingOverlay: hasBlockingReconnectOverlay,
|
||||
@@ -563,7 +568,6 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
|
||||
auth.needsAuth,
|
||||
handleRetry,
|
||||
hasBlockingReconnectOverlay,
|
||||
isComposeBarOpen,
|
||||
isReconnectActive,
|
||||
needsHostKeyVerification,
|
||||
status,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* zh-CN overlay for bundled Fig autocomplete descriptions.
|
||||
* The dictionary is keyed by the exact English description string.
|
||||
* Spec objects stay in English; only suggestion copies shown in the popup change.
|
||||
*/
|
||||
|
||||
type DescriptionDictionary = Record<string, string>;
|
||||
|
||||
let dictionaryPromise: Promise<DescriptionDictionary> | undefined;
|
||||
|
||||
function loadZhCnDescriptions(): Promise<DescriptionDictionary> {
|
||||
dictionaryPromise ??= import("./descriptions.zh-CN.json").then((mod) => {
|
||||
const loaded = mod as { default?: DescriptionDictionary };
|
||||
return loaded.default ?? (mod as unknown as DescriptionDictionary);
|
||||
});
|
||||
return dictionaryPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy spec-backed suggestions so a zh-CN description overlay cannot leak into
|
||||
* the cached spec or into a later query in another locale.
|
||||
*/
|
||||
export async function localizeSpecSuggestionDescriptions<T extends { description?: string }>(
|
||||
suggestions: readonly T[],
|
||||
locale: string | undefined,
|
||||
): Promise<readonly T[]> {
|
||||
if (locale !== "zh-CN" || suggestions.length === 0) return suggestions;
|
||||
|
||||
let dictionary: DescriptionDictionary;
|
||||
try {
|
||||
dictionary = await loadZhCnDescriptions();
|
||||
} catch {
|
||||
return suggestions;
|
||||
}
|
||||
let changed = false;
|
||||
const localized = suggestions.map((suggestion) => {
|
||||
const description = suggestion.description;
|
||||
if (!description) return suggestion;
|
||||
const translated = dictionary[description];
|
||||
if (!translated) return suggestion;
|
||||
changed = true;
|
||||
return { ...suggestion, description: translated };
|
||||
});
|
||||
return changed ? localized : suggestions;
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
resolvePathComponents,
|
||||
} from "./remotePathCompleter";
|
||||
import { getSnippetSuggestions } from "./snippetCompleter";
|
||||
import { localizeSpecSuggestionDescriptions } from "./autocompleteDescriptionI18n";
|
||||
import type { AutocompleteHistoryScope, Snippet } from "../../../domain/models";
|
||||
import type { AutocompleteCwdSource } from "./terminalAutocompleteLayout";
|
||||
|
||||
@@ -272,6 +273,11 @@ export async function getCompletions(
|
||||
* the shared directory cache for a later keystroke.
|
||||
*/
|
||||
onLatePathSuggestions?: (suggestions: CompletionSuggestion[]) => void;
|
||||
/**
|
||||
* UI locale. zh-CN overlays bundled spec descriptions; any other locale
|
||||
* keeps the original English text. Cached specs are not rewritten.
|
||||
*/
|
||||
locale?: string;
|
||||
} = {},
|
||||
): Promise<CompletionSuggestion[]> {
|
||||
const { hostId, maxResults = 15, historyScope = "host" } = options;
|
||||
@@ -368,7 +374,11 @@ export async function getCompletions(
|
||||
)
|
||||
: [];
|
||||
|
||||
for (const suggestion of specResult.suggestions) {
|
||||
const specSuggestions = await localizeSpecSuggestionDescriptions(
|
||||
specResult.suggestions,
|
||||
options.locale,
|
||||
);
|
||||
for (const suggestion of specSuggestions) {
|
||||
suggestions.push(suggestion);
|
||||
seenSuggestionTexts.add(suggestion.text);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -80,6 +80,10 @@ export function handleTerminalAutocompleteKeyEvent(
|
||||
now = Date.now,
|
||||
} = context;
|
||||
if (!settingsRef.current.enabled || e.type !== "keydown") return true;
|
||||
// Enter/arrows belong to the IME candidate window during composition.
|
||||
// Consuming them here can accept/execute a stale terminal suggestion and
|
||||
// prevent the input method from committing the user's characters.
|
||||
if (e.isComposing || e.keyCode === 229) return true;
|
||||
|
||||
const metaFollowUp = autocompleteEscMetaFollowUpSequence(e);
|
||||
if (metaFollowUp && now() < escMetaPrefixUntilRef.current) {
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface TerminalCompletionProviderRequest {
|
||||
* soft budget (cache-bypassed relative SSH cwd).
|
||||
*/
|
||||
onLatePathSuggestions?: (suggestions: CompletionSuggestion[]) => void;
|
||||
/** UI locale forwarded to built-in spec description overlay. */
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_PLUGIN_COMPLETION_RESPONSE_TIMEOUT_MS = 800;
|
||||
@@ -74,6 +76,7 @@ export async function provideTerminalCompletions(
|
||||
snippets: request.snippets,
|
||||
historyScope: request.historyScope,
|
||||
onLatePathSuggestions: request.onLatePathSuggestions,
|
||||
locale: request.locale,
|
||||
});
|
||||
const pluginRequestController = new AbortController();
|
||||
const abortPluginRequest = () => pluginRequestController.abort();
|
||||
|
||||
@@ -762,6 +762,7 @@ export function useTerminalAutocomplete(
|
||||
candidate: newCommand,
|
||||
os: hostOsRef.current,
|
||||
});
|
||||
if (seq === null) return;
|
||||
if (seq) writeToTerminal(seq);
|
||||
typedInputBufferRef.current = newCommand;
|
||||
typedBufferReliableRef.current = true;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type { FigSpec } from "./autocomplete/figSpecLoader.ts";
|
||||
|
||||
const SINCE = "Show logs since timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes)";
|
||||
const SINCE_ZH = "从指定时间起显示日志(例如 2013-01-02T13:23:37Z),也可用相对时间(例如 42m 表示 42 分钟)";
|
||||
const UNTRANSLATED = "This autocomplete description is intentionally untranslated.";
|
||||
|
||||
const dockerSpec: FigSpec = {
|
||||
name: "docker",
|
||||
subcommands: [
|
||||
{
|
||||
name: "logs",
|
||||
options: [
|
||||
{ name: "--since", description: SINCE },
|
||||
{ name: "--mystery", description: UNTRANSLATED },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: {
|
||||
getItem: () => null,
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
clear: () => {},
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: {
|
||||
netcatty: {
|
||||
listFigSpecs: async () => ["docker"],
|
||||
loadFigSpec: async (commandName: string) => commandName === "docker" ? dockerSpec : null,
|
||||
},
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const { getCompletions } = await import("./autocomplete/completionEngine.ts");
|
||||
|
||||
function sinceSuggestion(suggestions: Awaited<ReturnType<typeof getCompletions>>) {
|
||||
return suggestions.find((suggestion) => suggestion.displayText === "--since");
|
||||
}
|
||||
|
||||
test("zh-CN translates docker logs --since and falls back for unknown descriptions", async () => {
|
||||
const suggestions = await getCompletions("docker logs --", {
|
||||
locale: "zh-CN",
|
||||
skipPathCompletion: true,
|
||||
maxResults: 20,
|
||||
});
|
||||
const since = sinceSuggestion(suggestions);
|
||||
const mystery = suggestions.find((suggestion) => suggestion.displayText === "--mystery");
|
||||
|
||||
assert.equal(since?.displayText, "--since");
|
||||
assert.equal(since?.description, SINCE_ZH);
|
||||
assert.equal(mystery?.description, UNTRANSLATED);
|
||||
assert.equal(dockerSpec.subcommands?.[0].options?.[0].description, SINCE);
|
||||
});
|
||||
|
||||
test("en locale leaves autocomplete descriptions unchanged", async () => {
|
||||
const suggestions = await getCompletions("docker logs --", {
|
||||
locale: "en",
|
||||
skipPathCompletion: true,
|
||||
maxResults: 20,
|
||||
});
|
||||
const since = sinceSuggestion(suggestions);
|
||||
|
||||
assert.equal(since?.displayText, "--since");
|
||||
assert.equal(since?.description, SINCE);
|
||||
assert.equal(dockerSpec.subcommands?.[0].options?.[0].description, SINCE);
|
||||
});
|
||||
@@ -389,6 +389,23 @@ test("disconnected connection dialog keeps an Enter-reconnect focus sink", () =>
|
||||
assert.match(source, /restoreTerminalFocusFromDisconnectedDialog\(\{[\s\S]*isFocusedPane/);
|
||||
});
|
||||
|
||||
test("open compose bar does not globally gate enter reconnect", () => {
|
||||
const viewSource = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
|
||||
const gateIndex = viewSource.indexOf("export function shouldReconnectTerminalOnEnterKey");
|
||||
const bodyEnd = viewSource.indexOf("export function shouldBlockTerminalReconnectForTarget", gateIndex);
|
||||
const gateBody = viewSource.slice(gateIndex, bodyEnd);
|
||||
assert.equal(gateBody.includes("!isComposeBarOpen"), false);
|
||||
assert.match(viewSource, /setEnterReconnectEnabled\(sessionId, showEnterReconnectHint\)/);
|
||||
assert.match(
|
||||
readFileSync(new URL("./TerminalComposeBar.tsx", import.meta.url), "utf8"),
|
||||
/requestEnterReconnect\(sessionId\)/,
|
||||
);
|
||||
assert.match(
|
||||
readFileSync(new URL("./terminalHelpers.ts", import.meta.url), "utf8"),
|
||||
/data-section='terminal-compose-bar'/,
|
||||
);
|
||||
});
|
||||
|
||||
test("open terminal search does not globally gate enter reconnect", () => {
|
||||
const viewSource = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
|
||||
const gateIndex = viewSource.indexOf("export function shouldReconnectTerminalOnEnterKey");
|
||||
@@ -402,7 +419,7 @@ test("open terminal search does not globally gate enter reconnect", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal view receives the effective compose bar state for enter reconnect gating", () => {
|
||||
test("terminal view receives the effective compose bar state", () => {
|
||||
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
|
||||
const effectiveDefinitionIndex = source.indexOf("const effectiveComposeBarOpen =");
|
||||
const viewIndex = source.indexOf("<TerminalView ctx={{");
|
||||
@@ -413,7 +430,7 @@ test("terminal view receives the effective compose bar state for enter reconnect
|
||||
assert.notEqual(passEffectiveIndex, -1);
|
||||
assert.ok(
|
||||
effectiveDefinitionIndex < passEffectiveIndex,
|
||||
"TerminalView must use the visible workspace compose state before deciding whether Enter can reconnect",
|
||||
"TerminalView must receive the visible workspace compose state",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -541,7 +541,7 @@ test("Shift+Enter broadcast uses CSI-u only after the target negotiated a preser
|
||||
applicationCursorMode: false,
|
||||
encodedKeys: new Set<string>(),
|
||||
}), {
|
||||
data: "\n",
|
||||
data: "\u001b\r",
|
||||
kittyEncoded: false,
|
||||
urgentInterrupt: false,
|
||||
});
|
||||
@@ -552,7 +552,7 @@ test("Shift+Enter broadcast uses CSI-u only after the target negotiated a preser
|
||||
applicationCursorMode: false,
|
||||
encodedKeys: new Set<string>(),
|
||||
}), {
|
||||
data: "\n",
|
||||
data: "\u001b\r",
|
||||
kittyEncoded: false,
|
||||
urgentInterrupt: false,
|
||||
});
|
||||
|
||||
@@ -26,8 +26,8 @@ const keyEvent = (overrides: Partial<KeyboardEvent> = {}) => ({
|
||||
...overrides,
|
||||
}) as KeyboardEvent;
|
||||
|
||||
test("shift enter text defaults to newline", () => {
|
||||
assert.equal(resolveShiftEnterText(), "\n");
|
||||
test("shift enter text defaults to the legacy Alt+Enter encoding (ESC + CR)", () => {
|
||||
assert.equal(resolveShiftEnterText(), "\u001b\r");
|
||||
});
|
||||
|
||||
test("shift enter text decodes newline, tab, carriage return, and backslash escapes", () => {
|
||||
@@ -71,6 +71,20 @@ test("shift enter submitted input detects single command text with a line ending
|
||||
assert.equal(getShiftEnterSubmittedInput("foo\nbar\n"), null);
|
||||
});
|
||||
|
||||
test("Alt+Enter ESC+CR is a non-submitting logical line break", () => {
|
||||
assert.equal(getShiftEnterSubmittedInput("\u001b\r"), null);
|
||||
assert.equal(getShiftEnterSubmittedInput("echo hi\u001b\r"), null);
|
||||
assert.equal(getShiftEnterSubmittedInput("echo hi\u001b\r\n"), null);
|
||||
assert.deepEqual(getShiftEnterSubmittedInput("echo hi\r"), {
|
||||
text: "echo hi",
|
||||
lineEnding: "\r",
|
||||
});
|
||||
assert.deepEqual(getShiftEnterSubmittedInput("\u001b\n"), {
|
||||
text: "\u001b",
|
||||
lineEnding: "\n",
|
||||
});
|
||||
});
|
||||
|
||||
test("shift enter handler only matches plain Shift+Enter keydown", () => {
|
||||
assert.equal(shouldSendShiftEnterText(keyEvent()), true);
|
||||
assert.equal(shouldSendShiftEnterText(keyEvent({ type: "keyup" })), false);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TerminalSettings } from "../../../domain/models";
|
||||
|
||||
export const DEFAULT_SHIFT_ENTER_TEXT = "\\n";
|
||||
export const DEFAULT_SHIFT_ENTER_TEXT = "\\e\\r";
|
||||
|
||||
/** Kitty CSI-u encoding for Shift+Enter (keycode 13, modifier shift → 2). */
|
||||
export const SHIFT_ENTER_CSI_U_SEQUENCE = "\u001b[13;2u";
|
||||
@@ -109,10 +109,15 @@ export type ShiftEnterSubmittedInput = {
|
||||
lineEnding: "\r\n" | "\r" | "\n";
|
||||
};
|
||||
|
||||
/** ESC immediately before a terminating CR is the legacy Alt+Enter line break. */
|
||||
function isAltEnterLineBreak(text: string): boolean {
|
||||
return text.endsWith("\u001b\r") || text.endsWith("\u001b\r\n");
|
||||
}
|
||||
|
||||
export function getShiftEnterSubmittedInput(
|
||||
text: string,
|
||||
): ShiftEnterSubmittedInput | null {
|
||||
if (isShiftEnterLineContinuationText(text)) return null;
|
||||
if (isShiftEnterLineContinuationText(text) || isAltEnterLineBreak(text)) return null;
|
||||
const match = text.match(/^([^\r\n]*)(\r\n|\r|\n)$/);
|
||||
if (!match) return null;
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { reconcileAutocompletePopupState } from "./autocomplete/useTerminalAutocomplete.ts";
|
||||
import { DEFAULT_AUTOCOMPLETE_SETTINGS, reconcileAutocompletePopupState } from "./autocomplete/useTerminalAutocomplete.ts";
|
||||
import type { AutocompleteState } from "./autocomplete/useTerminalAutocomplete.ts";
|
||||
import { handleTerminalAutocompleteKeyEvent } from "./autocomplete/terminalAutocompleteKeyEvent.ts";
|
||||
|
||||
@@ -58,6 +58,7 @@ function createContext(overrides: Record<string, unknown> = {}) {
|
||||
context: {
|
||||
settingsRef: {
|
||||
current: {
|
||||
...DEFAULT_AUTOCOMPLETE_SETTINGS,
|
||||
enabled: true,
|
||||
showGhostText: false,
|
||||
showPopupMenu: true,
|
||||
@@ -379,3 +380,19 @@ for (const edited of [false, true]) {
|
||||
assert.equal(runs, edited ? 0 : 1);
|
||||
});
|
||||
}
|
||||
|
||||
for (const key of ['Enter', 'ArrowUp', 'ArrowDown', 'ArrowRight', 'Escape', 'Tab']) {
|
||||
for (const sentinel of ['isComposing', 'keyCode'] as const) {
|
||||
test(`IME ${sentinel} ${key} passes through autocomplete without accepting or rewriting input`, () => {
|
||||
const { context, writes, previews, accepted, clears } = createContext();
|
||||
context.stateRef.current.selectedIndex = 0;
|
||||
const original = context.stateRef.current;
|
||||
const event = keyEvent(key);
|
||||
Object.defineProperty(event, sentinel, { value: sentinel === 'isComposing' ? true : 229 });
|
||||
assert.equal(handleTerminalAutocompleteKeyEvent(event, context), true);
|
||||
assert.equal(event.defaultPrevented, false);
|
||||
assert.equal(context.stateRef.current, original);
|
||||
assert.deepEqual([...writes, ...previews, ...accepted, ...clears], []);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -448,6 +448,11 @@ export function shouldClaimDisconnectedDialogFocus({
|
||||
// Sink already focused.
|
||||
return active !== dialogNode;
|
||||
}
|
||||
// An open compose draft owns Enter: empty reconnects from the bar, and a
|
||||
// non-empty draft must keep send behavior (#3767). Do not park focus here.
|
||||
if (active.closest("[data-section='terminal-compose-bar']")) {
|
||||
return false;
|
||||
}
|
||||
const terminalRoot = resolveDisconnectedDialogTerminalRoot(dialogNode, sessionRoot);
|
||||
if (terminalRoot?.contains(active)) {
|
||||
return true;
|
||||
|
||||
@@ -98,6 +98,7 @@ test("disconnected dialog focus claim never steals from other panes", () => {
|
||||
id: string;
|
||||
parent?: FakeNode | null;
|
||||
interactive?: boolean;
|
||||
composeBar?: boolean;
|
||||
children?: FakeNode[];
|
||||
},
|
||||
) {
|
||||
@@ -118,6 +119,7 @@ test("disconnected dialog focus claim never steals from other panes", () => {
|
||||
return false;
|
||||
}
|
||||
closest(selector: string) {
|
||||
if (selector.includes("terminal-compose-bar") && this.opts.composeBar) return this;
|
||||
if (selector.includes("button") && this.opts.interactive) return this;
|
||||
return null;
|
||||
}
|
||||
@@ -227,6 +229,23 @@ test("disconnected dialog focus claim never steals from other panes", () => {
|
||||
}),
|
||||
false,
|
||||
);
|
||||
// Compose-bar focus stays put so an empty draft can reconnect and a
|
||||
// non-empty draft is not stolen by the disconnected dialog (#3767).
|
||||
const composeInput = new FakeNode({ id: "compose", composeBar: true });
|
||||
const composeDialog = new FakeNode({ id: "compose-dialog" });
|
||||
const composeSession = new FakeNode({
|
||||
id: "compose-session",
|
||||
children: [composeInput, composeDialog],
|
||||
});
|
||||
assert.equal(
|
||||
shouldClaimDisconnectedDialogFocus({
|
||||
activeElement: composeInput as unknown as Element,
|
||||
dialogNode: composeDialog as unknown as HTMLElement,
|
||||
sessionRoot: composeSession as unknown as Element,
|
||||
documentBody: body as unknown as Element,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
restoreTerminalFocusFromDisconnectedDialog({
|
||||
|
||||
@@ -30,6 +30,13 @@ import { handleTabMiddleClickClose, handleTabMiddleMouseDown } from '../../lib/t
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from '../ui/context-menu';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { SessionTabContextMenuContent } from './SessionTabContextMenuContent';
|
||||
import {
|
||||
TOP_TAB_ITEM_CLASS,
|
||||
clearInactiveTopTabHover,
|
||||
paintInactiveTopTabHover,
|
||||
resolveTopTabChromeStyle,
|
||||
topTabActiveValue,
|
||||
} from './topTabActiveStyle';
|
||||
import { renderHostIconGlyph } from '../hostIconRenderer';
|
||||
import type { PluginViewTab } from '../../application/state/pluginViewTabStore';
|
||||
import { PluginContributionIcon } from '../plugins/PluginContributionIcon';
|
||||
@@ -310,13 +317,19 @@ export const WindowControls: React.FC = memo(() => {
|
||||
|
||||
const controlClassName = 'window-control-btn app-no-drag';
|
||||
const closeControlClassName = 'window-control-btn window-control-btn--close app-no-drag';
|
||||
// Mouse press would move DOM focus onto minimize/maximize. After restore
|
||||
// that leaves the terminal textarea unfocused, so keystrokes do nothing.
|
||||
// Close skips this so the active field still blurs (inline rename commits on blur).
|
||||
const preventWindowControlFocus: React.MouseEventHandler<HTMLButtonElement> = (event) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ml-2 flex h-7 items-center overflow-visible app-no-drag">
|
||||
<button type="button" className={controlClassName} onClick={handleMinimize}>
|
||||
<button type="button" className={controlClassName} onMouseDown={preventWindowControlFocus} onClick={handleMinimize}>
|
||||
<Minus size={16} />
|
||||
</button>
|
||||
<button type="button" className={controlClassName} onClick={handleMaximize}>
|
||||
<button type="button" className={controlClassName} onMouseDown={preventWindowControlFocus} onClick={handleMaximize}>
|
||||
{isMaximized ? <Copy size={14} /> : <Square size={14} />}
|
||||
</button>
|
||||
<button type="button" className={closeControlClassName} onClick={handleClose}>
|
||||
@@ -440,31 +453,20 @@ export const RootTopTab: React.FC<RootTopTabProps> = memo(({ tabId, label, icon,
|
||||
data-tab-id={tabId}
|
||||
data-tab-type="root"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
data-active={topTabActiveValue(isActive)}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
TOP_TAB_ITEM_CLASS,
|
||||
"netcatty-tab relative h-7 overflow-hidden text-xs font-semibold cursor-pointer flex items-center app-no-drag transition-[padding,gap] duration-300 ease-out",
|
||||
compact ? "px-2 gap-0" : "px-3 gap-2",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: isActive && !suppressActiveBg
|
||||
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
||||
: 'transparent',
|
||||
color: isActive
|
||||
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
||||
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
}}
|
||||
style={resolveTopTabChromeStyle(isActive, { suppressActiveBg })}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
}
|
||||
if (!isActive) paintInactiveTopTabHover(e.currentTarget);
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
}
|
||||
if (!isActive) clearInactiveTopTabHover(e.currentTarget);
|
||||
}}
|
||||
>
|
||||
{shortcutNumber != null ? <TabShortcutNumberBadge number={shortcutNumber} /> : icon}
|
||||
@@ -523,6 +525,7 @@ export const PluginViewTopTab: React.FC<PluginViewTopTabProps> = memo(({
|
||||
data-tab-id={tab.id}
|
||||
data-tab-type="plugin-view"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
data-active={topTabActiveValue(isActive)}
|
||||
onClick={() => activeTabStore.setActiveTabId(tab.id)}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(event) => handleTabMiddleClickClose(event, close)}
|
||||
@@ -533,6 +536,7 @@ export const PluginViewTopTab: React.FC<PluginViewTopTabProps> = memo(({
|
||||
onDragLeave={onTabDragLeave}
|
||||
onDrop={(event) => onTabDrop(event, tab.id)}
|
||||
className={cn(
|
||||
TOP_TAB_ITEM_CLASS,
|
||||
'netcatty-tab relative h-7 min-w-[140px] max-w-[240px] flex-shrink-0 cursor-pointer items-center justify-between gap-2 overflow-hidden rounded-t-md pl-3 pr-2 text-xs font-semibold app-no-drag',
|
||||
'flex transition-transform duration-150',
|
||||
isBeingDragged && isDraggingForReorder && 'scale-95 opacity-40',
|
||||
@@ -540,8 +544,7 @@ export const PluginViewTopTab: React.FC<PluginViewTopTabProps> = memo(({
|
||||
)}
|
||||
style={{
|
||||
...shiftStyle,
|
||||
backgroundColor: isActive ? 'var(--top-tabs-active-bg, hsl(var(--background)))' : 'transparent',
|
||||
color: isActive ? 'var(--top-tabs-fg, hsl(var(--foreground)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
...resolveTopTabChromeStyle(isActive),
|
||||
}}
|
||||
>
|
||||
{showDropIndicatorBefore && isDraggingForReorder && <div className="absolute -left-0.5 bottom-1 top-1 w-0.5 rounded-full bg-primary" />}
|
||||
@@ -643,6 +646,7 @@ export const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
|
||||
data-tab-id={tabId}
|
||||
data-tab-type="editor"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
data-active={topTabActiveValue(isActive)}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onRequestCloseEditorTab(editorTab.id))}
|
||||
@@ -653,6 +657,7 @@ export const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
|
||||
onDragLeave={onTabDragLeave}
|
||||
onDrop={(e) => onTabDrop(e, tabId)}
|
||||
className={cn(
|
||||
TOP_TAB_ITEM_CLASS,
|
||||
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-transform duration-150",
|
||||
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : "",
|
||||
@@ -660,24 +665,13 @@ export const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
|
||||
)}
|
||||
style={{
|
||||
...shiftStyle,
|
||||
backgroundColor: isActive
|
||||
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
||||
: 'transparent',
|
||||
color: isActive
|
||||
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
||||
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
...resolveTopTabChromeStyle(isActive),
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
}
|
||||
if (!isActive) paintInactiveTopTabHover(e.currentTarget);
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
}
|
||||
if (!isActive) clearInactiveTopTabHover(e.currentTarget);
|
||||
}}
|
||||
>
|
||||
{showDropIndicatorBefore && isDraggingForReorder && (
|
||||
@@ -827,6 +821,7 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
|
||||
data-tab-id={session.id}
|
||||
data-tab-type="session"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
data-active={topTabActiveValue(isActive)}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
@@ -838,6 +833,7 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
|
||||
onDragLeave={onTabDragLeave}
|
||||
onDrop={(e) => onTabDrop(e, session.id)}
|
||||
className={cn(
|
||||
TOP_TAB_ITEM_CLASS,
|
||||
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-transform duration-150",
|
||||
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : "",
|
||||
@@ -845,24 +841,13 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
|
||||
)}
|
||||
style={{
|
||||
...shiftStyle,
|
||||
backgroundColor: isActive
|
||||
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
||||
: 'transparent',
|
||||
color: isActive
|
||||
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
||||
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
...resolveTopTabChromeStyle(isActive),
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
}
|
||||
if (!isActive) paintInactiveTopTabHover(e.currentTarget);
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
}
|
||||
if (!isActive) clearInactiveTopTabHover(e.currentTarget);
|
||||
}}
|
||||
>
|
||||
{showDropIndicatorBefore && isDraggingForReorder && (
|
||||
@@ -1039,6 +1024,7 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
|
||||
data-tab-id={workspace.id}
|
||||
data-tab-type="workspace"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
data-active={topTabActiveValue(isActive)}
|
||||
data-host-drop-active={isHostDropTarget ? 'true' : 'false'}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
@@ -1051,6 +1037,7 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
|
||||
onDragLeave={onTabDragLeave}
|
||||
onDrop={(e) => onTabDrop(e, workspace.id)}
|
||||
className={cn(
|
||||
TOP_TAB_ITEM_CLASS,
|
||||
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[150px] max-w-[260px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-transform duration-150",
|
||||
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : "",
|
||||
@@ -1059,24 +1046,13 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
|
||||
)}
|
||||
style={{
|
||||
...shiftStyle,
|
||||
backgroundColor: isActive
|
||||
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
||||
: 'transparent',
|
||||
color: isActive
|
||||
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
||||
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
...resolveTopTabChromeStyle(isActive),
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
}
|
||||
if (!isActive) paintInactiveTopTabHover(e.currentTarget);
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
}
|
||||
if (!isActive) clearInactiveTopTabHover(e.currentTarget);
|
||||
}}
|
||||
>
|
||||
{showDropIndicatorBefore && isDraggingForReorder && (
|
||||
@@ -1197,6 +1173,7 @@ export const LogViewTopTab: React.FC<LogViewTopTabProps> = memo(({
|
||||
data-tab-id={logView.id}
|
||||
data-tab-type="logView"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
data-active={topTabActiveValue(isActive)}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseLogView(logView.id))}
|
||||
@@ -1207,6 +1184,7 @@ export const LogViewTopTab: React.FC<LogViewTopTabProps> = memo(({
|
||||
onDragLeave={onTabDragLeave}
|
||||
onDrop={(e) => onTabDrop(e, logView.id)}
|
||||
className={cn(
|
||||
TOP_TAB_ITEM_CLASS,
|
||||
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-transform duration-150",
|
||||
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : "",
|
||||
@@ -1214,24 +1192,13 @@ export const LogViewTopTab: React.FC<LogViewTopTabProps> = memo(({
|
||||
)}
|
||||
style={{
|
||||
...shiftStyle,
|
||||
backgroundColor: isActive
|
||||
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
||||
: 'transparent',
|
||||
color: isActive
|
||||
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
||||
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
...resolveTopTabChromeStyle(isActive),
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
}
|
||||
if (!isActive) paintInactiveTopTabHover(e.currentTarget);
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
}
|
||||
if (!isActive) clearInactiveTopTabHover(e.currentTarget);
|
||||
}}
|
||||
>
|
||||
{showDropIndicatorBefore && isDraggingForReorder && (
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Stable hooks for the selected top tab.
|
||||
*
|
||||
* Defaults fall through to the existing chrome colors, so leaving the custom
|
||||
* properties unset keeps today's look. Custom CSS can set them on
|
||||
* `.top-tab[data-active="true"]` (or any ancestor) without !important.
|
||||
* `--top-tab-active-border` is read by index.css as a 1px inset outline and
|
||||
* defaults to transparent.
|
||||
*/
|
||||
|
||||
export const TOP_TAB_ITEM_CLASS = 'top-tab';
|
||||
|
||||
/** Previous selected fill, kept as the fallback when the hook is unset. */
|
||||
export const TOP_TAB_LEGACY_ACTIVE_BG = 'var(--top-tabs-active-bg, hsl(var(--background)))';
|
||||
|
||||
/** Previous selected label color, kept as the fallback when the hook is unset. */
|
||||
export const TOP_TAB_LEGACY_ACTIVE_FG = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
|
||||
export const TOP_TAB_ACTIVE_BG = `var(--top-tab-active-bg, ${TOP_TAB_LEGACY_ACTIVE_BG})`;
|
||||
|
||||
export const TOP_TAB_ACTIVE_FG = `var(--top-tab-active-fg, ${TOP_TAB_LEGACY_ACTIVE_FG})`;
|
||||
|
||||
export const TOP_TAB_ACTIVE_BORDER = 'var(--top-tab-active-border, transparent)';
|
||||
|
||||
export const TOP_TAB_INACTIVE_FG = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
|
||||
/** Same 40% mix as the previous hover fill. The inner color is the active-tab hook. */
|
||||
export const TOP_TAB_HOVER_BG = `color-mix(in srgb, ${TOP_TAB_ACTIVE_BG} 40%, transparent)`;
|
||||
|
||||
export const TOP_TAB_ACTIVE_OUTLINE = `1px solid ${TOP_TAB_ACTIVE_BORDER}`;
|
||||
|
||||
export function topTabActiveValue(isActive: boolean): 'true' | 'false' {
|
||||
return isActive ? 'true' : 'false';
|
||||
}
|
||||
|
||||
export interface TopTabChromeStyle {
|
||||
backgroundColor: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export function resolveTopTabChromeStyle(
|
||||
isActive: boolean,
|
||||
options?: { suppressActiveBg?: boolean },
|
||||
): TopTabChromeStyle {
|
||||
if (!isActive) {
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
color: TOP_TAB_INACTIVE_FG,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
backgroundColor: options?.suppressActiveBg ? 'transparent' : TOP_TAB_ACTIVE_BG,
|
||||
color: TOP_TAB_ACTIVE_FG,
|
||||
};
|
||||
}
|
||||
|
||||
type TopTabStyleTarget = {
|
||||
style: {
|
||||
backgroundColor: string;
|
||||
color: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function paintInactiveTopTabHover(element: TopTabStyleTarget): void {
|
||||
element.style.backgroundColor = TOP_TAB_HOVER_BG;
|
||||
element.style.color = TOP_TAB_ACTIVE_FG;
|
||||
}
|
||||
|
||||
export function clearInactiveTopTabHover(element: TopTabStyleTarget): void {
|
||||
element.style.backgroundColor = 'transparent';
|
||||
element.style.color = TOP_TAB_INACTIVE_FG;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
import en from '../application/i18n/locales/en';
|
||||
import es from '../application/i18n/locales/es';
|
||||
import ru from '../application/i18n/locales/ru';
|
||||
import zhCN from '../application/i18n/locales/zh-CN';
|
||||
import zhTW from '../application/i18n/locales/zh-TW';
|
||||
import {
|
||||
TOP_TAB_ACTIVE_BG,
|
||||
TOP_TAB_ACTIVE_BORDER,
|
||||
TOP_TAB_ACTIVE_FG,
|
||||
TOP_TAB_ACTIVE_OUTLINE,
|
||||
TOP_TAB_HOVER_BG,
|
||||
TOP_TAB_INACTIVE_FG,
|
||||
TOP_TAB_ITEM_CLASS,
|
||||
TOP_TAB_LEGACY_ACTIVE_BG,
|
||||
TOP_TAB_LEGACY_ACTIVE_FG,
|
||||
clearInactiveTopTabHover,
|
||||
paintInactiveTopTabHover,
|
||||
resolveTopTabChromeStyle,
|
||||
topTabActiveValue,
|
||||
} from './top-tabs/topTabActiveStyle';
|
||||
|
||||
const topTabItemsSource = readFileSync(
|
||||
fileURLToPath(new URL('./top-tabs/TopTabItems.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
const indexCss = readFileSync(fileURLToPath(new URL('../index.css', import.meta.url)), 'utf8');
|
||||
|
||||
const TAB_TYPES = ['root', 'plugin-view', 'editor', 'session', 'workspace', 'logView'] as const;
|
||||
const LOCALES = [
|
||||
{ name: 'en', messages: en },
|
||||
{ name: 'zh-CN', messages: zhCN },
|
||||
{ name: 'zh-TW', messages: zhTW },
|
||||
{ name: 'es', messages: es },
|
||||
{ name: 'ru', messages: ru },
|
||||
];
|
||||
const HOOKS = ['--top-tab-active-bg', '--top-tab-active-fg', '--top-tab-active-border'] as const;
|
||||
|
||||
test('active top tab colors fall back to the current chrome colors', () => {
|
||||
assert.equal(TOP_TAB_ACTIVE_BG, `var(--top-tab-active-bg, ${TOP_TAB_LEGACY_ACTIVE_BG})`);
|
||||
assert.equal(TOP_TAB_ACTIVE_FG, `var(--top-tab-active-fg, ${TOP_TAB_LEGACY_ACTIVE_FG})`);
|
||||
assert.equal(TOP_TAB_ACTIVE_BORDER, 'var(--top-tab-active-border, transparent)');
|
||||
assert.equal(TOP_TAB_LEGACY_ACTIVE_BG, 'var(--top-tabs-active-bg, hsl(var(--background)))');
|
||||
assert.equal(TOP_TAB_LEGACY_ACTIVE_FG, 'var(--top-tabs-fg, hsl(var(--foreground)))');
|
||||
assert.equal(
|
||||
TOP_TAB_HOVER_BG,
|
||||
`color-mix(in srgb, ${TOP_TAB_ACTIVE_BG} 40%, transparent)`,
|
||||
);
|
||||
|
||||
const active = resolveTopTabChromeStyle(true);
|
||||
assert.deepEqual(active, {
|
||||
backgroundColor: TOP_TAB_ACTIVE_BG,
|
||||
color: TOP_TAB_ACTIVE_FG,
|
||||
});
|
||||
|
||||
const inactive = resolveTopTabChromeStyle(false);
|
||||
assert.deepEqual(inactive, {
|
||||
backgroundColor: 'transparent',
|
||||
color: TOP_TAB_INACTIVE_FG,
|
||||
});
|
||||
assert.equal(TOP_TAB_INACTIVE_FG, 'var(--top-tabs-muted, hsl(var(--muted-foreground)))');
|
||||
|
||||
const vault = resolveTopTabChromeStyle(true, { suppressActiveBg: true });
|
||||
assert.equal(vault.backgroundColor, 'transparent');
|
||||
assert.equal(vault.color, TOP_TAB_ACTIVE_FG);
|
||||
});
|
||||
|
||||
test('top tab active attribute is a stable true/false hook', () => {
|
||||
assert.equal(topTabActiveValue(true), 'true');
|
||||
assert.equal(topTabActiveValue(false), 'false');
|
||||
assert.equal(TOP_TAB_ITEM_CLASS, 'top-tab');
|
||||
});
|
||||
|
||||
test('inactive hover uses the active-tab hook and clears back to the flat tab', () => {
|
||||
const element = { style: { backgroundColor: '', color: '' } };
|
||||
paintInactiveTopTabHover(element);
|
||||
assert.equal(element.style.backgroundColor, TOP_TAB_HOVER_BG);
|
||||
assert.equal(element.style.color, TOP_TAB_ACTIVE_FG);
|
||||
clearInactiveTopTabHover(element);
|
||||
assert.equal(element.style.backgroundColor, 'transparent');
|
||||
assert.equal(element.style.color, TOP_TAB_INACTIVE_FG);
|
||||
});
|
||||
|
||||
test('every top tab kind exposes the active style hook', () => {
|
||||
for (const type of TAB_TYPES) {
|
||||
const marker = `data-tab-type="${type}"`;
|
||||
const start = topTabItemsSource.indexOf(marker);
|
||||
assert.notEqual(start, -1, marker);
|
||||
const next = topTabItemsSource.indexOf('data-tab-type=', start + marker.length);
|
||||
const slice = topTabItemsSource.slice(start, next === -1 ? undefined : next);
|
||||
assert.match(slice, /data-active=\{topTabActiveValue\(isActive\)\}/);
|
||||
assert.match(slice, /TOP_TAB_ITEM_CLASS/);
|
||||
assert.match(slice, /resolveTopTabChromeStyle\(isActive/);
|
||||
}
|
||||
|
||||
assert.equal(topTabItemsSource.split('data-active={topTabActiveValue(isActive)}').length - 1, TAB_TYPES.length);
|
||||
assert.doesNotMatch(topTabItemsSource, /backgroundColor: isActive/);
|
||||
});
|
||||
|
||||
test('selected top tab border hook defaults to a transparent inset outline', () => {
|
||||
const start = indexCss.indexOf('[data-section="top-tabs"] .top-tab[data-active="true"]');
|
||||
assert.notEqual(start, -1);
|
||||
const rule = indexCss.slice(start, indexCss.indexOf('}', start));
|
||||
assert.match(rule, /outline:\s*1px solid var\(--top-tab-active-border, transparent\)/);
|
||||
assert.match(rule, /outline-offset:\s*-1px/);
|
||||
assert.equal(TOP_TAB_ACTIVE_OUTLINE, '1px solid var(--top-tab-active-border, transparent)');
|
||||
});
|
||||
|
||||
test('custom CSS sets the top tab hooks on the selected tab', () => {
|
||||
const dom = new JSDOM(`<!doctype html><html><head></head><body>
|
||||
<div data-section="top-tabs">
|
||||
<div id="active" class="top-tab" data-active="true"></div>
|
||||
<div id="idle" class="top-tab" data-active="false"></div>
|
||||
</div>
|
||||
</body></html>`);
|
||||
const { document, getComputedStyle } = dom.window;
|
||||
const active = document.getElementById('active')!;
|
||||
const idle = document.getElementById('idle')!;
|
||||
const activeStyle = resolveTopTabChromeStyle(true);
|
||||
active.style.backgroundColor = activeStyle.backgroundColor;
|
||||
active.style.color = activeStyle.color;
|
||||
Object.assign(idle.style, resolveTopTabChromeStyle(false));
|
||||
|
||||
assert.equal(active.getAttribute('data-active'), 'true');
|
||||
assert.equal(active.style.backgroundColor, TOP_TAB_ACTIVE_BG);
|
||||
assert.equal(active.style.color, TOP_TAB_ACTIVE_FG);
|
||||
assert.equal(idle.style.backgroundColor, 'transparent');
|
||||
assert.equal(getComputedStyle(active).getPropertyValue('--top-tab-active-bg').trim(), '');
|
||||
|
||||
const custom = document.createElement('style');
|
||||
custom.id = 'netcatty-custom-css';
|
||||
custom.textContent = `
|
||||
.top-tab[data-active="true"] {
|
||||
--top-tab-active-bg: rgb(37, 99, 235);
|
||||
--top-tab-active-fg: rgb(255, 255, 255);
|
||||
--top-tab-active-border: rgb(29, 78, 216);
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(custom);
|
||||
|
||||
const styled = getComputedStyle(active);
|
||||
const normalized = (value: string) => value.replace(/\s+/g, '');
|
||||
assert.equal(normalized(styled.getPropertyValue('--top-tab-active-bg')), 'rgb(37,99,235)');
|
||||
assert.equal(normalized(styled.getPropertyValue('--top-tab-active-fg')), 'rgb(255,255,255)');
|
||||
assert.equal(normalized(styled.getPropertyValue('--top-tab-active-border')), 'rgb(29,78,216)');
|
||||
assert.equal(getComputedStyle(idle).getPropertyValue('--top-tab-active-bg').trim(), '');
|
||||
assert.match(active.style.backgroundColor, /var\(--top-tab-active-bg,/);
|
||||
});
|
||||
|
||||
test('custom CSS help mentions the top tab hooks in every locale', () => {
|
||||
for (const locale of LOCALES) {
|
||||
const desc = locale.messages['settings.appearance.customCss.desc'];
|
||||
const help = locale.messages['settings.appearance.customCss.help.body'];
|
||||
const placeholder = locale.messages['settings.appearance.customCss.placeholder'];
|
||||
assert.equal(typeof desc, 'string', locale.name);
|
||||
assert.equal(typeof help, 'string', locale.name);
|
||||
assert.equal(typeof placeholder, 'string', locale.name);
|
||||
for (const hook of HOOKS) {
|
||||
assert.ok(desc.includes(hook), `${locale.name} description missing ${hook}`);
|
||||
assert.ok(help.includes(hook), `${locale.name} help missing ${hook}`);
|
||||
assert.ok(placeholder.includes(hook), `${locale.name} placeholder missing ${hook}`);
|
||||
}
|
||||
assert.match(help, /\.top-tab\[data-active="true"\]/);
|
||||
assert.match(placeholder, /\.top-tab\[data-active="true"\]/);
|
||||
}
|
||||
});
|
||||
@@ -31,20 +31,20 @@
|
||||
<text x="85" y="85.0" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">7K</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="226.9" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
|
||||
<text x="301.4" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
|
||||
<text x="373.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
|
||||
<text x="447.9" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
|
||||
<text x="520.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
|
||||
<text x="594.4" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
|
||||
<text x="668.9" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
|
||||
<text x="741.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Oct 2026</text>
|
||||
<text x="223.6" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
|
||||
<text x="296.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
|
||||
<text x="366.6" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
|
||||
<text x="439.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
|
||||
<text x="509.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
|
||||
<text x="582.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
|
||||
<text x="654.8" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
|
||||
<text x="725.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Oct 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,462.2 C 90.4,462.1 92.0,461.8 92.4,461.7 C 93.2,461.6 94.0,461.4 94.8,461.2 C 95.6,461.1 96.4,460.8 97.2,460.8 C 98.0,460.7 98.8,460.7 99.6,460.7 C 100.4,460.7 101.2,460.6 102.0,460.6 C 102.8,460.5 103.6,460.5 104.4,460.4 C 105.2,460.4 105.6,460.3 106.8,460.3 C 108.0,460.2 110.4,460.1 111.6,460.1 C 112.8,460.1 113.2,460.0 114.0,460.0 C 114.8,460.0 115.6,460.0 116.4,459.9 C 117.2,459.9 118.0,459.9 118.8,459.9 C 119.6,459.9 120.4,459.9 121.2,459.8 C 122.0,459.8 122.8,459.7 123.6,459.7 C 124.4,459.6 125.2,459.6 126.0,459.4 C 126.8,459.3 127.6,459.0 128.4,458.7 C 129.2,458.3 130.0,457.6 130.8,457.3 C 131.6,457.1 132.4,457.2 133.2,457.1 C 134.0,457.1 134.8,457.1 135.6,457.0 C 136.4,457.0 136.8,457.0 138.0,456.9 C 139.2,456.9 141.2,456.9 142.8,456.9 C 144.4,456.8 146.5,456.8 147.7,456.8 C 148.9,456.7 149.3,456.7 150.1,456.7 C 150.9,456.6 151.7,456.6 152.5,456.5 C 153.3,456.5 154.1,456.5 154.9,456.4 C 155.7,456.4 156.5,456.4 157.3,456.4 C 158.1,456.4 158.5,456.4 159.7,456.3 C 160.9,456.3 162.5,456.2 164.5,456.2 C 166.5,456.2 169.7,456.2 171.7,456.2 C 173.7,456.1 174.1,456.2 176.5,456.1 C 178.9,456.1 184.1,455.9 186.1,455.9 C 188.1,455.8 187.3,455.9 188.5,455.8 C 189.7,455.8 191.3,455.8 193.3,455.8 C 195.3,455.8 196.5,455.7 200.5,455.7 C 204.5,455.6 214.1,455.5 217.3,455.5 C 220.5,455.5 218.9,455.5 219.7,455.4 C 220.5,455.4 220.9,456.1 222.1,455.4 C 223.3,454.7 225.7,454.1 226.9,451.2 C 228.1,448.4 228.5,440.7 229.3,438.3 C 230.1,435.9 230.9,437.2 231.7,436.8 C 232.5,436.5 233.3,436.2 234.1,436.0 C 234.9,435.7 235.7,435.5 236.5,435.4 C 237.3,435.2 238.1,435.2 238.9,435.1 C 239.7,435.0 240.5,434.8 241.3,434.8 C 242.1,434.7 242.9,434.8 243.7,434.7 C 244.5,434.6 245.3,434.4 246.1,434.3 C 246.9,434.2 247.7,434.3 248.5,434.2 C 249.3,434.0 250.1,433.7 250.9,433.6 C 251.7,433.4 252.5,433.5 253.3,433.4 C 254.1,433.3 254.5,433.1 255.7,433.0 C 256.9,432.9 259.4,432.9 260.6,432.9 C 261.8,432.8 262.2,432.7 263.0,432.7 C 263.8,432.6 264.6,432.6 265.4,432.5 C 266.2,432.4 267.0,432.2 267.8,432.0 C 268.6,431.9 269.4,432.6 270.2,431.6 C 271.0,430.7 271.8,427.5 272.6,426.5 C 273.4,425.5 274.2,425.6 275.0,425.4 C 275.8,425.2 276.6,425.5 277.4,425.4 C 278.2,425.2 279.0,424.9 279.8,424.6 C 280.6,424.3 281.4,424.1 282.2,423.6 C 283.0,423.1 283.8,422.2 284.6,421.6 C 285.4,421.0 286.2,420.4 287.0,419.9 C 287.8,419.5 288.6,419.2 289.4,418.9 C 290.2,418.6 291.0,418.5 291.8,418.4 C 292.6,418.3 293.4,418.3 294.2,418.2 C 295.0,418.1 295.8,417.8 296.6,417.8 C 297.4,417.7 298.2,417.7 299.0,417.6 C 299.8,417.6 300.6,417.5 301.4,417.4 C 302.2,417.3 303.0,417.3 303.8,416.9 C 304.6,416.5 305.4,415.5 306.2,415.0 C 307.0,414.5 307.8,414.3 308.6,413.9 C 309.4,413.5 310.2,414.3 311.0,412.7 C 311.8,411.1 312.6,406.4 313.4,404.5 C 314.2,402.7 315.0,402.4 315.8,401.6 C 316.6,400.8 317.4,400.4 318.2,399.8 C 319.0,399.3 319.8,398.7 320.6,398.3 C 321.4,397.9 322.2,397.6 323.0,397.3 C 323.8,397.0 324.6,397.0 325.4,396.7 C 326.2,396.5 327.0,396.3 327.8,396.0 C 328.6,395.7 329.4,395.2 330.2,394.9 C 331.0,394.7 331.8,394.7 332.6,394.4 C 333.4,394.2 334.2,393.7 335.0,393.4 C 335.8,393.1 336.6,392.8 337.4,392.6 C 338.2,392.3 339.0,392.1 339.8,392.0 C 340.6,391.8 341.4,391.8 342.2,391.7 C 343.0,391.5 343.8,391.3 344.6,391.2 C 345.4,391.0 346.2,390.8 347.0,390.6 C 347.8,390.4 348.6,390.2 349.4,389.9 C 350.2,389.6 351.0,389.0 351.8,388.7 C 352.6,388.4 353.4,388.3 354.2,388.2 C 355.0,388.0 355.8,387.9 356.6,387.8 C 357.4,387.6 358.2,387.5 359.0,387.4 C 359.8,387.3 360.6,387.2 361.4,387.1 C 362.2,387.0 363.0,387.1 363.8,386.9 C 364.6,386.8 365.4,386.6 366.2,386.3 C 367.0,386.1 367.8,385.6 368.6,385.3 C 369.4,384.9 370.2,384.5 371.0,384.3 C 371.9,384.1 372.7,384.1 373.5,383.9 C 374.3,383.8 375.1,383.7 375.9,383.5 C 376.7,383.3 377.5,382.7 378.3,382.5 C 379.1,382.3 379.9,382.4 380.7,382.2 C 381.5,382.1 382.3,381.9 383.1,381.7 C 383.9,381.5 384.7,381.3 385.5,381.1 C 386.3,381.0 387.1,380.7 387.9,380.5 C 388.7,380.4 389.5,380.3 390.3,380.1 C 391.1,379.8 391.9,379.5 392.7,379.2 C 393.5,379.0 394.3,378.9 395.1,378.6 C 395.9,378.4 396.7,378.1 397.5,377.8 C 398.3,377.5 399.1,377.2 399.9,377.0 C 400.7,376.7 401.5,376.7 402.3,376.3 C 403.1,375.9 403.9,375.0 404.7,374.6 C 405.5,374.2 406.3,374.2 407.1,374.0 C 407.9,373.9 408.7,373.9 409.5,373.8 C 410.3,373.7 411.1,373.5 411.9,373.4 C 412.7,373.3 413.5,373.5 414.3,373.2 C 415.1,372.8 415.9,371.7 416.7,371.3 C 417.5,370.9 418.3,371.0 419.1,370.7 C 419.9,370.4 420.7,370.0 421.5,369.8 C 422.3,369.6 423.1,369.7 423.9,369.5 C 424.7,369.3 425.5,368.8 426.3,368.6 C 427.1,368.4 427.9,368.4 428.7,368.3 C 429.5,368.2 430.3,368.2 431.1,368.0 C 431.9,367.7 432.7,367.5 433.5,367.0 C 434.3,366.5 435.1,365.4 435.9,364.9 C 436.7,364.4 437.5,364.1 438.3,363.7 C 439.1,363.4 439.9,363.3 440.7,362.5 C 441.5,361.8 442.3,360.1 443.1,359.3 C 443.9,358.5 444.7,358.2 445.5,357.7 C 446.3,357.2 447.1,357.1 447.9,356.4 C 448.7,355.7 449.5,354.1 450.3,353.4 C 451.1,352.6 451.9,352.6 452.7,352.1 C 453.5,351.7 454.3,351.4 455.1,350.9 C 455.9,3Line truncated
|
||||
<path d="M 90.0,462.2 C 90.4,462.1 92.0,461.8 92.4,461.7 C 93.2,461.6 94.0,461.4 94.8,461.2 C 95.6,461.1 96.4,460.8 97.2,460.8 C 98.0,460.7 98.8,460.7 99.6,460.7 C 100.4,460.7 101.2,460.6 102.0,460.6 C 102.8,460.5 103.6,460.5 104.4,460.4 C 105.2,460.4 105.6,460.3 106.8,460.3 C 108.0,460.2 110.4,460.1 111.6,460.1 C 112.8,460.1 113.2,460.0 114.0,460.0 C 114.8,460.0 115.6,460.0 116.4,459.9 C 117.2,459.9 118.0,459.9 118.8,459.9 C 119.6,459.9 120.4,459.9 121.2,459.8 C 122.0,459.8 122.8,459.7 123.6,459.7 C 124.4,459.6 125.2,459.6 126.0,459.4 C 126.8,459.3 127.6,459.0 128.4,458.7 C 129.2,458.3 130.0,457.6 130.8,457.3 C 131.6,457.1 132.4,457.2 133.2,457.1 C 134.0,457.1 134.8,457.1 135.6,457.0 C 136.4,457.0 136.8,457.0 138.0,456.9 C 139.2,456.9 141.2,456.9 142.8,456.9 C 144.4,456.8 146.5,456.8 147.7,456.8 C 148.9,456.7 149.3,456.7 150.1,456.7 C 150.9,456.6 151.7,456.6 152.5,456.5 C 153.3,456.5 154.1,456.5 154.9,456.4 C 155.7,456.4 156.5,456.4 157.3,456.4 C 158.1,456.4 158.5,456.4 159.7,456.3 C 160.9,456.3 162.5,456.2 164.5,456.2 C 166.5,456.2 169.7,456.2 171.7,456.2 C 173.7,456.1 174.1,456.2 176.5,456.1 C 178.9,456.1 184.1,455.9 186.1,455.9 C 188.1,455.8 187.3,455.9 188.5,455.8 C 189.7,455.8 191.3,455.8 193.3,455.8 C 195.3,455.8 196.5,455.7 200.5,455.7 C 204.5,455.6 214.1,455.5 217.3,455.5 C 220.5,455.5 218.9,455.5 219.7,455.4 C 220.5,455.4 220.9,456.1 222.1,455.4 C 223.3,454.7 225.7,454.1 226.9,451.2 C 228.1,448.4 228.5,440.7 229.3,438.3 C 230.1,435.9 230.9,437.2 231.7,436.8 C 232.5,436.5 233.3,436.2 234.1,436.0 C 234.9,435.7 235.7,435.5 236.5,435.4 C 237.3,435.2 238.1,435.2 238.9,435.1 C 239.7,435.0 240.5,434.8 241.3,434.8 C 242.1,434.7 242.9,434.8 243.7,434.7 C 244.5,434.6 245.3,434.4 246.1,434.3 C 246.9,434.2 247.7,434.3 248.5,434.2 C 249.3,434.0 250.1,433.7 250.9,433.6 C 251.7,433.4 252.5,433.5 253.3,433.4 C 254.1,433.3 254.5,433.1 255.7,433.0 C 256.9,432.9 259.4,432.9 260.6,432.9 C 261.8,432.8 262.2,432.7 263.0,432.7 C 263.8,432.6 264.6,432.6 265.4,432.5 C 266.2,432.4 267.0,432.2 267.8,432.0 C 268.6,431.9 269.4,432.6 270.2,431.6 C 271.0,430.7 271.8,427.5 272.6,426.5 C 273.4,425.5 274.2,425.6 275.0,425.4 C 275.8,425.2 276.6,425.5 277.4,425.4 C 278.2,425.2 279.0,424.9 279.8,424.6 C 280.6,424.3 281.4,424.1 282.2,423.6 C 283.0,423.1 283.8,422.2 284.6,421.6 C 285.4,421.0 286.2,420.4 287.0,419.9 C 287.8,419.5 288.6,419.2 289.4,418.9 C 290.2,418.6 291.0,418.5 291.8,418.4 C 292.6,418.3 293.4,418.3 294.2,418.2 C 295.0,418.1 295.8,417.8 296.6,417.8 C 297.4,417.7 298.2,417.7 299.0,417.6 C 299.8,417.6 300.6,417.5 301.4,417.4 C 302.2,417.3 303.0,417.3 303.8,416.9 C 304.6,416.5 305.4,415.5 306.2,415.0 C 307.0,414.5 307.8,414.3 308.6,413.9 C 309.4,413.5 310.2,414.3 311.0,412.7 C 311.8,411.1 312.6,406.4 313.4,404.5 C 314.2,402.7 315.0,402.4 315.8,401.6 C 316.6,400.8 317.4,400.4 318.2,399.8 C 319.0,399.3 319.8,398.7 320.6,398.3 C 321.4,397.9 322.2,397.6 323.0,397.3 C 323.8,397.0 324.6,397.0 325.4,396.7 C 326.2,396.5 327.0,396.3 327.8,396.0 C 328.6,395.7 329.4,395.2 330.2,394.9 C 331.0,394.7 331.8,394.7 332.6,394.4 C 333.4,394.2 334.2,393.7 335.0,393.4 C 335.8,393.1 336.6,392.8 337.4,392.6 C 338.2,392.3 339.0,392.1 339.8,392.0 C 340.6,391.8 341.4,391.8 342.2,391.7 C 343.0,391.5 343.8,391.3 344.6,391.2 C 345.4,391.0 346.2,390.8 347.0,390.6 C 347.8,390.4 348.6,390.2 349.4,389.9 C 350.2,389.6 351.0,389.0 351.8,388.7 C 352.6,388.4 353.4,388.3 354.2,388.2 C 355.0,388.0 355.8,387.9 356.6,387.8 C 357.4,387.6 358.2,387.5 359.0,387.4 C 359.8,387.3 360.6,387.2 361.4,387.1 C 362.2,387.0 363.0,387.1 363.8,386.9 C 364.6,386.8 365.4,386.6 366.2,386.3 C 367.0,386.1 367.8,385.6 368.6,385.3 C 369.4,384.9 370.2,384.5 371.0,384.3 C 371.9,384.1 372.7,384.1 373.5,383.9 C 374.3,383.8 375.1,383.7 375.9,383.5 C 376.7,383.3 377.5,382.7 378.3,382.5 C 379.1,382.3 379.9,382.4 380.7,382.2 C 381.5,382.1 382.3,381.9 383.1,381.7 C 383.9,381.5 384.7,381.3 385.5,381.1 C 386.3,381.0 387.1,380.7 387.9,380.5 C 388.7,380.4 389.5,380.3 390.3,380.1 C 391.1,379.8 391.9,379.5 392.7,379.2 C 393.5,379.0 394.3,378.9 395.1,378.6 C 395.9,378.4 396.7,378.1 397.5,377.8 C 398.3,377.5 399.1,377.2 399.9,377.0 C 400.7,376.7 401.5,376.7 402.3,376.3 C 403.1,375.9 403.9,375.0 404.7,374.6 C 405.5,374.2 406.3,374.2 407.1,374.0 C 407.9,373.9 408.7,373.9 409.5,373.8 C 410.3,373.7 411.1,373.5 411.9,373.4 C 412.7,373.3 413.5,373.5 414.3,373.2 C 415.1,372.8 415.9,371.7 416.7,371.3 C 417.5,370.9 418.3,371.0 419.1,370.7 C 419.9,370.4 420.7,370.0 421.5,369.8 C 422.3,369.6 423.1,369.7 423.9,369.5 C 424.7,369.3 425.5,368.8 426.3,368.6 C 427.1,368.4 427.9,368.4 428.7,368.3 C 429.5,368.2 430.3,368.2 431.1,368.0 C 431.9,367.7 432.7,367.5 433.5,367.0 C 434.3,366.5 435.1,365.4 435.9,364.9 C 436.7,364.4 437.5,364.1 438.3,363.7 C 439.1,363.4 439.9,363.3 440.7,362.5 C 441.5,361.8 442.3,360.1 443.1,359.3 C 443.9,358.5 444.7,358.2 445.5,357.7 C 446.3,357.2 447.1,357.1 447.9,356.4 C 448.7,355.7 449.5,354.1 450.3,353.4 C 451.1,352.6 451.9,352.6 452.7,352.1 C 453.5,351.7 454.3,351.4 455.1,350.9 C 455.9,3Line truncated
|
||||
<circle cx="765.0" cy="106.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">6508</text>
|
||||
<path d="M 90.0,462.2 C 90.4,462.1 92.0,461.8 92.3,461.7 C 93.1,461.6 93.9,461.4 94.7,461.2 C 95.5,461.1 96.2,460.8 97.0,460.8 C 97.8,460.7 98.6,460.7 99.4,460.7 C 100.2,460.7 100.9,460.6 101.7,460.6 C 102.5,460.5 103.3,460.5 104.1,460.4 C 104.8,460.4 105.2,460.3 106.4,460.3 C 107.6,460.2 109.9,460.1 111.1,460.1 C 112.3,460.1 112.7,460.0 113.4,460.0 C 114.2,460.0 115.0,460.0 115.8,459.9 C 116.6,459.9 117.3,459.9 118.1,459.9 C 118.9,459.9 119.7,459.9 120.5,459.8 C 121.2,459.8 122.0,459.7 122.8,459.7 C 123.6,459.6 124.4,459.6 125.2,459.4 C 125.9,459.3 126.7,459.0 127.5,458.7 C 128.3,458.3 129.1,457.6 129.8,457.3 C 130.6,457.1 131.4,457.2 132.2,457.1 C 133.0,457.1 133.8,457.1 134.5,457.0 C 135.3,457.0 135.7,457.0 136.9,456.9 C 138.0,456.9 140.0,456.9 141.6,456.9 C 143.1,456.8 145.1,456.8 146.2,456.8 C 147.4,456.7 147.8,456.7 148.6,456.7 C 149.4,456.6 150.2,456.6 150.9,456.5 C 151.7,456.5 152.5,456.5 153.3,456.4 C 154.1,456.4 154.8,456.4 155.6,456.4 C 156.4,456.4 156.8,456.4 158.0,456.3 C 159.1,456.3 160.7,456.2 162.7,456.2 C 164.6,456.2 167.7,456.2 169.7,456.2 C 171.6,456.1 172.0,456.2 174.4,456.1 C 176.7,456.1 181.8,455.9 183.8,455.9 C 185.7,455.8 184.9,455.9 186.1,455.8 C 187.3,455.8 188.8,455.8 190.8,455.8 C 192.7,455.8 193.9,455.7 197.8,455.7 C 201.7,455.6 211.1,455.5 214.2,455.5 C 217.3,455.5 215.8,455.5 216.6,455.4 C 217.3,455.4 217.7,456.1 218.9,455.4 C 220.1,454.7 222.4,454.1 223.6,451.3 C 224.8,448.4 225.2,440.7 225.9,438.3 C 226.7,435.9 227.5,437.3 228.3,436.9 C 229.1,436.5 229.8,436.3 230.6,436.0 C 231.4,435.8 232.2,435.6 233.0,435.4 C 233.8,435.3 234.5,435.3 235.3,435.2 C 236.1,435.1 236.9,434.9 237.7,434.8 C 238.4,434.8 239.2,434.8 240.0,434.8 C 240.8,434.7 241.6,434.5 242.3,434.4 C 243.1,434.3 243.9,434.3 244.7,434.2 C 245.5,434.1 246.2,433.7 247.0,433.6 C 247.8,433.5 248.6,433.5 249.4,433.5 C 250.2,433.4 250.5,433.2 251.7,433.1 C 252.9,433.0 255.2,433.0 256.4,432.9 C 257.6,432.9 258.0,432.8 258.8,432.7 C 259.5,432.7 260.3,432.7 261.1,432.6 C 261.9,432.5 262.7,432.2 263.4,432.1 C 264.2,431.9 265.0,432.6 265.8,431.7 C 266.6,430.8 267.3,427.6 268.1,426.6 C 268.9,425.5 269.7,425.7 270.5,425.5 C 271.2,425.3 272.0,425.5 272.8,425.4 C 273.6,425.3 274.4,424.9 275.2,424.6 C 275.9,424.4 276.7,424.2 277.5,423.7 C 278.3,423.2 279.1,422.2 279.8,421.6 C 280.6,421.0 281.4,420.4 282.2,420.0 C 283.0,419.5 283.8,419.2 284.5,419.0 C 285.3,418.7 286.1,418.6 286.9,418.5 C 287.7,418.3 288.4,418.4 289.2,418.2 C 290.0,418.1 290.8,417.9 291.6,417.8 C 292.3,417.7 293.1,417.8 293.9,417.7 C 294.7,417.6 295.5,417.6 296.2,417.5 C 297.0,417.4 297.8,417.4 298.6,417.0 C 299.4,416.6 300.2,415.5 300.9,415.0 C 301.7,414.5 302.5,414.3 303.3,414.0 C 304.1,413.6 304.8,414.3 305.6,412.8 C 306.4,411.2 307.2,406.4 308.0,404.6 C 308.8,402.7 309.5,402.4 310.3,401.7 C 311.1,400.9 311.9,400.4 312.7,399.9 C 313.4,399.3 314.2,398.8 315.0,398.4 C 315.8,398.0 316.6,397.6 317.3,397.3 C 318.1,397.1 318.9,397.0 319.7,396.8 C 320.5,396.6 321.2,396.3 322.0,396.0 C 322.8,395.7 323.6,395.2 324.4,395.0 C 325.2,394.7 325.9,394.8 326.7,394.5 C 327.5,394.2 328.3,393.8 329.1,393.5 C 329.8,393.1 330.6,392.9 331.4,392.6 C 332.2,392.4 333.0,392.2 333.8,392.0 C 334.5,391.9 335.3,391.8 336.1,391.7 C 336.9,391.6 337.7,391.4 338.4,391.2 C 339.2,391.0 340.0,390.9 340.8,390.7 C 341.6,390.5 342.3,390.3 343.1,390.0 C 343.9,389.6 344.7,389.0 345.5,388.8 C 346.2,388.5 347.0,388.4 347.8,388.2 C 348.6,388.1 349.4,387.9 350.2,387.8 C 350.9,387.7 351.7,387.6 352.5,387.5 C 353.3,387.4 354.1,387.2 354.8,387.2 C 355.6,387.1 356.4,387.1 357.2,387.0 C 358.0,386.9 358.8,386.7 359.5,386.4 C 360.3,386.1 361.1,385.6 361.9,385.3 C 362.7,385.0 363.4,384.6 364.2,384.4 C 365.0,384.2 365.8,384.1 366.6,384.0 C 367.3,383.9 368.1,383.8 368.9,383.6 C 369.7,383.3 370.5,382.8 371.2,382.6 C 372.0,382.4 372.8,382.4 373.6,382.3 C 374.4,382.2 375.2,382.0 375.9,381.8 C 376.7,381.6 377.5,381.4 378.3,381.3 C 379.1,381.1 379.8,380.8 380.6,380.7 C 381.4,380.5 382.2,380.4 383.0,380.2 C 383.8,379.9 384.5,379.6 385.3,379.3 C 386.1,379.1 386.9,379.0 387.7,378.7 C 388.4,378.5 389.2,378.2 390.0,377.9 C 390.8,377.6 391.6,377.4 392.3,377.1 C 393.1,376.8 393.9,376.8 394.7,376.4 C 395.5,376.0 396.2,375.1 397.0,374.7 C 397.8,374.3 398.6,374.3 399.4,374.1 C 400.2,374.0 400.9,374.0 401.7,373.9 C 402.5,373.8 403.3,373.7 404.1,373.5 C 404.8,373.4 405.6,373.6 406.4,373.3 C 407.2,372.9 408.0,371.8 408.8,371.4 C 409.5,371.0 410.3,371.1 411.1,370.8 C 411.9,370.6 412.7,370.1 413.4,369.9 C 414.2,369.7 415.0,369.8 415.8,369.6 C 416.6,369.4 417.3,368.9 418.1,368.7 C 418.9,368.5 419.7,368.5 420.5,368.4 C 421.2,368.3 422.0,368.3 422.8,368.1 C 423.6,367.9 424.4,367.6 425.2,367.1 C 425.9,366.6 426.7,365.5 427.5,365.0 C 428.3,364.5 429.1,364.2 429.8,363.9 C 430.6,363.5 431.4,363.4 432.2,362.7 C 433.0,361.9 433.8,360.2 434.5,359.4 C 435.3,358.6 436.1,358.3 436.9,357.8 C 437.7,357.3 438.4,357.2 439.2,356.5 C 440.0,355.8 440.8,354.2 441.6,353.5 C 442.3,352.8 443.1,352.7 443.9,352.3 C 444.7,351.9 445.5,351.5 446.2,351.1 C 447.0,3Line truncated
|
||||
<path d="M 90.0,462.2 C 90.4,462.1 92.0,461.8 92.3,461.7 C 93.1,461.6 93.9,461.4 94.7,461.2 C 95.5,461.1 96.2,460.8 97.0,460.8 C 97.8,460.7 98.6,460.7 99.4,460.7 C 100.2,460.7 100.9,460.6 101.7,460.6 C 102.5,460.5 103.3,460.5 104.1,460.4 C 104.8,460.4 105.2,460.3 106.4,460.3 C 107.6,460.2 109.9,460.1 111.1,460.1 C 112.3,460.1 112.7,460.0 113.4,460.0 C 114.2,460.0 115.0,460.0 115.8,459.9 C 116.6,459.9 117.3,459.9 118.1,459.9 C 118.9,459.9 119.7,459.9 120.5,459.8 C 121.2,459.8 122.0,459.7 122.8,459.7 C 123.6,459.6 124.4,459.6 125.2,459.4 C 125.9,459.3 126.7,459.0 127.5,458.7 C 128.3,458.3 129.1,457.6 129.8,457.3 C 130.6,457.1 131.4,457.2 132.2,457.1 C 133.0,457.1 133.8,457.1 134.5,457.0 C 135.3,457.0 135.7,457.0 136.9,456.9 C 138.0,456.9 140.0,456.9 141.6,456.9 C 143.1,456.8 145.1,456.8 146.2,456.8 C 147.4,456.7 147.8,456.7 148.6,456.7 C 149.4,456.6 150.2,456.6 150.9,456.5 C 151.7,456.5 152.5,456.5 153.3,456.4 C 154.1,456.4 154.8,456.4 155.6,456.4 C 156.4,456.4 156.8,456.4 158.0,456.3 C 159.1,456.3 160.7,456.2 162.7,456.2 C 164.6,456.2 167.7,456.2 169.7,456.2 C 171.6,456.1 172.0,456.2 174.4,456.1 C 176.7,456.1 181.8,455.9 183.8,455.9 C 185.7,455.8 184.9,455.9 186.1,455.8 C 187.3,455.8 188.8,455.8 190.8,455.8 C 192.7,455.8 193.9,455.7 197.8,455.7 C 201.7,455.6 211.1,455.5 214.2,455.5 C 217.3,455.5 215.8,455.5 216.6,455.4 C 217.3,455.4 217.7,456.1 218.9,455.4 C 220.1,454.7 222.4,454.1 223.6,451.3 C 224.8,448.4 225.2,440.7 225.9,438.3 C 226.7,435.9 227.5,437.3 228.3,436.9 C 229.1,436.5 229.8,436.3 230.6,436.0 C 231.4,435.8 232.2,435.6 233.0,435.4 C 233.8,435.3 234.5,435.3 235.3,435.2 C 236.1,435.1 236.9,434.9 237.7,434.8 C 238.4,434.8 239.2,434.8 240.0,434.8 C 240.8,434.7 241.6,434.5 242.3,434.4 C 243.1,434.3 243.9,434.3 244.7,434.2 C 245.5,434.1 246.2,433.7 247.0,433.6 C 247.8,433.5 248.6,433.5 249.4,433.5 C 250.2,433.4 250.5,433.2 251.7,433.1 C 252.9,433.0 255.2,433.0 256.4,432.9 C 257.6,432.9 258.0,432.8 258.8,432.7 C 259.5,432.7 260.3,432.7 261.1,432.6 C 261.9,432.5 262.7,432.2 263.4,432.1 C 264.2,431.9 265.0,432.6 265.8,431.7 C 266.6,430.8 267.3,427.6 268.1,426.6 C 268.9,425.5 269.7,425.7 270.5,425.5 C 271.2,425.3 272.0,425.5 272.8,425.4 C 273.6,425.3 274.4,424.9 275.2,424.6 C 275.9,424.4 276.7,424.2 277.5,423.7 C 278.3,423.2 279.1,422.2 279.8,421.6 C 280.6,421.0 281.4,420.4 282.2,420.0 C 283.0,419.5 283.8,419.2 284.5,419.0 C 285.3,418.7 286.1,418.6 286.9,418.5 C 287.7,418.3 288.4,418.4 289.2,418.2 C 290.0,418.1 290.8,417.9 291.6,417.8 C 292.3,417.7 293.1,417.8 293.9,417.7 C 294.7,417.6 295.5,417.6 296.2,417.5 C 297.0,417.4 297.8,417.4 298.6,417.0 C 299.4,416.6 300.2,415.5 300.9,415.0 C 301.7,414.5 302.5,414.3 303.3,414.0 C 304.1,413.6 304.8,414.3 305.6,412.8 C 306.4,411.2 307.2,406.4 308.0,404.6 C 308.8,402.7 309.5,402.4 310.3,401.7 C 311.1,400.9 311.9,400.4 312.7,399.9 C 313.4,399.3 314.2,398.8 315.0,398.4 C 315.8,398.0 316.6,397.6 317.3,397.3 C 318.1,397.1 318.9,397.0 319.7,396.8 C 320.5,396.6 321.2,396.3 322.0,396.0 C 322.8,395.7 323.6,395.2 324.4,395.0 C 325.2,394.7 325.9,394.8 326.7,394.5 C 327.5,394.2 328.3,393.8 329.1,393.5 C 329.8,393.1 330.6,392.9 331.4,392.6 C 332.2,392.4 333.0,392.2 333.8,392.0 C 334.5,391.9 335.3,391.8 336.1,391.7 C 336.9,391.6 337.7,391.4 338.4,391.2 C 339.2,391.0 340.0,390.9 340.8,390.7 C 341.6,390.5 342.3,390.3 343.1,390.0 C 343.9,389.6 344.7,389.0 345.5,388.8 C 346.2,388.5 347.0,388.4 347.8,388.2 C 348.6,388.1 349.4,387.9 350.2,387.8 C 350.9,387.7 351.7,387.6 352.5,387.5 C 353.3,387.4 354.1,387.2 354.8,387.2 C 355.6,387.1 356.4,387.1 357.2,387.0 C 358.0,386.9 358.8,386.7 359.5,386.4 C 360.3,386.1 361.1,385.6 361.9,385.3 C 362.7,385.0 363.4,384.6 364.2,384.4 C 365.0,384.2 365.8,384.1 366.6,384.0 C 367.3,383.9 368.1,383.8 368.9,383.6 C 369.7,383.3 370.5,382.8 371.2,382.6 C 372.0,382.4 372.8,382.4 373.6,382.3 C 374.4,382.2 375.2,382.0 375.9,381.8 C 376.7,381.6 377.5,381.4 378.3,381.3 C 379.1,381.1 379.8,380.8 380.6,380.7 C 381.4,380.5 382.2,380.4 383.0,380.2 C 383.8,379.9 384.5,379.6 385.3,379.3 C 386.1,379.1 386.9,379.0 387.7,378.7 C 388.4,378.5 389.2,378.2 390.0,377.9 C 390.8,377.6 391.6,377.4 392.3,377.1 C 393.1,376.8 393.9,376.8 394.7,376.4 C 395.5,376.0 396.2,375.1 397.0,374.7 C 397.8,374.3 398.6,374.3 399.4,374.1 C 400.2,374.0 400.9,374.0 401.7,373.9 C 402.5,373.8 403.3,373.7 404.1,373.5 C 404.8,373.4 405.6,373.6 406.4,373.3 C 407.2,372.9 408.0,371.8 408.8,371.4 C 409.5,371.0 410.3,371.1 411.1,370.8 C 411.9,370.6 412.7,370.1 413.4,369.9 C 414.2,369.7 415.0,369.8 415.8,369.6 C 416.6,369.4 417.3,368.9 418.1,368.7 C 418.9,368.5 419.7,368.5 420.5,368.4 C 421.2,368.3 422.0,368.3 422.8,368.1 C 423.6,367.9 424.4,367.6 425.2,367.1 C 425.9,366.6 426.7,365.5 427.5,365.0 C 428.3,364.5 429.1,364.2 429.8,363.9 C 430.6,363.5 431.4,363.4 432.2,362.7 C 433.0,361.9 433.8,360.2 434.5,359.4 C 435.3,358.6 436.1,358.3 436.9,357.8 C 437.7,357.3 438.4,357.2 439.2,356.5 C 440.0,355.8 440.8,354.2 441.6,353.5 C 442.3,352.8 443.1,352.7 443.9,352.3 C 444.7,351.9 445.5,351.5 446.2,351.1 C 447.0,3Line truncated
|
||||
<circle cx="765.0" cy="99.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">6652</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: 35 KiB After Width: | Height: | Size: 36 KiB |
@@ -31,20 +31,20 @@
|
||||
<text x="85" y="85.0" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">7K</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="226.9" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
|
||||
<text x="301.4" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
|
||||
<text x="373.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
|
||||
<text x="447.9" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
|
||||
<text x="520.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
|
||||
<text x="594.4" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
|
||||
<text x="668.9" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
|
||||
<text x="741.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Oct 2026</text>
|
||||
<text x="223.6" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
|
||||
<text x="296.2" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
|
||||
<text x="366.6" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
|
||||
<text x="439.2" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
|
||||
<text x="509.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
|
||||
<text x="582.2" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
|
||||
<text x="654.8" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
|
||||
<text x="725.2" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Oct 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,462.2 C 90.4,462.1 92.0,461.8 92.4,461.7 C 93.2,461.6 94.0,461.4 94.8,461.2 C 95.6,461.1 96.4,460.8 97.2,460.8 C 98.0,460.7 98.8,460.7 99.6,460.7 C 100.4,460.7 101.2,460.6 102.0,460.6 C 102.8,460.5 103.6,460.5 104.4,460.4 C 105.2,460.4 105.6,460.3 106.8,460.3 C 108.0,460.2 110.4,460.1 111.6,460.1 C 112.8,460.1 113.2,460.0 114.0,460.0 C 114.8,460.0 115.6,460.0 116.4,459.9 C 117.2,459.9 118.0,459.9 118.8,459.9 C 119.6,459.9 120.4,459.9 121.2,459.8 C 122.0,459.8 122.8,459.7 123.6,459.7 C 124.4,459.6 125.2,459.6 126.0,459.4 C 126.8,459.3 127.6,459.0 128.4,458.7 C 129.2,458.3 130.0,457.6 130.8,457.3 C 131.6,457.1 132.4,457.2 133.2,457.1 C 134.0,457.1 134.8,457.1 135.6,457.0 C 136.4,457.0 136.8,457.0 138.0,456.9 C 139.2,456.9 141.2,456.9 142.8,456.9 C 144.4,456.8 146.5,456.8 147.7,456.8 C 148.9,456.7 149.3,456.7 150.1,456.7 C 150.9,456.6 151.7,456.6 152.5,456.5 C 153.3,456.5 154.1,456.5 154.9,456.4 C 155.7,456.4 156.5,456.4 157.3,456.4 C 158.1,456.4 158.5,456.4 159.7,456.3 C 160.9,456.3 162.5,456.2 164.5,456.2 C 166.5,456.2 169.7,456.2 171.7,456.2 C 173.7,456.1 174.1,456.2 176.5,456.1 C 178.9,456.1 184.1,455.9 186.1,455.9 C 188.1,455.8 187.3,455.9 188.5,455.8 C 189.7,455.8 191.3,455.8 193.3,455.8 C 195.3,455.8 196.5,455.7 200.5,455.7 C 204.5,455.6 214.1,455.5 217.3,455.5 C 220.5,455.5 218.9,455.5 219.7,455.4 C 220.5,455.4 220.9,456.1 222.1,455.4 C 223.3,454.7 225.7,454.1 226.9,451.2 C 228.1,448.4 228.5,440.7 229.3,438.3 C 230.1,435.9 230.9,437.2 231.7,436.8 C 232.5,436.5 233.3,436.2 234.1,436.0 C 234.9,435.7 235.7,435.5 236.5,435.4 C 237.3,435.2 238.1,435.2 238.9,435.1 C 239.7,435.0 240.5,434.8 241.3,434.8 C 242.1,434.7 242.9,434.8 243.7,434.7 C 244.5,434.6 245.3,434.4 246.1,434.3 C 246.9,434.2 247.7,434.3 248.5,434.2 C 249.3,434.0 250.1,433.7 250.9,433.6 C 251.7,433.4 252.5,433.5 253.3,433.4 C 254.1,433.3 254.5,433.1 255.7,433.0 C 256.9,432.9 259.4,432.9 260.6,432.9 C 261.8,432.8 262.2,432.7 263.0,432.7 C 263.8,432.6 264.6,432.6 265.4,432.5 C 266.2,432.4 267.0,432.2 267.8,432.0 C 268.6,431.9 269.4,432.6 270.2,431.6 C 271.0,430.7 271.8,427.5 272.6,426.5 C 273.4,425.5 274.2,425.6 275.0,425.4 C 275.8,425.2 276.6,425.5 277.4,425.4 C 278.2,425.2 279.0,424.9 279.8,424.6 C 280.6,424.3 281.4,424.1 282.2,423.6 C 283.0,423.1 283.8,422.2 284.6,421.6 C 285.4,421.0 286.2,420.4 287.0,419.9 C 287.8,419.5 288.6,419.2 289.4,418.9 C 290.2,418.6 291.0,418.5 291.8,418.4 C 292.6,418.3 293.4,418.3 294.2,418.2 C 295.0,418.1 295.8,417.8 296.6,417.8 C 297.4,417.7 298.2,417.7 299.0,417.6 C 299.8,417.6 300.6,417.5 301.4,417.4 C 302.2,417.3 303.0,417.3 303.8,416.9 C 304.6,416.5 305.4,415.5 306.2,415.0 C 307.0,414.5 307.8,414.3 308.6,413.9 C 309.4,413.5 310.2,414.3 311.0,412.7 C 311.8,411.1 312.6,406.4 313.4,404.5 C 314.2,402.7 315.0,402.4 315.8,401.6 C 316.6,400.8 317.4,400.4 318.2,399.8 C 319.0,399.3 319.8,398.7 320.6,398.3 C 321.4,397.9 322.2,397.6 323.0,397.3 C 323.8,397.0 324.6,397.0 325.4,396.7 C 326.2,396.5 327.0,396.3 327.8,396.0 C 328.6,395.7 329.4,395.2 330.2,394.9 C 331.0,394.7 331.8,394.7 332.6,394.4 C 333.4,394.2 334.2,393.7 335.0,393.4 C 335.8,393.1 336.6,392.8 337.4,392.6 C 338.2,392.3 339.0,392.1 339.8,392.0 C 340.6,391.8 341.4,391.8 342.2,391.7 C 343.0,391.5 343.8,391.3 344.6,391.2 C 345.4,391.0 346.2,390.8 347.0,390.6 C 347.8,390.4 348.6,390.2 349.4,389.9 C 350.2,389.6 351.0,389.0 351.8,388.7 C 352.6,388.4 353.4,388.3 354.2,388.2 C 355.0,388.0 355.8,387.9 356.6,387.8 C 357.4,387.6 358.2,387.5 359.0,387.4 C 359.8,387.3 360.6,387.2 361.4,387.1 C 362.2,387.0 363.0,387.1 363.8,386.9 C 364.6,386.8 365.4,386.6 366.2,386.3 C 367.0,386.1 367.8,385.6 368.6,385.3 C 369.4,384.9 370.2,384.5 371.0,384.3 C 371.9,384.1 372.7,384.1 373.5,383.9 C 374.3,383.8 375.1,383.7 375.9,383.5 C 376.7,383.3 377.5,382.7 378.3,382.5 C 379.1,382.3 379.9,382.4 380.7,382.2 C 381.5,382.1 382.3,381.9 383.1,381.7 C 383.9,381.5 384.7,381.3 385.5,381.1 C 386.3,381.0 387.1,380.7 387.9,380.5 C 388.7,380.4 389.5,380.3 390.3,380.1 C 391.1,379.8 391.9,379.5 392.7,379.2 C 393.5,379.0 394.3,378.9 395.1,378.6 C 395.9,378.4 396.7,378.1 397.5,377.8 C 398.3,377.5 399.1,377.2 399.9,377.0 C 400.7,376.7 401.5,376.7 402.3,376.3 C 403.1,375.9 403.9,375.0 404.7,374.6 C 405.5,374.2 406.3,374.2 407.1,374.0 C 407.9,373.9 408.7,373.9 409.5,373.8 C 410.3,373.7 411.1,373.5 411.9,373.4 C 412.7,373.3 413.5,373.5 414.3,373.2 C 415.1,372.8 415.9,371.7 416.7,371.3 C 417.5,370.9 418.3,371.0 419.1,370.7 C 419.9,370.4 420.7,370.0 421.5,369.8 C 422.3,369.6 423.1,369.7 423.9,369.5 C 424.7,369.3 425.5,368.8 426.3,368.6 C 427.1,368.4 427.9,368.4 428.7,368.3 C 429.5,368.2 430.3,368.2 431.1,368.0 C 431.9,367.7 432.7,367.5 433.5,367.0 C 434.3,366.5 435.1,365.4 435.9,364.9 C 436.7,364.4 437.5,364.1 438.3,363.7 C 439.1,363.4 439.9,363.3 440.7,362.5 C 441.5,361.8 442.3,360.1 443.1,359.3 C 443.9,358.5 444.7,358.2 445.5,357.7 C 446.3,357.2 447.1,357.1 447.9,356.4 C 448.7,355.7 449.5,354.1 450.3,353.4 C 451.1,352.6 451.9,352.6 452.7,352.1 C 453.5,351.7 454.3,351.4 455.1,350.9 C 455.9,3Line truncated
|
||||
<path d="M 90.0,462.2 C 90.4,462.1 92.0,461.8 92.4,461.7 C 93.2,461.6 94.0,461.4 94.8,461.2 C 95.6,461.1 96.4,460.8 97.2,460.8 C 98.0,460.7 98.8,460.7 99.6,460.7 C 100.4,460.7 101.2,460.6 102.0,460.6 C 102.8,460.5 103.6,460.5 104.4,460.4 C 105.2,460.4 105.6,460.3 106.8,460.3 C 108.0,460.2 110.4,460.1 111.6,460.1 C 112.8,460.1 113.2,460.0 114.0,460.0 C 114.8,460.0 115.6,460.0 116.4,459.9 C 117.2,459.9 118.0,459.9 118.8,459.9 C 119.6,459.9 120.4,459.9 121.2,459.8 C 122.0,459.8 122.8,459.7 123.6,459.7 C 124.4,459.6 125.2,459.6 126.0,459.4 C 126.8,459.3 127.6,459.0 128.4,458.7 C 129.2,458.3 130.0,457.6 130.8,457.3 C 131.6,457.1 132.4,457.2 133.2,457.1 C 134.0,457.1 134.8,457.1 135.6,457.0 C 136.4,457.0 136.8,457.0 138.0,456.9 C 139.2,456.9 141.2,456.9 142.8,456.9 C 144.4,456.8 146.5,456.8 147.7,456.8 C 148.9,456.7 149.3,456.7 150.1,456.7 C 150.9,456.6 151.7,456.6 152.5,456.5 C 153.3,456.5 154.1,456.5 154.9,456.4 C 155.7,456.4 156.5,456.4 157.3,456.4 C 158.1,456.4 158.5,456.4 159.7,456.3 C 160.9,456.3 162.5,456.2 164.5,456.2 C 166.5,456.2 169.7,456.2 171.7,456.2 C 173.7,456.1 174.1,456.2 176.5,456.1 C 178.9,456.1 184.1,455.9 186.1,455.9 C 188.1,455.8 187.3,455.9 188.5,455.8 C 189.7,455.8 191.3,455.8 193.3,455.8 C 195.3,455.8 196.5,455.7 200.5,455.7 C 204.5,455.6 214.1,455.5 217.3,455.5 C 220.5,455.5 218.9,455.5 219.7,455.4 C 220.5,455.4 220.9,456.1 222.1,455.4 C 223.3,454.7 225.7,454.1 226.9,451.2 C 228.1,448.4 228.5,440.7 229.3,438.3 C 230.1,435.9 230.9,437.2 231.7,436.8 C 232.5,436.5 233.3,436.2 234.1,436.0 C 234.9,435.7 235.7,435.5 236.5,435.4 C 237.3,435.2 238.1,435.2 238.9,435.1 C 239.7,435.0 240.5,434.8 241.3,434.8 C 242.1,434.7 242.9,434.8 243.7,434.7 C 244.5,434.6 245.3,434.4 246.1,434.3 C 246.9,434.2 247.7,434.3 248.5,434.2 C 249.3,434.0 250.1,433.7 250.9,433.6 C 251.7,433.4 252.5,433.5 253.3,433.4 C 254.1,433.3 254.5,433.1 255.7,433.0 C 256.9,432.9 259.4,432.9 260.6,432.9 C 261.8,432.8 262.2,432.7 263.0,432.7 C 263.8,432.6 264.6,432.6 265.4,432.5 C 266.2,432.4 267.0,432.2 267.8,432.0 C 268.6,431.9 269.4,432.6 270.2,431.6 C 271.0,430.7 271.8,427.5 272.6,426.5 C 273.4,425.5 274.2,425.6 275.0,425.4 C 275.8,425.2 276.6,425.5 277.4,425.4 C 278.2,425.2 279.0,424.9 279.8,424.6 C 280.6,424.3 281.4,424.1 282.2,423.6 C 283.0,423.1 283.8,422.2 284.6,421.6 C 285.4,421.0 286.2,420.4 287.0,419.9 C 287.8,419.5 288.6,419.2 289.4,418.9 C 290.2,418.6 291.0,418.5 291.8,418.4 C 292.6,418.3 293.4,418.3 294.2,418.2 C 295.0,418.1 295.8,417.8 296.6,417.8 C 297.4,417.7 298.2,417.7 299.0,417.6 C 299.8,417.6 300.6,417.5 301.4,417.4 C 302.2,417.3 303.0,417.3 303.8,416.9 C 304.6,416.5 305.4,415.5 306.2,415.0 C 307.0,414.5 307.8,414.3 308.6,413.9 C 309.4,413.5 310.2,414.3 311.0,412.7 C 311.8,411.1 312.6,406.4 313.4,404.5 C 314.2,402.7 315.0,402.4 315.8,401.6 C 316.6,400.8 317.4,400.4 318.2,399.8 C 319.0,399.3 319.8,398.7 320.6,398.3 C 321.4,397.9 322.2,397.6 323.0,397.3 C 323.8,397.0 324.6,397.0 325.4,396.7 C 326.2,396.5 327.0,396.3 327.8,396.0 C 328.6,395.7 329.4,395.2 330.2,394.9 C 331.0,394.7 331.8,394.7 332.6,394.4 C 333.4,394.2 334.2,393.7 335.0,393.4 C 335.8,393.1 336.6,392.8 337.4,392.6 C 338.2,392.3 339.0,392.1 339.8,392.0 C 340.6,391.8 341.4,391.8 342.2,391.7 C 343.0,391.5 343.8,391.3 344.6,391.2 C 345.4,391.0 346.2,390.8 347.0,390.6 C 347.8,390.4 348.6,390.2 349.4,389.9 C 350.2,389.6 351.0,389.0 351.8,388.7 C 352.6,388.4 353.4,388.3 354.2,388.2 C 355.0,388.0 355.8,387.9 356.6,387.8 C 357.4,387.6 358.2,387.5 359.0,387.4 C 359.8,387.3 360.6,387.2 361.4,387.1 C 362.2,387.0 363.0,387.1 363.8,386.9 C 364.6,386.8 365.4,386.6 366.2,386.3 C 367.0,386.1 367.8,385.6 368.6,385.3 C 369.4,384.9 370.2,384.5 371.0,384.3 C 371.9,384.1 372.7,384.1 373.5,383.9 C 374.3,383.8 375.1,383.7 375.9,383.5 C 376.7,383.3 377.5,382.7 378.3,382.5 C 379.1,382.3 379.9,382.4 380.7,382.2 C 381.5,382.1 382.3,381.9 383.1,381.7 C 383.9,381.5 384.7,381.3 385.5,381.1 C 386.3,381.0 387.1,380.7 387.9,380.5 C 388.7,380.4 389.5,380.3 390.3,380.1 C 391.1,379.8 391.9,379.5 392.7,379.2 C 393.5,379.0 394.3,378.9 395.1,378.6 C 395.9,378.4 396.7,378.1 397.5,377.8 C 398.3,377.5 399.1,377.2 399.9,377.0 C 400.7,376.7 401.5,376.7 402.3,376.3 C 403.1,375.9 403.9,375.0 404.7,374.6 C 405.5,374.2 406.3,374.2 407.1,374.0 C 407.9,373.9 408.7,373.9 409.5,373.8 C 410.3,373.7 411.1,373.5 411.9,373.4 C 412.7,373.3 413.5,373.5 414.3,373.2 C 415.1,372.8 415.9,371.7 416.7,371.3 C 417.5,370.9 418.3,371.0 419.1,370.7 C 419.9,370.4 420.7,370.0 421.5,369.8 C 422.3,369.6 423.1,369.7 423.9,369.5 C 424.7,369.3 425.5,368.8 426.3,368.6 C 427.1,368.4 427.9,368.4 428.7,368.3 C 429.5,368.2 430.3,368.2 431.1,368.0 C 431.9,367.7 432.7,367.5 433.5,367.0 C 434.3,366.5 435.1,365.4 435.9,364.9 C 436.7,364.4 437.5,364.1 438.3,363.7 C 439.1,363.4 439.9,363.3 440.7,362.5 C 441.5,361.8 442.3,360.1 443.1,359.3 C 443.9,358.5 444.7,358.2 445.5,357.7 C 446.3,357.2 447.1,357.1 447.9,356.4 C 448.7,355.7 449.5,354.1 450.3,353.4 C 451.1,352.6 451.9,352.6 452.7,352.1 C 453.5,351.7 454.3,351.4 455.1,350.9 C 455.9,3Line truncated
|
||||
<circle cx="765.0" cy="106.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">6508</text>
|
||||
<path d="M 90.0,462.2 C 90.4,462.1 92.0,461.8 92.3,461.7 C 93.1,461.6 93.9,461.4 94.7,461.2 C 95.5,461.1 96.2,460.8 97.0,460.8 C 97.8,460.7 98.6,460.7 99.4,460.7 C 100.2,460.7 100.9,460.6 101.7,460.6 C 102.5,460.5 103.3,460.5 104.1,460.4 C 104.8,460.4 105.2,460.3 106.4,460.3 C 107.6,460.2 109.9,460.1 111.1,460.1 C 112.3,460.1 112.7,460.0 113.4,460.0 C 114.2,460.0 115.0,460.0 115.8,459.9 C 116.6,459.9 117.3,459.9 118.1,459.9 C 118.9,459.9 119.7,459.9 120.5,459.8 C 121.2,459.8 122.0,459.7 122.8,459.7 C 123.6,459.6 124.4,459.6 125.2,459.4 C 125.9,459.3 126.7,459.0 127.5,458.7 C 128.3,458.3 129.1,457.6 129.8,457.3 C 130.6,457.1 131.4,457.2 132.2,457.1 C 133.0,457.1 133.8,457.1 134.5,457.0 C 135.3,457.0 135.7,457.0 136.9,456.9 C 138.0,456.9 140.0,456.9 141.6,456.9 C 143.1,456.8 145.1,456.8 146.2,456.8 C 147.4,456.7 147.8,456.7 148.6,456.7 C 149.4,456.6 150.2,456.6 150.9,456.5 C 151.7,456.5 152.5,456.5 153.3,456.4 C 154.1,456.4 154.8,456.4 155.6,456.4 C 156.4,456.4 156.8,456.4 158.0,456.3 C 159.1,456.3 160.7,456.2 162.7,456.2 C 164.6,456.2 167.7,456.2 169.7,456.2 C 171.6,456.1 172.0,456.2 174.4,456.1 C 176.7,456.1 181.8,455.9 183.8,455.9 C 185.7,455.8 184.9,455.9 186.1,455.8 C 187.3,455.8 188.8,455.8 190.8,455.8 C 192.7,455.8 193.9,455.7 197.8,455.7 C 201.7,455.6 211.1,455.5 214.2,455.5 C 217.3,455.5 215.8,455.5 216.6,455.4 C 217.3,455.4 217.7,456.1 218.9,455.4 C 220.1,454.7 222.4,454.1 223.6,451.3 C 224.8,448.4 225.2,440.7 225.9,438.3 C 226.7,435.9 227.5,437.3 228.3,436.9 C 229.1,436.5 229.8,436.3 230.6,436.0 C 231.4,435.8 232.2,435.6 233.0,435.4 C 233.8,435.3 234.5,435.3 235.3,435.2 C 236.1,435.1 236.9,434.9 237.7,434.8 C 238.4,434.8 239.2,434.8 240.0,434.8 C 240.8,434.7 241.6,434.5 242.3,434.4 C 243.1,434.3 243.9,434.3 244.7,434.2 C 245.5,434.1 246.2,433.7 247.0,433.6 C 247.8,433.5 248.6,433.5 249.4,433.5 C 250.2,433.4 250.5,433.2 251.7,433.1 C 252.9,433.0 255.2,433.0 256.4,432.9 C 257.6,432.9 258.0,432.8 258.8,432.7 C 259.5,432.7 260.3,432.7 261.1,432.6 C 261.9,432.5 262.7,432.2 263.4,432.1 C 264.2,431.9 265.0,432.6 265.8,431.7 C 266.6,430.8 267.3,427.6 268.1,426.6 C 268.9,425.5 269.7,425.7 270.5,425.5 C 271.2,425.3 272.0,425.5 272.8,425.4 C 273.6,425.3 274.4,424.9 275.2,424.6 C 275.9,424.4 276.7,424.2 277.5,423.7 C 278.3,423.2 279.1,422.2 279.8,421.6 C 280.6,421.0 281.4,420.4 282.2,420.0 C 283.0,419.5 283.8,419.2 284.5,419.0 C 285.3,418.7 286.1,418.6 286.9,418.5 C 287.7,418.3 288.4,418.4 289.2,418.2 C 290.0,418.1 290.8,417.9 291.6,417.8 C 292.3,417.7 293.1,417.8 293.9,417.7 C 294.7,417.6 295.5,417.6 296.2,417.5 C 297.0,417.4 297.8,417.4 298.6,417.0 C 299.4,416.6 300.2,415.5 300.9,415.0 C 301.7,414.5 302.5,414.3 303.3,414.0 C 304.1,413.6 304.8,414.3 305.6,412.8 C 306.4,411.2 307.2,406.4 308.0,404.6 C 308.8,402.7 309.5,402.4 310.3,401.7 C 311.1,400.9 311.9,400.4 312.7,399.9 C 313.4,399.3 314.2,398.8 315.0,398.4 C 315.8,398.0 316.6,397.6 317.3,397.3 C 318.1,397.1 318.9,397.0 319.7,396.8 C 320.5,396.6 321.2,396.3 322.0,396.0 C 322.8,395.7 323.6,395.2 324.4,395.0 C 325.2,394.7 325.9,394.8 326.7,394.5 C 327.5,394.2 328.3,393.8 329.1,393.5 C 329.8,393.1 330.6,392.9 331.4,392.6 C 332.2,392.4 333.0,392.2 333.8,392.0 C 334.5,391.9 335.3,391.8 336.1,391.7 C 336.9,391.6 337.7,391.4 338.4,391.2 C 339.2,391.0 340.0,390.9 340.8,390.7 C 341.6,390.5 342.3,390.3 343.1,390.0 C 343.9,389.6 344.7,389.0 345.5,388.8 C 346.2,388.5 347.0,388.4 347.8,388.2 C 348.6,388.1 349.4,387.9 350.2,387.8 C 350.9,387.7 351.7,387.6 352.5,387.5 C 353.3,387.4 354.1,387.2 354.8,387.2 C 355.6,387.1 356.4,387.1 357.2,387.0 C 358.0,386.9 358.8,386.7 359.5,386.4 C 360.3,386.1 361.1,385.6 361.9,385.3 C 362.7,385.0 363.4,384.6 364.2,384.4 C 365.0,384.2 365.8,384.1 366.6,384.0 C 367.3,383.9 368.1,383.8 368.9,383.6 C 369.7,383.3 370.5,382.8 371.2,382.6 C 372.0,382.4 372.8,382.4 373.6,382.3 C 374.4,382.2 375.2,382.0 375.9,381.8 C 376.7,381.6 377.5,381.4 378.3,381.3 C 379.1,381.1 379.8,380.8 380.6,380.7 C 381.4,380.5 382.2,380.4 383.0,380.2 C 383.8,379.9 384.5,379.6 385.3,379.3 C 386.1,379.1 386.9,379.0 387.7,378.7 C 388.4,378.5 389.2,378.2 390.0,377.9 C 390.8,377.6 391.6,377.4 392.3,377.1 C 393.1,376.8 393.9,376.8 394.7,376.4 C 395.5,376.0 396.2,375.1 397.0,374.7 C 397.8,374.3 398.6,374.3 399.4,374.1 C 400.2,374.0 400.9,374.0 401.7,373.9 C 402.5,373.8 403.3,373.7 404.1,373.5 C 404.8,373.4 405.6,373.6 406.4,373.3 C 407.2,372.9 408.0,371.8 408.8,371.4 C 409.5,371.0 410.3,371.1 411.1,370.8 C 411.9,370.6 412.7,370.1 413.4,369.9 C 414.2,369.7 415.0,369.8 415.8,369.6 C 416.6,369.4 417.3,368.9 418.1,368.7 C 418.9,368.5 419.7,368.5 420.5,368.4 C 421.2,368.3 422.0,368.3 422.8,368.1 C 423.6,367.9 424.4,367.6 425.2,367.1 C 425.9,366.6 426.7,365.5 427.5,365.0 C 428.3,364.5 429.1,364.2 429.8,363.9 C 430.6,363.5 431.4,363.4 432.2,362.7 C 433.0,361.9 433.8,360.2 434.5,359.4 C 435.3,358.6 436.1,358.3 436.9,357.8 C 437.7,357.3 438.4,357.2 439.2,356.5 C 440.0,355.8 440.8,354.2 441.6,353.5 C 442.3,352.8 443.1,352.7 443.9,352.3 C 444.7,351.9 445.5,351.5 446.2,351.1 C 447.0,3Line truncated
|
||||
<path d="M 90.0,462.2 C 90.4,462.1 92.0,461.8 92.3,461.7 C 93.1,461.6 93.9,461.4 94.7,461.2 C 95.5,461.1 96.2,460.8 97.0,460.8 C 97.8,460.7 98.6,460.7 99.4,460.7 C 100.2,460.7 100.9,460.6 101.7,460.6 C 102.5,460.5 103.3,460.5 104.1,460.4 C 104.8,460.4 105.2,460.3 106.4,460.3 C 107.6,460.2 109.9,460.1 111.1,460.1 C 112.3,460.1 112.7,460.0 113.4,460.0 C 114.2,460.0 115.0,460.0 115.8,459.9 C 116.6,459.9 117.3,459.9 118.1,459.9 C 118.9,459.9 119.7,459.9 120.5,459.8 C 121.2,459.8 122.0,459.7 122.8,459.7 C 123.6,459.6 124.4,459.6 125.2,459.4 C 125.9,459.3 126.7,459.0 127.5,458.7 C 128.3,458.3 129.1,457.6 129.8,457.3 C 130.6,457.1 131.4,457.2 132.2,457.1 C 133.0,457.1 133.8,457.1 134.5,457.0 C 135.3,457.0 135.7,457.0 136.9,456.9 C 138.0,456.9 140.0,456.9 141.6,456.9 C 143.1,456.8 145.1,456.8 146.2,456.8 C 147.4,456.7 147.8,456.7 148.6,456.7 C 149.4,456.6 150.2,456.6 150.9,456.5 C 151.7,456.5 152.5,456.5 153.3,456.4 C 154.1,456.4 154.8,456.4 155.6,456.4 C 156.4,456.4 156.8,456.4 158.0,456.3 C 159.1,456.3 160.7,456.2 162.7,456.2 C 164.6,456.2 167.7,456.2 169.7,456.2 C 171.6,456.1 172.0,456.2 174.4,456.1 C 176.7,456.1 181.8,455.9 183.8,455.9 C 185.7,455.8 184.9,455.9 186.1,455.8 C 187.3,455.8 188.8,455.8 190.8,455.8 C 192.7,455.8 193.9,455.7 197.8,455.7 C 201.7,455.6 211.1,455.5 214.2,455.5 C 217.3,455.5 215.8,455.5 216.6,455.4 C 217.3,455.4 217.7,456.1 218.9,455.4 C 220.1,454.7 222.4,454.1 223.6,451.3 C 224.8,448.4 225.2,440.7 225.9,438.3 C 226.7,435.9 227.5,437.3 228.3,436.9 C 229.1,436.5 229.8,436.3 230.6,436.0 C 231.4,435.8 232.2,435.6 233.0,435.4 C 233.8,435.3 234.5,435.3 235.3,435.2 C 236.1,435.1 236.9,434.9 237.7,434.8 C 238.4,434.8 239.2,434.8 240.0,434.8 C 240.8,434.7 241.6,434.5 242.3,434.4 C 243.1,434.3 243.9,434.3 244.7,434.2 C 245.5,434.1 246.2,433.7 247.0,433.6 C 247.8,433.5 248.6,433.5 249.4,433.5 C 250.2,433.4 250.5,433.2 251.7,433.1 C 252.9,433.0 255.2,433.0 256.4,432.9 C 257.6,432.9 258.0,432.8 258.8,432.7 C 259.5,432.7 260.3,432.7 261.1,432.6 C 261.9,432.5 262.7,432.2 263.4,432.1 C 264.2,431.9 265.0,432.6 265.8,431.7 C 266.6,430.8 267.3,427.6 268.1,426.6 C 268.9,425.5 269.7,425.7 270.5,425.5 C 271.2,425.3 272.0,425.5 272.8,425.4 C 273.6,425.3 274.4,424.9 275.2,424.6 C 275.9,424.4 276.7,424.2 277.5,423.7 C 278.3,423.2 279.1,422.2 279.8,421.6 C 280.6,421.0 281.4,420.4 282.2,420.0 C 283.0,419.5 283.8,419.2 284.5,419.0 C 285.3,418.7 286.1,418.6 286.9,418.5 C 287.7,418.3 288.4,418.4 289.2,418.2 C 290.0,418.1 290.8,417.9 291.6,417.8 C 292.3,417.7 293.1,417.8 293.9,417.7 C 294.7,417.6 295.5,417.6 296.2,417.5 C 297.0,417.4 297.8,417.4 298.6,417.0 C 299.4,416.6 300.2,415.5 300.9,415.0 C 301.7,414.5 302.5,414.3 303.3,414.0 C 304.1,413.6 304.8,414.3 305.6,412.8 C 306.4,411.2 307.2,406.4 308.0,404.6 C 308.8,402.7 309.5,402.4 310.3,401.7 C 311.1,400.9 311.9,400.4 312.7,399.9 C 313.4,399.3 314.2,398.8 315.0,398.4 C 315.8,398.0 316.6,397.6 317.3,397.3 C 318.1,397.1 318.9,397.0 319.7,396.8 C 320.5,396.6 321.2,396.3 322.0,396.0 C 322.8,395.7 323.6,395.2 324.4,395.0 C 325.2,394.7 325.9,394.8 326.7,394.5 C 327.5,394.2 328.3,393.8 329.1,393.5 C 329.8,393.1 330.6,392.9 331.4,392.6 C 332.2,392.4 333.0,392.2 333.8,392.0 C 334.5,391.9 335.3,391.8 336.1,391.7 C 336.9,391.6 337.7,391.4 338.4,391.2 C 339.2,391.0 340.0,390.9 340.8,390.7 C 341.6,390.5 342.3,390.3 343.1,390.0 C 343.9,389.6 344.7,389.0 345.5,388.8 C 346.2,388.5 347.0,388.4 347.8,388.2 C 348.6,388.1 349.4,387.9 350.2,387.8 C 350.9,387.7 351.7,387.6 352.5,387.5 C 353.3,387.4 354.1,387.2 354.8,387.2 C 355.6,387.1 356.4,387.1 357.2,387.0 C 358.0,386.9 358.8,386.7 359.5,386.4 C 360.3,386.1 361.1,385.6 361.9,385.3 C 362.7,385.0 363.4,384.6 364.2,384.4 C 365.0,384.2 365.8,384.1 366.6,384.0 C 367.3,383.9 368.1,383.8 368.9,383.6 C 369.7,383.3 370.5,382.8 371.2,382.6 C 372.0,382.4 372.8,382.4 373.6,382.3 C 374.4,382.2 375.2,382.0 375.9,381.8 C 376.7,381.6 377.5,381.4 378.3,381.3 C 379.1,381.1 379.8,380.8 380.6,380.7 C 381.4,380.5 382.2,380.4 383.0,380.2 C 383.8,379.9 384.5,379.6 385.3,379.3 C 386.1,379.1 386.9,379.0 387.7,378.7 C 388.4,378.5 389.2,378.2 390.0,377.9 C 390.8,377.6 391.6,377.4 392.3,377.1 C 393.1,376.8 393.9,376.8 394.7,376.4 C 395.5,376.0 396.2,375.1 397.0,374.7 C 397.8,374.3 398.6,374.3 399.4,374.1 C 400.2,374.0 400.9,374.0 401.7,373.9 C 402.5,373.8 403.3,373.7 404.1,373.5 C 404.8,373.4 405.6,373.6 406.4,373.3 C 407.2,372.9 408.0,371.8 408.8,371.4 C 409.5,371.0 410.3,371.1 411.1,370.8 C 411.9,370.6 412.7,370.1 413.4,369.9 C 414.2,369.7 415.0,369.8 415.8,369.6 C 416.6,369.4 417.3,368.9 418.1,368.7 C 418.9,368.5 419.7,368.5 420.5,368.4 C 421.2,368.3 422.0,368.3 422.8,368.1 C 423.6,367.9 424.4,367.6 425.2,367.1 C 425.9,366.6 426.7,365.5 427.5,365.0 C 428.3,364.5 429.1,364.2 429.8,363.9 C 430.6,363.5 431.4,363.4 432.2,362.7 C 433.0,361.9 433.8,360.2 434.5,359.4 C 435.3,358.6 436.1,358.3 436.9,357.8 C 437.7,357.3 438.4,357.2 439.2,356.5 C 440.0,355.8 440.8,354.2 441.6,353.5 C 442.3,352.8 443.1,352.7 443.9,352.3 C 444.7,351.9 445.5,351.5 446.2,351.1 C 447.0,3Line truncated
|
||||
<circle cx="765.0" cy="99.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">6652</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: 35 KiB After Width: | Height: | Size: 36 KiB |
@@ -0,0 +1,106 @@
|
||||
# Agent Note: Keep port forwarding usable when host OS metadata is missing
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
[Issue #3748](https://github.com/binaricat/Netcatty/issues/3748) reports that the
|
||||
port-forwarding page displays `Port forwarding could not load.` when rendering
|
||||
a selected host whose legacy `os` value is absent, null, or an empty string.
|
||||
|
||||
`EditPanel`, `NewFormPanel`, and both host-selection steps in `WizardContent`
|
||||
evaluate `selectedHost.os[0].toUpperCase()` while constructing `DistroAvatar`
|
||||
props. Missing or null values fail on indexing; an empty string fails on
|
||||
`toUpperCase`. This happens before the avatar can render its generic icon.
|
||||
Although `Host.os` is required by TypeScript, persisted runtime records can lack
|
||||
that metadata. The original source of the affected records is not established.
|
||||
|
||||
## Decision
|
||||
|
||||
The four call sites use `(selectedHost.os || 'unknown').charAt(0).toUpperCase()`
|
||||
to keep the required string fallback safe without changing the shared avatar
|
||||
API. Valid OS values retain their existing initials. Missing and empty values
|
||||
use the explicit unknown fallback, and the avatar keeps its existing generic
|
||||
icon behavior.
|
||||
|
||||
The fix stays in the affected presentation code. It does not change host data,
|
||||
normalization, persistence, or tunnel lifecycle behavior. A shared helper for a
|
||||
single short expression would add indirection without a separate responsibility,
|
||||
so the four call sites use the same expression directly.
|
||||
|
||||
## Validation
|
||||
|
||||
`components/PortForwardingPanels.test.tsx` renders the real components with
|
||||
`react-dom/server`, the existing i18n provider, and the tooltip provider. It does
|
||||
not mock the panels or avatar and does not inspect their source text.
|
||||
|
||||
The table-driven cases cover all four entry points, all three forwarding types,
|
||||
and missing, undefined, null, empty, Linux, Windows, and macOS values. Separate
|
||||
checks preserve distro icons and host-picker behavior when no host is selected
|
||||
or the selected host is no longer available. Invalid legacy fixture shapes use
|
||||
one explicit type assertion rather than weakening the production `Host` type.
|
||||
|
||||
Before the fix, 48 of the 96 new cases fail with the reported TypeError; the
|
||||
other 48 pass. After the fix, all 96 pass. Together with the existing avatar,
|
||||
port-forwarding toolbar, and utility tests, the focused run passes 109 tests:
|
||||
|
||||
```sh
|
||||
node --test --import tsx components/PortForwardingPanels.test.tsx \
|
||||
components/PortForwardingNew.test.ts components/portForwardingUtils.test.ts \
|
||||
components/DistroAvatar.test.tsx --test-concurrency=2
|
||||
```
|
||||
|
||||
These are rendering regression tests, not SSH or network end-to-end tests.
|
||||
|
||||
Additional checks against upstream commit `786a317`:
|
||||
|
||||
- `npm run build` succeeds.
|
||||
- `npm run lint` succeeds with the same eight existing warnings as upstream.
|
||||
The four changed TypeScript/TSX files also pass lint with `--max-warnings=0`.
|
||||
- `LC_ALL=C npm test -- --test-concurrency=1` runs 13,986 tests: 13,944 pass,
|
||||
four fail, and 38 are skipped. An unmodified upstream worktree runs 13,890
|
||||
tests with the same four failures and 38 skips, so this change adds 96 passing
|
||||
cases without adding a failing case. The baseline failures are the SFTP
|
||||
large-directory persistence timing test, two Bash OSC 7 setup tests, and the
|
||||
README clipboard-paste screenshot-dimension assertion. The SFTP timing test
|
||||
passes in isolated runs on both checkouts but fails in these full-suite runs.
|
||||
A non-English locale additionally causes the existing fish error-message
|
||||
assertion to fail; both comparison runs use the C locale.
|
||||
- The full-project `tsc --noEmit --pretty false` check has 759 existing errors.
|
||||
Its complete output matches the unmodified upstream output after normalizing
|
||||
checkout paths; none of the changed files has a diagnostic. This is not a
|
||||
claim that full-project type checking passes.
|
||||
|
||||
### Upstream CI follow-up
|
||||
|
||||
The original PR CI run [39215269619, attempt 2](https://github.com/binaricat/Netcatty/actions/runs/39215269619/attempts/2)
|
||||
passes all 96 new regression cases and fails only the existing README paste
|
||||
assertion. Upstream commit `b366967` fixes that assertion by removing its
|
||||
coupling to changing screenshot dimensions. The branch incorporates this fix
|
||||
through a normal merge of upstream `main` at `42b8e2f`, without rewriting the
|
||||
original fix commit or adding an independent change to the unrelated test.
|
||||
|
||||
After synchronization, the focused run including the entire clipboard-paste
|
||||
test file passes 149 tests. Lint and the production build also pass; lint keeps
|
||||
the same eight existing warnings. The earlier full-suite and type-check
|
||||
comparison above describes the original `786a317` baseline, not the updated
|
||||
base. Current remote CI results are tracked on the PR.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- Remove the unused fallback prop from the four calls: the shared avatar still
|
||||
requires it in its TypeScript contract. Changing that API and cleaning up all
|
||||
callers is unnecessary for this targeted bug fix.
|
||||
- Normalize or rewrite stored host records: broadens the persistence scope,
|
||||
obscures the rendering assumption, and cannot replace guarding optional
|
||||
runtime metadata at the point of use.
|
||||
- Add a new avatar wrapper or fallback utility: unnecessary for this small
|
||||
expression and would expand the production change without improving the
|
||||
boundary being fixed.
|
||||
|
||||
## Consequences
|
||||
|
||||
Incomplete host OS metadata no longer prevents new/edit panels or wizard
|
||||
host-selection steps from rendering. Existing icons, component contracts, and
|
||||
host records remain unchanged. The guard is local to port forwarding; auditing
|
||||
unrelated callers or redesigning the unused fallback API remains out of scope.
|
||||
@@ -522,7 +522,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
altAsMeta: false,
|
||||
optionArrowWordJump: false,
|
||||
shiftEnterNewlineEnabled: true,
|
||||
shiftEnterNewlineText: '\\n',
|
||||
shiftEnterNewlineText: '\\e\\r',
|
||||
shiftEnterForceText: false,
|
||||
kittyKeyboardProtocolEnabled: false,
|
||||
scrollOnInput: true,
|
||||
|
||||
@@ -269,9 +269,6 @@ test("repo README paste collapses shields badges without debris", () => {
|
||||
const payload = resolveNoteClipboardPaste({ plainText: readmeHead, htmlText: "" });
|
||||
assert.ok(payload.text.length > 50);
|
||||
assert.doesNotMatch(payload.text, /^\s*\]\([^)\n]+\)\s*$/m);
|
||||
// Large screenshot keeps dimensions in source.
|
||||
assert.match(payload.text, /width="3142"/);
|
||||
assert.match(payload.text, /height="1764"/);
|
||||
});
|
||||
|
||||
test("resolve uses full turndown for browser StartFragment html", () => {
|
||||
|
||||
@@ -714,6 +714,15 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
|
||||
labelKey: "ai.externalMcp.title",
|
||||
keywords: ["mcp"],
|
||||
},
|
||||
{
|
||||
id: "ai-external-mcp-approval-alert",
|
||||
tab: "ai",
|
||||
aiSubTab: "tools",
|
||||
labelKey: "ai.externalMcp.approvalAlert",
|
||||
descriptionKey: "ai.externalMcp.approvalAlert.description",
|
||||
sectionKey: "ai.externalMcp.title",
|
||||
keywords: ["notification", "approval", "dock"],
|
||||
},
|
||||
{
|
||||
id: "ai-user-skills",
|
||||
tab: "ai",
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { remoteSoftwareRequiresSingleChannel } from "./singleChannelSshBanner.shared.mjs";
|
||||
import { sftpRequiresDedicatedLogin } from "./sftpDedicatedLogin";
|
||||
|
||||
test("network-device mode forces a dedicated SFTP login before any banner", () => {
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "network" } }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({
|
||||
host: { deviceType: "network" },
|
||||
remoteSshVersion: "OpenSSH_9.6",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({
|
||||
host: { deviceType: "network" },
|
||||
remoteSshVersion: "SSH-2.0-OpenSSH_for_Windows_9.5",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("Huawei dash and Ruijie banners force a dedicated SFTP login", () => {
|
||||
for (const remoteSshVersion of ["-", "SSH-2.0--", "SSH-1.99--", "RGOS_SSH", "SSH-2.0-RGOS_SSH"]) {
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({
|
||||
host: { deviceType: "general" },
|
||||
remoteSshVersion,
|
||||
}),
|
||||
true,
|
||||
remoteSshVersion,
|
||||
);
|
||||
}
|
||||
// Huawei `-` must not become a global single-channel bastion token.
|
||||
assert.equal(remoteSoftwareRequiresSingleChannel("-"), false);
|
||||
assert.equal(remoteSoftwareRequiresSingleChannel("SSH-2.0--"), false);
|
||||
});
|
||||
|
||||
test("other network-vendor banners force a dedicated SFTP login", () => {
|
||||
for (const remoteSshVersion of [
|
||||
"SSH-2.0-Cisco-1.25",
|
||||
"SSH-2.0-H3C-Comware-7.1.064",
|
||||
"SSH-2.0-ROSSSH",
|
||||
]) {
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion }),
|
||||
true,
|
||||
remoteSshVersion,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("one-channel bastion banners still force a dedicated SFTP login", () => {
|
||||
for (const remoteSshVersion of [
|
||||
"BHostSSH_7.0",
|
||||
"SSH-2.0-TERM-SSHD",
|
||||
"CLOUDBILITY-4.14",
|
||||
"SSH-2.0-CloudLinker",
|
||||
]) {
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion }),
|
||||
true,
|
||||
remoteSshVersion,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("ordinary SSH servers keep the shared terminal SFTP transport", () => {
|
||||
assert.equal(sftpRequiresDedicatedLogin({ host: { deviceType: "general" } }), false);
|
||||
assert.equal(sftpRequiresDedicatedLogin({ host: null }), false);
|
||||
assert.equal(sftpRequiresDedicatedLogin({}), false);
|
||||
for (const remoteSshVersion of ["OpenSSH_9.6", "SSH-2.0-OpenSSH_9.6", "JumpServer", ""]) {
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion }),
|
||||
false,
|
||||
remoteSshVersion,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { detectVendorFromSshVersion, hostRestrictsExtraSshChannels } from "./host";
|
||||
import type { Host } from "./models";
|
||||
import { remoteSoftwareRequiresSingleChannel } from "./singleChannelSshBanner.shared.mjs";
|
||||
|
||||
export type SftpDedicatedLoginInput = {
|
||||
host?: Pick<Host, "deviceType"> | null;
|
||||
remoteSshVersion?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* SFTP must dial its own SSH login when an extra subsystem on the terminal
|
||||
* TCP session would drop the shell (#3631).
|
||||
*
|
||||
* Network-device mode is enough on its own, with or without a banner.
|
||||
* A network-vendor identification string (Huawei `-` / `SSH-2.0--`, Ruijie
|
||||
* RGOS, and the other `detectVendorFromSshVersion` matches) and one-channel
|
||||
* bastion software tokens are included here. This does not stamp
|
||||
* session.singleChannelSsh; that flag stays reserved for bastion banners.
|
||||
*/
|
||||
export function sftpRequiresDedicatedLogin(input: SftpDedicatedLoginInput): boolean {
|
||||
if (hostRestrictsExtraSshChannels(input.host)) return true;
|
||||
const remoteSshVersion = input.remoteSshVersion || undefined;
|
||||
return remoteSoftwareRequiresSingleChannel(remoteSshVersion)
|
||||
|| detectVendorFromSshVersion(remoteSshVersion) !== "";
|
||||
}
|
||||
@@ -93,7 +93,7 @@ test("normalizeTerminalSettings enables Shift+Enter newline by default", () => {
|
||||
const settings = normalizeTerminalSettings();
|
||||
|
||||
assert.equal(settings.shiftEnterNewlineEnabled, true);
|
||||
assert.equal(settings.shiftEnterNewlineText, "\\n");
|
||||
assert.equal(settings.shiftEnterNewlineText, "\\e\\r");
|
||||
});
|
||||
|
||||
test("normalizeTerminalSettings disables Kitty keyboard protocol by default", () => {
|
||||
@@ -114,7 +114,7 @@ test("normalizeTerminalSettings preserves Shift+Enter text", () => {
|
||||
test("normalizeTerminalSettings falls back when Shift+Enter text is not a string", () => {
|
||||
assert.equal(
|
||||
normalizeTerminalSettings({ shiftEnterNewlineText: null as never }).shiftEnterNewlineText,
|
||||
"\\n",
|
||||
"\\e\\r",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,12 @@ const { StringDecoder } = require("node:string_decoder");
|
||||
const DEFAULT_CODEX_CLI_TIMEOUT_MS = 10_000;
|
||||
const CODEX_AUTH_VALIDATION_TIMEOUT_MS = 10_000;
|
||||
const MAX_AGENT_CLI_BUFFER_CHARS = 10 * 1024 * 1024;
|
||||
// Bun-compiled OpenCode-family CLIs start slowly: `mimo --version` takes
|
||||
// 3.9–5.1s warm (6s+ cold) and `opencode --version` can exceed 5s on a cold
|
||||
// cache, so a 5s timeout killed healthy CLIs and dropped them from discovery.
|
||||
// 15s only affects CLIs that exist but are slow to answer — a missing command
|
||||
// fails spawn immediately.
|
||||
const VERSION_PROBE_TIMEOUT_MS = 15_000;
|
||||
|
||||
function createAgentCliHelpers(ctx) {
|
||||
with (ctx) {
|
||||
@@ -120,7 +126,7 @@ function createAgentCliHelpers(ctx) {
|
||||
|
||||
async function probeCliVersion(probeCmd, probeArgs, env) {
|
||||
try {
|
||||
const result = await runCommand(probeCmd, probeArgs, { env, timeoutMs: 5000 });
|
||||
const result = await runCommand(probeCmd, probeArgs, { env, timeoutMs: VERSION_PROBE_TIMEOUT_MS });
|
||||
return {
|
||||
launched: true,
|
||||
exitCode: result.exitCode,
|
||||
@@ -430,4 +436,5 @@ module.exports = {
|
||||
CODEX_AUTH_VALIDATION_TIMEOUT_MS,
|
||||
DEFAULT_CODEX_CLI_TIMEOUT_MS,
|
||||
MAX_AGENT_CLI_BUFFER_CHARS,
|
||||
VERSION_PROBE_TIMEOUT_MS,
|
||||
};
|
||||
@@ -10,6 +10,7 @@ const {
|
||||
CODEX_AUTH_VALIDATION_TIMEOUT_MS,
|
||||
DEFAULT_CODEX_CLI_TIMEOUT_MS,
|
||||
MAX_AGENT_CLI_BUFFER_CHARS,
|
||||
VERSION_PROBE_TIMEOUT_MS,
|
||||
} = require("./agentCliHelpers.cjs");
|
||||
|
||||
function createHungChild() {
|
||||
@@ -55,6 +56,37 @@ test("runCodexCli applies a default timeout to short status commands", async ()
|
||||
assert.deepEqual(child.kills, ["SIGTERM", "SIGKILL"]);
|
||||
});
|
||||
|
||||
test("probeCliVersion allows slow-starting CLIs beyond the old 5s probe limit", async () => {
|
||||
const child = createHungChild();
|
||||
const scheduled = [];
|
||||
const helpers = createAgentCliHelpers({
|
||||
prepareCommandForSpawn: (command, args) => ({ command, args, shell: false }),
|
||||
spawn: () => {
|
||||
queueMicrotask(() => {
|
||||
child.stdout.emit("data", Buffer.from("0.1.15\n"));
|
||||
child.emit("close", 0);
|
||||
});
|
||||
return child;
|
||||
},
|
||||
stripAnsi: (value) => value,
|
||||
getShellEnv: async () => ({}),
|
||||
normalizeCliPathForPlatform: (value) => value,
|
||||
resolveCliFromPathAsync: async () => "/fake/mimo",
|
||||
setTimeout: (callback, delay) => {
|
||||
scheduled.push(delay);
|
||||
return { unref() {} };
|
||||
},
|
||||
clearTimeout() {},
|
||||
});
|
||||
|
||||
const result = await helpers.probeCliVersion("/fake/mimo", ["--version"], {});
|
||||
assert.equal(result.launched, true);
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.equal(result.version, "0.1.15");
|
||||
assert.equal(scheduled[0], VERSION_PROBE_TIMEOUT_MS);
|
||||
assert.ok(scheduled[0] > 5000, "version probe timeout must exceed 5s for bun-compiled OpenCode-family CLIs");
|
||||
});
|
||||
|
||||
test("runCodexCli slices a single oversized output chunk to the hard limit", async () => {
|
||||
const child = createHungChild();
|
||||
const helpers = createAgentCliHelpers({
|
||||
|
||||
@@ -7,6 +7,11 @@ const DEFAULT_SSH_EXEC_OPEN_TIMEOUT_MS = 15_000;
|
||||
const DEFAULT_SSH_EXEC_RUN_TIMEOUT_MS = 10 * 60_000;
|
||||
const DEFAULT_SSH_EXEC_MAX_OUTPUT_BYTES = 64 * 1024;
|
||||
|
||||
// A best-effort probe may abandon an open without disconnecting other leases.
|
||||
// ssh2 cannot cancel that open. Fence further execs until its callback arrives,
|
||||
// bounding retained requests even when a panel keeps polling a stalled server.
|
||||
const abandonedExecOpens = new WeakMap();
|
||||
|
||||
function terminateSshExecStream(stream) {
|
||||
if (!stream) return;
|
||||
// Teardown can race with a final transport error after normal listeners have
|
||||
@@ -112,6 +117,11 @@ function executeBoundedSshCommand(sshClient, command, options = {}) {
|
||||
if (!sshClient || typeof sshClient.exec !== "function") {
|
||||
return Promise.reject(new Error("SSH exec unavailable"));
|
||||
}
|
||||
if (abandonedExecOpens.get(sshClient)?.size) {
|
||||
const error = new Error("SSH exec channel open still pending on this connection");
|
||||
error.code = "SSH_EXEC_OPEN_PENDING";
|
||||
return Promise.reject(error);
|
||||
}
|
||||
const signal = options.signal || null;
|
||||
const setTimeoutFn = options.setTimeoutFn || setTimeout;
|
||||
const clearTimeoutFn = options.clearTimeoutFn || clearTimeout;
|
||||
@@ -132,6 +142,19 @@ function executeBoundedSshCommand(sshClient, command, options = {}) {
|
||||
const stderrDecoder = new StringDecoder("utf8");
|
||||
let decodersEnded = false;
|
||||
let cleanupStreamListeners = () => {};
|
||||
const openToken = {};
|
||||
let invoked = false;
|
||||
const abandonOpen = () => {
|
||||
if (!invoked || streamRef || settled) return;
|
||||
let pending = abandonedExecOpens.get(sshClient);
|
||||
if (!pending) abandonedExecOpens.set(sshClient, pending = new Set());
|
||||
pending.add(openToken);
|
||||
};
|
||||
const releaseAbandonedOpen = () => {
|
||||
const pending = abandonedExecOpens.get(sshClient);
|
||||
pending?.delete(openToken);
|
||||
if (pending?.size === 0) abandonedExecOpens.delete(sshClient);
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (openingTimer) clearTimeoutFn(openingTimer);
|
||||
@@ -159,12 +182,15 @@ function executeBoundedSshCommand(sshClient, command, options = {}) {
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const onAbort = () => finish(createAbortError(signal), null, {
|
||||
terminate: true,
|
||||
// Before the callback arrives ssh2 owns an uncancellable channel-open
|
||||
// request. Closing the physical transport is the only public cleanup.
|
||||
invalidateTransport: !streamRef && options.invalidateTransportOnAbort !== false,
|
||||
});
|
||||
const onAbort = () => {
|
||||
if (options.invalidateTransportOnAbort === false) abandonOpen();
|
||||
return finish(createAbortError(signal), null, {
|
||||
terminate: true,
|
||||
// Before the callback arrives ssh2 owns an uncancellable channel-open
|
||||
// request. Preserve-and-fence callers wait for late callback cleanup.
|
||||
invalidateTransport: !streamRef && options.invalidateTransportOnAbort !== false,
|
||||
});
|
||||
};
|
||||
const append = (target, chunk) => {
|
||||
if (settled) return;
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
||||
@@ -190,11 +216,14 @@ function executeBoundedSshCommand(sshClient, command, options = {}) {
|
||||
openingTimer = setTimeoutFn(() => {
|
||||
const error = new Error(`SSH exec channel open timed out after ${openingTimeoutMs} ms`);
|
||||
error.code = "SSH_EXEC_OPEN_TIMEOUT";
|
||||
if (!invalidateOnOpenTimeout) abandonOpen();
|
||||
finish(error, null, { terminate: true, invalidateTransport: invalidateOnOpenTimeout });
|
||||
}, openingTimeoutMs);
|
||||
|
||||
try {
|
||||
invoked = true;
|
||||
sshClient.exec(command, (error, stream) => {
|
||||
releaseAbandonedOpen();
|
||||
if (openingTimer) clearTimeoutFn(openingTimer);
|
||||
openingTimer = null;
|
||||
if (settled) {
|
||||
|
||||
@@ -349,3 +349,42 @@ test("bounded SSH exec still honors the channel close when no exit-status is rep
|
||||
stream.emit("close", 7);
|
||||
assert.deepEqual(await result, { stdout: "done", stderr: "", code: 7 });
|
||||
});
|
||||
|
||||
for (const abandon of ['timeout', 'abort']) {
|
||||
test(`preserved ${abandon} fences exec retries until the late callback settles`, async () => {
|
||||
let callback;
|
||||
let opens = 0;
|
||||
let destroyed = 0;
|
||||
const client = {
|
||||
exec(_command, next) { opens++; callback = next; },
|
||||
end() { destroyed++; },
|
||||
destroy() { destroyed++; },
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const request = executeBoundedSshCommand(client, 'probe', {
|
||||
openingTimeoutMs: 5,
|
||||
invalidateOnOpenTimeout: false,
|
||||
invalidateTransportOnAbort: false,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (abandon === 'abort') controller.abort();
|
||||
await assert.rejects(request);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await assert.rejects(executeBoundedSshCommand(client, 'probe'),
|
||||
{ code: 'SSH_EXEC_OPEN_PENDING' });
|
||||
}
|
||||
assert.equal(opens, 1);
|
||||
assert.equal(destroyed, 0);
|
||||
const lateStream = createStream();
|
||||
callback(null, lateStream);
|
||||
assert.equal(lateStream.closed, 1);
|
||||
const retry = executeBoundedSshCommand(client, 'probe');
|
||||
const stream = createStream();
|
||||
callback(null, stream);
|
||||
stream.emit('data', Buffer.from('recovered'));
|
||||
stream.emit('close', 0);
|
||||
assert.equal((await retry).stdout, 'recovered');
|
||||
assert.equal(opens, 2);
|
||||
assert.equal(destroyed, 0);
|
||||
});
|
||||
}
|
||||
@@ -25,6 +25,7 @@ const { safeSend } = require("./ipcUtils.cjs");
|
||||
const { emitTerminalSessionData } = require("./emitTerminalSessionData.cjs");
|
||||
const { getCliDiscoveryFilePath } = require("../cli/discoveryPath.cjs");
|
||||
const { EXTERNAL_MCP_CHAT_SESSION_ID } = require("../cli/externalMcpDiscoveryPath.cjs");
|
||||
const { syncExternalMcpApprovalAttention } = require("./windowAttention.cjs");
|
||||
const sftpBridge = require("./sftpBridge.cjs");
|
||||
const portForwardingBridge = require("./portForwardingBridge.cjs");
|
||||
|
||||
@@ -236,6 +237,7 @@ function requestApprovalFromRenderer(toolName, args, chatSessionId, approvalCont
|
||||
resolve(false);
|
||||
// Notify renderer(s) to remove the stale approval card
|
||||
broadcastApprovalEvent('netcatty:ai:mcp:approval-cleared', { approvalIds: [approvalId] });
|
||||
syncExternalApprovalAttention();
|
||||
};
|
||||
|
||||
const clearTimer = () => {
|
||||
@@ -280,6 +282,7 @@ function requestApprovalFromRenderer(toolName, args, chatSessionId, approvalCont
|
||||
allowSession: chatSessionId === EXTERNAL_MCP_CHAT_SESSION_ID
|
||||
&& externalMcpSockets.has(approvalContext.externalSocket),
|
||||
});
|
||||
syncExternalApprovalAttention();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -338,9 +341,24 @@ function cancelApprovalTimeoutFromRenderer(approvalId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function countExternalMcpApprovals() {
|
||||
let pending = 0;
|
||||
for (const entry of pendingApprovals.values()) {
|
||||
if (entry.chatSessionId === EXTERNAL_MCP_CHAT_SESSION_ID) pending += 1;
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
function syncExternalApprovalAttention() {
|
||||
try {
|
||||
syncExternalMcpApprovalAttention(countExternalMcpApprovals());
|
||||
} catch { /* attention must not change approval results */ }
|
||||
}
|
||||
|
||||
function notifyRendererApprovalCleared(approvalIds) {
|
||||
if (!Array.isArray(approvalIds) || approvalIds.length === 0) return;
|
||||
broadcastApprovalEvent("netcatty:ai:mcp:approval-cleared", { approvalIds });
|
||||
syncExternalApprovalAttention();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const { createSessionOpsApi } = require('./sessionOps.cjs');
|
||||
|
||||
for (const method of ['getSessionDistroInfo', 'readRemoteHistory', 'getSessionPwd', 'listSessionDir']) {
|
||||
test(`${method} timeout cannot disconnect the terminal or accumulate pending opens`, async t => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let callback;
|
||||
let opens = 0;
|
||||
let destroys = 0;
|
||||
const conn = {
|
||||
exec(_command, next) { opens++; callback = next; },
|
||||
end() { destroys++; },
|
||||
destroy() { destroys++; },
|
||||
};
|
||||
const api = createSessionOpsApi({
|
||||
sessions: new Map([['ssh', { type: 'ssh', conn, shellPid: '4242' }]]),
|
||||
log() {},
|
||||
quoteShellArg: value => "'" + String(value).replace(/'/g, "'\\''") + "'",
|
||||
setTimeout, clearTimeout, Buffer,
|
||||
});
|
||||
const request = api[method]({}, { sessionId: 'ssh', path: '/tmp', limit: 5 });
|
||||
assert.equal(opens, 1);
|
||||
t.mock.timers.tick(10_000);
|
||||
assert.equal((await request).success, false);
|
||||
assert.equal(destroys, 0);
|
||||
assert.equal((await api[method]({}, { sessionId: 'ssh', path: '/tmp', limit: 5 })).success, false);
|
||||
assert.equal(opens, 1);
|
||||
const late = new EventEmitter();
|
||||
let closed = 0;
|
||||
late.close = () => { closed++; };
|
||||
callback(null, late);
|
||||
assert.equal(closed, 1);
|
||||
assert.equal(destroys, 0);
|
||||
});
|
||||
}
|
||||
|
||||
for (const method of ['getSessionDistroInfo', 'readRemoteHistory', 'getServerStats']) {
|
||||
test(`${method} still tears down an unresponsive dedicated companion`, async t => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let destroys = 0;
|
||||
const conn = { exec() {}, destroy() { destroys++; } };
|
||||
const api = createSessionOpsApi({
|
||||
sessions: new Map([['mosh', { type: 'mosh', moshStatsConn: conn }]]),
|
||||
quoteShellArg: value => "'" + String(value).replace(/'/g, "'\\''") + "'",
|
||||
setTimeout, clearTimeout, Buffer, log() {},
|
||||
});
|
||||
const request = api[method]({}, { sessionId: 'mosh', limit: 5 });
|
||||
t.mock.timers.tick(10_000);
|
||||
assert.equal((await request).success, false);
|
||||
assert.equal(destroys, 1);
|
||||
});
|
||||
}
|
||||
@@ -190,6 +190,7 @@ function createSessionOpsApi(ctx) {
|
||||
openingTimeoutMs: 5000,
|
||||
runTimeoutMs: 5000,
|
||||
maxOutputBytes: 256 * 1024,
|
||||
invalidateOnOpenTimeout: !session.conn,
|
||||
});
|
||||
return { success: true, stdout, stderr };
|
||||
} catch (err) {
|
||||
@@ -307,6 +308,7 @@ function createSessionOpsApi(ctx) {
|
||||
openingTimeoutMs: 8000,
|
||||
runTimeoutMs: 8000,
|
||||
maxOutputBytes: 10 * 1024 * 1024,
|
||||
invalidateOnOpenTimeout: !session.conn,
|
||||
});
|
||||
return { success: true, ...parse(stdout) };
|
||||
} catch (err) {
|
||||
@@ -760,14 +762,14 @@ function createSessionOpsApi(ctx) {
|
||||
)}`;
|
||||
|
||||
void executeBoundedSshCommand(session.conn, cmd, {
|
||||
// Do not shorten channel opening: a timeout there invalidates the
|
||||
// shared SSH transport. Only bound the best-effort command itself.
|
||||
// Directory tracking is auxiliary work on the terminal transport.
|
||||
// Preserve it on a slow open; the executor fences further probes.
|
||||
openingTimeoutMs: 5000,
|
||||
runTimeoutMs: timeoutMs,
|
||||
maxOutputBytes: 256 * 1024,
|
||||
setTimeoutFn: setTimeout,
|
||||
clearTimeoutFn: clearTimeout,
|
||||
invalidateOnOpenTimeout: !isTargetedRecovery,
|
||||
invalidateOnOpenTimeout: false,
|
||||
}).then(({ stdout, stderr, code }) => {
|
||||
if (!isCurrentPwdOwner()) {
|
||||
settle({ success: false, error: 'Session changed during cwd probe' });
|
||||
@@ -1018,6 +1020,7 @@ function createSessionOpsApi(ctx) {
|
||||
openingTimeoutMs: 3000,
|
||||
runTimeoutMs: 3000,
|
||||
maxOutputBytes: 1024 * 1024,
|
||||
invalidateOnOpenTimeout: false,
|
||||
}).then(({ stdout, stderr: errOut }) => {
|
||||
if (settled) return;
|
||||
try {
|
||||
@@ -1352,6 +1355,9 @@ function createSessionOpsApi(ctx) {
|
||||
const statsExecOptions = {
|
||||
signal: session.statsSignal,
|
||||
invalidateTransportOnAbort: false,
|
||||
// Preserve shared terminal transports; dedicated Mosh/ET companions
|
||||
// still need timeout teardown so their existing recovery can reconnect.
|
||||
invalidateOnOpenTimeout: !session.conn,
|
||||
openingTimeoutMs: statsRunTimeoutMs,
|
||||
runTimeoutMs: statsRunTimeoutMs,
|
||||
maxOutputBytes: 1024 * 1024,
|
||||
|
||||
@@ -919,7 +919,7 @@ test("getServerStats closes a stats stream delivered after timeout", async () =>
|
||||
assert.equal(closeCalls, 1);
|
||||
});
|
||||
|
||||
test("three stats retries on an unresponsive exec open leave no pooled transport or channel callbacks", async () => {
|
||||
test("stats open timeout preserves shared transport and fences repeated probes", async () => {
|
||||
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
||||
const endpoint = { hostId: "stats-host", hostname: "wedged.example", username: "root" };
|
||||
const conn = new EventEmitter();
|
||||
@@ -962,9 +962,16 @@ test("three stats retries on an unresponsive exec open leave no pooled transport
|
||||
results.push(await api.getServerStats({ sender: {} }, { sessionId: "sid" }));
|
||||
|
||||
assert.ok(results.every((result) => result.success === false));
|
||||
assert.equal(conn.pendingChannelCallbacks.length, 0);
|
||||
assert.equal(getTransportStats().transports, 0);
|
||||
assert.equal(findTransportByEndpoint(endpoint), null);
|
||||
assert.equal(conn.pendingChannelCallbacks.length, 1, "retries must not accumulate opens");
|
||||
assert.equal(getTransportStats().transports, 1);
|
||||
assert.equal(findTransportByEndpoint(endpoint), transport);
|
||||
assert.equal(conn._sock.destroyed, false);
|
||||
const lateStream = fakeStream("");
|
||||
let lateClosed = 0;
|
||||
lateStream.close = () => { lateClosed++; };
|
||||
conn.pendingChannelCallbacks.shift()(null, lateStream);
|
||||
assert.equal(lateClosed, 1);
|
||||
conn.destroy();
|
||||
|
||||
const replacement = new EventEmitter();
|
||||
replacement._sock = { destroyed: false };
|
||||
|
||||
@@ -7,42 +7,48 @@ const identities = new Map();
|
||||
const MAX_SAMPLERS = 512;
|
||||
function prune() {
|
||||
const now = Date.now();
|
||||
for (const [key, state] of identities) {
|
||||
state.points = state.points.filter((p) => now - p.at <= 30 * 60000);
|
||||
if (!state.pending && now - state.at > 30 * 60000) identities.delete(key);
|
||||
for (const [key, history] of identities) {
|
||||
history.points = history.points.filter((p) => now - p.at <= 30 * 60000);
|
||||
if (now - history.at > 30 * 60000) identities.delete(key);
|
||||
}
|
||||
}
|
||||
const cleanupTimer = setInterval(prune, 60000);
|
||||
cleanupTimer.unref?.();
|
||||
function stateFor(conn, endpoint) {
|
||||
const key = endpoint ? buildEndpointKey(endpoint) : null;
|
||||
let state = (key ? identities.get(key) : null) || states.get(conn);
|
||||
let state = states.get(conn);
|
||||
const history = (key ? identities.get(key) : null) || state?.history || {
|
||||
latest: null,
|
||||
at: 0,
|
||||
points: [],
|
||||
latestFull: false,
|
||||
fullResult: null,
|
||||
fullAt: 0,
|
||||
sequence: 0,
|
||||
};
|
||||
if (!state) {
|
||||
state = {
|
||||
context: { conn },
|
||||
context: { conn: null },
|
||||
pending: null,
|
||||
latest: null,
|
||||
at: 0,
|
||||
points: [],
|
||||
controller: null,
|
||||
latestFull: false,
|
||||
pendingFull: false,
|
||||
fullResult: null,
|
||||
fullAt: 0,
|
||||
history,
|
||||
};
|
||||
states.set(conn, state);
|
||||
} else if (key && !state.pending && !state.history.latest) {
|
||||
// A panel may read a snapshot before its first sample supplies the endpoint.
|
||||
// Attach that empty runtime to the existing history without moving live work.
|
||||
state.history = history;
|
||||
}
|
||||
states.set(conn, state);
|
||||
// Share measurements/history by identity, but in-flight requests, abort
|
||||
// controllers and CPU/network baselines belong to the physical transport.
|
||||
// A reconnect must never join (or cancel) work on an older connection.
|
||||
if (key) {
|
||||
identities.delete(key);
|
||||
identities.set(key, state);
|
||||
identities.set(key, state.history);
|
||||
if (identities.size > MAX_SAMPLERS) {
|
||||
for (const [oldKey, oldState] of identities) {
|
||||
if (!oldState.pending && oldKey !== key) {
|
||||
oldState.points = [];
|
||||
identities.delete(oldKey);
|
||||
break;
|
||||
}
|
||||
}
|
||||
const oldKey = identities.keys().next().value;
|
||||
identities.delete(oldKey);
|
||||
}
|
||||
}
|
||||
return state;
|
||||
@@ -51,11 +57,11 @@ function appendPoint(state, result, now, freshAfter = 0) {
|
||||
if (
|
||||
!result.success ||
|
||||
!result.stats ||
|
||||
(now - (state.points.at(-1)?.at ?? 0) < 1000 && (state.points.at(-1)?.at ?? 0) > freshAfter)
|
||||
(now - (state.history.points.at(-1)?.at ?? 0) < 1000 && (state.history.points.at(-1)?.at ?? 0) > freshAfter)
|
||||
)
|
||||
return;
|
||||
const s = result.stats;
|
||||
state.points.push({
|
||||
state.history.points.push({
|
||||
at: now,
|
||||
cpu: s.cpu,
|
||||
memory:
|
||||
@@ -66,7 +72,7 @@ function appendPoint(state, result, now, freshAfter = 0) {
|
||||
rx: s.networkReady ? s.netRxSpeed : null,
|
||||
tx: s.networkReady ? s.netTxSpeed : null,
|
||||
});
|
||||
state.points = state.points
|
||||
state.history.points = state.history.points
|
||||
.filter((p) => now - p.at <= 30 * 60000)
|
||||
.slice(-1800);
|
||||
}
|
||||
@@ -79,26 +85,26 @@ async function sampleSharedStats(session, collect, now = Date.now()) {
|
||||
const state = stateFor(companion);
|
||||
state.context.prevNetStats = session.prevNetStats;
|
||||
state.context.prevCpuStats = session.prevCpuStats;
|
||||
state.latest = result;
|
||||
state.at = Date.now();
|
||||
state.latestFull = true;
|
||||
state.fullResult = result;
|
||||
state.fullAt = state.at;
|
||||
state.history.latest = result;
|
||||
state.history.at = Date.now();
|
||||
state.history.latestFull = true;
|
||||
state.history.fullResult = result;
|
||||
state.history.fullAt = state.history.at;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const state = stateFor(conn, session._reuseEndpoint);
|
||||
const full = !session._monitorStats;
|
||||
const cacheMs = full ? 4000 : Math.min(4000, session._monitorIntervalMs ?? 5000);
|
||||
if (full && state.fullResult?.success && now - state.fullAt < 4000)
|
||||
return state.fullResult;
|
||||
if (full && state.history.fullResult?.success && now - state.history.fullAt < 4000)
|
||||
return state.history.fullResult;
|
||||
if (state.pending) {
|
||||
if (full) state.terminalJoined = true;
|
||||
if (!full || state.pendingFull) {
|
||||
const result = await state.pending;
|
||||
// A reset can join a terminal query that began before the new boundary.
|
||||
if (!full && state.at > session._monitorFreshAfter)
|
||||
appendPoint(state, result, state.at, session._monitorFreshAfter);
|
||||
if (!full && state.history.at > session._monitorFreshAfter)
|
||||
appendPoint(state, result, state.history.at, session._monitorFreshAfter);
|
||||
return result;
|
||||
}
|
||||
// A terminal needs process and GPU fields omitted from a monitor-only query.
|
||||
@@ -107,12 +113,12 @@ async function sampleSharedStats(session, collect, now = Date.now()) {
|
||||
return sampleSharedStats(session, collect);
|
||||
}
|
||||
if (
|
||||
state.latest?.success &&
|
||||
now - state.at < cacheMs &&
|
||||
(full || state.at > (session._monitorFreshAfter ?? 0)) &&
|
||||
(!full || state.latestFull)
|
||||
state.history.latest?.success &&
|
||||
now - state.history.at < cacheMs &&
|
||||
(full || state.history.at > (session._monitorFreshAfter ?? 0)) &&
|
||||
(!full || state.history.latestFull)
|
||||
)
|
||||
return state.latest;
|
||||
return state.history.latest;
|
||||
const collectFull = full;
|
||||
// Keep transport capabilities on the collection context; baseline is independent of a terminal.
|
||||
const companion = !session.conn;
|
||||
@@ -125,21 +131,23 @@ async function sampleSharedStats(session, collect, now = Date.now()) {
|
||||
hostname: session.hostname,
|
||||
_monitorStats: !collectFull,
|
||||
});
|
||||
// Starting a replacement retires older samples even if the new attempt fails.
|
||||
const sequence = ++state.history.sequence;
|
||||
state.controller = new AbortController();
|
||||
state.terminalJoined = full;
|
||||
state.pendingFull = collectFull;
|
||||
state.context.statsSignal = state.controller.signal;
|
||||
state.pending = Promise.resolve(collect(state.context))
|
||||
.then((result) => {
|
||||
if (!state.controller.signal.aborted && result.success) {
|
||||
state.latest = result;
|
||||
state.latestFull = collectFull;
|
||||
state.at = Date.now();
|
||||
if (!state.controller.signal.aborted && result.success && sequence === state.history.sequence) {
|
||||
state.history.latest = result;
|
||||
state.history.latestFull = collectFull;
|
||||
state.history.at = Date.now();
|
||||
if (collectFull) {
|
||||
state.fullResult = result;
|
||||
state.fullAt = state.at;
|
||||
state.history.fullResult = result;
|
||||
state.history.fullAt = state.history.at;
|
||||
}
|
||||
appendPoint(state, result, state.at, session._monitorFreshAfter);
|
||||
appendPoint(state, result, state.history.at, session._monitorFreshAfter);
|
||||
}
|
||||
return result;
|
||||
})
|
||||
@@ -155,9 +163,9 @@ async function sampleSharedStats(session, collect, now = Date.now()) {
|
||||
}
|
||||
function readSharedStats(conn) {
|
||||
const state = stateFor(conn);
|
||||
if (state.points[0] && Date.now() - state.points[0].at > 30 * 60000)
|
||||
state.points = state.points.filter((p) => Date.now() - p.at <= 30 * 60000);
|
||||
return { result: state.latestFull ? state.latest : withCachedMonitorGpu(conn, state.latest), at: state.at, statsAt: Math.max(state.at, monitorGpuUpdatedAt(conn)), points: state.points };
|
||||
if (state.history.points[0] && Date.now() - state.history.points[0].at > 30 * 60000)
|
||||
state.history.points = state.history.points.filter((p) => Date.now() - p.at <= 30 * 60000);
|
||||
return { result: state.history.latestFull ? state.history.latest : withCachedMonitorGpu(conn, state.history.latest), at: state.history.at, statsAt: Math.max(state.history.at, monitorGpuUpdatedAt(conn)), points: state.history.points };
|
||||
}
|
||||
function cancelSharedStats(conn) {
|
||||
cancelMonitorGpuProbe(conn);
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { sampleSharedStats, readSharedStats, cancelSharedStats, resetSharedStatsForTests } = require('./sharedStats.cjs');
|
||||
|
||||
const endpoint = { hostname: 'stats.example', username: 'test', port: 22 };
|
||||
const result = cpu => ({ success: true, stats: { cpu, memTotal: 100, memUsed: 50 } });
|
||||
|
||||
test('new transport samples without waiting for an old same-endpoint request', async () => {
|
||||
resetSharedStatsForTests();
|
||||
const oldConn = {}, newConn = {};
|
||||
let finishOld;
|
||||
let oldContext;
|
||||
const old = sampleSharedStats({ conn: oldConn, _reuseEndpoint: endpoint }, context => {
|
||||
oldContext = context;
|
||||
return new Promise(resolve => { finishOld = resolve; });
|
||||
});
|
||||
let newCalls = 0;
|
||||
const fresh = await sampleSharedStats({ conn: newConn, _reuseEndpoint: endpoint }, async context => {
|
||||
newCalls++;
|
||||
assert.equal(context.conn, newConn);
|
||||
return result(80);
|
||||
});
|
||||
assert.equal(newCalls, 1);
|
||||
assert.equal(fresh.stats.cpu, 80);
|
||||
assert.equal(oldContext.statsSignal.aborted, false);
|
||||
finishOld(result(10));
|
||||
await old;
|
||||
assert.equal(readSharedStats(newConn).result.stats.cpu, 80, 'late old reply must not replace newer measurements');
|
||||
assert.equal(readSharedStats(oldConn).points, readSharedStats(newConn).points);
|
||||
});
|
||||
|
||||
test('a failed replacement sample still retires pending results from the old transport', async () => {
|
||||
resetSharedStatsForTests();
|
||||
const oldConn = {}, newConn = {};
|
||||
await sampleSharedStats({ conn: oldConn, _reuseEndpoint: endpoint }, async () => result(42));
|
||||
const before = readSharedStats(oldConn);
|
||||
const now = Date.now() + 5000;
|
||||
let finishOld;
|
||||
const old = sampleSharedStats({ conn: oldConn, _reuseEndpoint: endpoint }, () =>
|
||||
new Promise(resolve => { finishOld = resolve; }), now);
|
||||
const failed = await sampleSharedStats({ conn: newConn, _reuseEndpoint: endpoint }, async () =>
|
||||
({ success: false, error: 'Connection closed' }), now);
|
||||
assert.equal(failed.success, false);
|
||||
finishOld(result(10));
|
||||
await old;
|
||||
const after = readSharedStats(newConn);
|
||||
assert.equal(after.result.stats.cpu, 42, 'keep the last published measurement');
|
||||
assert.equal(after.at, before.at);
|
||||
assert.deepEqual(after.points.map(point => point.cpu), [42], 'do not publish a late old sample');
|
||||
});
|
||||
|
||||
test('cancelling a monitor on one transport cannot abort another transport', async () => {
|
||||
resetSharedStatsForTests();
|
||||
const a = {}, b = {};
|
||||
const contexts = [];
|
||||
const finish = [];
|
||||
const collect = context => {
|
||||
contexts.push(context);
|
||||
return new Promise(resolve => finish.push(resolve));
|
||||
};
|
||||
const first = sampleSharedStats({ conn: a, _reuseEndpoint: endpoint, _monitorStats: true }, collect);
|
||||
const second = sampleSharedStats({ conn: b, _reuseEndpoint: endpoint, _monitorStats: true }, collect);
|
||||
assert.equal(contexts.length, 2);
|
||||
cancelSharedStats(a);
|
||||
assert.equal(contexts[0].statsSignal.aborted, true);
|
||||
assert.equal(contexts[1].statsSignal.aborted, false);
|
||||
finish[0](result(10));
|
||||
finish[1](result(80));
|
||||
await Promise.all([first, second]);
|
||||
assert.equal(readSharedStats(b).result.stats.cpu, 80);
|
||||
});
|
||||
|
||||
test('readers sharing one physical transport still coalesce an active sample', async () => {
|
||||
resetSharedStatsForTests();
|
||||
const conn = {};
|
||||
let resolve;
|
||||
let calls = 0;
|
||||
const collect = () => { calls++; return new Promise(done => { resolve = done; }); };
|
||||
const a = sampleSharedStats({ conn, _reuseEndpoint: endpoint }, collect);
|
||||
const b = sampleSharedStats({ conn, _reuseEndpoint: endpoint }, collect);
|
||||
assert.equal(calls, 1);
|
||||
resolve(result(42));
|
||||
assert.deepEqual(await a, await b);
|
||||
});
|
||||
|
||||
test('a snapshot read before sampling still joins the existing endpoint history', async () => {
|
||||
resetSharedStatsForTests();
|
||||
const terminal = {}, monitor = {};
|
||||
await sampleSharedStats({ conn: terminal, _reuseEndpoint: endpoint }, async () => result(42));
|
||||
assert.equal(readSharedStats(monitor).result, null);
|
||||
await sampleSharedStats({ conn: monitor, _reuseEndpoint: endpoint }, async () => result(80));
|
||||
assert.equal(readSharedStats(monitor).points, readSharedStats(terminal).points);
|
||||
assert.equal(readSharedStats(monitor).result.stats.cpu, 42);
|
||||
});
|
||||
@@ -92,12 +92,15 @@ function createExecOnSessionApi(ctx) {
|
||||
return conn;
|
||||
}
|
||||
|
||||
function execOnConnection(conn, command, timeoutMs, execOptions = {}) {
|
||||
function execOnConnection(conn, command, timeoutMs, execOptions = {}, sharedTransport = true) {
|
||||
const maxBuffer = normalizeExecMaxBuffer(execOptions.maxBuffer);
|
||||
return executeBoundedSshCommand(conn, command, {
|
||||
openingTimeoutMs: timeoutMs,
|
||||
runTimeoutMs: timeoutMs,
|
||||
maxOutputBytes: maxBuffer,
|
||||
// A slow auxiliary channel must not tear down the interactive shell
|
||||
// or SFTP transfers on the same transport. The executor fences retries.
|
||||
invalidateOnOpenTimeout: !sharedTransport,
|
||||
onStream(stream) {
|
||||
if (typeof execOptions.stdin === "string") {
|
||||
stream.write(execOptions.stdin);
|
||||
@@ -144,7 +147,7 @@ function createExecOnSessionApi(ctx) {
|
||||
return { success: false, error: "Session not found or not connected" };
|
||||
}
|
||||
|
||||
const result = await execOnConnection(conn, command, timeoutMs, execOptions);
|
||||
const result = await execOnConnection(conn, command, timeoutMs, execOptions, !!session.conn);
|
||||
if (
|
||||
allowCompanionRetry
|
||||
&& !result.success
|
||||
|
||||
@@ -142,3 +142,53 @@ test("execOnSession refuses extra channels when singleChannelSsh is set", async
|
||||
assert.equal(result.code, "ERR_SINGLE_CHANNEL_BASTION");
|
||||
assert.equal(execCalls, 0);
|
||||
});
|
||||
|
||||
test("auxiliary exec timeout preserves the terminal and manual retry recovers after late open", async () => {
|
||||
let opens = 0;
|
||||
let destroyed = 0;
|
||||
let callback;
|
||||
const conn = {
|
||||
_sock: { destroyed: false },
|
||||
exec(_command, next) { opens++; callback = next; },
|
||||
end() { destroyed++; },
|
||||
destroy() { destroyed++; },
|
||||
};
|
||||
const api = createExecOnSessionApi({ sessions: new Map([['ssh', { conn, type: 'ssh' }]]) });
|
||||
assert.equal((await api.execOnSession(null, 'ssh', 'docker ps', 5)).success, false);
|
||||
assert.equal(destroyed, 0);
|
||||
const fenced = await api.execOnSession(null, 'ssh', 'docker ps', 5);
|
||||
assert.equal(fenced.success, false);
|
||||
assert.match(fenced.error, /still pending/);
|
||||
assert.equal(opens, 1);
|
||||
const late = new EventEmitter();
|
||||
let closed = 0;
|
||||
late.close = () => { closed++; };
|
||||
callback(null, late);
|
||||
assert.equal(closed, 1);
|
||||
conn.exec = (_command, next) => {
|
||||
opens++;
|
||||
const stream = new EventEmitter();
|
||||
stream.stderr = new EventEmitter();
|
||||
next(null, stream);
|
||||
queueMicrotask(() => {
|
||||
stream.emit('data', Buffer.from('container recovered'));
|
||||
stream.emit('close', 0);
|
||||
});
|
||||
};
|
||||
const recovered = await api.execOnSession(null, 'ssh', 'docker ps', 1000);
|
||||
assert.equal(recovered.success, true);
|
||||
assert.equal(recovered.stdout, 'container recovered');
|
||||
assert.equal(opens, 2);
|
||||
assert.equal(destroyed, 0);
|
||||
});
|
||||
|
||||
test("auxiliary exec still reclaims an unresponsive dedicated Mosh companion", async () => {
|
||||
let destroys = 0;
|
||||
const conn = { exec() {}, destroy() { destroys++; } };
|
||||
const api = createExecOnSessionApi({
|
||||
sessions: new Map([['mosh', { type: 'mosh', moshStatsConn: conn }]]),
|
||||
});
|
||||
const result = await api.execOnSession(null, 'mosh', 'docker ps', 5);
|
||||
assert.equal(result.success, false);
|
||||
assert.equal(destroys, 1);
|
||||
});
|
||||
@@ -55,10 +55,18 @@ function showSystemNotification({
|
||||
return { shown: true };
|
||||
}
|
||||
|
||||
function registerSystemNotificationHandlers(ipcMain, { electronModule, BrowserWindow }) {
|
||||
function registerSystemNotificationHandlers(ipcMain, { electronModule, BrowserWindow, getAppFocused }) {
|
||||
const shownKeys = new Set();
|
||||
ipcMain.handle("netcatty:notification:show", async (event, payload) => {
|
||||
try {
|
||||
return showSystemNotification({
|
||||
if (payload?.whenUnfocused === true && typeof getAppFocused === "function" && getAppFocused()) {
|
||||
return { shown: false, reason: "focused" };
|
||||
}
|
||||
const dedupeKey = typeof payload?.dedupeKey === "string" ? payload.dedupeKey : "";
|
||||
if (dedupeKey && shownKeys.has(dedupeKey)) {
|
||||
return { shown: false, reason: "duplicate" };
|
||||
}
|
||||
const result = showSystemNotification({
|
||||
Notification: electronModule?.Notification,
|
||||
BrowserWindow,
|
||||
sender: event?.sender,
|
||||
@@ -66,6 +74,8 @@ function registerSystemNotificationHandlers(ipcMain, { electronModule, BrowserWi
|
||||
body: payload?.body,
|
||||
sessionId: payload?.sessionId,
|
||||
});
|
||||
if (result?.shown && dedupeKey) shownKeys.add(dedupeKey);
|
||||
return result;
|
||||
} catch (err) {
|
||||
return { shown: false, reason: err?.message || "failed" };
|
||||
}
|
||||
|
||||
@@ -72,6 +72,67 @@ test("showSystemNotification reports unsupported platforms", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("registerSystemNotificationHandlers skips unfocused alerts while the app is focused", async () => {
|
||||
const handlers = new Map();
|
||||
const shown = [];
|
||||
function FakeNotification(payload) {
|
||||
shown.push(payload);
|
||||
}
|
||||
FakeNotification.isSupported = () => true;
|
||||
FakeNotification.prototype.on = () => {};
|
||||
FakeNotification.prototype.show = () => {};
|
||||
|
||||
registerSystemNotificationHandlers({
|
||||
handle(channel, handler) { handlers.set(channel, handler); },
|
||||
}, {
|
||||
electronModule: { Notification: FakeNotification },
|
||||
BrowserWindow: { fromWebContents: () => null },
|
||||
getAppFocused: () => true,
|
||||
});
|
||||
|
||||
const skipped = await handlers.get("netcatty:notification:show")(
|
||||
{ sender: { isDestroyed: () => false, send() {} } },
|
||||
{ title: "External MCP approval", body: "sftp.write is waiting", whenUnfocused: true, dedupeKey: "a1" },
|
||||
);
|
||||
assert.deepEqual(skipped, { shown: false, reason: "focused" });
|
||||
assert.equal(shown.length, 0);
|
||||
|
||||
const forced = await handlers.get("netcatty:notification:show")(
|
||||
{ sender: { isDestroyed: () => false, send() {} } },
|
||||
{ title: "External MCP approval", body: "sftp.write is waiting", whenUnfocused: false, dedupeKey: "a1" },
|
||||
);
|
||||
assert.deepEqual(forced, { shown: true });
|
||||
assert.equal(shown.length, 1);
|
||||
});
|
||||
|
||||
test("registerSystemNotificationHandlers dedupes the same approval across windows", async () => {
|
||||
const handlers = new Map();
|
||||
const shown = [];
|
||||
function FakeNotification(payload) {
|
||||
shown.push(payload);
|
||||
}
|
||||
FakeNotification.isSupported = () => true;
|
||||
FakeNotification.prototype.on = () => {};
|
||||
FakeNotification.prototype.show = () => {};
|
||||
|
||||
registerSystemNotificationHandlers({
|
||||
handle(channel, handler) { handlers.set(channel, handler); },
|
||||
}, {
|
||||
electronModule: { Notification: FakeNotification },
|
||||
BrowserWindow: { fromWebContents: () => null },
|
||||
getAppFocused: () => false,
|
||||
});
|
||||
|
||||
const show = (dedupeKey) => handlers.get("netcatty:notification:show")(
|
||||
{ sender: { isDestroyed: () => false, send() {} } },
|
||||
{ title: "External MCP approval", body: "tool", dedupeKey },
|
||||
);
|
||||
assert.deepEqual(await show("mcp_approval_1"), { shown: true });
|
||||
assert.deepEqual(await show("mcp_approval_1"), { shown: false, reason: "duplicate" });
|
||||
assert.deepEqual(await show("mcp_approval_2"), { shown: true });
|
||||
assert.equal(shown.length, 2);
|
||||
});
|
||||
|
||||
test("registerSystemNotificationHandlers exposes the IPC channel", async () => {
|
||||
const handlers = new Map();
|
||||
const shown = [];
|
||||
|
||||
@@ -5107,6 +5107,7 @@ async function downloadFile(
|
||||
await new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let onFastSftpError = null;
|
||||
const onFastSftpClose = () => finish(new Error("SFTP channel closed during download"));
|
||||
const finish = (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
@@ -5117,6 +5118,8 @@ async function downloadFile(
|
||||
try { fastSftp.removeListener("error", onFastSftpError); } catch { }
|
||||
onFastSftpError = null;
|
||||
}
|
||||
fastSftp.removeListener("end", onFastSftpClose);
|
||||
fastSftp.removeListener("close", onFastSftpClose);
|
||||
releaseIsolatedDownloadChannel(client, fastSftp, {
|
||||
dispose: !!err || transfer.cancelled,
|
||||
});
|
||||
@@ -5133,6 +5136,8 @@ async function downloadFile(
|
||||
transfer.abort = abortFastTransfer;
|
||||
onFastSftpError = (err) => finish(err);
|
||||
fastSftp.once("error", onFastSftpError);
|
||||
fastSftp.once("end", onFastSftpClose);
|
||||
fastSftp.once("close", onFastSftpClose);
|
||||
|
||||
if (transfer.cancelled) {
|
||||
finish(new Error("Transfer cancelled"));
|
||||
@@ -6173,13 +6178,15 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
lastObservedTotal = fileSize;
|
||||
lastObservedTransferred = Math.min(lastObservedTransferred, fileSize);
|
||||
}
|
||||
// fastGet's success callback alone does not establish a complete file.
|
||||
// Enforce the planned size for direct/non-resumable downloads as well.
|
||||
if (transfer.cancelled) throw new Error("Transfer cancelled");
|
||||
const downloadedStat = await fs.promises.stat(downloadTargetPath);
|
||||
if (transfer.cancelled) throw new Error("Transfer cancelled");
|
||||
if (downloadedStat.size !== fileSize) {
|
||||
throw new Error(`Downloaded file size mismatch: expected ${fileSize}, got ${downloadedStat.size}`);
|
||||
}
|
||||
if (stageLocalDownload) {
|
||||
if (transfer.cancelled) throw new Error("Transfer cancelled");
|
||||
const stagedStat = await fs.promises.stat(downloadTargetPath);
|
||||
if (transfer.cancelled) throw new Error("Transfer cancelled");
|
||||
if (stagedStat.size !== fileSize) {
|
||||
throw new Error(`Downloaded file size mismatch: expected ${fileSize}, got ${stagedStat.size}`);
|
||||
}
|
||||
if (transfer.cancelled) throw new Error("Transfer cancelled");
|
||||
const {
|
||||
promotionTargetPath,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const bridge = require('./transferBridge.cjs');
|
||||
const tempDirBridge = require('./tempDirBridge.cjs');
|
||||
|
||||
for (const scenario of ['truncated', 'oversized', 'end', 'close', 'complete']) {
|
||||
test(`non-resumable fastGet handles ${scenario} without a false completion or hang`, async t => {
|
||||
const id = `integrity-${randomUUID()}`;
|
||||
const targetPath = tempDirBridge.getTempFilePath(`${id}.bin`);
|
||||
t.after(() => fs.rm(targetPath, { force: true }));
|
||||
const channel = new EventEmitter();
|
||||
channel.end = () => {};
|
||||
channel.fastGet = (_remote, local, _options, done) => {
|
||||
if (scenario === 'end' || scenario === 'close') {
|
||||
queueMicrotask(() => channel.emit(scenario));
|
||||
} else {
|
||||
const bytes = scenario === 'truncated' ? 'ab' : scenario === 'oversized' ? 'abcdef' : 'abcd';
|
||||
fs.writeFile(local, bytes).then(() => done(), done);
|
||||
}
|
||||
};
|
||||
const sftp = new EventEmitter();
|
||||
sftp.readdir = (_path, callback) => callback(null, []);
|
||||
sftp.stat = (_path, callback) => callback(null, { size: 4 });
|
||||
sftp.mkdir = (_path, callback) => callback(null);
|
||||
sftp.unlink = (_path, callback) => callback(null);
|
||||
for (const method of ['readdir', 'stat', 'mkdir', 'unlink']) channel[method] = sftp[method];
|
||||
const client = { sftp, stat: async () => ({ size: 4 }), client: {
|
||||
sftp(callback) { callback(null, channel); },
|
||||
} };
|
||||
bridge.init({ sftpClients: new Map([['source', client]]) });
|
||||
const events = [];
|
||||
let timer;
|
||||
const running = bridge.startTransfer({ sender: { send: (event, data) => events.push({ event, data }) } }, {
|
||||
transferId: id, sourceType: 'sftp', targetType: 'local', sourceSftpId: 'source',
|
||||
sourcePath: '/file.bin', targetPath, totalBytes: 4, resumable: false,
|
||||
});
|
||||
t.after(async () => { clearTimeout(timer); await bridge.cancelTransfer({}, { transferId: id }); await running; });
|
||||
const response = await Promise.race([running, new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error('download did not settle')), 1000);
|
||||
})]);
|
||||
if (scenario === 'complete') {
|
||||
assert.equal(response.error, undefined);
|
||||
assert.equal(await fs.readFile(targetPath, 'utf8'), 'abcd');
|
||||
assert.ok(events.some(({ event }) => event === 'netcatty:transfer:complete'));
|
||||
} else {
|
||||
assert.match(response.error, scenario === 'truncated' || scenario === 'oversized'
|
||||
? /size mismatch/ : /channel closed/);
|
||||
assert.ok(!events.some(({ event }) => event === 'netcatty:transfer:complete'));
|
||||
}
|
||||
assert.equal(channel.listenerCount('end'), 0);
|
||||
assert.equal(channel.listenerCount('close'), 0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use strict";
|
||||
|
||||
const DEFAULT_MODE = "unfocused";
|
||||
|
||||
function normalizeExternalMcpApprovalAlertMode(value) {
|
||||
return value === "off" || value === "always" || value === "unfocused" ? value : DEFAULT_MODE;
|
||||
}
|
||||
|
||||
function createWindowAttentionController({ app, getWindows, platform }) {
|
||||
let bounceId = -1;
|
||||
let flashing = false;
|
||||
let flashedWin = null;
|
||||
let watchingFocus = false;
|
||||
|
||||
function onBrowserWindowFocus() {
|
||||
stop();
|
||||
}
|
||||
|
||||
function startFocusWatch() {
|
||||
if (watchingFocus || typeof app?.on !== "function") return;
|
||||
app.on("browser-window-focus", onBrowserWindowFocus);
|
||||
watchingFocus = true;
|
||||
}
|
||||
|
||||
function stopFocusWatch() {
|
||||
if (!watchingFocus) return;
|
||||
watchingFocus = false;
|
||||
try { app.removeListener("browser-window-focus", onBrowserWindowFocus); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function stop() {
|
||||
stopFocusWatch();
|
||||
if (flashing && flashedWin && !flashedWin.isDestroyed?.() && typeof flashedWin.flashFrame === "function") {
|
||||
try { flashedWin.flashFrame(false); } catch { /* ignore */ }
|
||||
}
|
||||
flashing = false;
|
||||
flashedWin = null;
|
||||
if (bounceId >= 0) {
|
||||
try { app?.dock?.cancelBounce?.(bounceId); } catch { /* ignore */ }
|
||||
bounceId = -1;
|
||||
}
|
||||
}
|
||||
|
||||
function request() {
|
||||
const windows = (typeof getWindows === "function" ? getWindows() : [])
|
||||
.filter((win) => win && !win.isDestroyed?.());
|
||||
if (windows.length === 0) return { requested: false, reason: "no-window" };
|
||||
if (windows.some((win) => typeof win.isFocused === "function" && win.isFocused())) {
|
||||
return { requested: false, reason: "focused" };
|
||||
}
|
||||
|
||||
if (platform === "darwin") {
|
||||
if (bounceId >= 0) return { requested: true, kind: "bounce" };
|
||||
const id = app?.dock?.bounce?.("informational");
|
||||
if (typeof id !== "number" || id < 0) return { requested: false, reason: "unavailable" };
|
||||
bounceId = id;
|
||||
startFocusWatch();
|
||||
return { requested: true, kind: "bounce" };
|
||||
}
|
||||
|
||||
const win = windows[0];
|
||||
if (typeof win.flashFrame !== "function") return { requested: false, reason: "unavailable" };
|
||||
win.flashFrame(true);
|
||||
flashing = true;
|
||||
flashedWin = win;
|
||||
startFocusWatch();
|
||||
return { requested: true, kind: "flash" };
|
||||
}
|
||||
|
||||
return { request, stop };
|
||||
}
|
||||
|
||||
let mode = DEFAULT_MODE;
|
||||
let controller = null;
|
||||
|
||||
function installWindowAttention(options) {
|
||||
controller?.stop?.();
|
||||
controller = createWindowAttentionController(options);
|
||||
return controller;
|
||||
}
|
||||
|
||||
function setExternalMcpApprovalAlertMode(value) {
|
||||
mode = normalizeExternalMcpApprovalAlertMode(value);
|
||||
if (mode === "off") controller?.stop?.();
|
||||
return mode;
|
||||
}
|
||||
|
||||
function getExternalMcpApprovalAlertMode() {
|
||||
return mode;
|
||||
}
|
||||
|
||||
function syncExternalMcpApprovalAttention(pendingCount) {
|
||||
if (!controller) return { attention: false, reason: "uninstalled" };
|
||||
if (!(pendingCount > 0) || mode === "off") {
|
||||
controller.stop();
|
||||
return { attention: false };
|
||||
}
|
||||
return controller.request();
|
||||
}
|
||||
|
||||
function resetWindowAttentionForTests() {
|
||||
controller?.stop?.();
|
||||
controller = null;
|
||||
mode = DEFAULT_MODE;
|
||||
}
|
||||
|
||||
function registerExternalMcpApprovalAlertHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:mcp:approval-alert-mode", (_event, payload) => {
|
||||
return { mode: setExternalMcpApprovalAlertMode(payload?.mode) };
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeExternalMcpApprovalAlertMode,
|
||||
createWindowAttentionController,
|
||||
installWindowAttention,
|
||||
setExternalMcpApprovalAlertMode,
|
||||
getExternalMcpApprovalAlertMode,
|
||||
syncExternalMcpApprovalAttention,
|
||||
resetWindowAttentionForTests,
|
||||
registerExternalMcpApprovalAlertHandlers,
|
||||
};
|
||||
@@ -0,0 +1,186 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { EventEmitter } = require("node:events");
|
||||
|
||||
const mcpServerBridge = require("./mcpServerBridge.cjs");
|
||||
const {
|
||||
createWindowAttentionController,
|
||||
installWindowAttention,
|
||||
resetWindowAttentionForTests,
|
||||
setExternalMcpApprovalAlertMode,
|
||||
syncExternalMcpApprovalAttention,
|
||||
} = require("./windowAttention.cjs");
|
||||
|
||||
function createWindow(overrides = {}) {
|
||||
const win = new EventEmitter();
|
||||
win.destroyed = false;
|
||||
win.focused = false;
|
||||
win.flashes = [];
|
||||
win.isDestroyed = () => win.destroyed;
|
||||
win.isFocused = () => win.focused;
|
||||
win.flashFrame = (flag) => { win.flashes.push(flag); };
|
||||
return Object.assign(win, overrides);
|
||||
}
|
||||
|
||||
test("linux attention flashes until focus or stop", () => {
|
||||
const app = new EventEmitter();
|
||||
const win = createWindow();
|
||||
const controller = createWindowAttentionController({
|
||||
app,
|
||||
platform: "linux",
|
||||
getWindows: () => [win],
|
||||
});
|
||||
assert.deepEqual(controller.request(), { requested: true, kind: "flash" });
|
||||
assert.deepEqual(win.flashes, [true]);
|
||||
assert.equal(app.listenerCount("browser-window-focus"), 1);
|
||||
app.emit("browser-window-focus", {}, win);
|
||||
assert.deepEqual(win.flashes, [true, false]);
|
||||
assert.equal(app.listenerCount("browser-window-focus"), 0);
|
||||
|
||||
assert.deepEqual(controller.request(), { requested: true, kind: "flash" });
|
||||
controller.stop();
|
||||
assert.deepEqual(win.flashes, [true, false, true, false]);
|
||||
assert.equal(app.listenerCount("browser-window-focus"), 0);
|
||||
});
|
||||
|
||||
test("focus on a window created after attention starts clears the flash", () => {
|
||||
const app = new EventEmitter();
|
||||
const original = createWindow();
|
||||
const windows = [original];
|
||||
const controller = createWindowAttentionController({
|
||||
app,
|
||||
platform: "win32",
|
||||
getWindows: () => windows,
|
||||
});
|
||||
assert.deepEqual(controller.request(), { requested: true, kind: "flash" });
|
||||
|
||||
const later = createWindow();
|
||||
windows.push(later);
|
||||
later.emit("focus");
|
||||
assert.deepEqual(original.flashes, [true]);
|
||||
|
||||
app.emit("browser-window-focus", {}, later);
|
||||
assert.deepEqual(original.flashes, [true, false]);
|
||||
assert.equal(app.listenerCount("browser-window-focus"), 0);
|
||||
app.emit("browser-window-focus", {}, later);
|
||||
assert.deepEqual(original.flashes, [true, false]);
|
||||
});
|
||||
|
||||
test("attention stays quiet while any window is focused", () => {
|
||||
const idle = createWindow();
|
||||
const active = createWindow({ focused: true });
|
||||
const controller = createWindowAttentionController({
|
||||
platform: "win32",
|
||||
getWindows: () => [idle, active],
|
||||
});
|
||||
assert.deepEqual(controller.request(), { requested: false, reason: "focused" });
|
||||
assert.deepEqual(idle.flashes, []);
|
||||
});
|
||||
|
||||
test("macOS attention uses an informational dock bounce and cancels it", () => {
|
||||
const win = createWindow();
|
||||
const cancelled = [];
|
||||
const app = new EventEmitter();
|
||||
app.dock = {
|
||||
bounce(type) {
|
||||
assert.equal(type, "informational");
|
||||
return 4;
|
||||
},
|
||||
cancelBounce(id) { cancelled.push(id); },
|
||||
};
|
||||
const controller = createWindowAttentionController({
|
||||
platform: "darwin",
|
||||
app,
|
||||
getWindows: () => [win],
|
||||
});
|
||||
assert.deepEqual(controller.request(), { requested: true, kind: "bounce" });
|
||||
assert.deepEqual(controller.request(), { requested: true, kind: "bounce" });
|
||||
assert.equal(app.listenerCount("browser-window-focus"), 1);
|
||||
assert.deepEqual(win.flashes, []);
|
||||
controller.stop();
|
||||
assert.deepEqual(cancelled, [4]);
|
||||
app.emit("browser-window-focus", {}, win);
|
||||
assert.deepEqual(cancelled, [4]);
|
||||
assert.equal(app.listenerCount("browser-window-focus"), 0);
|
||||
});
|
||||
|
||||
test("external MCP approvals flash while unfocused and stop when resolved", (t) => {
|
||||
const contents = new EventEmitter();
|
||||
contents.id = 42;
|
||||
contents.send = (channel, payload) => contents.emit("send", channel, payload);
|
||||
contents.isDestroyed = () => false;
|
||||
const win = createWindow();
|
||||
win.webContents = contents;
|
||||
|
||||
installWindowAttention({
|
||||
platform: "linux",
|
||||
getWindows: () => [win],
|
||||
});
|
||||
setExternalMcpApprovalAlertMode("unfocused");
|
||||
mcpServerBridge.setMainWindowGetter(() => win);
|
||||
t.after(() => {
|
||||
mcpServerBridge.clearPendingApprovals();
|
||||
mcpServerBridge.setMainWindowGetter(() => null);
|
||||
resetWindowAttentionForTests();
|
||||
});
|
||||
|
||||
const requests = [];
|
||||
contents.on("send", (channel, payload) => {
|
||||
if (channel === "netcatty:ai:mcp:approval-request") requests.push(payload);
|
||||
});
|
||||
|
||||
const approval = mcpServerBridge.requestApprovalFromRenderer(
|
||||
"sftp.write",
|
||||
{ path: "/tmp/x" },
|
||||
mcpServerBridge.EXTERNAL_MCP_CHAT_SESSION_ID,
|
||||
);
|
||||
assert.equal(requests.length, 1);
|
||||
assert.deepEqual(win.flashes, [true]);
|
||||
|
||||
mcpServerBridge.resolveApprovalFromRenderer(requests[0].approvalId, false);
|
||||
assert.deepEqual(win.flashes, [true, false]);
|
||||
return approval.then((approved) => {
|
||||
assert.equal(approved, false);
|
||||
});
|
||||
});
|
||||
|
||||
test("approval attention follows the off preference and ignores in-app approvals", (t) => {
|
||||
const contents = new EventEmitter();
|
||||
contents.id = 7;
|
||||
contents.send = (channel, payload) => contents.emit("send", channel, payload);
|
||||
contents.isDestroyed = () => false;
|
||||
const win = createWindow();
|
||||
win.webContents = contents;
|
||||
|
||||
installWindowAttention({
|
||||
platform: "linux",
|
||||
getWindows: () => [win],
|
||||
});
|
||||
setExternalMcpApprovalAlertMode("off");
|
||||
mcpServerBridge.setMainWindowGetter(() => win);
|
||||
t.after(() => {
|
||||
mcpServerBridge.clearPendingApprovals();
|
||||
mcpServerBridge.setMainWindowGetter(() => null);
|
||||
resetWindowAttentionForTests();
|
||||
});
|
||||
|
||||
const external = mcpServerBridge.requestApprovalFromRenderer(
|
||||
"sftp.write",
|
||||
{},
|
||||
mcpServerBridge.EXTERNAL_MCP_CHAT_SESSION_ID,
|
||||
);
|
||||
const internal = mcpServerBridge.requestApprovalFromRenderer(
|
||||
"terminal.execute",
|
||||
{},
|
||||
"chat-1",
|
||||
);
|
||||
assert.deepEqual(win.flashes, []);
|
||||
assert.deepEqual(syncExternalMcpApprovalAttention(2), { attention: false });
|
||||
|
||||
mcpServerBridge.clearPendingApprovals();
|
||||
return Promise.all([external, internal]).then((results) => {
|
||||
assert.deepEqual(results, [false, false]);
|
||||
});
|
||||
});
|
||||
+12
-8
@@ -97,9 +97,9 @@ const { PLUGIN_PROTOCOL_SCHEME } = require("./plugins/constants.cjs");
|
||||
const { runPluginShutdown } = require("./plugins/shutdownCoordinator.cjs");
|
||||
const {
|
||||
OPEN_TERMINAL_PATH_CHANNEL,
|
||||
collectOpenTerminalPathArgs,
|
||||
hasOpenTerminalLaunchArgs,
|
||||
resolveOpenTerminalPath,
|
||||
resolveOpenTerminalPathsFromArgs,
|
||||
resolveOpenTerminalRequestsFromArgs,
|
||||
} = require("./openTerminalPath.cjs");
|
||||
const {
|
||||
applyExplorerContextMenuPreference,
|
||||
@@ -709,7 +709,7 @@ const rawLaunchArgvForHandoff = [...process.argv];
|
||||
// SecureCRT operands may contain PuTTY switch names; scrub them first.
|
||||
redactSecureCrtCommandLinePasswords(process.argv);
|
||||
redactPuttyCommandLinePasswords(process.argv);
|
||||
const pendingOpenTerminalPaths = resolveOpenTerminalPathsFromArgs(process.argv);
|
||||
const pendingOpenTerminalPaths = resolveOpenTerminalRequestsFromArgs(process.argv);
|
||||
let flushingSshDeepLinks = false;
|
||||
let flushingTelnetDeepLinks = false;
|
||||
let flushingOpenTerminalPaths = false;
|
||||
@@ -744,7 +744,7 @@ function queueTelnetDeepLink(rawUrl, { viaCommandLine = false, tabName } = {}) {
|
||||
function queueOpenTerminalPath(rawPath, options = {}) {
|
||||
const resolvedPath = resolveOpenTerminalPath(rawPath, options);
|
||||
if (!resolvedPath) return;
|
||||
pendingOpenTerminalPaths.push(resolvedPath);
|
||||
pendingOpenTerminalPaths.push({ path: resolvedPath });
|
||||
if (app.isReady?.()) {
|
||||
void flushPendingOpenTerminalPaths();
|
||||
}
|
||||
@@ -1092,13 +1092,17 @@ async function flushPendingTelnetDeepLinks() {
|
||||
}
|
||||
|
||||
async function deliverOpenTerminalPath(targetPath) {
|
||||
const request = typeof targetPath === "string" ? { path: targetPath } : (targetPath || {});
|
||||
const payload = {};
|
||||
if (typeof request.path === "string" && request.path) payload.path = request.path;
|
||||
if (typeof request.shell === "string" && request.shell) payload.shell = request.shell;
|
||||
const win = await createAndShowMainWindow();
|
||||
focusMainWindow();
|
||||
const windowManager = getWindowManager();
|
||||
const result = await windowManager.sendWhenRendererReady?.(
|
||||
win,
|
||||
OPEN_TERMINAL_PATH_CHANNEL,
|
||||
{ path: targetPath },
|
||||
payload,
|
||||
{ timeoutMs: 0 },
|
||||
);
|
||||
if (result && result.success === false) {
|
||||
@@ -1275,15 +1279,15 @@ if (!gotLock) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (collectOpenTerminalPathArgs(secondInstanceArgv).length > 0) {
|
||||
if (hasOpenTerminalLaunchArgs(secondInstanceArgv)) {
|
||||
const baseDirectory = typeof workingDirectory === "string" ? workingDirectory : undefined;
|
||||
const openTerminalPaths = resolveOpenTerminalPathsFromArgs(secondInstanceArgv, { baseDirectory });
|
||||
const openTerminalPaths = resolveOpenTerminalRequestsFromArgs(secondInstanceArgv, { baseDirectory });
|
||||
if (openTerminalPaths.length > 0) {
|
||||
queueResolvedOpenTerminalPaths(openTerminalPaths);
|
||||
} else {
|
||||
// Still bring the app forward when Explorer launched us but the path
|
||||
// failed validation — silent no-op feels like a broken menu item.
|
||||
console.warn("[Main] Open-terminal-path args present but no valid path resolved:", secondInstanceArgv);
|
||||
console.warn("[Main] Open-terminal args present but no valid request resolved:", secondInstanceArgv);
|
||||
if (!focusMainWindow()) {
|
||||
// Explicit foreground request (a second instance launch): if a
|
||||
// still-pending hidden auto-launch cold start races ahead of the
|
||||
|
||||
@@ -4,6 +4,10 @@ let bridgesRegistered = false;
|
||||
let cloudSyncSessionPassword = null;
|
||||
const { readClipboardFiles, readClipboardImage, hasClipboardImage } = require("../bridges/clipboardFiles.cjs");
|
||||
const { registerSystemNotificationHandlers } = require("../bridges/systemNotification.cjs");
|
||||
const {
|
||||
installWindowAttention,
|
||||
registerExternalMcpApprovalAlertHandlers,
|
||||
} = require("../bridges/windowAttention.cjs");
|
||||
|
||||
const excludedFigSpecPrefixes = ["aws", "gcloud", "az"];
|
||||
|
||||
@@ -992,7 +996,26 @@ function createBridgeRegistrar(context) {
|
||||
);
|
||||
|
||||
// OSC 9/777/99 desktop notifications (Codex, iTerm2, kitty, rxvt)
|
||||
registerSystemNotificationHandlers(ipcMain, { electronModule, BrowserWindow });
|
||||
// and External MCP approval alerts, which reuse the same click-to-focus channel.
|
||||
registerSystemNotificationHandlers(ipcMain, {
|
||||
electronModule,
|
||||
BrowserWindow,
|
||||
getAppFocused: () => BrowserWindow.getAllWindows().some(
|
||||
(win) => win && !win.isDestroyed?.() && win.isFocused?.(),
|
||||
),
|
||||
});
|
||||
installWindowAttention({
|
||||
app,
|
||||
platform: process.platform,
|
||||
getWindows: () => {
|
||||
try {
|
||||
return BrowserWindow.getAllWindows();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
});
|
||||
registerExternalMcpApprovalAlertHandlers(ipcMain);
|
||||
|
||||
// Clipboard helpers for renderer fallback paths (e.g. Monaco paste in Electron)
|
||||
ipcMain.handle("netcatty:clipboard:readText", async () => {
|
||||
|
||||
@@ -28,7 +28,7 @@ function createHarness() {
|
||||
isDev: false,
|
||||
readSshDeepLinkEnabledPreference: () => true,
|
||||
readJmsDeepLinkEnabledPreference: () => false,
|
||||
resolveOpenTerminalPathsFromArgs: () => [],
|
||||
resolveOpenTerminalRequestsFromArgs: () => [],
|
||||
resolveExplorerContextMenuEnabled: () => ({ enabled: false }),
|
||||
applySshProtocolClientPreference: () => true,
|
||||
writeSshDeepLinkEnabledPreference: () => true,
|
||||
|
||||
@@ -4,11 +4,16 @@ const test = require("node:test");
|
||||
const { readFileSync } = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
const deepLink = require("./deepLink.cjs");
|
||||
const {
|
||||
collectSshDeepLinkQueueItems,
|
||||
redactPuttyCommandLinePasswords,
|
||||
redactSecureCrtCommandLinePasswords,
|
||||
} = require("./deepLink.cjs");
|
||||
} = deepLink;
|
||||
const {
|
||||
hasOpenTerminalLaunchArgs,
|
||||
resolveOpenTerminalRequestsFromArgs,
|
||||
} = require("./openTerminalPath.cjs");
|
||||
|
||||
test("second instance forwards its raw argv through the single-instance lock", () => {
|
||||
const source = readFileSync(path.join(__dirname, "main.cjs"), "utf8");
|
||||
@@ -81,6 +86,77 @@ test("failed protocol registration keeps command-line launch intents queued", ()
|
||||
assert.match(callSource, /dropSchemePendingDeepLinks\(\)/);
|
||||
});
|
||||
|
||||
function createOpenTerminalHarness() {
|
||||
const source = readFileSync(path.join(__dirname, "main.cjs"), "utf8");
|
||||
const start = source.indexOf('app.on("second-instance"');
|
||||
const end = source.indexOf("// Application lifecycle", start);
|
||||
assert.ok(start >= 0 && end > start);
|
||||
let handle;
|
||||
const queued = [];
|
||||
let focusCount = 0;
|
||||
const context = vm.createContext({
|
||||
...deepLink,
|
||||
app: { on: (_event, handler) => { handle = handler; } },
|
||||
sshDeepLinkEnabled: false,
|
||||
jmsDeepLinkEnabled: false,
|
||||
queueSshDeepLink: (url) => queued.push({ kind: "ssh", url }),
|
||||
queueTelnetDeepLink: (url) => queued.push({ kind: "telnet", url }),
|
||||
queueJmsDeepLink: (url) => queued.push({ kind: "jms", url }),
|
||||
hasOpenTerminalLaunchArgs,
|
||||
resolveOpenTerminalRequestsFromArgs: (argv, options) => resolveOpenTerminalRequestsFromArgs(argv, {
|
||||
...options,
|
||||
fsModule: {
|
||||
statSync: (target) => {
|
||||
if (String(target).includes("missing")) throw new Error("missing");
|
||||
return { isDirectory: () => true, isFile: () => false };
|
||||
},
|
||||
},
|
||||
logWarn: () => {},
|
||||
}),
|
||||
queueResolvedOpenTerminalPaths: (resolved) => queued.push(Array.from(resolved)),
|
||||
focusMainWindow: () => {
|
||||
focusCount += 1;
|
||||
return true;
|
||||
},
|
||||
createAndShowMainWindow: () => Promise.resolve(),
|
||||
console: { warn: () => {}, error: () => {} },
|
||||
});
|
||||
vm.runInContext(source.slice(start, end), context);
|
||||
return { handle, queued, focused: () => focusCount };
|
||||
}
|
||||
|
||||
test("second-instance shell flag queues a local terminal without a path", () => {
|
||||
const h = createOpenTerminalHarness();
|
||||
const args = ["--open-terminal-shell", "pwsh"];
|
||||
const argv = ["netcatty", ...args];
|
||||
h.handle(null, argv, "/working-directory", { rawLaunchArgv: [...args] });
|
||||
assert.deepEqual(h.queued, [[{ shell: "pwsh" }]]);
|
||||
assert.equal(h.focused(), 0);
|
||||
assert.equal(argv.length, 0);
|
||||
});
|
||||
|
||||
test("second-instance path flag still queues a path-only terminal request", () => {
|
||||
const h = createOpenTerminalHarness();
|
||||
const args = ["--open-terminal-path", "proj"];
|
||||
h.handle(null, ["netcatty", ...args], "/working-directory", { rawLaunchArgv: [...args] });
|
||||
assert.deepEqual(h.queued, [[{ path: "/working-directory/proj" }]]);
|
||||
});
|
||||
|
||||
test("second-instance shell and path flags queue one combined request", () => {
|
||||
const h = createOpenTerminalHarness();
|
||||
const args = ["--open-terminal-shell=pwsh", "--open-terminal-path", "proj"];
|
||||
h.handle(null, ["netcatty", ...args], "/working-directory", { rawLaunchArgv: [...args] });
|
||||
assert.deepEqual(h.queued, [[{ shell: "pwsh", path: "/working-directory/proj" }]]);
|
||||
});
|
||||
|
||||
test("second-instance rejects an unsafe shell and still focuses the window", () => {
|
||||
const h = createOpenTerminalHarness();
|
||||
const args = ["--open-terminal-shell", "pwsh\0"];
|
||||
h.handle(null, ["netcatty", ...args], "/working-directory", { rawLaunchArgv: [...args] });
|
||||
assert.deepEqual(h.queued, []);
|
||||
assert.equal(h.focused(), 1);
|
||||
});
|
||||
|
||||
for (const secureCrt of [false, true]) {
|
||||
for (const gotLock of [true, false]) {
|
||||
test(`launch password snapshot is released after handoff (primary=${gotLock}, secureCrt=${secureCrt})`, () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ const { readFileSync } = require("node:fs");
|
||||
const test = require("node:test");
|
||||
const vm = require("node:vm");
|
||||
const deepLink = require("./deepLink.cjs");
|
||||
const { collectOpenTerminalPathArgs } = require("./openTerminalPath.cjs");
|
||||
const { hasOpenTerminalLaunchArgs } = require("./openTerminalPath.cjs");
|
||||
|
||||
function createHarness() {
|
||||
const source = readFileSync(require.resolve("./main.cjs"), "utf8");
|
||||
@@ -22,8 +22,8 @@ function createHarness() {
|
||||
queueSshDeepLink: (url) => queued.push(url),
|
||||
queueTelnetDeepLink: (url) => queued.push(url),
|
||||
queueJmsDeepLink: (url) => queued.push(url),
|
||||
collectOpenTerminalPathArgs,
|
||||
resolveOpenTerminalPathsFromArgs: (argv, { baseDirectory }) => {
|
||||
hasOpenTerminalLaunchArgs,
|
||||
resolveOpenTerminalRequestsFromArgs: (argv, { baseDirectory }) => {
|
||||
paths.push({ argv: Array.from(argv), baseDirectory });
|
||||
return ["/resolved"];
|
||||
},
|
||||
|
||||
+129
-14
@@ -4,6 +4,10 @@ const path = require("node:path");
|
||||
|
||||
const OPEN_TERMINAL_PATH_CHANNEL = "netcatty:openTerminalPath";
|
||||
const OPEN_TERMINAL_PATH_ARG = "--open-terminal-path";
|
||||
const OPEN_TERMINAL_SHELL_ARG = "--open-terminal-shell";
|
||||
// Discovered ids are short. The cap still leaves room for a nested executable
|
||||
// path. Main only sanity-checks; the renderer resolves the id or path.
|
||||
const MAX_OPEN_TERMINAL_SHELL_LENGTH = 4096;
|
||||
|
||||
function expandHomePath(targetPath, { osHomedir = os.homedir } = {}) {
|
||||
if (!targetPath) return targetPath;
|
||||
@@ -48,31 +52,120 @@ function normalizeOpenTerminalPathToken(rawPath) {
|
||||
return value;
|
||||
}
|
||||
|
||||
function collectOpenTerminalPathArgs(argv) {
|
||||
function isOpenTerminalFlag(arg) {
|
||||
return arg === OPEN_TERMINAL_PATH_ARG
|
||||
|| arg === OPEN_TERMINAL_SHELL_ARG
|
||||
|| (typeof arg === "string" && (
|
||||
arg.startsWith(`${OPEN_TERMINAL_PATH_ARG}=`)
|
||||
|| arg.startsWith(`${OPEN_TERMINAL_SHELL_ARG}=`)
|
||||
));
|
||||
}
|
||||
|
||||
function readOpenTerminalFlagValue(argv, index, flag) {
|
||||
const arg = argv[index];
|
||||
if (arg === flag) {
|
||||
const next = argv[index + 1];
|
||||
if (typeof next !== "string" || !next.trim() || isOpenTerminalFlag(next)) {
|
||||
return { value: "", consumed: 0 };
|
||||
}
|
||||
return { value: next, consumed: 1 };
|
||||
}
|
||||
const prefix = `${flag}=`;
|
||||
if (typeof arg === "string" && arg.startsWith(prefix)) {
|
||||
return { value: arg.slice(prefix.length), consumed: 0 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sanitizeOpenTerminalShell(rawShell) {
|
||||
if (typeof rawShell !== "string") return "";
|
||||
let value = rawShell.trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"') && value.length >= 2)
|
||||
|| (value.startsWith("'") && value.endsWith("'") && value.length >= 2)
|
||||
) {
|
||||
value = value.slice(1, -1).trim();
|
||||
}
|
||||
if (!value) return "";
|
||||
if (value.length > MAX_OPEN_TERMINAL_SHELL_LENGTH) return "";
|
||||
if (value.includes("\0")) return "";
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group adjacent path and shell flags into launch requests.
|
||||
* A second path or shell starts a new request. Either field may be absent.
|
||||
*/
|
||||
function collectOpenTerminalRequests(argv) {
|
||||
if (!Array.isArray(argv)) return [];
|
||||
const paths = [];
|
||||
const requests = [];
|
||||
let current = null;
|
||||
|
||||
const flush = () => {
|
||||
if (!current) return;
|
||||
if (current.path || current.shell) requests.push(current);
|
||||
current = null;
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (typeof arg !== "string") continue;
|
||||
|
||||
if (arg === OPEN_TERMINAL_PATH_ARG) {
|
||||
const next = argv[index + 1];
|
||||
if (typeof next === "string" && next.trim()) {
|
||||
paths.push(normalizeOpenTerminalPathToken(next));
|
||||
index += 1;
|
||||
const pathFlag = readOpenTerminalFlagValue(argv, index, OPEN_TERMINAL_PATH_ARG);
|
||||
if (pathFlag) {
|
||||
if (current?.path) flush();
|
||||
const normalized = normalizeOpenTerminalPathToken(pathFlag.value);
|
||||
if (normalized) {
|
||||
if (!current) current = {};
|
||||
current.path = normalized;
|
||||
}
|
||||
index += pathFlag.consumed;
|
||||
continue;
|
||||
}
|
||||
|
||||
const prefix = `${OPEN_TERMINAL_PATH_ARG}=`;
|
||||
if (arg.startsWith(prefix)) {
|
||||
const value = arg.slice(prefix.length);
|
||||
if (value.trim()) paths.push(normalizeOpenTerminalPathToken(value));
|
||||
const shellFlag = readOpenTerminalFlagValue(argv, index, OPEN_TERMINAL_SHELL_ARG);
|
||||
if (shellFlag) {
|
||||
if (current?.shell) flush();
|
||||
const shell = sanitizeOpenTerminalShell(shellFlag.value);
|
||||
if (shell) {
|
||||
if (!current) current = {};
|
||||
current.shell = shell;
|
||||
}
|
||||
index += shellFlag.consumed;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return paths.filter(Boolean);
|
||||
flush();
|
||||
return requests;
|
||||
}
|
||||
|
||||
function collectOpenTerminalPathArgs(argv) {
|
||||
return collectOpenTerminalRequests(argv)
|
||||
.map((request) => request.path)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function hasOpenTerminalLaunchArgs(argv) {
|
||||
if (!Array.isArray(argv)) return false;
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (typeof arg !== "string") continue;
|
||||
if (arg === OPEN_TERMINAL_PATH_ARG || arg === OPEN_TERMINAL_SHELL_ARG) {
|
||||
const next = argv[index + 1];
|
||||
if (typeof next === "string" && next.trim() && !isOpenTerminalFlag(next)) return true;
|
||||
continue;
|
||||
}
|
||||
const pathPrefix = `${OPEN_TERMINAL_PATH_ARG}=`;
|
||||
const shellPrefix = `${OPEN_TERMINAL_SHELL_ARG}=`;
|
||||
if (arg.startsWith(pathPrefix) || arg.startsWith(shellPrefix)) {
|
||||
const value = arg.startsWith(pathPrefix)
|
||||
? arg.slice(pathPrefix.length)
|
||||
: arg.slice(shellPrefix.length);
|
||||
if (value.trim()) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveOpenTerminalPath(rawPath, {
|
||||
@@ -99,18 +192,40 @@ function resolveOpenTerminalPath(rawPath, {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveOpenTerminalRequestsFromArgs(argv, options = {}) {
|
||||
const requests = [];
|
||||
for (const raw of collectOpenTerminalRequests(argv)) {
|
||||
if (raw.path) {
|
||||
const resolvedPath = resolveOpenTerminalPath(raw.path, options);
|
||||
// A requested directory that does not resolve must not fall open in the
|
||||
// default directory, even when a shell was paired with it.
|
||||
if (!resolvedPath) continue;
|
||||
requests.push(raw.shell ? { path: resolvedPath, shell: raw.shell } : { path: resolvedPath });
|
||||
continue;
|
||||
}
|
||||
if (raw.shell) requests.push({ shell: raw.shell });
|
||||
}
|
||||
return requests;
|
||||
}
|
||||
|
||||
function resolveOpenTerminalPathsFromArgs(argv, options = {}) {
|
||||
return collectOpenTerminalPathArgs(argv)
|
||||
.map((rawPath) => resolveOpenTerminalPath(rawPath, options))
|
||||
return resolveOpenTerminalRequestsFromArgs(argv, options)
|
||||
.map((request) => request.path)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_OPEN_TERMINAL_SHELL_LENGTH,
|
||||
OPEN_TERMINAL_PATH_ARG,
|
||||
OPEN_TERMINAL_PATH_CHANNEL,
|
||||
OPEN_TERMINAL_SHELL_ARG,
|
||||
collectOpenTerminalPathArgs,
|
||||
collectOpenTerminalRequests,
|
||||
expandHomePath,
|
||||
hasOpenTerminalLaunchArgs,
|
||||
normalizeOpenTerminalPathToken,
|
||||
resolveOpenTerminalPath,
|
||||
resolveOpenTerminalPathsFromArgs,
|
||||
resolveOpenTerminalRequestsFromArgs,
|
||||
sanitizeOpenTerminalShell,
|
||||
};
|
||||
@@ -2,11 +2,16 @@ const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const {
|
||||
MAX_OPEN_TERMINAL_SHELL_LENGTH,
|
||||
collectOpenTerminalPathArgs,
|
||||
collectOpenTerminalRequests,
|
||||
expandHomePath,
|
||||
hasOpenTerminalLaunchArgs,
|
||||
normalizeOpenTerminalPathToken,
|
||||
resolveOpenTerminalPath,
|
||||
resolveOpenTerminalPathsFromArgs,
|
||||
resolveOpenTerminalRequestsFromArgs,
|
||||
sanitizeOpenTerminalShell,
|
||||
} = require("./openTerminalPath.cjs");
|
||||
|
||||
test("collectOpenTerminalPathArgs extracts explicit open terminal paths", () => {
|
||||
@@ -126,6 +131,173 @@ test("resolveOpenTerminalPath uses parent directory for files", () => {
|
||||
);
|
||||
});
|
||||
|
||||
function directoryFs() {
|
||||
return {
|
||||
statSync: () => ({
|
||||
isDirectory: () => true,
|
||||
isFile: () => false,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
test("collectOpenTerminalRequests pairs a shell with a path in either order", () => {
|
||||
assert.deepEqual(
|
||||
collectOpenTerminalRequests([
|
||||
"Netcatty.exe",
|
||||
"--",
|
||||
"--open-terminal-shell=pwsh",
|
||||
"--open-terminal-path",
|
||||
String.raw`D:\proj`,
|
||||
]),
|
||||
[{ shell: "pwsh", path: String.raw`D:\proj` }],
|
||||
);
|
||||
assert.deepEqual(
|
||||
collectOpenTerminalRequests([
|
||||
"--open-terminal-path=/tmp/demo",
|
||||
"--open-terminal-shell",
|
||||
"/usr/local/bin/fish",
|
||||
]),
|
||||
[{ path: "/tmp/demo", shell: "/usr/local/bin/fish" }],
|
||||
);
|
||||
});
|
||||
|
||||
test("collectOpenTerminalRequests opens on a shell flag alone and keeps path-only requests", () => {
|
||||
assert.deepEqual(
|
||||
collectOpenTerminalRequests(["--open-terminal-shell", "wsl-ubuntu"]),
|
||||
[{ shell: "wsl-ubuntu" }],
|
||||
);
|
||||
assert.deepEqual(
|
||||
collectOpenTerminalRequests(["--open-terminal-path", "/tmp/demo"]),
|
||||
[{ path: "/tmp/demo" }],
|
||||
);
|
||||
});
|
||||
|
||||
test("collectOpenTerminalRequests starts a new request when a flag repeats", () => {
|
||||
assert.deepEqual(
|
||||
collectOpenTerminalRequests([
|
||||
"--open-terminal-shell",
|
||||
"pwsh",
|
||||
"--open-terminal-path",
|
||||
"/tmp/a",
|
||||
"--open-terminal-shell",
|
||||
"cmd",
|
||||
"--open-terminal-path",
|
||||
"/tmp/b",
|
||||
]),
|
||||
[
|
||||
{ shell: "pwsh", path: "/tmp/a" },
|
||||
{ shell: "cmd", path: "/tmp/b" },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("collectOpenTerminalRequests does not swallow a sibling flag as a value", () => {
|
||||
assert.deepEqual(
|
||||
collectOpenTerminalRequests([
|
||||
"--open-terminal-shell",
|
||||
"--open-terminal-path=/tmp/demo",
|
||||
]),
|
||||
[{ path: "/tmp/demo" }],
|
||||
);
|
||||
});
|
||||
|
||||
test("sanitizeOpenTerminalShell rejects empty, NUL, and over-long values", () => {
|
||||
assert.equal(sanitizeOpenTerminalShell(" pwsh "), "pwsh");
|
||||
assert.equal(sanitizeOpenTerminalShell('"pwsh"'), "pwsh");
|
||||
assert.equal(sanitizeOpenTerminalShell(""), "");
|
||||
assert.equal(sanitizeOpenTerminalShell(" "), "");
|
||||
assert.equal(sanitizeOpenTerminalShell("pwsh\0"), "");
|
||||
assert.equal(sanitizeOpenTerminalShell(`\0pwsh`), "");
|
||||
assert.equal(sanitizeOpenTerminalShell("a".repeat(MAX_OPEN_TERMINAL_SHELL_LENGTH)), "a".repeat(MAX_OPEN_TERMINAL_SHELL_LENGTH));
|
||||
assert.equal(sanitizeOpenTerminalShell("a".repeat(MAX_OPEN_TERMINAL_SHELL_LENGTH + 1)), "");
|
||||
});
|
||||
|
||||
test("resolveOpenTerminalRequestsFromArgs keeps a shell-only launch without touching the filesystem", () => {
|
||||
assert.deepEqual(
|
||||
resolveOpenTerminalRequestsFromArgs(["--open-terminal-shell", "cmd"], {
|
||||
fsModule: {
|
||||
statSync: () => {
|
||||
throw new Error("shell-only must not stat");
|
||||
},
|
||||
},
|
||||
logWarn: () => {},
|
||||
}),
|
||||
[{ shell: "cmd" }],
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveOpenTerminalRequestsFromArgs returns a path-only request unchanged", () => {
|
||||
assert.deepEqual(
|
||||
resolveOpenTerminalRequestsFromArgs([
|
||||
"--open-terminal-path",
|
||||
"project",
|
||||
], {
|
||||
baseDirectory: "/Users/alice",
|
||||
fsModule: directoryFs(),
|
||||
logWarn: () => {},
|
||||
}),
|
||||
[{ path: "/Users/alice/project" }],
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveOpenTerminalRequestsFromArgs pairs a resolved directory with the shell", () => {
|
||||
assert.deepEqual(
|
||||
resolveOpenTerminalRequestsFromArgs([
|
||||
"--open-terminal-shell=pwsh",
|
||||
"--open-terminal-path",
|
||||
"project",
|
||||
], {
|
||||
baseDirectory: "/Users/alice",
|
||||
fsModule: directoryFs(),
|
||||
logWarn: () => {},
|
||||
}),
|
||||
[{ shell: "pwsh", path: "/Users/alice/project" }],
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveOpenTerminalRequestsFromArgs drops a paired shell when the path is invalid", () => {
|
||||
assert.deepEqual(
|
||||
resolveOpenTerminalRequestsFromArgs([
|
||||
"--open-terminal-shell",
|
||||
"pwsh",
|
||||
"--open-terminal-path",
|
||||
"/tmp/missing",
|
||||
], {
|
||||
fsModule: {
|
||||
statSync: () => {
|
||||
throw new Error("missing");
|
||||
},
|
||||
},
|
||||
logWarn: () => {},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveOpenTerminalRequestsFromArgs keeps a valid path when the shell fails the sanity check", () => {
|
||||
assert.deepEqual(
|
||||
resolveOpenTerminalRequestsFromArgs([
|
||||
"--open-terminal-path",
|
||||
"/tmp/project",
|
||||
"--open-terminal-shell",
|
||||
"pwsh\0",
|
||||
], {
|
||||
fsModule: directoryFs(),
|
||||
logWarn: () => {},
|
||||
}),
|
||||
[{ path: "/tmp/project" }],
|
||||
);
|
||||
});
|
||||
|
||||
test("hasOpenTerminalLaunchArgs is true for either flag and false for an empty value", () => {
|
||||
assert.equal(hasOpenTerminalLaunchArgs(["--open-terminal-shell", "pwsh"]), true);
|
||||
assert.equal(hasOpenTerminalLaunchArgs(["--open-terminal-path=/tmp/demo"]), true);
|
||||
assert.equal(hasOpenTerminalLaunchArgs(["--open-terminal-shell", "bad\0shell"]), true);
|
||||
assert.equal(hasOpenTerminalLaunchArgs(["--open-terminal-shell="]), false);
|
||||
assert.equal(hasOpenTerminalLaunchArgs(["--open-terminal-path"]), false);
|
||||
assert.equal(hasOpenTerminalLaunchArgs(["--other"]), false);
|
||||
});
|
||||
|
||||
test("resolveOpenTerminalPath rejects missing paths", () => {
|
||||
const warnings = [];
|
||||
const fsModule = {
|
||||
|
||||
@@ -1840,6 +1840,9 @@ function createPreloadApi(ctx) {
|
||||
showSystemNotification: async (payload) => {
|
||||
return ipcRenderer.invoke("netcatty:notification:show", payload ?? {});
|
||||
},
|
||||
setExternalMcpApprovalAlertMode: async (payload) => {
|
||||
return ipcRenderer.invoke("netcatty:mcp:approval-alert-mode", payload ?? {});
|
||||
},
|
||||
|
||||
// Clipboard fallback helpers
|
||||
readClipboardText: async () => {
|
||||
|
||||
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user