mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-10 16:21:22 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5463915f5 | ||
|
|
69ef663844 |
No files matched your search
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,56 +1,34 @@
|
||||
/**
|
||||
* zh-CN / zh-TW overlays for bundled Fig autocomplete descriptions.
|
||||
* Each dictionary is keyed by the exact English description string.
|
||||
* 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.
|
||||
*
|
||||
* The two dictionaries are separate dynamic imports so the renderer fetches
|
||||
* only the chunk for the active UI locale. zh-TW is generated from zh-CN with
|
||||
* OpenCC s2twp via scripts/generate-autocomplete-descriptions-zh-TW.mjs.
|
||||
*/
|
||||
|
||||
type DescriptionDictionary = Record<string, string>;
|
||||
|
||||
let zhCnDictionaryPromise: Promise<DescriptionDictionary> | undefined;
|
||||
let zhTwDictionaryPromise: Promise<DescriptionDictionary> | undefined;
|
||||
|
||||
function dictionaryFromModule(
|
||||
mod: { default?: DescriptionDictionary } | DescriptionDictionary,
|
||||
): DescriptionDictionary {
|
||||
const loaded = mod as { default?: DescriptionDictionary };
|
||||
return loaded.default ?? (mod as DescriptionDictionary);
|
||||
}
|
||||
let dictionaryPromise: Promise<DescriptionDictionary> | undefined;
|
||||
|
||||
function loadZhCnDescriptions(): Promise<DescriptionDictionary> {
|
||||
zhCnDictionaryPromise ??= import("./descriptions.zh-CN.json").then(dictionaryFromModule);
|
||||
return zhCnDictionaryPromise;
|
||||
}
|
||||
|
||||
function loadZhTwDescriptions(): Promise<DescriptionDictionary> {
|
||||
zhTwDictionaryPromise ??= import("./descriptions.zh-TW.json").then(dictionaryFromModule);
|
||||
return zhTwDictionaryPromise;
|
||||
}
|
||||
|
||||
function loadDescriptionsForLocale(locale: string): Promise<DescriptionDictionary> | undefined {
|
||||
if (locale === "zh-CN") return loadZhCnDescriptions();
|
||||
if (locale === "zh-TW") return loadZhTwDescriptions();
|
||||
return undefined;
|
||||
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 locale overlay cannot leak into the
|
||||
* cached spec or into a later query in another locale.
|
||||
* 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 || suggestions.length === 0) return suggestions;
|
||||
const pending = loadDescriptionsForLocale(locale);
|
||||
if (!pending) return suggestions;
|
||||
if (locale !== "zh-CN" || suggestions.length === 0) return suggestions;
|
||||
|
||||
let dictionary: DescriptionDictionary;
|
||||
try {
|
||||
dictionary = await pending;
|
||||
dictionary = await loadZhCnDescriptions();
|
||||
} catch {
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
@@ -274,9 +274,8 @@ export async function getCompletions(
|
||||
*/
|
||||
onLatePathSuggestions?: (suggestions: CompletionSuggestion[]) => void;
|
||||
/**
|
||||
* UI locale. zh-CN and zh-TW overlay bundled spec descriptions from their
|
||||
* own dictionaries; any other locale keeps the original English text.
|
||||
* Cached specs are not rewritten.
|
||||
* UI locale. zh-CN overlays bundled spec descriptions; any other locale
|
||||
* keeps the original English text. Cached specs are not rewritten.
|
||||
*/
|
||||
locale?: string;
|
||||
} = {},
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,20 +1,10 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
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 SINCE_TW = "從指定時間起顯示日誌(例如 2013-01-02T13:23:37Z),也可用相對時間(例如 42m 表示 42 分鐘)";
|
||||
const FILE_REMOVED = "\"%s: File removed before we read it\"";
|
||||
const FILE_REMOVED_TW = "\"%s: 讀取前檔案已被刪除\"";
|
||||
const DEFAULT_LABEL = "(Default)";
|
||||
const NETWORK = "Add a network";
|
||||
const PROCESS_TREE = "ASCII art process tree";
|
||||
const COMPRESSION_PROGRAM = "Do not use archive suffix to determine the compression program";
|
||||
const UNTRANSLATED = "This autocomplete description is intentionally untranslated.";
|
||||
|
||||
const dockerSpec: FigSpec = {
|
||||
@@ -25,10 +15,6 @@ const dockerSpec: FigSpec = {
|
||||
options: [
|
||||
{ name: "--since", description: SINCE },
|
||||
{ name: "--mystery", description: UNTRANSLATED },
|
||||
{ name: "--default-sample", description: DEFAULT_LABEL },
|
||||
{ name: "--network-sample", description: NETWORK },
|
||||
{ name: "--process-sample", description: PROCESS_TREE },
|
||||
{ name: "--file-sample", description: FILE_REMOVED },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -60,13 +46,6 @@ function sinceSuggestion(suggestions: Awaited<ReturnType<typeof getCompletions>>
|
||||
return suggestions.find((suggestion) => suggestion.displayText === "--since");
|
||||
}
|
||||
|
||||
function suggestionNamed(
|
||||
suggestions: Awaited<ReturnType<typeof getCompletions>>,
|
||||
displayText: string,
|
||||
) {
|
||||
return suggestions.find((suggestion) => suggestion.displayText === displayText);
|
||||
}
|
||||
|
||||
test("zh-CN translates docker logs --since and falls back for unknown descriptions", async () => {
|
||||
const suggestions = await getCompletions("docker logs --", {
|
||||
locale: "zh-CN",
|
||||
@@ -94,85 +73,3 @@ test("en locale leaves autocomplete descriptions unchanged", async () => {
|
||||
assert.equal(since?.description, SINCE);
|
||||
assert.equal(dockerSpec.subcommands?.[0].options?.[0].description, SINCE);
|
||||
});
|
||||
|
||||
test("zh-TW translates descriptions with Taiwan phrases and falls back when missing", async () => {
|
||||
const suggestions = await getCompletions("docker logs --", {
|
||||
locale: "zh-TW",
|
||||
skipPathCompletion: true,
|
||||
maxResults: 20,
|
||||
});
|
||||
|
||||
assert.equal(sinceSuggestion(suggestions)?.description, SINCE_TW);
|
||||
assert.equal(suggestionNamed(suggestions, "--default-sample")?.description, "(預設)");
|
||||
assert.equal(suggestionNamed(suggestions, "--network-sample")?.description, "新增網路");
|
||||
assert.equal(suggestionNamed(suggestions, "--process-sample")?.description, "ASCII 藝術行程樹");
|
||||
assert.equal(suggestionNamed(suggestions, "--file-sample")?.description, FILE_REMOVED_TW);
|
||||
assert.equal(suggestionNamed(suggestions, "--mystery")?.description, UNTRANSLATED);
|
||||
assert.equal(dockerSpec.subcommands?.[0].options?.[0].description, SINCE);
|
||||
});
|
||||
|
||||
test("locales other than zh-CN and zh-TW keep English descriptions", async () => {
|
||||
for (const locale of ["ru", "es", "zh-Hant", undefined]) {
|
||||
const suggestions = await getCompletions("docker logs --", {
|
||||
locale,
|
||||
skipPathCompletion: true,
|
||||
maxResults: 20,
|
||||
});
|
||||
assert.equal(sinceSuggestion(suggestions)?.description, SINCE);
|
||||
assert.equal(suggestionNamed(suggestions, "--process-sample")?.description, PROCESS_TREE);
|
||||
}
|
||||
assert.equal(dockerSpec.subcommands?.[0].options?.[0].description, SINCE);
|
||||
});
|
||||
|
||||
test("zh-CN and zh-TW overlays do not leak across later lookups", async () => {
|
||||
const traditional = await getCompletions("docker logs --", {
|
||||
locale: "zh-TW",
|
||||
skipPathCompletion: true,
|
||||
maxResults: 20,
|
||||
});
|
||||
const simplified = await getCompletions("docker logs --", {
|
||||
locale: "zh-CN",
|
||||
skipPathCompletion: true,
|
||||
maxResults: 20,
|
||||
});
|
||||
const english = await getCompletions("docker logs --", {
|
||||
locale: "en",
|
||||
skipPathCompletion: true,
|
||||
maxResults: 20,
|
||||
});
|
||||
|
||||
assert.equal(sinceSuggestion(traditional)?.description, SINCE_TW);
|
||||
assert.equal(sinceSuggestion(simplified)?.description, SINCE_ZH);
|
||||
assert.equal(sinceSuggestion(english)?.description, SINCE);
|
||||
assert.equal(dockerSpec.subcommands?.[0].options?.[0].description, SINCE);
|
||||
});
|
||||
|
||||
test("committed zh-TW dictionary keeps zh-CN keys and Taiwan terminology", () => {
|
||||
const dictDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "autocomplete");
|
||||
const simplified = JSON.parse(fs.readFileSync(path.join(dictDir, "descriptions.zh-CN.json"), "utf8"));
|
||||
const traditional = JSON.parse(fs.readFileSync(path.join(dictDir, "descriptions.zh-TW.json"), "utf8"));
|
||||
|
||||
assert.deepEqual(Object.keys(traditional), Object.keys(simplified));
|
||||
assert.equal(simplified[DEFAULT_LABEL], "(默认)");
|
||||
assert.equal(traditional[DEFAULT_LABEL], "(預設)");
|
||||
assert.equal(traditional[NETWORK], "新增網路");
|
||||
assert.equal(traditional[PROCESS_TREE], "ASCII 藝術行程樹");
|
||||
assert.equal(traditional[FILE_REMOVED], FILE_REMOVED_TW);
|
||||
assert.equal(traditional[SINCE], SINCE_TW);
|
||||
assert.equal(traditional[COMPRESSION_PROGRAM], "不根據歸檔字尾判斷壓縮程式");
|
||||
|
||||
let processCount = 0;
|
||||
let tripCount = 0;
|
||||
for (const key of Object.keys(simplified)) {
|
||||
const source = simplified[key];
|
||||
const converted = traditional[key];
|
||||
assert.equal(typeof converted, "string");
|
||||
assert.equal(converted.includes("进程"), false);
|
||||
assert.equal(converted.includes("默认"), false);
|
||||
assert.equal(converted.includes("网络"), false);
|
||||
processCount += source.split("进程").length - 1;
|
||||
tripCount += converted.split("行程").length - 1;
|
||||
}
|
||||
assert.ok(processCount > 0);
|
||||
assert.equal(tripCount, processCount);
|
||||
});
|
||||
+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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Generated
-8
@@ -99,7 +99,6 @@
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"micromark-extension-gfm-strikethrough": "^2.1.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"opencc-js": "^1.4.2",
|
||||
"patch-package": "^8.0.0",
|
||||
"pinyin-pro": "^3.28.1",
|
||||
"re2js": "2.8.6",
|
||||
@@ -16866,13 +16865,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/opencc-js": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/opencc-js/-/opencc-js-1.4.2.tgz",
|
||||
"integrity": "sha512-EdcspgdROiCCZDM+jZR6Pa670xW+EW2QhctPBQ8gRjg6lrx4IHEl+2s+kt5izk/TkxVRgdWuFgaGkaEdIUfdaw==",
|
||||
"dev": true,
|
||||
"license": "MIT AND Apache-2.0"
|
||||
},
|
||||
"node_modules/opentype.js": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-2.0.0.tgz",
|
||||
|
||||
@@ -50,7 +50,6 @@
|
||||
"check:codex-app-server-schema": "node scripts/generate-codex-app-server-schema.cjs --check",
|
||||
"check:codebuddy-sdk-contract": "tsc -p tsconfig.codebuddy-sdk-contract.json",
|
||||
"generate:plugin-contract": "node scripts/generate-plugin-contract.mjs",
|
||||
"generate:autocomplete-descriptions-zh-TW": "node scripts/generate-autocomplete-descriptions-zh-TW.mjs",
|
||||
"check:plugin-contract": "node scripts/generate-plugin-contract.mjs --check",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
@@ -160,7 +159,6 @@
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"micromark-extension-gfm-strikethrough": "^2.1.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"opencc-js": "^1.4.2",
|
||||
"patch-package": "^8.0.0",
|
||||
"pinyin-pro": "^3.28.1",
|
||||
"re2js": "2.8.6",
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* Regenerate the zh-TW autocomplete description overlay from the committed
|
||||
* zh-CN dictionary.
|
||||
*
|
||||
* Conversion is OpenCC s2twp: Simplified Chinese to Traditional Chinese
|
||||
* (Taiwan standard) with Taiwan phrases, for example 文件→檔案, 默认→預設,
|
||||
* 网络→網路. English dictionary keys are copied unchanged.
|
||||
*
|
||||
* OpenCC's TWPhrases entry for 進程 lists 程序 first, then 進程 and 行程.
|
||||
* opencc-js keeps that first candidate, so plain s2twp turns 进程 into 程序.
|
||||
* Command descriptions use the Taiwan IT term 行程 instead: 进程 is held out
|
||||
* of the converter and restored as 行程. 程序 in the source still follows
|
||||
* s2twp (程序→程式).
|
||||
*
|
||||
* opencc-js is a devDependency used only by this script. The renderer loads
|
||||
* the committed JSON and does not import opencc-js.
|
||||
*
|
||||
* Usage: node scripts/generate-autocomplete-descriptions-zh-TW.mjs
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import OpenCC from "opencc-js";
|
||||
|
||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const sourcePath = path.join(
|
||||
rootDir,
|
||||
"components/terminal/autocomplete/descriptions.zh-CN.json",
|
||||
);
|
||||
const outputPath = path.join(
|
||||
rootDir,
|
||||
"components/terminal/autocomplete/descriptions.zh-TW.json",
|
||||
);
|
||||
|
||||
function printfTokens(text) {
|
||||
return text.match(/%[-+ #0]*\d*(?:\.\d+)?[a-zA-Z]|%%/g) ?? [];
|
||||
}
|
||||
|
||||
// Private-use mark so s2twp cannot rewrite 进程 before it becomes 行程.
|
||||
const PROCESS_MARK = "\u{E000}";
|
||||
|
||||
function toTraditionalTaiwan(text, convert) {
|
||||
if (text.includes(PROCESS_MARK)) {
|
||||
throw new Error("description already contains the 进程 placeholder");
|
||||
}
|
||||
const marked = text.replaceAll("进程", PROCESS_MARK);
|
||||
const converted = convert(marked);
|
||||
if (converted.includes("进程") || (marked.includes(PROCESS_MARK) && !converted.includes(PROCESS_MARK))) {
|
||||
throw new Error("OpenCC dropped the 进程 placeholder");
|
||||
}
|
||||
return converted.replaceAll(PROCESS_MARK, "行程");
|
||||
}
|
||||
|
||||
const source = JSON.parse(fs.readFileSync(sourcePath, "utf8"));
|
||||
if (!source || typeof source !== "object" || Array.isArray(source)) {
|
||||
throw new Error("zh-CN description dictionary must be a JSON object");
|
||||
}
|
||||
|
||||
const convert = OpenCC.Converter({ from: "cn", to: "twp" });
|
||||
const converted = {};
|
||||
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (typeof value !== "string") {
|
||||
throw new Error(`Expected a string value for ${JSON.stringify(key)}`);
|
||||
}
|
||||
const traditional = toTraditionalTaiwan(value, convert);
|
||||
const sourceTokens = printfTokens(value);
|
||||
const convertedTokens = printfTokens(traditional);
|
||||
if (sourceTokens.join(" ") !== convertedTokens.join(" ")) {
|
||||
throw new Error(
|
||||
`Printf placeholders changed for ${JSON.stringify(key)}: ${sourceTokens.join(" ")} -> ${convertedTokens.join(" ")}`,
|
||||
);
|
||||
}
|
||||
converted[key] = traditional;
|
||||
}
|
||||
|
||||
fs.writeFileSync(outputPath, `${JSON.stringify(converted, null, 2)}\n`);
|
||||
console.log(
|
||||
`Wrote ${Object.keys(converted).length} zh-TW descriptions to ${path.relative(rootDir, outputPath)}`,
|
||||
);
|
||||
+1
-1
@@ -33,7 +33,7 @@ declare global {
|
||||
onUpdateNeedsSave?(cb: () => void): () => void;
|
||||
onSshDeepLink?(cb: (payload: { url?: string; tabName?: string }) => void): () => void;
|
||||
onTelnetDeepLink?(cb: (payload: { url?: string; tabName?: string }) => void): () => void;
|
||||
onOpenTerminalPath?(cb: (payload: { path?: string }) => void): () => void;
|
||||
onOpenTerminalPath?(cb: (payload: { path?: string; shell?: string }) => void): () => void;
|
||||
/** Fired once after cold-start deep-link / open-terminal queues have been drained. */
|
||||
onColdStartIntentsSettled?(cb: () => void): () => void;
|
||||
setSshDeepLinkEnabled?(enabled: boolean): Promise<boolean | { success: boolean; enabled: boolean }>;
|
||||
|
||||
Reference in new issue
Block a user